mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 02:57:36 +00:00
* feat(wsl-mover): pause a moving project's terminals through Sleep and prove they exited (D1b-5d) * fix(wsl-mover): gate again under the fence, swap journal phases atomically, keep sessions frozen through commit (D1b-5d) * test(wsl-mover): the fence e2e walks the journal one phase at a time, as a move writes it (D1b-5d)
440 lines
20 KiB
TypeScript
440 lines
20 KiB
TypeScript
import type { WorkspaceSessionState } from './workspace-session-state-types'
|
|
import {
|
|
WORKSPACE_SESSION_FIELD_OWNERSHIP,
|
|
type WorkspaceSessionFieldOwnership
|
|
} from './workspace-session-host-field-ownership'
|
|
import { normalizeWorkspaceSessionKeyToWorkspaceId } from './workspace-scope'
|
|
import { isWorktreeHostIdentity as isHostQualifiedSessionKey } from './worktree/host-qualified-identity'
|
|
import {
|
|
buildWorktreeIdByFileId,
|
|
buildWorktreeIdByTabId,
|
|
worktreeIdForPaneKey
|
|
} from './workspace-session-host-records'
|
|
import { keepUnsavedDrafts } from './workspace-session-draft-retention'
|
|
|
|
/**
|
|
* Fold rows a host partition holds alone back into the session the readers assemble.
|
|
*
|
|
* Shipping builds split one SSH workspace's session across two partitions: the renderer wrote
|
|
* `local`, the main-process runtime wrote `ssh:<targetId>` (#12723). `workspaceSessionPartitionHostId`
|
|
* 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.
|
|
*
|
|
* **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.
|
|
*
|
|
* 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.
|
|
*
|
|
* Adoption is told which session keys the read found **contested**. Every rule below rests on the
|
|
* premise that `local` and `ssh:<targetId>` are one workspace written twice — and a bare
|
|
* `repoId::path` id claimed by more than one host is exactly where that premise is false. The
|
|
* contention split cannot answer it, because ssh slices are deliberately kept out of its claimant
|
|
* set, so the caller reaches the verdict itself — from the repo catalog and from the other
|
|
* partitions it read — and passes it in: a contested key may still be gap-filled, never replaced.
|
|
* Without that, an SSH workspace's rows overwrote a different workspace's rows under the same id,
|
|
* and routing then wrote them into that workspace's own partition.
|
|
*
|
|
* `adoptedWorkspaceIds` reports which workspaces came out of `host` uncontested. The write path
|
|
* routes those back to that partition directly, so a row does not depend on a repo catalog naming
|
|
* the host before it can be returned to where it was read from.
|
|
*
|
|
* 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 (docs/reference/ssh-execution-boundary.md: "we could not see it" is
|
|
* `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). Nor is a copy of a workspace the
|
|
* repo catalog places on this host while the host holds its tabs: the base's rows there are residue
|
|
* that dropped the host's agent-resume records on the first save (#23390, #25616), so they are
|
|
* dropped before adoption — except unsaved drafts, which nothing else holds.
|
|
*/
|
|
|
|
type KeyedRecord = Record<string, unknown>
|
|
|
|
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Object.keys over the ownership table yields exactly the session field names it is keyed by.
|
|
const SESSION_FIELDS = Object.keys(
|
|
WORKSPACE_SESSION_FIELD_OWNERSHIP
|
|
) as (keyof WorkspaceSessionState)[]
|
|
|
|
function isRecord(value: unknown): value is KeyedRecord {
|
|
return !!value && typeof value === 'object' && !Array.isArray(value)
|
|
}
|
|
|
|
function asRecord(value: unknown): KeyedRecord | null {
|
|
return isRecord(value) ? value : null
|
|
}
|
|
|
|
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: unknown = Array.isArray(entry) ? entry[0] : 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 a partition names in any scoped field.
|
|
*
|
|
* Exported because the caller has to ask the repo catalog about the same set before adoption runs:
|
|
* a `worktreeKeyed` sweep alone misses an id a partition names only through
|
|
* `browserPagesByWorkspace`, a sleeping-agent record or `activeWorktreeIdsOnShutdown`, and those
|
|
* are adopted just like the rest.
|
|
*/
|
|
export function workspaceIdsNamedByPartition(host: WorkspaceSessionState): Set<string> {
|
|
return collectWorkspaceIds(host, () => false)
|
|
}
|
|
|
|
/** 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 owned = workspacesTheBaseOwns(base)
|
|
return collectWorkspaceIds(host, (workspaceId) => owned.has(workspaceId))
|
|
}
|
|
|
|
function collectWorkspaceIds(
|
|
host: WorkspaceSessionState,
|
|
skip: (workspaceId: string) => boolean
|
|
): Set<string> {
|
|
const collected = new Set<string>()
|
|
const consider = (value: string | null | undefined): void => {
|
|
if (!value) {
|
|
return
|
|
}
|
|
const workspaceId = normalizeWorkspaceSessionKeyToWorkspaceId(value)
|
|
if (!skip(workspaceId)) {
|
|
collected.add(workspaceId)
|
|
}
|
|
}
|
|
// Tab-, pane- and file-keyed rows resolve to nothing here: the worktree-keyed rows they hang
|
|
// off already name their workspaces.
|
|
const none = new Map<string, string>()
|
|
for (const field of SESSION_FIELDS) {
|
|
const workspaceOf = entryWorkspaceResolver(field, none, none)
|
|
const value: unknown = host[field]
|
|
const entries = Array.isArray(value)
|
|
? value.map((id: unknown) => [id, id] as const)
|
|
: Object.entries(asRecord(value) ?? {})
|
|
for (const [key, entry] of entries) {
|
|
consider(typeof key === 'string' ? workspaceOf?.(key, entry) : null)
|
|
}
|
|
}
|
|
return collected
|
|
}
|
|
|
|
/**
|
|
* Whether the host has nothing to say about a key. `[]`, `{}` and null/undefined all mean the host
|
|
* holds no rows, which is never evidence that the base's rows are wrong — the same reading the base
|
|
* side already gives an empty tab row, and `docs/reference/ssh-execution-boundary.md` generalises.
|
|
* Without this an empty host `openFilesByWorktree` row replaced a populated base one and destroyed
|
|
* an unsaved `dirtyDraftContent`, which no other channel can recover. The symmetric cost is that a
|
|
* row the host really did empty stays visible for one more launch, and a resurrected editor tab is
|
|
* non-destructive where a destroyed draft is not.
|
|
*/
|
|
function hostHasNothingFor(entry: unknown): boolean {
|
|
if (entry === null || entry === undefined) {
|
|
return true
|
|
}
|
|
if (Array.isArray(entry)) {
|
|
return entry.length === 0
|
|
}
|
|
return isRecord(entry) && Object.keys(entry).length === 0
|
|
}
|
|
|
|
function adoptRecord(
|
|
next: WorkspaceSessionState,
|
|
host: WorkspaceSessionState,
|
|
field: keyof WorkspaceSessionState,
|
|
shouldAdopt: (key: string, entry: unknown) => boolean,
|
|
/** Whether this key's host row may replace the base's, rather than only fill a gap. An adoptable
|
|
* workspace is host-owned, so its populated rows supersede the base's leftovers — but only where
|
|
* the id names one workspace and the host actually holds something. */
|
|
mayReplace: boolean | ((key: string) => boolean) = false
|
|
): void {
|
|
const hostRecord = asRecord(host[field])
|
|
if (!hostRecord) {
|
|
return
|
|
}
|
|
const merged = { ...asRecord(next[field]) }
|
|
for (const [key, entry] of Object.entries(hostRecord)) {
|
|
const replaces =
|
|
(typeof mayReplace === 'function' ? mayReplace(key) : mayReplace) && !hostHasNothingFor(entry)
|
|
if (shouldAdopt(key, entry) && (replaces || !Object.hasOwn(merged, key))) {
|
|
merged[key] = entry
|
|
}
|
|
}
|
|
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the merged record is written back through a dynamic field key, which the session type cannot express.
|
|
;(next as KeyedRecord)[field] = merged
|
|
}
|
|
|
|
/**
|
|
* The worktree-keyed rows a partition holds for workspaces the write will not route back to it.
|
|
*
|
|
* Parked rather than dropped. A partition write replaces each field with exactly what the unified
|
|
* session routed there, so a row this read left out — declined as residue, withheld as contested,
|
|
* or skipped because the base already holds the live copy — is erased the moment any sibling
|
|
* workspace writes the same partition. `attachHostSessionShadow` puts these back into the slice
|
|
* first, which is the protection a contested runtime co-claimant already gets. Declining to show a
|
|
* row must never mean deleting it: docs/reference/ssh-execution-boundary.md makes leak, never kill,
|
|
* the safe direction, and a row no partition holds at all is unrecoverable.
|
|
*/
|
|
export function partitionRowsTheWriteWontReturn(
|
|
host: WorkspaceSessionState,
|
|
adoptedWorkspaceIds: ReadonlySet<string>
|
|
): WorkspaceSessionState | null {
|
|
let parked: KeyedRecord | null = null
|
|
for (const field of SESSION_FIELDS) {
|
|
if (WORKSPACE_SESSION_FIELD_OWNERSHIP[field] !== 'worktreeKeyed') {
|
|
continue
|
|
}
|
|
const record = asRecord(host[field])
|
|
if (!record) {
|
|
continue
|
|
}
|
|
let kept: KeyedRecord | null = null
|
|
for (const [key, entry] of Object.entries(record)) {
|
|
if (adoptedWorkspaceIds.has(normalizeWorkspaceSessionKeyToWorkspaceId(key))) {
|
|
continue
|
|
}
|
|
kept ??= {}
|
|
kept[key] = entry
|
|
}
|
|
if (kept) {
|
|
parked ??= {}
|
|
parked[field] = kept
|
|
}
|
|
}
|
|
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every key written here is a session field name taken from the ownership table, and every value is that field's own record copied by reference.
|
|
return parked as WorkspaceSessionState | null
|
|
}
|
|
|
|
/** Which workspace an entry of a scoped field belongs to; null for global and host-private fields. */
|
|
function entryWorkspaceResolver(
|
|
field: keyof WorkspaceSessionState,
|
|
worktreeIdByTabId: Map<string, string>,
|
|
worktreeIdByFileId: Map<string, string>
|
|
): ((key: string, entry: unknown) => string | null | undefined) | null {
|
|
const ownership: WorkspaceSessionFieldOwnership = WORKSPACE_SESSION_FIELD_OWNERSHIP[field]
|
|
switch (ownership) {
|
|
case 'global':
|
|
case 'hostPrivate':
|
|
return null
|
|
case 'worktreeKeyed':
|
|
case 'worktreeArray':
|
|
return (key) => key
|
|
case 'tabKeyed':
|
|
return (key) => worktreeIdByTabId.get(key)
|
|
case 'paneKeyed':
|
|
return (key) => worktreeIdForPaneKey(worktreeIdByTabId, key)
|
|
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.
|
|
return (_key, entry) => recordWorkspaceId(entry)
|
|
case 'browserWorkspaceKeyed':
|
|
return (_key, entry) => browserPagesWorkspaceId(entry)
|
|
case 'fileKeyed':
|
|
// Routed by the open file's workspace, through the same index the split routed it by.
|
|
return (key) => worktreeIdByFileId.get(key)
|
|
}
|
|
}
|
|
|
|
/** The base without any rows for these workspaces, routed by the same indexes the split uses. */
|
|
export function withoutWorkspaces(
|
|
base: WorkspaceSessionState,
|
|
workspaceIds: ReadonlySet<string>
|
|
): WorkspaceSessionState {
|
|
return filterWorkspaceRows(base, (workspaceId) => !workspaceIds.has(workspaceId))
|
|
}
|
|
|
|
/** The scoped rows of the workspaces kept, by the same routing; global fields stay as they are. */
|
|
export function filterWorkspaceRows(
|
|
base: WorkspaceSessionState,
|
|
keepsWorkspace: (workspaceId: string) => boolean
|
|
): WorkspaceSessionState {
|
|
const worktreeIdByTabId = buildWorktreeIdByTabId(base)
|
|
const worktreeIdByFileId = buildWorktreeIdByFileId(base)
|
|
const next: KeyedRecord = { ...base }
|
|
for (const field of SESSION_FIELDS) {
|
|
const workspaceOf = entryWorkspaceResolver(field, worktreeIdByTabId, worktreeIdByFileId)
|
|
const keeps = (key: string, entry: unknown): boolean =>
|
|
keepsWorkspace(normalizeWorkspaceSessionKeyToWorkspaceId(workspaceOf?.(key, entry) ?? ''))
|
|
const value: unknown = base[field]
|
|
if (!workspaceOf || !value) {
|
|
continue
|
|
}
|
|
next[field] = Array.isArray(value)
|
|
? value.filter((id: string) => keeps(id, id))
|
|
: Object.fromEntries(Object.entries(value).filter(([key, entry]) => keeps(key, entry)))
|
|
}
|
|
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every field is the base's own value or a filtered copy of it.
|
|
return next as WorkspaceSessionState
|
|
}
|
|
|
|
/** Owned, uncontested and holding tabs: the host's copy is the live one. No tabs is not a close. */
|
|
export function workspacesTheHostSupersedes(
|
|
host: WorkspaceSessionState,
|
|
ownedSessionKeys: ReadonlySet<string> | undefined,
|
|
contested: ReadonlySet<string>
|
|
): Set<string> {
|
|
const superseded = new Set<string>()
|
|
for (const [key, tabs] of Object.entries(host.tabsByWorktree ?? {})) {
|
|
const id = normalizeWorkspaceSessionKeyToWorkspaceId(key)
|
|
if (!hostHasNothingFor(tabs) && !contested.has(id) && ownedSessionKeys?.has(id)) {
|
|
superseded.add(id)
|
|
}
|
|
}
|
|
return superseded
|
|
}
|
|
|
|
export type StrandedPartitionAdoptionOptions = {
|
|
/** Session keys the read found claimed by more than one partition. */
|
|
contestedSessionKeys?: ReadonlySet<string>
|
|
/**
|
|
* Keys the repo catalog positively attributes to a DIFFERENT host: residue this partition holds
|
|
* but does not own. Not adopted at all — gap-filling a stale row would put it in front of the
|
|
* live one and then route it into the live partition on the next write. Nothing is deleted; the
|
|
* rows stay where they are, which is the leak direction the boundary doc asks for.
|
|
*/
|
|
foreignSessionKeys?: ReadonlySet<string>
|
|
/** Keys the repo catalog attributes to this host. */
|
|
ownedSessionKeys?: ReadonlySet<string>
|
|
}
|
|
|
|
export type StrandedPartitionAdoption = {
|
|
session: WorkspaceSessionState
|
|
/** Bare workspace ids whose rows this partition owns outright, so the write returns them here. */
|
|
adoptedWorkspaceIds: ReadonlySet<string>
|
|
}
|
|
|
|
const NOTHING_ADOPTED: ReadonlySet<string> = new Set<string>()
|
|
|
|
export function adoptStrandedHostPartitionSession(
|
|
originalBase: WorkspaceSessionState,
|
|
host: WorkspaceSessionState | null | undefined,
|
|
options: StrandedPartitionAdoptionOptions = {}
|
|
): StrandedPartitionAdoption {
|
|
if (!host) {
|
|
return { session: originalBase, adoptedWorkspaceIds: NOTHING_ADOPTED }
|
|
}
|
|
const contested = new Set<string>()
|
|
for (const key of options.contestedSessionKeys ?? []) {
|
|
contested.add(normalizeWorkspaceSessionKeyToWorkspaceId(key))
|
|
}
|
|
const superseded = workspacesTheHostSupersedes(host, options.ownedSessionKeys, contested)
|
|
const base = superseded.size > 0 ? withoutWorkspaces(originalBase, superseded) : originalBase
|
|
const adoptable = adoptableWorkspaceIds(base, host)
|
|
for (const key of options.foreignSessionKeys ?? []) {
|
|
adoptable.delete(normalizeWorkspaceSessionKeyToWorkspaceId(key))
|
|
}
|
|
if (adoptable.size === 0) {
|
|
return { session: base, adoptedWorkspaceIds: NOTHING_ADOPTED }
|
|
}
|
|
const adopts = (key: string): boolean =>
|
|
adoptable.has(normalizeWorkspaceSessionKeyToWorkspaceId(key))
|
|
const isContested = (key: string): boolean =>
|
|
contested.has(normalizeWorkspaceSessionKeyToWorkspaceId(key))
|
|
|
|
const next: WorkspaceSessionState = { ...base, tabsByWorktree: { ...base.tabsByWorktree } }
|
|
for (const [key, tabs] of Object.entries(host.tabsByWorktree ?? {})) {
|
|
if (!adopts(key) || !Array.isArray(tabs)) {
|
|
continue
|
|
}
|
|
// A contested id is not this workspace written twice, so the base's own row stays — but an
|
|
// EMPTY base row is not a row, it is the gap this repair exists to fill. Reading `hasOwn` as
|
|
// "the base has tabs here" is what let #12721's empty local list win over the host's real one
|
|
// whenever the id happened to be contested.
|
|
if (!isContested(key) || hostHasNothingFor(next.tabsByWorktree[key])) {
|
|
next.tabsByWorktree[key] = tabs
|
|
}
|
|
}
|
|
// Why the split's own indexes: they are what decided which partition each tab-, pane- and
|
|
// file-keyed row was written to, so reading them back through anything else lets the two walks
|
|
// disagree. `buildWorktreeIdByTabId` also covers unified-only tabs, whose layout and PTY records
|
|
// the split routes here and a `tabsByWorktree`-only walk never adopted back. Computed up front so
|
|
// the keyed fields do not depend on the ownership table's declaration order.
|
|
const worktreeIdByTabId = buildWorktreeIdByTabId(host)
|
|
const worktreeIdByFileId = buildWorktreeIdByFileId(host)
|
|
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') {
|
|
// Why a bare recency key only fills a gap: `lastVisitedAtByWorktreeId` is the one field in
|
|
// this kind whose key may carry a host (`<hostId>|<worktreeId>`), and the split has its
|
|
// own branch for that. A qualified key names its owner and cannot collide; a bare one is
|
|
// indistinguishable from another host's entry for the same id, and replacing moved a
|
|
// local workspace's Cmd+J position permanently.
|
|
adoptRecord(
|
|
next,
|
|
host,
|
|
field,
|
|
(key) => adopts(key),
|
|
(key) =>
|
|
!isContested(key) &&
|
|
(field !== 'lastVisitedAtByWorktreeId' || isHostQualifiedSessionKey(key))
|
|
)
|
|
}
|
|
break
|
|
case 'worktreeArray': {
|
|
const hostIds = host[field]
|
|
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: worktreeArray fields hold worktree ids; the session type does not carry that through a dynamic key.
|
|
const adopted = (Array.isArray(hostIds) ? (hostIds as string[]) : []).filter(adopts)
|
|
if (adopted.length > 0) {
|
|
const baseIds = next[field]
|
|
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the adopted union is written back through a dynamic field key, which the session type cannot express.
|
|
;(next as KeyedRecord)[field] = [
|
|
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: worktreeArray fields hold worktree ids; the session type does not carry that through a dynamic key.
|
|
...new Set([...(Array.isArray(baseIds) ? (baseIds as string[]) : []), ...adopted])
|
|
]
|
|
}
|
|
break
|
|
}
|
|
case 'tabKeyed':
|
|
case 'paneKeyed':
|
|
case 'sleepingAgentKeyed':
|
|
case 'surfaceTombstoneKeyed':
|
|
case 'browserWorkspaceKeyed':
|
|
case 'fileKeyed': {
|
|
const workspaceOf = entryWorkspaceResolver(field, worktreeIdByTabId, worktreeIdByFileId)
|
|
adoptRecord(next, host, field, (key, entry) => adopts(workspaceOf?.(key, entry) ?? ''))
|
|
break
|
|
}
|
|
}
|
|
}
|
|
keepUnsavedDrafts(next, originalBase)
|
|
// Why contested ids are withheld: the write path would route the whole bare id here, carrying the
|
|
// co-claimant's rows into this host's partition — the loss the gap-fill above exists to prevent.
|
|
const adoptedWorkspaceIds = new Set(
|
|
[...adoptable].filter((workspaceId) => !contested.has(workspaceId))
|
|
)
|
|
return { session: next, adoptedWorkspaceIds }
|
|
}
|