mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 16:02:29 +00:00
refactor(terminal): one commit module for terminal topology closes, with a boundary check (#25329)
* refactor(terminal): move the topology revision and leaf-lookup helpers into terminal-topology
advanceTerminalTopologyRevision, findTerminalTabIdForLeaf and
hasHostAuthoritativeTerminalMembership move verbatim from the renderer-save
membership rebase into persistence/terminal-topology, the home of the commit
boundary. Importers are repointed; no behavior change.
* test(terminal): test-only guard for topology writes outside the commit boundary
The three session sinks now publish through one commitWorkspaceSessionPartition
helper, which hands the prior and published partition to the topology write
guard. Production never arms the guard, so each sink pays one global lookup.
The unit suite arms it in report mode: a sink write that changes class-(a)
topology (membership, root, bindings, titles, incarnations, sleeping records,
remote session ids, tombstones, default-applied, revision) outside a commit
scope is attributed to its writer's file by stack, and fails the test unless
the writer is on the unrouted-writers allowlist that later routing PRs shrink.
Renderer saves and test seeding are exempt. Deep-freeze is available but stays
off suite-wide until the in-place writers return new sessions.
* refactor(terminal): add the topology commit module with bindLeaf, closeLeaf and closeTab
terminal-topology-commit.ts is the boundary for class-(a) terminal topology.
bindLeaf forwards to persistPtyBinding, whose write now runs in a commit
scope; the spawn commits and the relay reattach bind through it. closeLeaf and
closeTab wrap the existing close mutation in a commit scope and one
persistence.terminal-topology span (kind, outcome, refusal reason; no ids),
and the runtime close goes through them. Session output is unchanged: tests
compare it byte for byte with the old writers for local, ssh: and folder
workspaces.
* chore(terminal): drop an unused lint suppression from the topology write guard
* fix(terminal): attribute topology guard writers relative to the repo root
The guard read a frame's file through its last /src/ segment, so a test under
tests/ (folder-upgrade-identity-persistence.unit.test.ts) had no source frame
and failed as an unknown writer. Frames are now taken relative to the repo
root the setup passes in, tests/ counts as test seeding, Windows backslashes
are normalized before the node_modules skip, and nested src/ paths keep their
full path. The two runtime funnel files skip only their funnel function, so
another writer in them still shows. The R9 allowlist key names the file whose
frame actually writes. The class-(a) diff and the attribution move into their
own files; the stack limit is restored in a finally.
* refactor(terminal): drop bindLeaf until binding reaches a sink; add the boundary ratchet
bindLeaf and the commit scope inside persistPtyBinding changed nothing: the
binding write never reaches a session sink, and a scope inside the Store method
would have admitted every direct caller once it did. Both return in B1-4; the
spawn commits and the relay reattach call persistPtyBinding directly again.
The runtime close now calls one closeLeafOrTab entry, so its callbacks keep
their contextual types. A census test is the primary enforcement: only
persistence/terminal-topology and the callers it lists may call
persistPtyBinding, the session setters or the three sinks, and every
unrouted-writer allowlist entry must name an existing file.
* fix(test): resolve the topology guard's repo root without the global URL
Under happy-dom the global URL is not Node's, so fileURLToPath(new URL(...))
threw in the setup file and failed every happy-dom test file.
* refactor(terminal): drop the runtime topology write guard; the boundary ratchet enforces
The AST boundary ratchet is the enforcement for B1. The stack-attributed
runtime guard, its class-(a) diff, the unrouted-writer allowlist, the vitest
setup and the freeze option are removed; it saw two writers in the whole unit
suite and its real value starts only once binding reaches a sink (B1-4).
Also: one closeLeafOrTab wraps the close in the span (no per-kind copies or
narrowed types), the span has one finish like persistence.pty-binding, the
sink helper is publishWorkspaceSessionPartition (it publishes; the commit
boundary is the module), the ratchet drops the private publishSession row and
checks that every listed caller file exists, and the close comparison keeps
its two meaningful cases with span cases chosen by name.
* refactor(terminal): trim the B1-1 commit module and ratchet to what they enforce
- Point the acknowledged-tab-retirement audit fixture at the moved
advanceTerminalTopologyRevision; its old import no longer resolved.
- Drop publishWorkspaceSessionPartition: it was the removed guard's
interception point, so the three session sinks return to origin/main.
- One traced(kind, mutate) wrapper in the commit file replaces the span
factory; closeLeafOrTab is one call.
- The ratchet walks src/main with the shared scanSourceTree, drops the
loading-store-internal rows and the redundant file-exists test; exact-set
equality already fails on a missing file.
- The commit test is a pure unit test of the span outcomes: no Store
harness, electron mock or self-comparing close.
* refactor(terminal): census the runtime session controller's write and drop stage ids from comments
The controller's setter was named set, which the boundary census could not
list without matching every Map.set, so a new OrcaRuntime mixin could write
sessions through it unseen. Rename it setForWorktree and census it.
Comments now describe state instead of citing plan stage ids.
* refactor(terminal): census writer references and trace refusals by callback
- traced() takes refusalOf instead of assuming an Error refusal, and only
mutate() sits in the try, so a span outcome of threw means the write threw.
- The boundary ratchet counts references, not just direct calls: non-null
calls, bracket keys, aliases, destructures, .call/.bind and parenthesized
callees all count; declared names and type positions do not.
- Census terminalSurfaceCloseMutation (boundary-only) and the partition sinks
setLocalWorkspaceSession / setHostWorkspaceSession.
* test(terminal): count writer uses in extends clauses and instantiations, skip type-only imports and local declarations
The census skipped ExpressionWithTypeArguments as a type, which also holds
`extends f(x)` and `x<T>` value expressions. Type-only import/export
specifiers and declared names (variables, parameters, accessors, enum
members) no longer count as uses. The audit fixture is listed in the table
instead of a separate exemption.
* test(terminal): count quoted and assignment-pattern destructures of layout writers
* test(terminal): count every mention of a layout writer except its definition
Telling definitions from uses per syntax kind kept missing nested and
for-of destructures. Exempt only the writer's own function or class-member
definition; any other mention (including object-literal keys) counts, so the
census errs toward a loud false alarm rather than a silent miss. Quoted names
count only in member-name position.
* test(terminal): count every string literal naming a layout writer
Member-name positions missed wrapped keys like store[('name')] and
store['name' as const]. Counting every string literal outside types is
shorter and errs toward a loud false alarm.
* test(terminal): exempt only class members and functions as writer definitions
Object-literal methods and accessors were exempt while equivalent arrow
properties counted; all object-literal keys now count alike.
* test(terminal): parse files with unicode escapes in the writer census
A name spelled with a \u escape never appears verbatim, so the text
prefilter skipped it.
* test(terminal): parse any file with an escape in the writer census
\x, identity and line-continuation escapes also decode to a writer name
without it appearing verbatim.
This commit is contained in:
@@ -9,7 +9,7 @@ import { rmSync, mkdtempSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { getDefaultWorkspaceSession } from '../shared/constants'
|
||||
import { findTerminalTabIdForLeaf } from './runtime/workspace-session-terminal-membership-authority'
|
||||
import { findTerminalTabIdForLeaf } from './persistence/terminal-topology/terminal-topology-membership'
|
||||
|
||||
import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures'
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Ratchet: outside `persistence/terminal-topology/`, only the files listed here may reference a
|
||||
* named layout writer. Blind to in-place mutation of the object `getWorkspaceSession` returns.
|
||||
*/
|
||||
import { resolve } from 'node:path'
|
||||
import ts from 'typescript-api'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { scanSourceTree } from '../../../shared/source-scan/source-tree-scan'
|
||||
|
||||
const MAIN_ROOT = resolve(__dirname, '../..')
|
||||
const BOUNDARY_DIR = 'persistence/terminal-topology/'
|
||||
|
||||
/**
|
||||
* Files (relative to `src/main`) outside the boundary referencing each writer; each routing change
|
||||
* deletes its own rows. Not listed: lifecycle writers (repo/worktree removal, identity rekey) and
|
||||
* SSH lease marks that drop a dead PTY's binding; they delete or rekey layout, never choose it.
|
||||
*/
|
||||
const ALLOWED_REFERENCES: Record<string, readonly string[]> = {
|
||||
terminalSurfaceCloseMutation: [],
|
||||
persistPtyBinding: [
|
||||
'ipc/pty/ipc/spawn-commit-persist.ts',
|
||||
'ipc/pty/pane/stable-owner.ts',
|
||||
'ipc/pty/runtime/spawn-commit.ts',
|
||||
'ssh/ssh-relay-session.ts'
|
||||
],
|
||||
// Several runtime files only check it exists, then write through setWorkspaceSessionForWorktree.
|
||||
setWorkspaceSession: [
|
||||
'ipc/pty/pane/stable-owner.ts',
|
||||
'ipc/session.ts',
|
||||
// Store-internal: patchWorkspaceSession -> setWorkspaceSession.
|
||||
'persistence/loading-store/session-snapshot-operations.ts',
|
||||
// Test support: seeds sessions for the acknowledged-tab retirement audit.
|
||||
'runtime/acknowledged-terminal-tab-retirement-fixture.ts',
|
||||
'runtime/client-hosted-browser-page-persistence.ts',
|
||||
'runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts',
|
||||
'runtime/orca-runtime-apply-mobile-session-tab-navigation.ts',
|
||||
'runtime/orca-runtime-attach-window.ts',
|
||||
'runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts',
|
||||
'runtime/orca-runtime-move-headless-mobile-session-tab.ts',
|
||||
'runtime/orca-runtime-persist-headless-session-tab-props.ts',
|
||||
'runtime/orca-runtime-persist-headless-terminal-title.ts',
|
||||
'runtime/orca-runtime-persist-terminal-surface-retirements.ts',
|
||||
'runtime/orca-runtime-pty-foreground-process-reads.ts',
|
||||
'runtime/orca-runtime-stop-terminals-for-worktree.ts',
|
||||
'runtime/runtime-legacy-worker-terminal-recovery-persistence.ts',
|
||||
'runtime/runtime-workspace-session-controller.ts'
|
||||
],
|
||||
// The partition sinks under setWorkspaceSession and stageWorkspaceSessionBeforeUnload.
|
||||
setLocalWorkspaceSession: ['persistence/loading-store/session-snapshot-operations.ts'],
|
||||
setHostWorkspaceSession: ['persistence/loading-store/session-snapshot-operations.ts'],
|
||||
// The runtime's session controller, reachable from every OrcaRuntime mixin.
|
||||
setForWorktree: ['runtime/orca-runtime-get-runtime-id.ts'],
|
||||
patchWorkspaceSession: ['ipc/session.ts'],
|
||||
stageWorkspaceSessionBeforeUnload: ['ipc/renderer-shutdown-checkpoint.ts'],
|
||||
setWorkspaceSessionForWorktree: [
|
||||
'runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts',
|
||||
'runtime/orca-runtime-apply-mobile-session-tab-navigation.ts',
|
||||
'runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts',
|
||||
'runtime/orca-runtime-move-headless-mobile-session-tab.ts',
|
||||
'runtime/orca-runtime-persist-headless-session-tab-props.ts',
|
||||
'runtime/orca-runtime-persist-headless-terminal-title.ts',
|
||||
'runtime/orca-runtime-pty-foreground-process-reads.ts'
|
||||
]
|
||||
}
|
||||
|
||||
/** A writer's own definition (a function or class member), as opposed to any other mention. */
|
||||
function isDefinitionName(node: ts.Node): boolean {
|
||||
const parent = node.parent
|
||||
return (
|
||||
(ts.isFunctionDeclaration(parent) ||
|
||||
(ts.isClassElement(parent) && ts.isClassLike(parent.parent))) &&
|
||||
parent.name === node
|
||||
)
|
||||
}
|
||||
|
||||
function isTypeOnlyImportOrExport(node: ts.Node): boolean {
|
||||
return (
|
||||
((ts.isImportSpecifier(node) || ts.isExportSpecifier(node)) && node.isTypeOnly) ||
|
||||
(ts.isImportClause(node) && node.isTypeOnly) ||
|
||||
(ts.isExportDeclaration(node) && node.isTypeOnly)
|
||||
)
|
||||
}
|
||||
|
||||
/** Any name or string literal except the writer's own definition; types are skipped. */
|
||||
function referencedName(node: ts.Node): string | undefined {
|
||||
if (ts.isIdentifier(node) || ts.isStringLiteralLike(node)) {
|
||||
return isDefinitionName(node) ? undefined : node.text
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function referencingFilesByWriter(): Map<string, Set<string>> {
|
||||
const writers = Object.keys(ALLOWED_REFERENCES)
|
||||
const references = new Map(writers.map((writer) => [writer, new Set<string>()]))
|
||||
for (const file of scanSourceTree(MAIN_ROOT)) {
|
||||
// Why prefilter: parsing every main-process file would dominate the test's budget. A matched
|
||||
// name appears verbatim in the text unless it is spelled with an escape.
|
||||
if (
|
||||
file.relativePath.startsWith(BOUNDARY_DIR) ||
|
||||
(!file.source.includes('\\') && !writers.some((writer) => file.source.includes(writer)))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const source = ts.createSourceFile(file.relativePath, file.source, ts.ScriptTarget.Latest, true)
|
||||
const visit = (node: ts.Node): void => {
|
||||
// Why: ExpressionWithTypeArguments is a type node but also holds `extends f(x)` and `x<T>`.
|
||||
const typeOnly =
|
||||
(ts.isTypeNode(node) && !ts.isExpressionWithTypeArguments(node)) ||
|
||||
ts.isInterfaceDeclaration(node) ||
|
||||
isTypeOnlyImportOrExport(node)
|
||||
if (typeOnly) {
|
||||
return
|
||||
}
|
||||
const name = referencedName(node)
|
||||
if (name !== undefined) {
|
||||
references.get(name)?.add(file.relativePath)
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
visit(source)
|
||||
}
|
||||
return references
|
||||
}
|
||||
|
||||
describe('terminal topology boundary ratchet', () => {
|
||||
const references = referencingFilesByWriter()
|
||||
|
||||
for (const [writer, allowed] of Object.entries(ALLOWED_REFERENCES)) {
|
||||
it(`only the boundary and listed files reference ${writer}`, () => {
|
||||
expect(references.get(writer)).toEqual(new Set(allowed))
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,152 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { TerminalSurfaceCloseTarget } from '../../../shared/terminal-surface-close-target'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
import { _resetTracerForTests, setActiveSink } from '../../observability/tracer'
|
||||
import type { TerminalSurfaceCloseCommit } from '../../runtime/terminal-surface-close'
|
||||
import { closeLeafOrTab } from './terminal-topology-commit'
|
||||
|
||||
// An ssh: partition, so a leaked host id or worktree path would show in the span.
|
||||
const HOST_ID: ExecutionHostId = 'ssh:target-1'
|
||||
const WORKTREE_ID = 'ssh-repo::/srv/app'
|
||||
const LEAF_1 = '11111111-1111-4111-8111-111111111111'
|
||||
const LEAF_2 = '22222222-2222-4222-8222-222222222222'
|
||||
const SPLIT_TAB = 'tab-split'
|
||||
const PINNED_TAB = 'tab-pinned'
|
||||
const CLOSED_TAB = 'tab-closed-earlier'
|
||||
const NOW = 1_700_000_000_000
|
||||
|
||||
function tab(id: string, ptyId: string, isPinned = false) {
|
||||
return {
|
||||
id,
|
||||
ptyId,
|
||||
worktreeId: WORKTREE_ID,
|
||||
title: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1,
|
||||
...(isPinned ? { isPinned } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function session(): WorkspaceSessionState {
|
||||
return {
|
||||
activeRepoId: 'ssh-repo',
|
||||
activeWorktreeId: WORKTREE_ID,
|
||||
activeTabId: SPLIT_TAB,
|
||||
tabsByWorktree: { [WORKTREE_ID]: [tab(SPLIT_TAB, 'pty-1'), tab(PINNED_TAB, 'pty-3', true)] },
|
||||
terminalLayoutsByTabId: {
|
||||
[SPLIT_TAB]: {
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: { type: 'leaf', leafId: LEAF_1 },
|
||||
second: { type: 'leaf', leafId: LEAF_2 }
|
||||
},
|
||||
activeLeafId: LEAF_1,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_1]: 'pty-1', [LEAF_2]: 'pty-2' }
|
||||
}
|
||||
},
|
||||
closedTerminalTabTombstonesByTabId: {
|
||||
[CLOSED_TAB]: { closedAt: NOW - 1000, worktreeId: WORKTREE_ID, reason: 'user' }
|
||||
},
|
||||
terminalTopologyRevisionByRepoId: { 'ssh-repo': 3 }
|
||||
}
|
||||
}
|
||||
|
||||
function commitFor(
|
||||
target: TerminalSurfaceCloseTarget,
|
||||
overrides: Partial<TerminalSurfaceCloseCommit> = {}
|
||||
): TerminalSurfaceCloseCommit {
|
||||
let current = session()
|
||||
return {
|
||||
worktreeId: WORKTREE_ID,
|
||||
target,
|
||||
options: {},
|
||||
requestedSession: current,
|
||||
ownerMatches: () => true,
|
||||
hostId: () => HOST_ID,
|
||||
getSession: () => current,
|
||||
setSession: (next) => {
|
||||
current = next
|
||||
},
|
||||
onClosed: () => {},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('persistence.terminal-topology span', () => {
|
||||
let records: { name: string; attributes: Record<string, unknown>; exit: unknown }[]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(NOW)
|
||||
records = []
|
||||
setActiveSink({
|
||||
push: (record) => {
|
||||
records.push(JSON.parse(JSON.stringify(record)))
|
||||
},
|
||||
flush: () => {},
|
||||
close: () => {}
|
||||
})
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
_resetTracerForTests()
|
||||
})
|
||||
|
||||
function attributesAfter(commit: TerminalSurfaceCloseCommit): Record<string, unknown> {
|
||||
closeLeafOrTab(commit)()
|
||||
expect(records).toHaveLength(1)
|
||||
expect(records[0].name).toBe('persistence.terminal-topology')
|
||||
return records[0].attributes
|
||||
}
|
||||
|
||||
it('records a committed pane close without ids', () => {
|
||||
expect(attributesAfter(commitFor({ kind: 'pane', tabId: SPLIT_TAB, leafId: LEAF_2 }))).toEqual({
|
||||
kind: 'persistence',
|
||||
'topology.kind': 'close_leaf',
|
||||
'topology.outcome': 'committed'
|
||||
})
|
||||
expect(JSON.stringify(records[0])).not.toMatch(/pty-|tab-split|ssh-repo|srv|target-1/)
|
||||
})
|
||||
|
||||
it('records a committed tab close', () => {
|
||||
expect(attributesAfter(commitFor({ kind: 'tab', tabId: SPLIT_TAB }))).toMatchObject({
|
||||
'topology.kind': 'close_tab',
|
||||
'topology.outcome': 'committed'
|
||||
})
|
||||
})
|
||||
|
||||
it('records a refusal with its reason code', () => {
|
||||
expect(attributesAfter(commitFor({ kind: 'tab', tabId: PINNED_TAB }))).toMatchObject({
|
||||
'topology.kind': 'close_tab',
|
||||
'topology.outcome': 'refused',
|
||||
'topology.refusal': 'terminal_tab_pinned'
|
||||
})
|
||||
})
|
||||
|
||||
it('records a close that changes nothing as a noop', () => {
|
||||
const echo = commitFor({ kind: 'tab', tabId: CLOSED_TAB }, { options: { allowMissing: true } })
|
||||
expect(attributesAfter(echo)).toMatchObject({ 'topology.outcome': 'noop' })
|
||||
})
|
||||
|
||||
it('records a thrown commit as a failed span and rethrows', () => {
|
||||
const mutation = closeLeafOrTab(
|
||||
commitFor(
|
||||
{ kind: 'tab', tabId: SPLIT_TAB },
|
||||
{
|
||||
getSession: () => {
|
||||
throw new Error('read failed')
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
expect(mutation).toThrow('read failed')
|
||||
expect(records).toHaveLength(1)
|
||||
expect(records[0].attributes).toMatchObject({ 'topology.outcome': 'threw' })
|
||||
expect(records[0].exit).toMatchObject({ _tag: 'Failure' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { startSpan } from '../../observability/tracer'
|
||||
import {
|
||||
terminalSurfaceCloseMutation,
|
||||
type TerminalSurfaceCloseCommit
|
||||
} from '../../runtime/terminal-surface-close'
|
||||
import type { DurableProfileStateMutation } from '../loading-store/store-runtime-state'
|
||||
|
||||
// The commit boundary for terminal layout (tabs, panes, pane-to-PTY bindings). Today it wraps only
|
||||
// the close, whose transform still lives in runtime/; the other writers move here later.
|
||||
|
||||
/** Bindings are not listed: `persistPtyBinding` already records `persistence.pty-binding`. */
|
||||
type TerminalTopologyCommitKind = 'close_leaf' | 'close_tab'
|
||||
|
||||
export function closeLeafOrTab(
|
||||
commit: TerminalSurfaceCloseCommit
|
||||
): () => DurableProfileStateMutation<Error | undefined> {
|
||||
return traced(
|
||||
commit.target.kind === 'pane' ? 'close_leaf' : 'close_tab',
|
||||
terminalSurfaceCloseMutation(commit),
|
||||
// Refusals are fixed reason codes, never ids.
|
||||
(refusal) => refusal?.message
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One `persistence.terminal-topology` span per commit, from admission to the in-memory write.
|
||||
* Attributes stay low-cardinality: no pane key, PTY id or path.
|
||||
*/
|
||||
function traced<T>(
|
||||
kind: TerminalTopologyCommitKind,
|
||||
mutate: () => DurableProfileStateMutation<T>,
|
||||
refusalOf: (value: T) => string | undefined
|
||||
): () => DurableProfileStateMutation<T> {
|
||||
return () => {
|
||||
const span = startSpan('persistence.terminal-topology', {
|
||||
attributes: { kind: 'persistence', 'topology.kind': kind }
|
||||
})
|
||||
let result: DurableProfileStateMutation<T>
|
||||
// Why only mutate(): `threw` must mean the write failed, never that tracing did.
|
||||
try {
|
||||
result = mutate()
|
||||
} catch (error) {
|
||||
span.setAttribute('topology.outcome', 'threw')
|
||||
span.fail(error instanceof Error ? error : String(error))
|
||||
throw error
|
||||
}
|
||||
const refusal = refusalOf(result.value)
|
||||
if (refusal !== undefined) {
|
||||
span.setAttribute('topology.outcome', 'refused')
|
||||
span.setAttribute('topology.refusal', refusal)
|
||||
} else {
|
||||
span.setAttribute('topology.outcome', result.persist === false ? 'noop' : 'committed')
|
||||
}
|
||||
span.end()
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id'
|
||||
import { layoutContainsLeafId } from '../restoring-sessions/terminal-layout-normalization'
|
||||
|
||||
export function advanceTerminalTopologyRevision(
|
||||
session: WorkspaceSessionState,
|
||||
worktreeId: string
|
||||
): WorkspaceSessionState {
|
||||
const repoId = getRepoIdFromWorktreeId(worktreeId)
|
||||
return {
|
||||
...session,
|
||||
terminalTopologyRevisionByRepoId: {
|
||||
...session.terminalTopologyRevisionByRepoId,
|
||||
[repoId]: (session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0) + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab whose live layout holds this leaf. Only the leaf half of a pane key is remint-stable —
|
||||
* `detachTerminalPaneToTab` moves a live pane into a new tab, so a stored tabId names the tab the
|
||||
* pane left. Callers fencing on location must resolve it here rather than trust a frozen tabId.
|
||||
*
|
||||
* Stateless on purpose: writers graft leaves by assigning into a layout that is already inside the
|
||||
* layouts record, so any cache here would need a revalidation key that is itself O(tabs) per read —
|
||||
* the same cost as this walk, with a staleness invariant to keep. `Object.keys` over a guarded
|
||||
* `for...in` is deliberate too: the key array is cheaper than a `hasOwn` call per tab (measured).
|
||||
*/
|
||||
export function findTerminalTabIdForLeaf(
|
||||
session: WorkspaceSessionState | undefined,
|
||||
leafId: string
|
||||
): string | undefined {
|
||||
const layouts = session?.terminalLayoutsByTabId
|
||||
if (!layouts) {
|
||||
return undefined
|
||||
}
|
||||
for (const tabId of Object.keys(layouts)) {
|
||||
if (layoutContainsLeafId(layouts[tabId]?.root ?? null, leafId)) {
|
||||
return tabId
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function hasHostAuthoritativeTerminalMembership(
|
||||
session: WorkspaceSessionState | undefined,
|
||||
worktreeId: string
|
||||
): boolean {
|
||||
const repoId = getRepoIdFromWorktreeId(worktreeId)
|
||||
return (
|
||||
(session?.terminalTopologyRevisionByRepoId?.[repoId] ?? 0) > 0 ||
|
||||
Object.values(session?.terminalSurfaceTombstonesByPaneKey ?? {}).some(
|
||||
(tombstone) => tombstone.worktreeId === worktreeId
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { Store } from '../persistence/loading-store/store'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { buildHeadlessMobileSessionTerminalTabs } from './mobile-session-terminal-projection'
|
||||
import { setRuntimeDesktopSurface } from './runtime-desktop-surface'
|
||||
import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority'
|
||||
import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership'
|
||||
|
||||
export const ACK_WORKTREE = 'repo1::/tmp/worktree'
|
||||
export const ACK_TAB = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
ACK_WORKTREE,
|
||||
createAcknowledgedTabRetirementFixture
|
||||
} from './acknowledged-terminal-tab-retirement-fixture'
|
||||
import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority'
|
||||
import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership'
|
||||
import { delegatedMobileSessionTabClose } from './mobile-session-tab-close-outcome'
|
||||
|
||||
const fixtures: ReturnType<typeof createAcknowledgedTabRetirementFixture>[] = []
|
||||
|
||||
@@ -6,10 +6,8 @@ import {
|
||||
retireLeavesFromTerminalLayout,
|
||||
type RetiredTerminalSurface
|
||||
} from './mobile-session-terminal-retirement'
|
||||
import {
|
||||
advanceTerminalTopologyRevision,
|
||||
rebaseWorkspaceSessionTerminalMembership
|
||||
} from './workspace-session-terminal-membership-authority'
|
||||
import { rebaseWorkspaceSessionTerminalMembership } from './workspace-session-terminal-membership-authority'
|
||||
import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership'
|
||||
|
||||
function visibleTypeForContentType(
|
||||
contentType: string | undefined
|
||||
|
||||
@@ -12,7 +12,6 @@ import { holdAgentSessionInventory } from './structured-agent-session-inventory-
|
||||
import type { Tab } from '../../shared/tab-types'
|
||||
import {
|
||||
resolveTerminalCloseTarget,
|
||||
terminalSurfaceCloseMutation,
|
||||
type PaneCloseResolution,
|
||||
type RendererTerminalClose,
|
||||
type TerminalSurfaceCloseOptions
|
||||
@@ -25,6 +24,7 @@ import { retireTerminalSurfacesFromSnapshot } from './mobile-session-terminal-re
|
||||
import type { PtyControllerInventory } from './runtime-pty-controller-contract'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants'
|
||||
import { captureAcknowledgedTerminalTabRetirement } from './workspace-session-terminal-tab-retirement-identity'
|
||||
import { closeLeafOrTab } from '../persistence/terminal-topology/terminal-topology-commit'
|
||||
|
||||
export class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs extends OrcaRuntimeWithPersistTerminalSurfaceRetirements {
|
||||
// Why: headless serve backs browser panes with offscreen WebContents that live
|
||||
@@ -131,7 +131,7 @@ export class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs extends OrcaRu
|
||||
let refusal: Error | undefined
|
||||
try {
|
||||
refusal = await store.runDurableMutation(
|
||||
terminalSurfaceCloseMutation({
|
||||
closeLeafOrTab({
|
||||
worktreeId,
|
||||
target,
|
||||
options,
|
||||
|
||||
@@ -188,7 +188,7 @@ export class OrcaRuntimeWithGetRuntimeId extends OrcaRuntimeWithHasExactPersiste
|
||||
worktreeId: string,
|
||||
session: WorkspaceSessionState
|
||||
): void {
|
||||
this.workspaceSessions.set(worktreeId, session)
|
||||
this.workspaceSessions.setForWorktree(worktreeId, session)
|
||||
}
|
||||
|
||||
protected getKnownWorkspaceSessionWorktreeIds(): Set<string> {
|
||||
|
||||
@@ -15,7 +15,7 @@ import { sshRemotePtyLeaseAllowsReattach } from '../../shared/ssh-types'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
import type { RuntimeStore } from './runtime-store-contract'
|
||||
import { SSH_PANE_RECOVERY_GRACE_MS } from './orca-runtime-core'
|
||||
import { findTerminalTabIdForLeaf } from './workspace-session-terminal-membership-authority'
|
||||
import { findTerminalTabIdForLeaf } from '../persistence/terminal-topology/terminal-topology-membership'
|
||||
|
||||
export class OrcaRuntimeWithReconcileHeadlessMobileSessionBrowserTabs extends OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspaceSession {
|
||||
// Why: keep an existing snapshot's browser tabs in sync with the live bridge
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
WORKTREE_PATH,
|
||||
makeSession
|
||||
} from './__fixtures__/orca-runtime-terminal-close-continuity-fixtures'
|
||||
import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority'
|
||||
import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership'
|
||||
|
||||
const SSH_REPO_ID = 'ssh-repo'
|
||||
const SSH_HOST_ID = 'ssh:target-1'
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
retireTerminalSurfaceFromPersistence,
|
||||
sanitizeWorkspaceSessionTerminalRetirements
|
||||
} from './mobile-session-terminal-persistence-retirement'
|
||||
import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority'
|
||||
import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership'
|
||||
|
||||
const splitLayout = {
|
||||
root: {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { OrcaRuntimeWithPublishPtyBackedMobileSessionTerminal } from './orca-runtime-publish-pty-backed-mobile-session-terminal'
|
||||
import { getRepoIdFromWorktreeId } from '../../shared/worktree/id'
|
||||
import { parsePaneKey } from '../../shared/stable-pane-id'
|
||||
import { hasHostAuthoritativeTerminalMembership } from './workspace-session-terminal-membership-authority'
|
||||
import { hasHostAuthoritativeTerminalMembership } from '../persistence/terminal-topology/terminal-topology-membership'
|
||||
import type {
|
||||
RuntimeMobileSessionTabsSnapshot,
|
||||
RuntimeSyncedLeaf
|
||||
|
||||
@@ -5,7 +5,7 @@ import { collectPersistedTerminalLeafIds } from './mobile-session-layout-project
|
||||
import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
|
||||
import { mergeTerminalOrphanGroupLayout } from './terminal-orphan-topology'
|
||||
import { canonicalizeTerminalSessionWorktreeId } from './workspace-session-worktree-id'
|
||||
import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority'
|
||||
import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership'
|
||||
|
||||
type Claim = RuntimeTerminalOrphanAdoptionRequest['claims'][number]
|
||||
type Topology = NonNullable<RuntimeTerminalOrphanAdoptionRequest['topology']>
|
||||
|
||||
@@ -116,7 +116,7 @@ export class RuntimeWorkspaceSessionController {
|
||||
: null
|
||||
}
|
||||
|
||||
set(worktreeId: string, session: WorkspaceSessionState): void {
|
||||
setForWorktree(worktreeId: string, session: WorkspaceSessionState): void {
|
||||
this.deps.getStore()?.setWorkspaceSession?.(session, this.getHostId(worktreeId))
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
WORKTREE_PATH,
|
||||
makeSession
|
||||
} from './__fixtures__/orca-runtime-terminal-close-continuity-state-fixture'
|
||||
import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority'
|
||||
import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership'
|
||||
|
||||
const REPLACEMENT_PTY_ID = 'pty-close-continuity-replacement'
|
||||
const REPLACEMENT_INCARNATION_ID = '77777777-7777-4777-8777-777777777777'
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { TerminalLayoutSnapshot } from '../../shared/terminal-tab-types'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
import { findTerminalTabIdForLeaf } from './workspace-session-terminal-membership-authority'
|
||||
import { findTerminalTabIdForLeaf } from '../persistence/terminal-topology/terminal-topology-membership'
|
||||
|
||||
function layout(...leafIds: string[]): TerminalLayoutSnapshot {
|
||||
let root = { type: 'leaf' as const, leafId: leafIds[0] }
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type WorkspaceSessionTerminalTabCloseResult
|
||||
} from '../../shared/workspace-session-terminal-tab-close'
|
||||
import { retireTerminalSurfaceFromPersistence } from './mobile-session-terminal-persistence-retirement'
|
||||
import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority'
|
||||
import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership'
|
||||
import type { DurableProfileStateMutation } from '../persistence/loading-store/store-runtime-state'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
} from '../../shared/terminal-tab-types'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
import { getRepoIdFromWorktreeId } from '../../shared/worktree/id'
|
||||
import { layoutContainsLeafId } from '../persistence/restoring-sessions/terminal-layout-normalization'
|
||||
import { pruneTabGroupLayoutAfterRetirement } from './mobile-session-terminal-retirement'
|
||||
|
||||
function collectLeafIds(node: TerminalPaneLayoutNode | null, ids: Set<string>): void {
|
||||
@@ -148,59 +147,6 @@ function rebaseIncarnationBindings(
|
||||
return Object.keys(retained).length > 0 ? retained : undefined
|
||||
}
|
||||
|
||||
export function advanceTerminalTopologyRevision(
|
||||
session: WorkspaceSessionState,
|
||||
worktreeId: string
|
||||
): WorkspaceSessionState {
|
||||
const repoId = getRepoIdFromWorktreeId(worktreeId)
|
||||
return {
|
||||
...session,
|
||||
terminalTopologyRevisionByRepoId: {
|
||||
...session.terminalTopologyRevisionByRepoId,
|
||||
[repoId]: (session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0) + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab whose live layout holds this leaf. Only the leaf half of a pane key is remint-stable —
|
||||
* `detachTerminalPaneToTab` moves a live pane into a new tab, so a stored tabId names the tab the
|
||||
* pane left. Callers fencing on location must resolve it here rather than trust a frozen tabId.
|
||||
*
|
||||
* Stateless on purpose: writers graft leaves by assigning into a layout that is already inside the
|
||||
* layouts record, so any cache here would need a revalidation key that is itself O(tabs) per read —
|
||||
* the same cost as this walk, with a staleness invariant to keep. `Object.keys` over a guarded
|
||||
* `for...in` is deliberate too: the key array is cheaper than a `hasOwn` call per tab (measured).
|
||||
*/
|
||||
export function findTerminalTabIdForLeaf(
|
||||
session: WorkspaceSessionState | undefined,
|
||||
leafId: string
|
||||
): string | undefined {
|
||||
const layouts = session?.terminalLayoutsByTabId
|
||||
if (!layouts) {
|
||||
return undefined
|
||||
}
|
||||
for (const tabId of Object.keys(layouts)) {
|
||||
if (layoutContainsLeafId(layouts[tabId]?.root ?? null, leafId)) {
|
||||
return tabId
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function hasHostAuthoritativeTerminalMembership(
|
||||
session: WorkspaceSessionState | undefined,
|
||||
worktreeId: string
|
||||
): boolean {
|
||||
const repoId = getRepoIdFromWorktreeId(worktreeId)
|
||||
return (
|
||||
(session?.terminalTopologyRevisionByRepoId?.[repoId] ?? 0) > 0 ||
|
||||
Object.values(session?.terminalSurfaceTombstonesByPaneKey ?? {}).some(
|
||||
(tombstone) => tombstone.worktreeId === worktreeId
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function rebaseWorkspaceSessionTerminalMembership(
|
||||
incoming: WorkspaceSessionState,
|
||||
prior: WorkspaceSessionState | undefined
|
||||
|
||||
@@ -183,7 +183,7 @@ describe('STA-3077: an SSH reattach binds panes without grafting them back', ()
|
||||
)
|
||||
|
||||
const { findTerminalTabIdForLeaf } =
|
||||
await import('./runtime/workspace-session-terminal-membership-authority')
|
||||
await import('./persistence/terminal-topology/terminal-topology-membership')
|
||||
// The relay resolves the tab from the live layout before binding, exactly as the production
|
||||
// path does; forwarding the lease's frozen `TAB` here is what would strand the pane.
|
||||
const resolvedTabId = findTerminalTabIdForLeaf(store.getWorkspaceSession(), TEST_LEAF_1)
|
||||
|
||||
@@ -94,7 +94,7 @@ import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import {
|
||||
findTerminalTabIdForLeaf,
|
||||
hasHostAuthoritativeTerminalMembership
|
||||
} from '../runtime/workspace-session-terminal-membership-authority'
|
||||
} from '../persistence/terminal-topology/terminal-topology-membership'
|
||||
import { DEFAULT_PTY_SOURCE_WINDOW_SU } from '../../shared/pty-source-credit-contract'
|
||||
import { PTY_CONSUMER_STALE_OWNER_RECOVERY_ERROR } from '../../shared/pty-consumer-session'
|
||||
import {
|
||||
|
||||
Reference in New Issue
Block a user