mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 00:02:41 +00:00
test(wire): pair session-tabs retirement proof and worktree identity across builds
Two cross-version surfaces with no prior coverage: the terminal suite covers the binary stream and the agent-session suite covers agentSession.*, neither reaches session-tabs or persisted worktree identity. Both pin v1.4.199 as the pre-stack build and carry an anti-vacuous-pass oracle asserting the two builds resolved to distinct module instances, so a pairing cannot pass by being same-version in disguise.
This commit is contained in:
+253
@@ -0,0 +1,253 @@
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout'
|
||||
|
||||
/**
|
||||
* The session-tabs retirement-proof surface, paired across two builds.
|
||||
*
|
||||
* `cross-version-terminal-wire` covers the terminal binary stream and
|
||||
* `cross-version-agent-session-wire` covers `agentSession.*`; neither reaches the
|
||||
* session-tabs frame, which is where a paired client learns that a mirrored terminal
|
||||
* is gone. This pairs the two halves of that surface across versions:
|
||||
*
|
||||
* - the HOST half changed — a host now ships a retirement proof on its own frame when
|
||||
* no surface removal carries one (`attachRetirementProofsToSnapshot`);
|
||||
* - the CLIENT half did not change, which this asserts by running both builds' ledger
|
||||
* over the same frames rather than by reading the diff.
|
||||
*
|
||||
* The claim under test is the one written into the change: that this is Rule 1, because
|
||||
* `retiredTerminalSurfaces` is an existing optional field on an existing path. Rule 3's
|
||||
* fourth bullet says "a frame the host ... starts sending, on an existing path" is a wire
|
||||
* change even with no codec movement, so the claim is checked against an actual old
|
||||
* build rather than accepted.
|
||||
*
|
||||
* The pre-stack ref is pinned rather than derived: this contract needs a release from
|
||||
* before the proof-only frame existed, which is the fallback
|
||||
* docs/reference/remote-wire-compatibility.md sanctions for exactly this case.
|
||||
*/
|
||||
const PRE_STACK_REF = 'v1.4.199'
|
||||
|
||||
const SUITE_TIMEOUT_MS = 180_000
|
||||
|
||||
const WORKTREE_ID = 'repo::/worktree'
|
||||
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const PARENT_TAB_ID = 'tab'
|
||||
const PTY_ID = 'pty-left'
|
||||
const TERMINAL_HANDLE = 'remote:terminal-handle-1'
|
||||
|
||||
type Snapshot = {
|
||||
worktree: string
|
||||
publicationEpoch: string
|
||||
snapshotVersion: number
|
||||
activeGroupId: null
|
||||
activeTabId: string | null
|
||||
activeTabType: string | null
|
||||
tabs: Record<string, unknown>[]
|
||||
retiredTerminalSurfaces?: Record<string, unknown>[]
|
||||
}
|
||||
|
||||
type ProofLedger = {
|
||||
appendRetiredTerminalSurfaceProofs: (
|
||||
existing: readonly Record<string, unknown>[] | undefined,
|
||||
retired: readonly Record<string, unknown>[]
|
||||
) => Record<string, unknown>[]
|
||||
dropRetirementProofsForLiveSurfaces: (
|
||||
retired: readonly Record<string, unknown>[],
|
||||
tabs: readonly Record<string, unknown>[]
|
||||
) => Record<string, unknown>[]
|
||||
}
|
||||
|
||||
type HostProofPublisher = {
|
||||
attachRetirementProofsToSnapshot?: (
|
||||
snapshot: Snapshot,
|
||||
proofs: readonly Record<string, unknown>[]
|
||||
) => Snapshot | null
|
||||
retireTerminalSurfacesFromSnapshot: (
|
||||
args: Record<string, unknown>
|
||||
) => { snapshot: Snapshot } | null
|
||||
}
|
||||
|
||||
type Build = {
|
||||
label: string
|
||||
ledger: ProofLedger
|
||||
host: HostProofPublisher
|
||||
}
|
||||
|
||||
/** The surface as the host still holds it, before the close's two halves land. */
|
||||
function liveSnapshot(): Snapshot {
|
||||
return {
|
||||
worktree: WORKTREE_ID,
|
||||
publicationEpoch: 'renderer',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: null,
|
||||
activeTabId: `tab::${LEAF_ID}`,
|
||||
activeTabType: 'terminal',
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: `tab::${LEAF_ID}`,
|
||||
parentTabId: PARENT_TAB_ID,
|
||||
leafId: LEAF_ID,
|
||||
ptyId: PTY_ID,
|
||||
title: 'Left',
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The renderer-first ordering, which is the one users hit: the close transaction already
|
||||
* de-persisted the surface and republished without it, so the PTY exit that follows finds
|
||||
* nothing left for persistence to accept.
|
||||
*/
|
||||
function snapshotAfterRendererRepublished(): Snapshot {
|
||||
return { ...liveSnapshot(), snapshotVersion: 2, tabs: [], activeTabId: null, activeTabType: null }
|
||||
}
|
||||
|
||||
function exitProof(): Record<string, unknown> {
|
||||
return {
|
||||
parentTabId: PARENT_TAB_ID,
|
||||
leafId: LEAF_ID,
|
||||
ptyId: PTY_ID,
|
||||
terminal: TERMINAL_HANDLE,
|
||||
incarnationId: 'inc-1'
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBuild(ref: string | null): Promise<Build> {
|
||||
if (ref === null) {
|
||||
const [ledger, proof, retirement] = await Promise.all([
|
||||
import('../../../src/shared/terminal-retirement-proof-ledger'),
|
||||
import('../../../src/main/runtime/mobile-session-terminal-retirement-proof'),
|
||||
import('../../../src/main/runtime/mobile-session-terminal-retirement')
|
||||
])
|
||||
return {
|
||||
label: 'stack',
|
||||
ledger: ledger as unknown as ProofLedger,
|
||||
host: { ...proof, ...retirement } as unknown as HostProofPublisher
|
||||
}
|
||||
}
|
||||
const checkout = await materializeReleaseCheckout(ref)
|
||||
const [ledger, proof, retirement] = await Promise.all([
|
||||
importReleaseCheckoutModule(checkout, 'src/shared/terminal-retirement-proof-ledger.ts'),
|
||||
importReleaseCheckoutModule(
|
||||
checkout,
|
||||
'src/main/runtime/mobile-session-terminal-retirement-proof.ts'
|
||||
),
|
||||
importReleaseCheckoutModule(checkout, 'src/main/runtime/mobile-session-terminal-retirement.ts')
|
||||
])
|
||||
return {
|
||||
label: ref,
|
||||
ledger: ledger as unknown as ProofLedger,
|
||||
host: { ...proof, ...retirement } as unknown as HostProofPublisher
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a host of this build publishes when the PTY exit lands after the renderer already
|
||||
* dropped the surface. `null` means it publishes nothing, which is the stuck-pane defect.
|
||||
*/
|
||||
function hostPublishesOnExit(build: Build, snapshot: Snapshot): Snapshot | null {
|
||||
const attach = build.host.attachRetirementProofsToSnapshot
|
||||
if (typeof attach !== 'function') {
|
||||
// Derived, not written down: this build's only route to a proof is the removal helper,
|
||||
// and with nothing left to remove it declines to produce a frame.
|
||||
return (
|
||||
build.host.retireTerminalSurfacesFromSnapshot({
|
||||
snapshot,
|
||||
ptyId: PTY_ID,
|
||||
exactSurfaces: [],
|
||||
exactOnly: true,
|
||||
retirementProofs: [exitProof()]
|
||||
})?.snapshot ?? null
|
||||
)
|
||||
}
|
||||
return attach(snapshot, [exitProof()])
|
||||
}
|
||||
|
||||
/** What this build's client retains after the host frame, i.e. the evidence it can act on. */
|
||||
function clientRetains(build: Build, frame: Snapshot | null): Record<string, unknown>[] {
|
||||
if (frame === null) {
|
||||
return []
|
||||
}
|
||||
return build.ledger.dropRetirementProofsForLiveSurfaces(
|
||||
build.ledger.appendRetiredTerminalSurfaceProofs(undefined, frame.retiredTerminalSurfaces ?? []),
|
||||
frame.tabs
|
||||
)
|
||||
}
|
||||
|
||||
let preStack: Build
|
||||
let stack: Build
|
||||
|
||||
beforeAll(async () => {
|
||||
;[preStack, stack] = await Promise.all([loadBuild(PRE_STACK_REF), loadBuild(null)])
|
||||
}, SUITE_TIMEOUT_MS)
|
||||
|
||||
describe('cross-version session-tabs retirement proof', () => {
|
||||
it('pairs the stack against a real pre-stack release', () => {
|
||||
expect(preStack.label).toBe(PRE_STACK_REF)
|
||||
expect(typeof preStack.ledger.dropRetirementProofsForLiveSurfaces).toBe('function')
|
||||
expect(typeof stack.ledger.dropRetirementProofsForLiveSurfaces).toBe('function')
|
||||
// The anti-vacuous-pass oracle. Two builds that resolved to one module would make every
|
||||
// pairing below a same-version run wearing a skew label, and all of them would pass.
|
||||
expect(preStack.ledger).not.toBe(stack.ledger)
|
||||
expect(preStack.ledger.dropRetirementProofsForLiveSurfaces).not.toBe(
|
||||
stack.ledger.dropRetirementProofsForLiveSurfaces
|
||||
)
|
||||
// Load-bearing for reading the old-host cells: they mean "this release cannot publish a
|
||||
// proof-only frame", not "the helper happened to decline". Safe to state against a pinned
|
||||
// legacy ref, which is what PRE_STACK_REF is.
|
||||
expect(preStack.host.attachRetirementProofsToSnapshot).toBeUndefined()
|
||||
expect(typeof stack.host.attachRetirementProofsToSnapshot).toBe('function')
|
||||
})
|
||||
|
||||
it('old host against old client publishes no proof on the renderer-first close (the defect)', () => {
|
||||
const frame = hostPublishesOnExit(preStack, snapshotAfterRendererRepublished())
|
||||
expect(frame).toBeNull()
|
||||
expect(clientRetains(preStack, frame)).toEqual([])
|
||||
})
|
||||
|
||||
it('new host against new client publishes a proof the client retains (the fix)', () => {
|
||||
const frame = hostPublishesOnExit(stack, snapshotAfterRendererRepublished())
|
||||
expect(frame).not.toBeNull()
|
||||
expect(clientRetains(stack, frame)).toEqual([exitProof()])
|
||||
})
|
||||
|
||||
it('new host against OLD client: the old client acts on the proof-only frame', () => {
|
||||
const frame = hostPublishesOnExit(stack, snapshotAfterRendererRepublished())
|
||||
expect(frame).not.toBeNull()
|
||||
// The claim under test. An old client that cannot act on this frame would leave the
|
||||
// dead pane in its tab bar exactly as before the fix.
|
||||
expect(clientRetains(preStack, frame)).toEqual([exitProof()])
|
||||
})
|
||||
|
||||
it('new host bumps snapshotVersion so a version-gating old client accepts the frame', () => {
|
||||
const before = snapshotAfterRendererRepublished()
|
||||
const frame = hostPublishesOnExit(stack, before)
|
||||
// A client that drops a frame whose version did not advance would silently ignore the
|
||||
// proof; this is what makes the proof-only frame reachable at all.
|
||||
expect(frame?.snapshotVersion).toBeGreaterThan(before.snapshotVersion)
|
||||
})
|
||||
|
||||
it('old host against NEW client degrades to the two-inventory route, with no crash', () => {
|
||||
const frame = hostPublishesOnExit(preStack, snapshotAfterRendererRepublished())
|
||||
expect(frame).toBeNull()
|
||||
expect(clientRetains(stack, frame)).toEqual([])
|
||||
})
|
||||
|
||||
it('both builds drop a proof whose surface is published live again, identically', () => {
|
||||
const stillLive = liveSnapshot()
|
||||
const proofs = [exitProof()]
|
||||
// Rule 3 hazard: a proof naming a surface the host is still publishing must not retire
|
||||
// it. Both builds must agree, or a skewed pairing retires a live pane.
|
||||
expect(preStack.ledger.dropRetirementProofsForLiveSurfaces(proofs, stillLive.tabs)).toEqual([])
|
||||
expect(stack.ledger.dropRetirementProofsForLiveSurfaces(proofs, stillLive.tabs)).toEqual([])
|
||||
})
|
||||
|
||||
it('re-delivering the same exit does not fan out a second frame', () => {
|
||||
const first = hostPublishesOnExit(stack, snapshotAfterRendererRepublished())
|
||||
expect(first).not.toBeNull()
|
||||
// A version bump carrying nothing new would wake every paired client for no reason.
|
||||
expect(hostPublishesOnExit(stack, first as Snapshot)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,154 @@
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout'
|
||||
|
||||
/**
|
||||
* The downgrade direction for persisted worktree identity.
|
||||
*
|
||||
* Upgrade is the easy direction. The risk PR #19955 records is the other one: a user runs a new
|
||||
* build, it writes durable state, then they roll back. State the new build wrote must stay
|
||||
* readable by the old one.
|
||||
*
|
||||
* The stack widens `migrateWorktreeIdentity` to repoint the `worktreeId` INSIDE session rows the
|
||||
* pre-stack build leaves pointing at the old id. A renamed worktree therefore leaves different
|
||||
* bytes on disk depending on which build did the rename, with no wire change anywhere — Rule 3's
|
||||
* shape applied to persistence, which is why it is measured here rather than reasoned about.
|
||||
*/
|
||||
const PRE_STACK_REF = 'v1.4.199'
|
||||
const SUITE_TIMEOUT_MS = 180_000
|
||||
|
||||
const OLD_ID = 'repo::/worktrees/before'
|
||||
const NEW_ID = 'repo::/worktrees/after'
|
||||
const THIRD_ID = 'repo::/worktrees/third'
|
||||
const PANE_KEY = 'pane-1'
|
||||
|
||||
type Migrate = (state: Record<string, unknown>, oldId: string, newId: string) => boolean
|
||||
type Row = { worktreeId?: string }
|
||||
|
||||
function sessionWithRows(): Record<string, unknown> {
|
||||
return {
|
||||
tabsByWorktree: { [OLD_ID]: [] },
|
||||
sleepingAgentSessionsByPaneKey: { [PANE_KEY]: { worktreeId: OLD_ID, agent: 'claude' } },
|
||||
terminalSurfaceTombstonesByPaneKey: { [PANE_KEY]: { worktreeId: OLD_ID, retiredAt: 1 } },
|
||||
closedTerminalTabTombstonesByTabId: { tab: { worktreeId: OLD_ID, closedAt: 1 } },
|
||||
clientHostedBrowserCloseIntentsByEnvironment: {
|
||||
env: [{ worktreeId: OLD_ID, url: 'https://example.test' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function persistedStateAfterRename(): Record<string, unknown> {
|
||||
return {
|
||||
worktreeMeta: { [OLD_ID]: { createdAt: 1 } },
|
||||
worktreeLineageById: {},
|
||||
workspaceLineageByChildKey: {},
|
||||
workspaceSession: sessionWithRows(),
|
||||
workspaceSessionsByHostId: {},
|
||||
mobileClientTabSelectionsByDeviceId: {},
|
||||
ui: { showDotfilesByWorktree: {} }
|
||||
}
|
||||
}
|
||||
|
||||
/** The `worktreeId` each row kind names after a migration, which is what downgrade turns on. */
|
||||
function rowsById(state: Record<string, unknown>): Record<string, string | undefined> {
|
||||
const session = state.workspaceSession as Record<string, unknown>
|
||||
const record = (field: string, key: string): string | undefined =>
|
||||
(session[field] as Record<string, Row> | undefined)?.[key]?.worktreeId
|
||||
return {
|
||||
sleepingAgentSessionsByPaneKey: record('sleepingAgentSessionsByPaneKey', PANE_KEY),
|
||||
terminalSurfaceTombstonesByPaneKey: record('terminalSurfaceTombstonesByPaneKey', PANE_KEY),
|
||||
closedTerminalTabTombstonesByTabId: record('closedTerminalTabTombstonesByTabId', 'tab'),
|
||||
clientHostedBrowserCloseIntentsByEnvironment: (
|
||||
session.clientHostedBrowserCloseIntentsByEnvironment as Record<string, Row[]> | undefined
|
||||
)?.env?.[0]?.worktreeId
|
||||
}
|
||||
}
|
||||
|
||||
let preStackMigrate: Migrate
|
||||
let stackMigrate: Migrate
|
||||
|
||||
beforeAll(async () => {
|
||||
const checkout = await materializeReleaseCheckout(PRE_STACK_REF)
|
||||
const [oldModule, newModule] = await Promise.all([
|
||||
importReleaseCheckoutModule(
|
||||
checkout,
|
||||
'src/main/persistence/tracking-repos/worktree-identity-migration.ts'
|
||||
),
|
||||
import('../../../src/main/persistence/tracking-repos/worktree-identity-migration')
|
||||
])
|
||||
preStackMigrate = oldModule.migrateWorktreeIdentity as Migrate
|
||||
stackMigrate = newModule.migrateWorktreeIdentity as Migrate
|
||||
}, SUITE_TIMEOUT_MS)
|
||||
|
||||
describe('cross-version worktree identity downgrade', () => {
|
||||
it('pairs two real builds', () => {
|
||||
expect(typeof preStackMigrate).toBe('function')
|
||||
expect(typeof stackMigrate).toBe('function')
|
||||
// Anti-vacuous-pass oracle: one module resolved twice would make every cell same-version.
|
||||
expect(preStackMigrate).not.toBe(stackMigrate)
|
||||
})
|
||||
|
||||
it('the pre-stack build repoints two of the four row kinds, and strands two', () => {
|
||||
const state = persistedStateAfterRename()
|
||||
expect(preStackMigrate(state, OLD_ID, NEW_ID)).toBe(true)
|
||||
// Measured, not assumed: an earlier draft of this suite asserted the old build repointed
|
||||
// nothing at all, and the probe that produced these four values is what corrected it.
|
||||
expect(rowsById(state)).toEqual({
|
||||
sleepingAgentSessionsByPaneKey: NEW_ID,
|
||||
terminalSurfaceTombstonesByPaneKey: NEW_ID,
|
||||
closedTerminalTabTombstonesByTabId: OLD_ID,
|
||||
clientHostedBrowserCloseIntentsByEnvironment: OLD_ID
|
||||
})
|
||||
})
|
||||
|
||||
it('the stack repoints all four', () => {
|
||||
const state = persistedStateAfterRename()
|
||||
expect(stackMigrate(state, OLD_ID, NEW_ID)).toBe(true)
|
||||
expect(rowsById(state)).toEqual({
|
||||
sleepingAgentSessionsByPaneKey: NEW_ID,
|
||||
terminalSurfaceTombstonesByPaneKey: NEW_ID,
|
||||
closedTerminalTabTombstonesByTabId: NEW_ID,
|
||||
clientHostedBrowserCloseIntentsByEnvironment: NEW_ID
|
||||
})
|
||||
})
|
||||
|
||||
it('DOWNGRADE: the old build reads new-build state without loss or throw', () => {
|
||||
const state = persistedStateAfterRename()
|
||||
stackMigrate(state, OLD_ID, NEW_ID)
|
||||
// The rolled-back build renames again over state the new build wrote. Nothing it does not
|
||||
// understand may throw, and no row may vanish.
|
||||
expect(() => preStackMigrate(state, NEW_ID, THIRD_ID)).not.toThrow()
|
||||
expect(rowsById(state)).toEqual({
|
||||
sleepingAgentSessionsByPaneKey: THIRD_ID,
|
||||
terminalSurfaceTombstonesByPaneKey: THIRD_ID,
|
||||
// The two this build cannot repoint stay where the NEW build put them — stale, but present,
|
||||
// and no worse than this build's own renames already leave them. That is the #19955 check:
|
||||
// new-build state does not break the old build.
|
||||
closedTerminalTabTombstonesByTabId: NEW_ID,
|
||||
clientHostedBrowserCloseIntentsByEnvironment: NEW_ID
|
||||
})
|
||||
})
|
||||
|
||||
it('UPGRADE: the stack inherits, and does not resurrect, rows an old build stranded', () => {
|
||||
const state = persistedStateAfterRename()
|
||||
preStackMigrate(state, OLD_ID, NEW_ID)
|
||||
stackMigrate(state, NEW_ID, THIRD_ID)
|
||||
expect(rowsById(state)).toEqual({
|
||||
sleepingAgentSessionsByPaneKey: THIRD_ID,
|
||||
terminalSurfaceTombstonesByPaneKey: THIRD_ID,
|
||||
// Still on the id the old build stranded them under: the stack repoints from the id it is
|
||||
// renaming, and these never reached it. It fixes new renames, not damage already on disk.
|
||||
closedTerminalTabTombstonesByTabId: OLD_ID,
|
||||
clientHostedBrowserCloseIntentsByEnvironment: OLD_ID
|
||||
})
|
||||
})
|
||||
|
||||
it('neither build drops a row shape it does not recognise', () => {
|
||||
const state = persistedStateAfterRename()
|
||||
const session = state.workspaceSession as Record<string, unknown>
|
||||
session.someFutureFieldByKey = { k: { worktreeId: OLD_ID, fromANewerBuild: true } }
|
||||
preStackMigrate(state, OLD_ID, NEW_ID)
|
||||
expect((state.workspaceSession as Record<string, unknown>).someFutureFieldByKey).toEqual({
|
||||
k: { worktreeId: OLD_ID, fromANewerBuild: true }
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user