Skip to content
Open
11 changes: 2 additions & 9 deletions config/scripts/ci-unit-files.mjs
Original file line number Diff line number Diff line change
@@ -1,18 +1,11 @@
import { globSync } from 'node:fs'
import { defaultExclude } from 'vitest/config'
import { UNIT_INCLUDE } from './unit-test-file-patterns.mjs'
export { UNIT_INCLUDE } from './unit-test-file-patterns.mjs'

// Why one constant: the cross-version-wire job runs this directory, so a new file there runs with no list to update.
export const CROSS_VERSION_WIRE_DIR = 'tests/e2e/cross-version-wire/'

export const UNIT_INCLUDE = [
'src/**/*.test.ts',
'src/**/*.test.tsx',
'config/scripts/**/*.test.ts',
'config/scripts/**/*.test.mjs',
'tests/tools/**/*.test.mjs',
'tests/e2e/**/*.unit.test.ts'
]

export const UNIT_EXCLUDE = [
...defaultExclude,
'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts',
Expand Down
13 changes: 3 additions & 10 deletions config/scripts/node-server-change-scope.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { appendFileSync, globSync, readFileSync } from 'node:fs'
import { appendFileSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { nodeServerTestPaths } from './node-server-test-paths.mjs'
import { nodeServerTestFiles, nodeServerTestPaths } from './node-server-test-paths.mjs'
import { nodeServerQualification } from './node-server-qualification.mjs'

const ROOT = resolve(import.meta.dirname, '../..')
Expand Down Expand Up @@ -54,14 +54,7 @@ const ALWAYS_PREFIXES = [
]

export function discoverNodeServerTests(root = ROOT) {
const selectors = nodeServerTestPaths({ artifact: true, crossRuntime: true })
return globSync(
['src/**/*.test.{ts,tsx}', 'config/scripts/**/*.test.{ts,mjs}', 'tests/e2e/**/*.unit.test.ts'],
{ cwd: root }
)
.map((file) => file.replaceAll('\\', '/'))
.filter((file) => selectors.some((selector) => file.includes(selector)))
.sort()
return nodeServerTestFiles({ root, artifact: true, crossRuntime: true })
}

export async function collectNodeServerInputs({ root = ROOT, entryPoints } = {}) {
Expand Down
23 changes: 17 additions & 6 deletions config/scripts/node-server-change-scope.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -135,12 +135,15 @@ it.each([
])('fails closed or requests dependencies in an uninstalled checkout: %j', (scenario) => {
const root = moduleTree(
Object.fromEntries(
['node-server-change-scope', 'node-server-test-paths', 'node-server-qualification'].map(
(name) => [
`config/scripts/${name}.mjs`,
readFileSync(new URL(`./${name}.mjs`, import.meta.url), 'utf8')
]
)
[
'node-server-change-scope',
'node-server-test-paths',
'node-server-qualification',
'unit-test-file-patterns'
].map((name) => [
`config/scripts/${name}.mjs`,
readFileSync(new URL(`./${name}.mjs`, import.meta.url), 'utf8')
])
)
)
const changes = join(root, 'changes')
Expand Down Expand Up @@ -215,6 +218,14 @@ describe('the actual Bun build and profile-test dependency graph', () => {
expect((await classifyNodeServerChanges([file], async () => inputs)).shouldRun).toBe(true)
})

it('keeps desktop Electron probes outside the headless server selection', () => {
const tests = discoverNodeServerTests()
expect(tests).toContain('src/main/persistence/profile-state/profile-state-access.test.ts')
expect(tests).not.toContain(
'src/main/persistence/profile-state/profile-state-writer-stall.electron.test.ts'
)
})

it('retains all selected tests and the selectors the Bun runner uses', () => {
const tests = discoverNodeServerTests()
expect(tests.length).toBeGreaterThan(80)
Expand Down
21 changes: 21 additions & 0 deletions config/scripts/node-server-test-paths.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { globSync } from 'node:fs'
import { resolve } from 'node:path'
import { UNIT_INCLUDE } from './unit-test-file-patterns.mjs'

/** Mirrors ORCA_REQUIRED_TEST_INPUTS_ENV in src/main/orcad/orcad-node-slot-fixture.ts. */
export const REQUIRED_TEST_INPUTS_ENV = 'ORCA_REQUIRED_TEST_INPUTS'

Expand Down Expand Up @@ -40,3 +44,20 @@ export function nodeServerTestPaths({ artifact = false, crossRuntime = false } =
...(crossRuntime ? CROSS_RUNTIME_TEST_PATHS : [])
]
}

export function nodeServerTestFiles({
root = resolve(import.meta.dirname, '../..'),
artifact = false,
crossRuntime = false
} = {}) {
const selectors = nodeServerTestPaths({ artifact, crossRuntime })
return globSync(UNIT_INCLUDE, { cwd: root })
.map((file) => file.replaceAll('\\', '/'))
.filter(
(file) =>
!file.endsWith('.electron.test.ts') &&
(crossRuntime || !CROSS_RUNTIME_TEST_PATHS.includes(file)) &&
selectors.some((selector) => file.includes(selector))
)
.sort()
}
20 changes: 3 additions & 17 deletions config/scripts/run-node-server-tests.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Runs the headless-server suites under the pinned Node (design D4/D4a), not the host's.
import { join, resolve } from 'node:path'
import { randomUUID } from 'node:crypto'
import { globSync, readFileSync } from 'node:fs'
import { readFileSync } from 'node:fs'
import { ORCAD_VERSION_FILENAME } from '../../src/shared/orcad-artifacts.ts'
import { NODE_RUNTIME_PIN } from '../../src/shared/node-runtime-pin.ts'
import {
Expand All @@ -11,13 +11,8 @@ import {
import { packagedNodeRuntimePath } from './build-orcad-node.mjs'
import { ensurePinnedNodeExecutable } from './pinned-node-downloads.mjs'
import { currentTarget } from './server-build-target.mjs'
import { UNIT_INCLUDE } from './ci-unit-files.mjs'
import { describeProcessFailure, runProcessSync } from './script-child-process.mjs'
import {
CROSS_RUNTIME_TEST_PATHS,
nodeServerTestPaths,
REQUIRED_TEST_INPUTS_ENV
} from './node-server-test-paths.mjs'
import { nodeServerTestFiles, REQUIRED_TEST_INPUTS_ENV } from './node-server-test-paths.mjs'

const root = resolve(import.meta.dirname, '../..')
const target = currentTarget()
Expand Down Expand Up @@ -85,14 +80,5 @@ run(runtimePath, [
])

function defaultTestArgs() {
// Why: vitest 5 drops CLI --exclude for inline projects, so the selectors' substring matches
// are resolved to files here and filtered before vitest sees them.
// Electron probes run in desktop jobs; headless compatibility containers have no display.
const selectors = nodeServerTestPaths({ artifact, crossRuntime })
const skipped = new Set(crossRuntime ? [] : CROSS_RUNTIME_TEST_PATHS)
return globSync(UNIT_INCLUDE, { cwd: root })
.map((path) => path.replaceAll('\\', '/'))
.filter((path) => selectors.some((selector) => path.includes(selector)))
.filter((path) => !path.endsWith('.electron.test.ts') && !skipped.has(path))
.sort()
return nodeServerTestFiles({ root, artifact, crossRuntime })
}
8 changes: 8 additions & 0 deletions config/scripts/unit-test-file-patterns.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const UNIT_INCLUDE = [
'src/**/*.test.ts',
'src/**/*.test.tsx',
'config/scripts/**/*.test.ts',
'config/scripts/**/*.test.mjs',
'tests/tools/**/*.test.mjs',
'tests/e2e/**/*.unit.test.ts'
]
3 changes: 3 additions & 0 deletions config/scripts/vitest-sqlite-database-runtime-files.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
// Database opening and lifecycle fixtures exercise Node SQLite behavior.
export const SQLITE_DATABASE_RUNTIME_INCLUDE = [
'src/main/ipc/runtime-environment-session-retirement.test.ts',
'src/main/ipc/runtime-environment-session-reconcile.test.ts',
'src/main/runtime/runtime-workspace-session-namespace-custody.test.ts',
'src/main/ai-vault-search/session-search-file-write.test.ts',
'src/main/ai-vault-search/session-search-live-transcript.test.ts',
'src/main/ai-vault/session-scanner-devin-contention.test.ts',
Expand Down
30 changes: 30 additions & 0 deletions docs/reference/runtime-host-session-retirement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Removed paired-host sessions

Removing a pairing retires its client-side session partition only when the current main catalog
does not own that namespace. It does not stop processes on the remote host or declare them exited.
Local, direct SSH, and historical runtime namespace aliases remain separate authorities.

Before removing active state, Orca durably writes a private recovery file under the active profile's
`retired-runtime-sessions/` directory, beside `profile-state.db`. Each version-1 JSON file records
the execution host, the exact session (including unsaved editor text and disk signatures), and
available referenced terminal scrollback, including the full stored buffer rather than the smaller
replay window. Missing, unreadable, or oversized legacy snapshots are listed explicitly and their
source files are kept. Only successfully archived, unshared profile snapshots are deleted.
Archives use the profile's secure file writer and are limited to 64 MiB. An archive failure keeps
the active session and permits ordinary saves; a failed database commit restores the partition.

The database retains the archive filename and a fence against late session or PTY writes. This
small record survives restart; the old buffers no longer participate in routine session writes.
A delayed save with newer dirty editor text produces a content-addressed `late-editor-draft`
archive. Identical buffers share one file; they never recreate an active session partition.
Keep the archive directory when copying a profile. Database-only backups and exports do not
include these separate files. Archives are retained until explicitly recovered or removed.

To recover text from a removed host, preserve the archive, identify the recorded workspace and
path in `session.openFilesByWorktree`, and recover its `dirtyDraftContent` on the intended owner.
Do not restore PTY bindings or silently save the text through the currently focused host. Re-pairing
creates a new host ID; the archive preserves the previous identity rather than guessing a new one.

Startup cleanup handles absent paired-host UUIDs only with a readable, nonempty registry and no
current namespace custody. Missing, corrupt, unsupported, or empty registries preserve historical
state. An explicitly observed removal can retire the last host through the GUI or transport watch.
8 changes: 6 additions & 2 deletions src/main/ipc/ephemeral-vm-runtime-handlers.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { retireUnownedRuntimeSession } from '../runtime/retire-unowned-runtime-session'
import { app, ipcMain } from 'electron'
import type { Store } from '../persistence'
import {
Expand Down Expand Up @@ -107,8 +108,11 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void {
}
if (result.ok && runtime.runtimeEnvironmentId) {
try {
removeEnvironment(userDataPath, runtime.runtimeEnvironmentId)
} catch {
const removed = removeEnvironment(userDataPath, runtime.runtimeEnvironmentId)
await invalidateRuntimeEnvironmentTransport(removed.id)
await retireUnownedRuntimeSession(store, removed.id)
} catch (error) {
console.warn('[ephemeral-vm] Preserving session after local retirement failure:', error)
// Cleanup of provider resources matters more than hiding a stale local
// environment row; users can still remove that manually.
}
Expand Down
8 changes: 8 additions & 0 deletions src/main/ipc/ephemeral-vm.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getDefaultWorkspaceSession } from '../../shared/constants'
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
Expand Down Expand Up @@ -82,6 +83,9 @@ function makeStore(repoPath: string) {
return {
getRepo: vi.fn((repoId: string) => (repoId === 'repo-1' ? repo : null)),
getRepos: vi.fn(() => [repo]),
getFolderWorkspaces: vi.fn(() => []),
getWorkspaceSession: vi.fn(() => getDefaultWorkspaceSession()),
removeRuntimeWorkspaceSessionPartition: vi.fn(async () => true),
getSettings: vi.fn(() => ({ activeRuntimeEnvironmentId })),
updateSettings: vi.fn((updates: { activeRuntimeEnvironmentId: string | null }) => {
activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId
Expand Down Expand Up @@ -411,6 +415,10 @@ describe('registerEphemeralVmHandlers', () => {
} as never)
expect(cleaned).toEqual(expect.objectContaining({ status: 'cleaned' }))
expect(listEnvironments(userDataPath)).toEqual([])
expect(store.removeRuntimeWorkspaceSessionPartition).toHaveBeenCalledExactlyOnceWith(
`runtime:${result.environment!.id}`,
expect.any(Function)
)
expect(store.getSettings().activeRuntimeEnvironmentId).toBe(result.environment!.id)
expect(store.updateSettings).toHaveBeenCalledTimes(1)
})
Expand Down
4 changes: 2 additions & 2 deletions src/main/ipc/managed-orcad-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ export type ManagedOrcadActionOptions = {
invalidateTransport: (environmentId: string) => Promise<void> | void
/** Drops the SSH host's stale managed-server state once its server is unlinked. */
clearHostServerStatus: (sshTargetId: string) => void
/** Drops the unlinked server's workspace session partition. */
forgetHostSession: (hostId: ExecutionHostId) => void
/** Preserves and retires the unlinked server's workspace session partition. */
forgetHostSession: (hostId: ExecutionHostId) => Promise<void> | void
}

export function createManagedOrcadActions(
Expand Down
1 change: 1 addition & 0 deletions src/main/ipc/pty-pane-restart-replace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ function installRestartHarness(
setLocalPtyProvider(provider as unknown as Parameters<typeof setLocalPtyProvider>[0])
let session = seedSession('pty-old')
const store = {
isRuntimeWorkspaceSessionRetired: vi.fn(() => false),
getWorkspaceSession: vi.fn((_hostId?: string): FakeSession => session),
setWorkspaceSession: vi.fn((next) => {
session = next
Expand Down
4 changes: 4 additions & 0 deletions src/main/ipc/renderer-shutdown-checkpoint.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
vi.mock('./renderer-workspace-session-admission', () => ({
canCreateRendererSessionPartition: () => true
}))

import { beforeEach, describe, expect, it, vi } from 'vitest'

const { syncHandlers, invokeHandlers } = vi.hoisted(() => ({
Expand Down
17 changes: 16 additions & 1 deletion src/main/ipc/renderer-shutdown-checkpoint.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { canAdmitRendererSessionWrite } from './renderer-workspace-session-admission'
import { ipcMain } from 'electron'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { PersistedUIState } from '../../shared/persisted-ui-state-types'
Expand Down Expand Up @@ -50,7 +51,10 @@ export function registerRendererShutdownCheckpointHandler(store: Store): void {
let ok = true
try {
for (const { state, hostId } of args.sessions) {
if (!isFrozenOrcadSourceSessionPartition(store, hostId)) {
if (
!isFrozenOrcadSourceSessionPartition(store, hostId) &&
isShutdownSessionAdmitted(store, hostId)
) {
store.stageWorkspaceSessionBeforeUnload(state, hostId)
}
}
Expand All @@ -68,3 +72,14 @@ export function registerRendererShutdownCheckpointHandler(store: Store): void {
(): Promise<ShutdownCheckpointResult> => pendingCheckpoint
)
}

// Why fail open: this is the last save before the window closes, so an unrelated host's ambiguous
// custody verdict must not be allowed to discard the staged state.
function isShutdownSessionAdmitted(store: Store, hostId?: string | null): boolean {
try {
return canAdmitRendererSessionWrite(store, hostId)
} catch (error) {
console.error('[app] Staging session state after partition authority failure:', error)
return true
}
}
22 changes: 22 additions & 0 deletions src/main/ipc/renderer-workspace-session-admission.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { app } from 'electron'
import { parseExecutionHostId } from '../../shared/execution-host'
import { listEnvironments } from '../../shared/runtime-environment-store'
import type { Store } from '../persistence'
import { hasMainOwnedRuntimeSessionNamespace } from '../runtime/runtime-workspace-session-namespace-custody'

export function canAdmitRendererSessionWrite(store: Store, hostId?: string | null): boolean {
const parsed = parseExecutionHostId(hostId)
// Retired snapshots reach the Store's archive-only writer.
if (parsed && store.isRuntimeWorkspaceSessionRetired(parsed.id)) {
return true
}
if (parsed?.kind !== 'runtime' || store.getWorkspaceSessionHostIds().includes(parsed.id)) {
return true
}
if (hasMainOwnedRuntimeSessionNamespace(store, parsed.id)) {
return true
}
return listEnvironments(app.getPath('userData'), { requireStoreFile: true }).some(
(entry) => entry.id === parsed.environmentId
)
}
11 changes: 7 additions & 4 deletions src/main/ipc/runtime-environment-connectivity-handlers.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { retireUnownedRuntimeSession } from '../runtime/retire-unowned-runtime-session'
import { ipcMain } from 'electron'
import {
addEnvironmentFromPairingCode,
Expand Down Expand Up @@ -102,16 +103,18 @@ export function registerRuntimeEnvironmentConnectivityHandlers({
)
ipcMain.handle(
'runtimeEnvironments:remove',
(_event, args: { selector: string }): { removed: PublicKnownRuntimeEnvironment } => {
async (
_event,
args: { selector: string }
): Promise<{ removed: PublicKnownRuntimeEnvironment }> => {
const environment = resolveEnvironment(getUserDataPath(), args.selector)
if (store.getSettings().activeRuntimeEnvironmentId === environment.id) {
throw new Error('Choose another Active Server in Advanced before removing this server.')
}
const removed = removeEnvironment(getUserDataPath(), args.selector)
void retireRemovedRuntimeEnvironment(removed.id, invalidateTransport, (hostId) =>
store.removeWorkspaceSessionHost(hostId)
)
void retireRemovedRuntimeEnvironment(removed.id, invalidateTransport)
closeLegacySelectorTransport(args.selector, removed.id)
await retireUnownedRuntimeSession(store, removed.id)
return { removed: redactRuntimeEnvironment(removed) }
}
)
Expand Down
27 changes: 27 additions & 0 deletions src/main/ipc/runtime-environment-removal-cleanup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,31 @@ describe('retiring a removed runtime environment', () => {
await retireRemovedRuntimeEnvironment('env 1', vi.fn(), forgetHostSession)
expect(forgetHostSession).toHaveBeenCalledWith('runtime:env%201')
})

it('waits for the durable session archive before completing managed-server cleanup', async () => {
const archive = Promise.withResolvers<void>()
const invalidateTransport = vi.fn()
const completed = vi.fn()
const retiring = retireRemovedRuntimeEnvironment(
'env',
invalidateTransport,
() => archive.promise
).then(completed)
await Promise.resolve()
expect(invalidateTransport).toHaveBeenCalledWith('env')
expect(completed).not.toHaveBeenCalled()
archive.resolve()
await retiring
expect(completed).toHaveBeenCalledOnce()
})

it('starts transport cleanup and reports a failed session archive', async () => {
const invalidateTransport = vi.fn()
await expect(
retireRemovedRuntimeEnvironment('env', invalidateTransport, async () => {
throw new Error('archive failed')
})
).rejects.toThrow('archive failed')
expect(invalidateTransport).toHaveBeenCalledWith('env')
})
})
Loading
Loading