mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix: retire unowned paired-host session partitions on GUI removal
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
# GUI unpair: retire an unowned paired-host session partition
|
||||
|
||||
Related report: [#12241](https://github.com/stablyai/orca/issues/12241). This artifact demonstrates retained client profile state after explicit GUI removal. It does not measure RSS or attribute the memory in #12241 or #19831 to this mechanism alone.
|
||||
|
||||
## Finding and change
|
||||
|
||||
The actual GUI removal handler removes the saved pairing and retires its transport, but previously kept the matching `workspaceSessionsByHostId` entry in the main Store and profile file. Repeated pairing/removal accumulates distinct environment IDs. Deleting that entry alone is insufficient: all four renderer session-write channels can recreate it. An async legacy-recovery rollback can also recreate an explicitly removed entry and reopen an existing-partition admission fast path.
|
||||
|
||||
The fix removes the exact `runtime:<removed environment ID>` partition on GUI unpair when current main namespace custody does not require it. Renderer ingress admits a missing runtime partition only for an exact saved environment ID or current main-owned namespace. Existing partitions, local, and direct SSH retain their fast paths. The async recovery catch skips rollback into a partition that is now absent. No new cache, timer, tombstone, wire field, or process-liveness judgment is introduced.
|
||||
|
||||
## Reproduce
|
||||
|
||||
Run from the repository root with installed working dependencies:
|
||||
|
||||
```sh
|
||||
ORCA_BACKGROUND_LAUNCH=1 node docs/audits/paired-host-session-partition-retirement/reproduce.mjs
|
||||
```
|
||||
|
||||
An optional output-path argument writes the report elsewhere. For Electron, invoke its installed executable with this script and output path, setting `ELECTRON_RUN_AS_NODE=1` and `ORCA_BACKGROUND_LAUNCH=1`. No windows, network, or native PTYs are created; Electron/browser transport boundaries are inert. Store, pairing files, registered IPC handlers, runtime controller, browser projection, and recovery persistence are actual source.
|
||||
|
||||
Both checked-in runtime reports contain **78 passing cases**: 13 cases for each current/main × before/fixed/without-rollback phase. Node 26.6.0 and Electron 43 / Node 24 produce the same state results:
|
||||
|
||||
| Control | Before | Fixed | Fixed except rollback guard |
|
||||
| --------------------------------------------------------------------------------- | ------------ | ---------- | --------------------------- |
|
||||
| 32 pair/write/unpair/late-patch cycles: memory / flushed disk / reload partitions | 32 / 32 / 32 | 0 / 0 / 0 | 0 / 0 / 0 |
|
||||
| Catalog rows after cycles; transport invalidations | 0; 32 | 0; 32 | 0; 32 |
|
||||
| Four late renderer write channels recreate an absent partition | yes | no | no |
|
||||
| Failed `exited` recovery recreates partition after owner removal/unpair | yes | no | yes |
|
||||
| Failed `adopted` recovery recreates an absent partition | no | no | no |
|
||||
| Existing local/SSH/runtime adopted rollback restores its record | yes | yes | yes |
|
||||
| Sync admission with unreadable catalog: receipt / cold partition created | true / yes | false / no | false / no |
|
||||
| Existing namespace sync write with the same unreadable catalog | succeeds | succeeds | succeeds |
|
||||
|
||||
The before-phase late-write and rollback controls explicitly **simulate deletion** of the exact partition, since the baseline has no deletion API. The repeated 32-cycle finding uses ordinary GUI removal without simulated deletion. Fixed phases use actual GUI removal. The omitted-rollback phase changes only the recovery module back to baseline. Main repo namespace collision, controller-selected unique historical alias, and a late browser projection remain preserved.
|
||||
|
||||
The permanent regression can also be run against the fenced baseline:
|
||||
|
||||
```sh
|
||||
ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config docs/audits/paired-host-session-partition-retirement/before.config.mjs
|
||||
```
|
||||
|
||||
Expected: **11 failures, 5 passes**. These cover late ingress, first-use catalog admission, same-name replacement, authority-read failure, repeated retirement, and new deletion/failure-order contracts. Normal repository config passes all 16 cases. The separate 12-case namespace suite and seven relevant existing suites bring the targeted total to **79 passes**; see `validation.json` for commands.
|
||||
|
||||
## Source identity and publication portability
|
||||
|
||||
`source-versions.json` fences a union of **888 source paths**, with exact evaluated graphs of 857/860 modules for current before/fixed and 679/682 for publication main before/fixed. The publication base is **291b4ddd6f1c1af480169885e0fda7f9c78ff053**. Main has older internal module paths, so this artifact supplies the full evaluated source differences rather than importing current dependencies behind a five-file overlay.
|
||||
|
||||
`fix.patch` and `main-fix.patch` independently map their exact baselines to their fixes. `publication.patch` reversibly maps the current fixed graph to the main fixed graph. The loader accepts all four complete exact identities, rejects drift, reconstructs each variant in memory, and verifies every evaluated module hash. The runner checks all four reconstruction inputs, canonical CRLF reads, and exact evaluated graph equality. All patches have zero context. No Git checkout/ref or ignored notes are required to run the artifact. Reports record actual evaluated hashes and runtime versions.
|
||||
|
||||
The source overlays include the full evaluated `src/` graphs. Vitest configuration/setup files and the permanent baseline fixture are separately fenced. Installed Vitest/esbuild/diff and other `node_modules` remain working dependencies, not historical lockfile installs. Main `session.ts` excludes the audit branch's unrelated empty-terminal-tab retirement handler. This is a source-compatibility control on named main, not a claim that a packaged historical application was launched.
|
||||
|
||||
## Ownership and failure boundaries
|
||||
|
||||
- [GUI removal](../../../src/main/ipc/runtime-environment-connectivity-handlers.ts) establishes custody before catalog mutation, then starts existing transport/browser retirement before Store deletion. A custody lookup error preserves pairing. A later deletion error surfaces after existing retirement has started.
|
||||
- [Renderer admission](../../../src/main/ipc/renderer-workspace-session-admission.ts) uses exact saved IDs, never display names. Existing-partition/local/direct-SSH writes do not read the pairing file. [Session IPC](../../../src/main/ipc/session.ts) and [shutdown staging](../../../src/main/ipc/renderer-shutdown-checkpoint.ts) catch only new admission lookup failures: cold unverifiable hosts are skipped, unrelated state still flushes, and synchronous receipts report failure. Actual Store exceptions retain prior semantics.
|
||||
- [Namespace custody](../../../src/main/runtime/runtime-workspace-session-namespace-custody.ts) reuses the actual runtime controller's alias resolution. Historical main repo stamps, current explicit folder custody, and a unique controller-selected older alias remain valid. Local/SSH workspace-ID collisions and ambiguous aliases do not supply runtime custody. Folder host stamps are deliberately stripped during Store loading by `src/shared/folder-workspaces.ts`; folder tests declare an **in-memory** main stamp and do not claim persisted historical folder custody.
|
||||
- [Exact Store removal](../../../src/main/persistence/loading-store/session-host-partitions.ts) refuses local/SSH deletion, invalidates existing metadata-prune inputs, and schedules the normal save. [Recovery rollback](../../../src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts) checks partition presence. It preserves the existing field-level merge for a remaining partition; it is not an incarnation fence for a partition deleted and recreated with the same ID before rejection.
|
||||
|
||||
Audited main writers resolve controller ownership afresh or write local/direct-SSH bindings. Synchronous terminal retirement cannot interleave a GUI removal between its read and rollback. The async recovery path was the demonstrated absent-partition bypass. A missing-repo browser callback currently routes to local, not the retired runtime namespace; that separate existing behavior is not changed. Explicit profile transfer/import is outside this removal lifecycle.
|
||||
|
||||
## Limits
|
||||
|
||||
- GUI removal is not an atomic pairing-file/profile-file transaction. The disk/reload control explicitly joins `Store.flushOrThrow()`. A crash or save failure before the scheduled profile save can leave old disk state; the GUI receipt does not guarantee profile deletion on disk.
|
||||
- CLI `environment rm` and ephemeral-environment cleanup remove catalog rows directly and do not use this GUI boundary. Existing historical namespaces are not swept merely because a pairing is absent.
|
||||
- Renderer folder/tab/shadow objects and in-flight hydration are separate owners. Admission prevents them from recreating an explicitly retired pure mirror in Store; it does not free those renderer objects.
|
||||
- A main-owned namespace remains preserved even when its suffix equals the removed saved-environment ID. The change neither terminates remote work nor treats loss of contact as process death.
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import base from '../../../config/vitest.config'
|
||||
|
||||
const sources = createRequire(import.meta.url)('./sources.cjs')
|
||||
export default {
|
||||
...base,
|
||||
plugins: [sources.phasePlugin('current-before')],
|
||||
test: {
|
||||
...base.test,
|
||||
include: ['src/main/ipc/runtime-environment-session-retirement.test.ts'],
|
||||
maxWorkers: 1
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,178 @@
|
||||
--- a/src/main/ipc/renderer-shutdown-checkpoint.ts
|
||||
+++ b/src/main/ipc/renderer-shutdown-checkpoint.ts
|
||||
@@ -0,0 +1 @@
|
||||
+import { canCreateRendererSessionPartition } from './renderer-workspace-session-admission'
|
||||
@@ -49,0 +51 @@
|
||||
+ let admissionOk = true
|
||||
@@ -52 +54,11 @@
|
||||
- store.stageWorkspaceSessionBeforeUnload(state, hostId)
|
||||
+ let admitted: boolean
|
||||
+ try {
|
||||
+ admitted = canCreateRendererSessionPartition(store, hostId)
|
||||
+ } catch (error) {
|
||||
+ console.error('[app] Failed to establish runtime session partition authority:', error)
|
||||
+ admissionOk = false
|
||||
+ continue
|
||||
+ }
|
||||
+ if (admitted) {
|
||||
+ store.stageWorkspaceSessionBeforeUnload(state, hostId)
|
||||
+ }
|
||||
@@ -59,2 +71,4 @@
|
||||
- pendingCheckpoint = ok ? flushStagedStateWithDeadline(store) : Promise.resolve({ ok: false })
|
||||
- event.returnValue = { ok }
|
||||
+ pendingCheckpoint = ok
|
||||
+ ? flushStagedStateWithDeadline(store).then((result) => ({ ok: result.ok && admissionOk }))
|
||||
+ : Promise.resolve({ ok: false })
|
||||
+ event.returnValue = { ok: ok && admissionOk }
|
||||
--- /dev/null
|
||||
+++ b/src/main/ipc/renderer-workspace-session-admission.ts
|
||||
@@ -0,0 +1,18 @@
|
||||
+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 canCreateRendererSessionPartition(store: Store, hostId?: string | null): boolean {
|
||||
+ const parsed = parseExecutionHostId(hostId)
|
||||
+ if (parsed?.kind !== 'runtime' || store.getWorkspaceSessionHostIds().includes(parsed.id)) {
|
||||
+ return true
|
||||
+ }
|
||||
+ if (hasMainOwnedRuntimeSessionNamespace(store, parsed.id)) {
|
||||
+ return true
|
||||
+ }
|
||||
+ return listEnvironments(app.getPath('userData')).some(
|
||||
+ (entry) => entry.id === parsed.environmentId
|
||||
+ )
|
||||
+}
|
||||
--- a/src/main/ipc/runtime-environment-connectivity-handlers.ts
|
||||
+++ b/src/main/ipc/runtime-environment-connectivity-handlers.ts
|
||||
@@ -0,0 +1,2 @@
|
||||
+import { hasMainOwnedRuntimeSessionNamespace } from '../runtime/runtime-workspace-session-namespace-custody'
|
||||
+import { toRuntimeExecutionHostId } from '../../shared/execution-host'
|
||||
@@ -107,0 +110,2 @@
|
||||
+ const hostId = toRuntimeExecutionHostId(environment.id)
|
||||
+ const preserveMainNamespace = hasMainOwnedRuntimeSessionNamespace(store, hostId)
|
||||
@@ -124,0 +129,3 @@
|
||||
+ if (!preserveMainNamespace) {
|
||||
+ store.removeRuntimeWorkspaceSessionPartition(hostId)
|
||||
+ }
|
||||
--- a/src/main/ipc/session.ts
|
||||
+++ b/src/main/ipc/session.ts
|
||||
@@ -0,0 +1 @@
|
||||
+import { canCreateRendererSessionPartition } from './renderer-workspace-session-admission'
|
||||
@@ -23 +24,3 @@
|
||||
- store.setWorkspaceSession(args, hostId)
|
||||
+ if (canCreateRendererSessionPartition(store, hostId)) {
|
||||
+ store.setWorkspaceSession(args, hostId)
|
||||
+ }
|
||||
@@ -27 +30,3 @@
|
||||
- store.patchWorkspaceSession(args, hostId)
|
||||
+ if (canCreateRendererSessionPartition(store, hostId)) {
|
||||
+ store.patchWorkspaceSession(args, hostId)
|
||||
+ }
|
||||
@@ -41 +46,11 @@
|
||||
- store.setWorkspaceSession(args, hostId)
|
||||
+ let admitted = false
|
||||
+ let admissionOk = true
|
||||
+ try {
|
||||
+ admitted = canCreateRendererSessionPartition(store, hostId)
|
||||
+ } catch (error) {
|
||||
+ console.error('[session] Failed to establish runtime session partition authority:', error)
|
||||
+ admissionOk = false
|
||||
+ }
|
||||
+ if (admitted) {
|
||||
+ store.setWorkspaceSession(args, hostId)
|
||||
+ }
|
||||
@@ -43 +58 @@
|
||||
- event.returnValue = true
|
||||
+ event.returnValue = admissionOk
|
||||
--- a/src/main/persistence/loading-store/session-host-partitions.ts
|
||||
+++ b/src/main/persistence/loading-store/session-host-partitions.ts
|
||||
@@ -6,0 +7 @@
|
||||
+ parseExecutionHostId,
|
||||
@@ -80,0 +82,17 @@
|
||||
+ }
|
||||
+
|
||||
+ removeRuntimeWorkspaceSessionPartition(hostId: ExecutionHostId): boolean {
|
||||
+ if (
|
||||
+ parseExecutionHostId(hostId)?.kind !== 'runtime' ||
|
||||
+ !hasPersistedWorkspaceSession(this, hostId)
|
||||
+ ) {
|
||||
+ return false
|
||||
+ }
|
||||
+ const partitions = {
|
||||
+ ...this[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId
|
||||
+ }
|
||||
+ delete partitions[hostId]
|
||||
+ this[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId = partitions
|
||||
+ invalidateLocalWorktreeMetadataPruneInputs()
|
||||
+ scheduleSave(this[sessionHostPartitionOperationsContext].scheduling)
|
||||
+ return true
|
||||
--- a/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts
|
||||
+++ b/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts
|
||||
@@ -77,0 +78,3 @@
|
||||
+ if (store.getWorkspaceSessionHostIds?.().includes(hostId) === false) {
|
||||
+ continue
|
||||
+ }
|
||||
--- /dev/null
|
||||
+++ b/src/main/runtime/runtime-workspace-session-namespace-custody.ts
|
||||
@@ -0,0 +1,58 @@
|
||||
+import { RuntimeWorkspaceSessionController } from './runtime-workspace-session-controller'
|
||||
+import { inferFolderWorkspacePathConnection } from '../project-groups/folder-workspace-path-status'
|
||||
+import { getRepoExecutionHostId, parseExecutionHostId } from '../../shared/execution-host'
|
||||
+import { getRepoIdFromWorktreeId } from '../../shared/worktree/id'
|
||||
+import { parseWorkspaceKey } from '../../shared/workspace-scope'
|
||||
+import type { Store } from '../persistence'
|
||||
+
|
||||
+export function hasMainOwnedRuntimeSessionNamespace(store: Store, hostId: string): boolean {
|
||||
+ if (parseExecutionHostId(hostId)?.kind !== 'runtime') {
|
||||
+ return false
|
||||
+ }
|
||||
+ const repos = store.getRepos()
|
||||
+ const folders = store.getFolderWorkspaces()
|
||||
+ if (repos.some((repo) => getRepoExecutionHostId(repo) === hostId)) {
|
||||
+ return true
|
||||
+ }
|
||||
+ if (folders.some((folder) => parseExecutionHostId(folder.executionHostId)?.id === hostId)) {
|
||||
+ return true
|
||||
+ }
|
||||
+
|
||||
+ const controller = new RuntimeWorkspaceSessionController({
|
||||
+ getStore: () => store,
|
||||
+ resolveFolderConnectionId: (workspace) => {
|
||||
+ const connection = inferFolderWorkspacePathConnection({
|
||||
+ folderPath: workspace.folderPath,
|
||||
+ projectGroupId: workspace.projectGroupId,
|
||||
+ connectionId: workspace.connectionId ?? null,
|
||||
+ projectGroups: store.getProjectGroups(),
|
||||
+ repos
|
||||
+ })
|
||||
+ if (connection.kind === 'ambiguous') {
|
||||
+ throw new Error('folder_workspace_connection_ambiguous')
|
||||
+ }
|
||||
+ return connection.kind === 'ssh' ? connection.connectionId : null
|
||||
+ },
|
||||
+ hasRuntimeOwnedPtyCandidate: () => false
|
||||
+ })
|
||||
+ for (const workspaceId of Object.keys(store.getWorkspaceSession(hostId).tabsByWorktree)) {
|
||||
+ const scope = parseWorkspaceKey(workspaceId)
|
||||
+ const catalogHost =
|
||||
+ scope?.type === 'folder'
|
||||
+ ? folders.find((folder) => folder.id === scope.folderWorkspaceId)?.executionHostId
|
||||
+ : (() => {
|
||||
+ const repo = store.getRepo(
|
||||
+ getRepoIdFromWorktreeId(scope?.type === 'worktree' ? scope.worktreeId : workspaceId)
|
||||
+ )
|
||||
+ return repo ? getRepoExecutionHostId(repo) : null
|
||||
+ })()
|
||||
+ // Only a current runtime-owned catalog row can exercise the controller's legacy alias fallback.
|
||||
+ if (
|
||||
+ parseExecutionHostId(catalogHost)?.kind === 'runtime' &&
|
||||
+ controller.tryGetHostId(workspaceId) === hostId
|
||||
+ ) {
|
||||
+ return true
|
||||
+ }
|
||||
+ }
|
||||
+ return false
|
||||
+}
|
||||
@@ -0,0 +1,178 @@
|
||||
--- a/src/main/ipc/renderer-shutdown-checkpoint.ts
|
||||
+++ b/src/main/ipc/renderer-shutdown-checkpoint.ts
|
||||
@@ -0,0 +1 @@
|
||||
+import { canCreateRendererSessionPartition } from './renderer-workspace-session-admission'
|
||||
@@ -49,0 +51 @@
|
||||
+ let admissionOk = true
|
||||
@@ -52 +54,11 @@
|
||||
- store.stageWorkspaceSessionBeforeUnload(state, hostId)
|
||||
+ let admitted: boolean
|
||||
+ try {
|
||||
+ admitted = canCreateRendererSessionPartition(store, hostId)
|
||||
+ } catch (error) {
|
||||
+ console.error('[app] Failed to establish runtime session partition authority:', error)
|
||||
+ admissionOk = false
|
||||
+ continue
|
||||
+ }
|
||||
+ if (admitted) {
|
||||
+ store.stageWorkspaceSessionBeforeUnload(state, hostId)
|
||||
+ }
|
||||
@@ -59,2 +71,4 @@
|
||||
- pendingCheckpoint = ok ? flushStagedStateWithDeadline(store) : Promise.resolve({ ok: false })
|
||||
- event.returnValue = { ok }
|
||||
+ pendingCheckpoint = ok
|
||||
+ ? flushStagedStateWithDeadline(store).then((result) => ({ ok: result.ok && admissionOk }))
|
||||
+ : Promise.resolve({ ok: false })
|
||||
+ event.returnValue = { ok: ok && admissionOk }
|
||||
--- /dev/null
|
||||
+++ b/src/main/ipc/renderer-workspace-session-admission.ts
|
||||
@@ -0,0 +1,18 @@
|
||||
+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 canCreateRendererSessionPartition(store: Store, hostId?: string | null): boolean {
|
||||
+ const parsed = parseExecutionHostId(hostId)
|
||||
+ if (parsed?.kind !== 'runtime' || store.getWorkspaceSessionHostIds().includes(parsed.id)) {
|
||||
+ return true
|
||||
+ }
|
||||
+ if (hasMainOwnedRuntimeSessionNamespace(store, parsed.id)) {
|
||||
+ return true
|
||||
+ }
|
||||
+ return listEnvironments(app.getPath('userData')).some(
|
||||
+ (entry) => entry.id === parsed.environmentId
|
||||
+ )
|
||||
+}
|
||||
--- a/src/main/ipc/runtime-environment-connectivity-handlers.ts
|
||||
+++ b/src/main/ipc/runtime-environment-connectivity-handlers.ts
|
||||
@@ -0,0 +1,2 @@
|
||||
+import { hasMainOwnedRuntimeSessionNamespace } from '../runtime/runtime-workspace-session-namespace-custody'
|
||||
+import { toRuntimeExecutionHostId } from '../../shared/execution-host'
|
||||
@@ -107,0 +110,2 @@
|
||||
+ const hostId = toRuntimeExecutionHostId(environment.id)
|
||||
+ const preserveMainNamespace = hasMainOwnedRuntimeSessionNamespace(store, hostId)
|
||||
@@ -124,0 +129,3 @@
|
||||
+ if (!preserveMainNamespace) {
|
||||
+ store.removeRuntimeWorkspaceSessionPartition(hostId)
|
||||
+ }
|
||||
--- a/src/main/ipc/session.ts
|
||||
+++ b/src/main/ipc/session.ts
|
||||
@@ -0,0 +1 @@
|
||||
+import { canCreateRendererSessionPartition } from './renderer-workspace-session-admission'
|
||||
@@ -17 +18,3 @@
|
||||
- store.setWorkspaceSession(args, hostId)
|
||||
+ if (canCreateRendererSessionPartition(store, hostId)) {
|
||||
+ store.setWorkspaceSession(args, hostId)
|
||||
+ }
|
||||
@@ -21 +24,3 @@
|
||||
- store.patchWorkspaceSession(args, hostId)
|
||||
+ if (canCreateRendererSessionPartition(store, hostId)) {
|
||||
+ store.patchWorkspaceSession(args, hostId)
|
||||
+ }
|
||||
@@ -35 +40,11 @@
|
||||
- store.setWorkspaceSession(args, hostId)
|
||||
+ let admitted = false
|
||||
+ let admissionOk = true
|
||||
+ try {
|
||||
+ admitted = canCreateRendererSessionPartition(store, hostId)
|
||||
+ } catch (error) {
|
||||
+ console.error('[session] Failed to establish runtime session partition authority:', error)
|
||||
+ admissionOk = false
|
||||
+ }
|
||||
+ if (admitted) {
|
||||
+ store.setWorkspaceSession(args, hostId)
|
||||
+ }
|
||||
@@ -37 +52 @@
|
||||
- event.returnValue = true
|
||||
+ event.returnValue = admissionOk
|
||||
--- a/src/main/persistence/loading-store/session-host-partitions.ts
|
||||
+++ b/src/main/persistence/loading-store/session-host-partitions.ts
|
||||
@@ -6,0 +7 @@
|
||||
+ parseExecutionHostId,
|
||||
@@ -80,0 +82,17 @@
|
||||
+ }
|
||||
+
|
||||
+ removeRuntimeWorkspaceSessionPartition(hostId: ExecutionHostId): boolean {
|
||||
+ if (
|
||||
+ parseExecutionHostId(hostId)?.kind !== 'runtime' ||
|
||||
+ !hasPersistedWorkspaceSession(this, hostId)
|
||||
+ ) {
|
||||
+ return false
|
||||
+ }
|
||||
+ const partitions = {
|
||||
+ ...this[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId
|
||||
+ }
|
||||
+ delete partitions[hostId]
|
||||
+ this[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId = partitions
|
||||
+ invalidateLocalWorktreeMetadataPruneInputs()
|
||||
+ scheduleSave(this[sessionHostPartitionOperationsContext].scheduling)
|
||||
+ return true
|
||||
--- a/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts
|
||||
+++ b/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts
|
||||
@@ -77,0 +78,3 @@
|
||||
+ if (store.getWorkspaceSessionHostIds?.().includes(hostId) === false) {
|
||||
+ continue
|
||||
+ }
|
||||
--- /dev/null
|
||||
+++ b/src/main/runtime/runtime-workspace-session-namespace-custody.ts
|
||||
@@ -0,0 +1,58 @@
|
||||
+import { RuntimeWorkspaceSessionController } from './runtime-workspace-session-controller'
|
||||
+import { inferFolderWorkspacePathConnection } from '../project-groups/folder-workspace-path-status'
|
||||
+import { getRepoExecutionHostId, parseExecutionHostId } from '../../shared/execution-host'
|
||||
+import { getRepoIdFromWorktreeId } from '../../shared/worktree/id'
|
||||
+import { parseWorkspaceKey } from '../../shared/workspace-scope'
|
||||
+import type { Store } from '../persistence'
|
||||
+
|
||||
+export function hasMainOwnedRuntimeSessionNamespace(store: Store, hostId: string): boolean {
|
||||
+ if (parseExecutionHostId(hostId)?.kind !== 'runtime') {
|
||||
+ return false
|
||||
+ }
|
||||
+ const repos = store.getRepos()
|
||||
+ const folders = store.getFolderWorkspaces()
|
||||
+ if (repos.some((repo) => getRepoExecutionHostId(repo) === hostId)) {
|
||||
+ return true
|
||||
+ }
|
||||
+ if (folders.some((folder) => parseExecutionHostId(folder.executionHostId)?.id === hostId)) {
|
||||
+ return true
|
||||
+ }
|
||||
+
|
||||
+ const controller = new RuntimeWorkspaceSessionController({
|
||||
+ getStore: () => store,
|
||||
+ resolveFolderConnectionId: (workspace) => {
|
||||
+ const connection = inferFolderWorkspacePathConnection({
|
||||
+ folderPath: workspace.folderPath,
|
||||
+ projectGroupId: workspace.projectGroupId,
|
||||
+ connectionId: workspace.connectionId ?? null,
|
||||
+ projectGroups: store.getProjectGroups(),
|
||||
+ repos
|
||||
+ })
|
||||
+ if (connection.kind === 'ambiguous') {
|
||||
+ throw new Error('folder_workspace_connection_ambiguous')
|
||||
+ }
|
||||
+ return connection.kind === 'ssh' ? connection.connectionId : null
|
||||
+ },
|
||||
+ hasRuntimeOwnedPtyCandidate: () => false
|
||||
+ })
|
||||
+ for (const workspaceId of Object.keys(store.getWorkspaceSession(hostId).tabsByWorktree)) {
|
||||
+ const scope = parseWorkspaceKey(workspaceId)
|
||||
+ const catalogHost =
|
||||
+ scope?.type === 'folder'
|
||||
+ ? folders.find((folder) => folder.id === scope.folderWorkspaceId)?.executionHostId
|
||||
+ : (() => {
|
||||
+ const repo = store.getRepo(
|
||||
+ getRepoIdFromWorktreeId(scope?.type === 'worktree' ? scope.worktreeId : workspaceId)
|
||||
+ )
|
||||
+ return repo ? getRepoExecutionHostId(repo) : null
|
||||
+ })()
|
||||
+ // Only a current runtime-owned catalog row can exercise the controller's legacy alias fallback.
|
||||
+ if (
|
||||
+ parseExecutionHostId(catalogHost)?.kind === 'runtime' &&
|
||||
+ controller.tryGetHostId(workspaceId) === hostId
|
||||
+ ) {
|
||||
+ return true
|
||||
+ }
|
||||
+ }
|
||||
+ return false
|
||||
+}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
import base from '../../../config/vitest.config'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const sources = require('./sources.cjs')
|
||||
|
||||
const phase = `${process.env.ORCA_PARTITION_SNAPSHOT}-${process.env.ORCA_PARTITION_VARIANT}`
|
||||
export default {
|
||||
...base,
|
||||
plugins: [sources.phasePlugin(phase)],
|
||||
test: {
|
||||
...base.test,
|
||||
include: ['docs/audits/paired-host-session-partition-retirement/scenario.test.mjs'],
|
||||
maxWorkers: 1,
|
||||
execArgv: base.test.execArgv.filter(
|
||||
(arg) =>
|
||||
arg !== '--no-experimental-webstorage' || Number(process.versions.node.split('.')[0]) >= 26
|
||||
)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,158 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, relative, resolve } from 'node:path'
|
||||
import { build } from 'esbuild'
|
||||
import sources from './sources.cjs'
|
||||
|
||||
assert.equal(process.env.ORCA_BACKGROUND_LAUNCH, '1')
|
||||
const { root, sha, versions, loadVariants } = sources
|
||||
const artifact = import.meta.dirname
|
||||
const loaded = loadVariants()
|
||||
const mapHashes = (maps) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(maps).map(([phase, files]) => [
|
||||
phase,
|
||||
Object.fromEntries(
|
||||
Object.entries(files).map(([file, source]) => [file, source === null ? null : sha(source)])
|
||||
)
|
||||
])
|
||||
)
|
||||
const expectedMaps = mapHashes(loaded.maps)
|
||||
const reconstructedInputs = []
|
||||
for (const phase of ['current-before', 'current-fixed', 'main-before', 'main-fixed']) {
|
||||
const reconstructed = loadVariants((file) => loaded.maps[phase][file])
|
||||
assert.deepEqual(mapHashes(reconstructed.maps), expectedMaps)
|
||||
reconstructedInputs.push(phase)
|
||||
}
|
||||
const crlf = loadVariants(
|
||||
(file) => loaded.maps['main-fixed'][file]?.replaceAll('\n', '\r\n') ?? null
|
||||
)
|
||||
assert.deepEqual(mapHashes(crlf.maps), expectedMaps)
|
||||
const artifactHashes = {}
|
||||
for (const file of [
|
||||
'scenario.test.mjs',
|
||||
'sources.cjs',
|
||||
'phase.config.mjs',
|
||||
'before.config.mjs',
|
||||
'reproduce.mjs',
|
||||
'source-versions.json',
|
||||
'fix.patch',
|
||||
'main-fix.patch',
|
||||
'publication.patch'
|
||||
]) {
|
||||
artifactHashes[file] = sha(
|
||||
(await readFile(join(artifact, file), 'utf8')).replaceAll('\r\n', '\n')
|
||||
)
|
||||
}
|
||||
const scratch = await mkdtemp(join(tmpdir(), 'orca-paired-partition-retirement-'))
|
||||
const require = createRequire(import.meta.url)
|
||||
let runnerId
|
||||
try {
|
||||
const runnerPath = join(scratch, 'run-process.cjs')
|
||||
const bundle = await build({
|
||||
absWorkingDir: root,
|
||||
entryPoints: [join(root, 'src/shared/child-process/run-process.ts')],
|
||||
outfile: runnerPath,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
metafile: true,
|
||||
logLevel: 'silent'
|
||||
})
|
||||
const runnerSourceHashes = {}
|
||||
for (const input of Object.keys(bundle.metafile.inputs)) {
|
||||
const file = relative(root, resolve(root, input)).replaceAll('\\', '/')
|
||||
assert.ok(versions.sources[file], `Unfenced runner dependency: ${file}`)
|
||||
const actual = sha((await readFile(join(root, file), 'utf8')).replaceAll('\r\n', '\n'))
|
||||
assert.equal(actual, versions.sources[file][loaded.flavor], `Runner dependency drift: ${file}`)
|
||||
runnerSourceHashes[file] = actual
|
||||
}
|
||||
runnerId = require.resolve(runnerPath)
|
||||
const { runProcess } = require(runnerId)
|
||||
const phases = {}
|
||||
for (const snapshot of ['current', 'main']) {
|
||||
for (const variant of ['before', 'fixed', 'without-rollback']) {
|
||||
const phase = `${snapshot}-${variant}`
|
||||
const report = join(scratch, `${phase}-tests.json`)
|
||||
const metrics = join(scratch, `${phase}-metrics.json`)
|
||||
const evaluated = join(scratch, `${phase}-evaluated.json`)
|
||||
const result = await runProcess({
|
||||
program: process.execPath,
|
||||
args: [
|
||||
join(root, 'node_modules/vitest/vitest.mjs'),
|
||||
'run',
|
||||
'--config',
|
||||
join(artifact, 'phase.config.mjs'),
|
||||
'--reporter=json',
|
||||
`--outputFile=${report}`
|
||||
],
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_PARTITION_SNAPSHOT: snapshot,
|
||||
ORCA_PARTITION_VARIANT: variant,
|
||||
ORCA_PARTITION_METRICS: metrics,
|
||||
ORCA_PARTITION_EVALUATED: evaluated
|
||||
},
|
||||
timeoutMs: 120_000,
|
||||
maxOutputBytes: 4 * 1024 * 1024
|
||||
})
|
||||
assert.equal(result.timedOut, false, `${phase} timed out`)
|
||||
assert.equal(result.code, 0, `${phase} failed: ${result.stderr || result.stdout}`)
|
||||
const tests = JSON.parse(await readFile(report, 'utf8'))
|
||||
assert.equal(tests.numPassedTests, 13, `${phase} expected 13 cases`)
|
||||
assert.equal(tests.numFailedTests, 0)
|
||||
const actual = JSON.parse(await readFile(evaluated, 'utf8'))
|
||||
assert.deepEqual(
|
||||
actual.hashes,
|
||||
versions.evaluated[phase],
|
||||
`${phase} evaluated source graph differs`
|
||||
)
|
||||
phases[phase] = {
|
||||
passed: tests.numPassedTests,
|
||||
failed: tests.numFailedTests,
|
||||
cases: tests.testResults.flatMap((suite) =>
|
||||
suite.assertionResults.map((test) => ({ name: test.fullName, status: test.status }))
|
||||
),
|
||||
metrics: JSON.parse(await readFile(metrics, 'utf8')),
|
||||
evaluated: actual
|
||||
}
|
||||
console.log(
|
||||
`${phase}: ${tests.numPassedTests} passed; ${Object.keys(actual.hashes).length} evaluated sources`
|
||||
)
|
||||
}
|
||||
}
|
||||
const result = {
|
||||
passed: true,
|
||||
runtime: {
|
||||
node: process.versions.node,
|
||||
electron: process.versions.electron ?? null,
|
||||
platform: process.platform,
|
||||
arch: process.arch
|
||||
},
|
||||
workingFlavor: loaded.flavor,
|
||||
publicationCommit: versions.publicationCommit,
|
||||
dependencyScope: versions.scope,
|
||||
installedToolVersions: {
|
||||
vitest: require('vitest/package.json').version,
|
||||
esbuild: require('esbuild/package.json').version,
|
||||
diff: require('diff/package.json').version
|
||||
},
|
||||
reconstructedInputs,
|
||||
canonicalCrLfControl: true,
|
||||
runnerSourceHashes,
|
||||
artifactHashes,
|
||||
phases
|
||||
}
|
||||
await writeFile(
|
||||
process.argv[2] ? resolve(process.argv[2]) : join(artifact, 'node-results.json'),
|
||||
`${JSON.stringify(result, null, 2)}\n`
|
||||
)
|
||||
} finally {
|
||||
if (runnerId) {
|
||||
delete require.cache[runnerId]
|
||||
}
|
||||
await rm(scratch, { recursive: true, force: true })
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { handlers, clearStorage, authority } = vi.hoisted(() => ({
|
||||
authority: { userDataPath: '', reads: 0 },
|
||||
handlers: new Map(),
|
||||
clearStorage: vi.fn(async () => ({ clearedPartitions: [], livePartitions: [] }))
|
||||
}))
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => authority.userDataPath || tmpdir(),
|
||||
getName: () => 'orca-audit',
|
||||
getVersion: () => '0.0.0',
|
||||
isPackaged: false,
|
||||
on() {},
|
||||
whenReady: () => Promise.resolve()
|
||||
},
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => false,
|
||||
encryptString: (value) => Buffer.from(value),
|
||||
decryptString: (value) => value.toString()
|
||||
},
|
||||
ipcMain: {
|
||||
on: (name, handler) => handlers.set(name, handler),
|
||||
handle: (name, handler) => handlers.set(name, handler)
|
||||
},
|
||||
BrowserWindow: { getAllWindows: () => [] }
|
||||
}))
|
||||
vi.mock('../../../src/main/browser/browser-route-partition-storage-runtime', () => ({
|
||||
clearBrowserRoutePartitionStorageForEnvironment: clearStorage
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/shared/runtime-environment-store', async (importOriginal) => {
|
||||
const original = await importOriginal()
|
||||
return {
|
||||
...original,
|
||||
listEnvironments: (...args) => {
|
||||
authority.reads++
|
||||
return original.listEnvironments(...args)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const { Store } = await import('../../../src/main/persistence/loading-store/store')
|
||||
const { registerRuntimeEnvironmentConnectivityHandlers } =
|
||||
await import('../../../src/main/ipc/runtime-environment-connectivity-handlers')
|
||||
const { registerSessionHandlers } = await import('../../../src/main/ipc/session')
|
||||
const { registerRendererShutdownCheckpointHandler } =
|
||||
await import('../../../src/main/ipc/renderer-shutdown-checkpoint')
|
||||
const { addEnvironmentFromPairingCode, listEnvironments, getEnvironmentStorePath } =
|
||||
await import('../../../src/shared/runtime-environment-store')
|
||||
const { encodePairingOffer } = await import('../../../src/shared/pairing')
|
||||
const { getDefaultWorkspaceSession } = await import('../../../src/shared/constants')
|
||||
const { toRuntimeExecutionHostId } = await import('../../../src/shared/execution-host')
|
||||
const cleanups = []
|
||||
beforeEach(() => {
|
||||
authority.reads = 0
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
afterEach(() => {
|
||||
for (const cleanup of cleanups.splice(0)) {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
function fixture() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-partition-audit-'))
|
||||
authority.userDataPath = dir
|
||||
const dataFile = join(dir, 'orca-data.json')
|
||||
const store = new Store({ dataFile })
|
||||
const invalidateTransport = vi.fn(async () => {})
|
||||
registerRuntimeEnvironmentConnectivityHandlers({
|
||||
store,
|
||||
getUserDataPath: () => dir,
|
||||
invalidateTransport
|
||||
})
|
||||
registerSessionHandlers(store, undefined)
|
||||
registerRendererShutdownCheckpointHandler(store)
|
||||
cleanups.push(() => {
|
||||
store.flush()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
return { dir, dataFile, store, invalidateTransport }
|
||||
}
|
||||
|
||||
function pair(dir, name) {
|
||||
return addEnvironmentFromPairingCode(dir, {
|
||||
name,
|
||||
pairingCode: encodePairingOffer({
|
||||
v: 2,
|
||||
endpoint: 'ws://192.0.2.10:6768',
|
||||
deviceToken: 'audit-inert',
|
||||
publicKeyB64: Buffer.alloc(32, 1).toString('base64')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function session(id) {
|
||||
const worktreeId = `repo-a::/audit/${id}`
|
||||
return {
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: {
|
||||
[worktreeId]: [
|
||||
{
|
||||
id: `tab-${id}`,
|
||||
worktreeId,
|
||||
ptyId: null,
|
||||
title: 'Audit',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
import { RuntimeWorkspaceSessionController } from '../../../src/main/runtime/runtime-workspace-session-controller'
|
||||
import { RuntimeLegacyWorkerTerminalRecoveryPersistence } from '../../../src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence'
|
||||
import { persistClientHostedBrowserPages } from '../../../src/main/runtime/client-hosted-browser-page-persistence'
|
||||
import { RuntimeBrowserPageRegistry } from '../../../src/main/runtime/runtime-browser-page-registry'
|
||||
|
||||
const variant = process.env.ORCA_PARTITION_VARIANT
|
||||
if (!['before', 'fixed', 'without-rollback'].includes(variant)) {
|
||||
throw new Error('Unknown proof variant')
|
||||
}
|
||||
const fixed = variant !== 'before'
|
||||
const rollbackGuard = variant === 'fixed'
|
||||
const metrics = {}
|
||||
afterAll(() => writeFileSync(process.env.ORCA_PARTITION_METRICS, JSON.stringify(metrics, null, 2)))
|
||||
const WORKTREE = 'repo-a::/audit/worktree'
|
||||
const LEAF = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const TAB = 'legacy-worker-tab'
|
||||
const PANE = `${TAB}:${LEAF}`
|
||||
const INCARNATION = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
||||
|
||||
function ownRepo(store, hostId) {
|
||||
store.addRepo({
|
||||
id: 'repo-a',
|
||||
path: '/audit',
|
||||
displayName: 'Audit',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
executionHostId: hostId,
|
||||
kind: 'folder'
|
||||
})
|
||||
}
|
||||
function controllerFor(store) {
|
||||
return new RuntimeWorkspaceSessionController({
|
||||
getStore: () => store,
|
||||
resolveFolderConnectionId: () => null,
|
||||
hasRuntimeOwnedPtyCandidate: () => false
|
||||
})
|
||||
}
|
||||
function retiringSession() {
|
||||
return {
|
||||
...getDefaultWorkspaceSession(),
|
||||
activeTabId: TAB,
|
||||
tabsByWorktree: {
|
||||
[WORKTREE]: [
|
||||
{
|
||||
id: TAB,
|
||||
worktreeId: WORKTREE,
|
||||
ptyId: 'pty-a',
|
||||
title: 'Audit',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
[TAB]: {
|
||||
root: { type: 'leaf', leafId: LEAF },
|
||||
activeLeafId: LEAF,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF]: 'pty-a' }
|
||||
}
|
||||
},
|
||||
terminalPtyIncarnationsByPaneKey: { [PANE]: INCARNATION },
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
[PANE]: {
|
||||
paneKey: PANE,
|
||||
tabId: TAB,
|
||||
worktreeId: WORKTREE,
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'audit-codex' },
|
||||
prompt: 'continue',
|
||||
state: 'working',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1,
|
||||
origin: 'live'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
function recoveryCandidate() {
|
||||
return {
|
||||
dispatchId: 'dispatch-a',
|
||||
dispatchStatus: 'dispatched',
|
||||
contractVersion: 1,
|
||||
taskId: 'task-a',
|
||||
worktreeId: WORKTREE,
|
||||
terminalHandle: 'term-a',
|
||||
paneKey: PANE,
|
||||
tabId: TAB,
|
||||
leafId: LEAF,
|
||||
processIncarnation: `pty-a:${INCARNATION}`,
|
||||
ptyId: 'pty-a',
|
||||
incarnationId: INCARNATION
|
||||
}
|
||||
}
|
||||
function unpair(base, environment) {
|
||||
handlers.get('runtimeEnvironments:remove')(null, { selector: environment.id })
|
||||
}
|
||||
function isolateAbsentPartitionBeforeFix(store, hostId) {
|
||||
// Before has no removal API; isolate later-writer admission/rollback with an explicit simulated deletion.
|
||||
if (!fixed) {
|
||||
delete store.runtime.state.workspaceSessionsByHostId[hostId]
|
||||
}
|
||||
}
|
||||
async function invokeWrite(channel, state, hostId) {
|
||||
const event = {}
|
||||
if (channel === 'app:stage-before-unload-sync') {
|
||||
handlers.get(channel)(event, { sessions: [{ state, hostId }], ui: {} })
|
||||
return handlers.get('app:await-before-unload-checkpoint')()
|
||||
}
|
||||
await handlers.get(channel)(event, state, hostId)
|
||||
return event.returnValue
|
||||
}
|
||||
|
||||
describe('actual GUI unpair persistence', () => {
|
||||
it('measures 32 completed pair/write/unpair cycles through memory, explicit flush, and reload', async () => {
|
||||
const base = fixture()
|
||||
for (let index = 0; index < 32; index++) {
|
||||
const environment = pair(base.dir, `cycle-${index}`)
|
||||
const hostId = toRuntimeExecutionHostId(environment.id)
|
||||
await invokeWrite('session:set', session(`cycle-${index}`), hostId)
|
||||
unpair(base, environment)
|
||||
await invokeWrite('session:patch', { activeTabId: 'late' }, hostId)
|
||||
}
|
||||
const memory = base.store.getWorkspaceSessionHostIds().length - 1
|
||||
base.store.flushOrThrow()
|
||||
const disk = Object.keys(
|
||||
JSON.parse(readFileSync(base.dataFile, 'utf8')).workspaceSessionsByHostId ?? {}
|
||||
).length
|
||||
const reloaded = new Store({ dataFile: base.dataFile })
|
||||
const reload = reloaded.getWorkspaceSessionHostIds().length - 1
|
||||
reloaded.freezeWrites()
|
||||
metrics.cycles = {
|
||||
iterations: 32,
|
||||
memory,
|
||||
disk,
|
||||
reload,
|
||||
environmentCount: listEnvironments(base.dir).length,
|
||||
invalidations: base.invalidateTransport.mock.calls.length
|
||||
}
|
||||
expect(metrics.cycles).toEqual({
|
||||
iterations: 32,
|
||||
memory: fixed ? 0 : 32,
|
||||
disk: fixed ? 0 : 32,
|
||||
reload: fixed ? 0 : 32,
|
||||
environmentCount: 0,
|
||||
invalidations: 32
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['session:set', 'session:patch', 'session:set-sync', 'app:stage-before-unload-sync'])(
|
||||
'checks absent-partition late %s admission',
|
||||
async (channel) => {
|
||||
const base = fixture()
|
||||
const environment = pair(base.dir, channel)
|
||||
const hostId = toRuntimeExecutionHostId(environment.id)
|
||||
base.store.setWorkspaceSession(session('before'), hostId)
|
||||
unpair(base, environment)
|
||||
isolateAbsentPartitionBeforeFix(base.store, hostId)
|
||||
await invokeWrite(
|
||||
channel,
|
||||
channel === 'session:patch' ? { activeTabId: 'late' } : session('late'),
|
||||
hostId
|
||||
)
|
||||
const recreated = base.store.getWorkspaceSessionHostIds().includes(hostId)
|
||||
metrics[channel] = { recreated, beforeUsesSimulatedDeletion: !fixed }
|
||||
expect(recreated).toBe(!fixed)
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves exact same-ID main repository custody and its browser callback', () => {
|
||||
const base = fixture()
|
||||
const environment = pair(base.dir, 'same-id-namespace')
|
||||
const hostId = toRuntimeExecutionHostId(environment.id)
|
||||
ownRepo(base.store, hostId)
|
||||
base.store.setWorkspaceSession(retiringSession(), hostId)
|
||||
const controller = controllerFor(base.store)
|
||||
const registry = new RuntimeBrowserPageRegistry()
|
||||
registry.publishClientPage({
|
||||
browserPageId: 'page-a',
|
||||
workspaceId: WORKTREE,
|
||||
browserProfileId: 'profile-a',
|
||||
executionHostKey: 'native:runtime-a:1',
|
||||
placement: {
|
||||
kind: 'client',
|
||||
browserHostClientId: 'host-a',
|
||||
browserHostGeneration: 1,
|
||||
pageHostGeneration: 1
|
||||
},
|
||||
pairedDeviceId: 'device-a',
|
||||
url: 'https://example.invalid/',
|
||||
title: 'Owned page',
|
||||
loading: false,
|
||||
active: false
|
||||
})
|
||||
unpair(base, environment)
|
||||
expect(base.store.getWorkspaceSessionHostIds()).toContain(hostId)
|
||||
expect(
|
||||
persistClientHostedBrowserPages(
|
||||
{
|
||||
getWorkspaceSession: (id) => controller.get(id),
|
||||
setWorkspaceSession: (id, value) => controller.set(id, value)
|
||||
},
|
||||
registry,
|
||||
WORKTREE
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
base.store.getWorkspaceSession(hostId).clientHostedBrowserPagesByWorktree[WORKTREE]
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('preserves the unique old namespace selected by the actual controller', () => {
|
||||
const base = fixture()
|
||||
const environment = pair(base.dir, 'old-stamp')
|
||||
const hostId = toRuntimeExecutionHostId(environment.id)
|
||||
ownRepo(base.store, 'runtime:current-self-stamp')
|
||||
base.store.setWorkspaceSession(retiringSession(), hostId)
|
||||
expect(controllerFor(base.store).getHostId(WORKTREE)).toBe(hostId)
|
||||
unpair(base, environment)
|
||||
expect(base.store.getWorkspaceSessionHostIds()).toContain(hostId)
|
||||
})
|
||||
|
||||
it.each(['adopted', 'exited'])(
|
||||
'records failed %s recovery after catalog removal and unpair',
|
||||
async (resolution) => {
|
||||
const base = fixture()
|
||||
const environment = pair(base.dir, 'recovery')
|
||||
const hostId = toRuntimeExecutionHostId(environment.id)
|
||||
ownRepo(base.store, hostId)
|
||||
base.store.setWorkspaceSession(retiringSession(), hostId)
|
||||
const controller = controllerFor(base.store)
|
||||
let rejectFlush
|
||||
vi.spyOn(base.store, 'flushPendingOrThrowAsync').mockReturnValue(
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectFlush = reject
|
||||
})
|
||||
)
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const recovery = new RuntimeLegacyWorkerTerminalRecoveryPersistence(
|
||||
() => base.store,
|
||||
() => {
|
||||
throw new Error('No database access expected')
|
||||
},
|
||||
(id) => controller.tryGetHostId(id)
|
||||
)
|
||||
const work = recovery.persist([{ candidate: recoveryCandidate(), resolution }])
|
||||
base.store.removeProjectForHost('repo-a', hostId)
|
||||
unpair(base, environment)
|
||||
isolateAbsentPartitionBeforeFix(base.store, hostId)
|
||||
rejectFlush(new Error('Controlled asynchronous persistence failure'))
|
||||
await expect(work).resolves.toEqual(new Set())
|
||||
const recreated = base.store.getWorkspaceSessionHostIds().includes(hostId)
|
||||
metrics[`recovery-${resolution}`] = { recreated, beforeUsesSimulatedDeletion: !fixed }
|
||||
expect(recreated).toBe(resolution === 'exited' && !rollbackGuard)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['local', 'ssh:direct-target', 'runtime:historical-self-stamp'])(
|
||||
'preserves existing adopted rollback for %s',
|
||||
async (hostId) => {
|
||||
const base = fixture()
|
||||
ownRepo(base.store, hostId)
|
||||
base.store.setWorkspaceSession(retiringSession(), hostId)
|
||||
const controller = controllerFor(base.store)
|
||||
vi.spyOn(base.store, 'flushPendingOrThrowAsync').mockRejectedValue(
|
||||
new Error('Controlled persistence failure')
|
||||
)
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const recovery = new RuntimeLegacyWorkerTerminalRecoveryPersistence(
|
||||
() => base.store,
|
||||
() => {
|
||||
throw new Error('No database access expected')
|
||||
},
|
||||
(id) => controller.tryGetHostId(id)
|
||||
)
|
||||
await expect(
|
||||
recovery.persist([{ candidate: recoveryCandidate(), resolution: 'adopted' }])
|
||||
).resolves.toEqual(new Set())
|
||||
expect(
|
||||
base.store.getWorkspaceSession(hostId).sleepingAgentSessionsByPaneKey[PANE].prompt
|
||||
).toBe('continue')
|
||||
}
|
||||
)
|
||||
|
||||
it('records the legacy sync receipt when new-partition authority is unreadable', async () => {
|
||||
const base = fixture()
|
||||
const environment = pair(base.dir, 'unreadable')
|
||||
const hostId = toRuntimeExecutionHostId(environment.id)
|
||||
const knownHost = 'runtime:known-existing'
|
||||
base.store.setWorkspaceSession(session('known'), knownHost)
|
||||
base.store.setWorkspaceSession(session('local'))
|
||||
writeFileSync(getEnvironmentStorePath(base.dir), '{broken')
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const receipt = await invokeWrite('session:set-sync', session('unknown'), hostId)
|
||||
expect(receipt).toBe(!fixed)
|
||||
const knownReceipt = await invokeWrite('session:set-sync', session('known-after'), knownHost)
|
||||
expect(knownReceipt).toBe(true)
|
||||
metrics.syncAuthorityFailure = {
|
||||
receipt,
|
||||
knownReceipt,
|
||||
created: base.store.getWorkspaceSessionHostIds().includes(hostId)
|
||||
}
|
||||
expect(metrics.syncAuthorityFailure.created).toBe(!fixed)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,152 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const { createHash } = require('node:crypto')
|
||||
const { existsSync, readFileSync, writeFileSync } = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { applyPatch, parsePatch, reversePatch } = require('diff')
|
||||
|
||||
const root = path.resolve(__dirname, '../../..')
|
||||
const read = (file) => readFileSync(file, 'utf8').replaceAll('\r\n', '\n')
|
||||
const sha = (text) => createHash('sha256').update(text).digest('hex')
|
||||
const versions = JSON.parse(read(path.join(__dirname, 'source-versions.json')))
|
||||
const flavors = ['currentFixed', 'mainFixed', 'currentBefore', 'mainBefore']
|
||||
|
||||
function checkMap(sources, flavor) {
|
||||
for (const [file, expected] of Object.entries(versions.sources)) {
|
||||
const actual = sources[file] === null ? null : sha(sources[file])
|
||||
assert.equal(actual, expected[flavor], `${flavor} source drift: ${file}`)
|
||||
}
|
||||
}
|
||||
|
||||
function transition(sources, patchName, reverse, flavor) {
|
||||
const result = { ...sources }
|
||||
for (const original of parsePatch(read(path.join(__dirname, patchName)))) {
|
||||
const patch = reverse ? reversePatch(original) : original
|
||||
const file = (patch.newFileName === '/dev/null' ? patch.oldFileName : patch.newFileName).slice(
|
||||
2
|
||||
)
|
||||
assert.ok(Object.hasOwn(versions.sources, file), `Unfenced patch path: ${file}`)
|
||||
const updated = applyPatch(result[file] ?? '', patch)
|
||||
assert.notEqual(updated, false, `Patch failed: ${patchName}:${file}`)
|
||||
if (versions.sources[file][flavor] === null) {
|
||||
assert.equal(updated, '', `Deleted source is not empty: ${file}`)
|
||||
result[file] = null
|
||||
} else {
|
||||
result[file] = updated
|
||||
}
|
||||
}
|
||||
checkMap(result, flavor)
|
||||
return result
|
||||
}
|
||||
|
||||
function loadVariants(readSource) {
|
||||
assert.equal(process.env.ORCA_BACKGROUND_LAUNCH, '1')
|
||||
const physical = {}
|
||||
for (const file of Object.keys(versions.sources)) {
|
||||
const absolute = path.join(root, file)
|
||||
const source = readSource ? readSource(file) : existsSync(absolute) ? read(absolute) : null
|
||||
physical[file] = source?.replaceAll('\r\n', '\n') ?? null
|
||||
}
|
||||
const physicalHashes = Object.fromEntries(
|
||||
Object.entries(physical).map(([file, source]) => [file, source === null ? null : sha(source)])
|
||||
)
|
||||
const flavor = flavors.find((candidate) =>
|
||||
Object.entries(versions.sources).every(
|
||||
([file, expected]) => physicalHashes[file] === expected[candidate]
|
||||
)
|
||||
)
|
||||
assert.ok(
|
||||
flavor,
|
||||
'Working source graph matches no exact audited/publication before/fixed identity'
|
||||
)
|
||||
for (const [file, expected] of Object.entries(versions.runnerSources)) {
|
||||
assert.equal(sha(read(path.join(root, file))), expected, `Runner source drift: ${file}`)
|
||||
}
|
||||
let fixed = physical
|
||||
if (flavor === 'currentBefore') {
|
||||
fixed = transition(fixed, 'fix.patch', false, 'currentFixed')
|
||||
} else if (flavor === 'mainBefore') {
|
||||
fixed = transition(fixed, 'main-fix.patch', false, 'mainFixed')
|
||||
}
|
||||
if (flavor.startsWith('main')) {
|
||||
fixed = transition(fixed, 'publication.patch', true, 'currentFixed')
|
||||
}
|
||||
const currentBefore = transition(fixed, 'fix.patch', true, 'currentBefore')
|
||||
const mainFixed = transition(fixed, 'publication.patch', false, 'mainFixed')
|
||||
const mainBefore = transition(mainFixed, 'main-fix.patch', true, 'mainBefore')
|
||||
const recovery = 'src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts'
|
||||
return {
|
||||
flavor,
|
||||
maps: {
|
||||
'current-fixed': fixed,
|
||||
'current-before': currentBefore,
|
||||
'current-without-rollback': { ...fixed, [recovery]: currentBefore[recovery] },
|
||||
'main-fixed': mainFixed,
|
||||
'main-before': mainBefore,
|
||||
'main-without-rollback': { ...mainFixed, [recovery]: mainBefore[recovery] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function phasePlugin(phase) {
|
||||
const loaded = loadVariants()
|
||||
const sources = loaded.maps[phase]
|
||||
const expected = versions.evaluated[phase]
|
||||
assert.ok(sources && expected, `Unknown proof phase: ${phase}`)
|
||||
const evaluated = {}
|
||||
const relative = (file) => path.relative(root, file.split('?')[0]).replaceAll('\\', '/')
|
||||
process.once('exit', () => {
|
||||
if (process.env.ORCA_PARTITION_EVALUATED) {
|
||||
writeFileSync(
|
||||
process.env.ORCA_PARTITION_EVALUATED,
|
||||
JSON.stringify({ workingFlavor: loaded.flavor, hashes: evaluated }, null, 2)
|
||||
)
|
||||
}
|
||||
})
|
||||
return {
|
||||
name: 'fenced-paired-host-partition-sources',
|
||||
enforce: 'pre',
|
||||
resolveId(specifier, importer) {
|
||||
if (!importer || !specifier.startsWith('.') || !relative(importer).startsWith('src/')) {
|
||||
return undefined
|
||||
}
|
||||
const stem = path.resolve(path.dirname(importer), specifier)
|
||||
for (const candidate of [
|
||||
stem,
|
||||
`${stem}.ts`,
|
||||
`${stem}.tsx`,
|
||||
`${stem}.js`,
|
||||
path.join(stem, 'index.ts')
|
||||
]) {
|
||||
const file = relative(candidate)
|
||||
if (sources[file] != null) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
load(id) {
|
||||
const file = relative(id)
|
||||
return Object.hasOwn(sources, file) && sources[file] !== null ? sources[file] : undefined
|
||||
},
|
||||
transform(_source, id) {
|
||||
const file = relative(id)
|
||||
if (!file.startsWith('src/')) {
|
||||
return undefined
|
||||
}
|
||||
if (versions.runnerSources[file]) {
|
||||
assert.equal(sha(_source.replaceAll('\r\n', '\n')), versions.runnerSources[file])
|
||||
return undefined
|
||||
}
|
||||
assert.ok(expected[file], `Unfenced evaluated module: ${phase}:${file}`)
|
||||
assert.equal(
|
||||
sha(sources[file]),
|
||||
expected[file],
|
||||
`Evaluated source mismatch: ${phase}:${file}`
|
||||
)
|
||||
evaluated[file] = sha(sources[file])
|
||||
return { code: sources[file], map: null }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { root, sha, read, versions, loadVariants, phasePlugin }
|
||||
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"scope": "Scoped GUI unpair persistence retirement; no process memory or incident attribution",
|
||||
"permanentTests": {
|
||||
"passed": 79,
|
||||
"failed": 0,
|
||||
"suites": 9,
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/runtime-environment-session-retirement.test.ts src/main/runtime/runtime-workspace-session-namespace-custody.test.ts src/main/ipc/runtime-environment-removal-storage.test.ts src/main/ipc/runtime-environments-pairing.test.ts src/main/ipc/runtime-environments-capability-cache.test.ts src/main/ipc/runtime-environments-subscription-teardown.test.ts src/main/ipc/renderer-shutdown-checkpoint.test.ts src/main/persistence/loading-store/store-runtime-authored-session-writes.test.ts src/main/persistence/loading-store/workspace-session-partitions.test.ts"
|
||||
},
|
||||
"baselinePermanentControl": {
|
||||
"passed": 5,
|
||||
"expectedFailures": 11,
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config docs/audits/paired-host-session-partition-retirement/before.config.mjs",
|
||||
"failingCases": [
|
||||
"renderer session partition admission rejects late session:set after actual GUI unpair",
|
||||
"renderer session partition admission rejects late session:patch after actual GUI unpair",
|
||||
"renderer session partition admission rejects late session:set-sync after actual GUI unpair",
|
||||
"renderer session partition admission rejects late app:stage-before-unload-sync after actual GUI unpair",
|
||||
"renderer session partition admission consults disk for the first valid runtime partition and not its next 64 scalar patches",
|
||||
"renderer session partition admission keeps a newly paired same-name ID and rejects the old ID even when used as another pair name",
|
||||
"renderer session partition admission persists unrelated shutdown sessions and UI while reporting an unreadable new-host authority",
|
||||
"bounded paired mirror retirement keeps 32 pair/unpair cycles absent in memory, disk, and reload",
|
||||
"removal failure ordering replies to the legacy sync channel and flushes unrelated state when admission fails",
|
||||
"removal failure ordering keeps pairing and transport intact if custody cannot be established",
|
||||
"removal failure ordering starts transport and browser retirement even if exact Store partition deletion fails"
|
||||
],
|
||||
"interpretation": "Expected failures cover added admission/removal/failure-order contracts, including two controls requiring the new Store deletion API. The portable 13-case fixture separately demonstrates actual baseline retention and the isolated rollback-guard omission."
|
||||
},
|
||||
"portableProof": {
|
||||
"node": {
|
||||
"report": "node-results.json",
|
||||
"cases": 78,
|
||||
"passed": true
|
||||
},
|
||||
"electron": {
|
||||
"report": "electron-results.json",
|
||||
"cases": 78,
|
||||
"passed": true
|
||||
},
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 node docs/audits/paired-host-session-partition-retirement/reproduce.mjs [optional-output-path]",
|
||||
"sourceGraphs": "Complete evaluated src module graphs, exact before/fixed identities on current audit and publication main291b; installed working node_modules dependencies"
|
||||
},
|
||||
"typecheck": {
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm tc:node",
|
||||
"exitCode": 0
|
||||
},
|
||||
"quality": {
|
||||
"changedGate": {
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm run check:code-quality:changed HEAD",
|
||||
"exitCode": 0,
|
||||
"newFindings": 0
|
||||
},
|
||||
"ordinaryLintAllOwnedCode": {
|
||||
"exitCode": 0,
|
||||
"includesIgnoredArtifacts": true
|
||||
},
|
||||
"newCodeAndAllArtifactFullFileScans": {
|
||||
"scans": [
|
||||
"ordinary",
|
||||
"casting",
|
||||
"type-aware-quality",
|
||||
"React Doctor",
|
||||
"design-system",
|
||||
"ordinary-type-aware"
|
||||
],
|
||||
"exitCodes": [0, 0, 0, 0, 0, 0],
|
||||
"paths": [
|
||||
"src/main/ipc/renderer-workspace-session-admission.ts",
|
||||
"src/main/runtime/runtime-workspace-session-namespace-custody.ts",
|
||||
"src/main/ipc/runtime-environment-session-retirement.test.ts",
|
||||
"src/main/runtime/runtime-workspace-session-namespace-custody.test.ts",
|
||||
"docs/audits/paired-host-session-partition-retirement/before.config.mjs",
|
||||
"docs/audits/paired-host-session-partition-retirement/phase.config.mjs",
|
||||
"docs/audits/paired-host-session-partition-retirement/reproduce.mjs",
|
||||
"docs/audits/paired-host-session-partition-retirement/scenario.test.mjs",
|
||||
"docs/audits/paired-host-session-partition-retirement/sources.cjs"
|
||||
]
|
||||
},
|
||||
"existingFullFileLimits": "Additional full-file casting/type-aware scans expose pre-existing assertions in old suites and an unchanged disconnect-handler floating promise; changed-lines gate passes, no suppressions or unrelated edits added."
|
||||
},
|
||||
"whitespace": {
|
||||
"productDiff": true,
|
||||
"allArtifactFilesAsAdditions": true,
|
||||
"zeroContextPatches": true
|
||||
},
|
||||
"limitations": [
|
||||
"GUI response does not join scheduled profile deletion or make catalog/profile writes atomic",
|
||||
"CLI catalog removal and ephemeral cleanup are outside this GUI boundary",
|
||||
"Folder host-stamp controls explicitly declare in-memory main custody; persisted folder normalization strips those stamps",
|
||||
"Rollback check is partition presence, not a same-ID incarnation fence",
|
||||
"Renderer retained objects and existing unknown historical partitions are not swept",
|
||||
"No RSS, byte reduction, or exclusive incident attribution claimed"
|
||||
]
|
||||
}
|
||||
@@ -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(() => ({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { canCreateRendererSessionPartition } 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'
|
||||
@@ -47,17 +48,30 @@ export function registerRendererShutdownCheckpointHandler(store: Store): void {
|
||||
|
||||
ipcMain.on('app:stage-before-unload-sync', (event, args: StageBeforeUnloadSyncArgs) => {
|
||||
let ok = true
|
||||
let admissionOk = true
|
||||
try {
|
||||
for (const { state, hostId } of args.sessions) {
|
||||
store.stageWorkspaceSessionBeforeUnload(state, hostId)
|
||||
let admitted: boolean
|
||||
try {
|
||||
admitted = canCreateRendererSessionPartition(store, hostId)
|
||||
} catch (error) {
|
||||
console.error('[app] Failed to establish runtime session partition authority:', error)
|
||||
admissionOk = false
|
||||
continue
|
||||
}
|
||||
if (admitted) {
|
||||
store.stageWorkspaceSessionBeforeUnload(state, hostId)
|
||||
}
|
||||
}
|
||||
store.updateUI(args.ui)
|
||||
} catch (error) {
|
||||
console.error('[app] Failed to stage renderer state before unload:', error)
|
||||
ok = false
|
||||
}
|
||||
pendingCheckpoint = ok ? flushStagedStateWithDeadline(store) : Promise.resolve({ ok: false })
|
||||
event.returnValue = { ok }
|
||||
pendingCheckpoint = ok
|
||||
? flushStagedStateWithDeadline(store).then((result) => ({ ok: result.ok && admissionOk }))
|
||||
: Promise.resolve({ ok: false })
|
||||
event.returnValue = { ok: ok && admissionOk }
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
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 canCreateRendererSessionPartition(store: Store, hostId?: string | null): boolean {
|
||||
const parsed = parseExecutionHostId(hostId)
|
||||
if (parsed?.kind !== 'runtime' || store.getWorkspaceSessionHostIds().includes(parsed.id)) {
|
||||
return true
|
||||
}
|
||||
if (hasMainOwnedRuntimeSessionNamespace(store, parsed.id)) {
|
||||
return true
|
||||
}
|
||||
return listEnvironments(app.getPath('userData')).some(
|
||||
(entry) => entry.id === parsed.environmentId
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { hasMainOwnedRuntimeSessionNamespace } from '../runtime/runtime-workspace-session-namespace-custody'
|
||||
import { toRuntimeExecutionHostId } from '../../shared/execution-host'
|
||||
import { ipcMain } from 'electron'
|
||||
import {
|
||||
addEnvironmentFromPairingCode,
|
||||
@@ -105,6 +107,8 @@ export function registerRuntimeEnvironmentConnectivityHandlers({
|
||||
if (store.getSettings().activeRuntimeEnvironmentId === environment.id) {
|
||||
throw new Error('Choose another Active Server in Advanced before removing this server.')
|
||||
}
|
||||
const hostId = toRuntimeExecutionHostId(environment.id)
|
||||
const preserveMainNamespace = hasMainOwnedRuntimeSessionNamespace(store, hostId)
|
||||
const removed = removeEnvironment(getUserDataPath(), args.selector)
|
||||
clearRuntimeEnvironmentCapabilityEvidence(removed.id)
|
||||
clearRuntimeEnvironmentManualDisconnect(removed.id)
|
||||
@@ -122,6 +126,9 @@ export function registerRuntimeEnvironmentConnectivityHandlers({
|
||||
}).catch((error) => {
|
||||
console.warn('[runtime-environments] browser partition storage clear failed:', error)
|
||||
})
|
||||
if (!preserveMainNamespace) {
|
||||
store.removeRuntimeWorkspaceSessionPartition(hostId)
|
||||
}
|
||||
return { removed: redactRuntimeEnvironment(removed) }
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
vi.mock('../runtime/runtime-workspace-session-namespace-custody', () => ({
|
||||
hasMainOwnedRuntimeSessionNamespace: () => false
|
||||
}))
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { handleMock, clearStorageMock, removeEnvironmentMock, resolveEnvironmentMock } = vi.hoisted(
|
||||
@@ -47,7 +51,8 @@ describe('runtime environment removal storage clearing', () => {
|
||||
})
|
||||
clearStorageMock.mockResolvedValue({ clearedPartitions: ['persist:one'], livePartitions: [] })
|
||||
registerRuntimeEnvironmentConnectivityHandlers({
|
||||
store: { getSettings: () => ({}) } as never,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mocked custody path uses only these Store methods.
|
||||
store: { getSettings: () => ({}), removeRuntimeWorkspaceSessionPartition: vi.fn() } as never,
|
||||
getUserDataPath: () => '/tmp/orca-user-data',
|
||||
invalidateTransport: () => teardown
|
||||
})
|
||||
@@ -67,7 +72,8 @@ describe('runtime environment removal storage clearing', () => {
|
||||
.mockResolvedValueOnce({ clearedPartitions: [], livePartitions: ['persist:one'] })
|
||||
.mockResolvedValueOnce({ clearedPartitions: ['persist:one'], livePartitions: [] })
|
||||
registerRuntimeEnvironmentConnectivityHandlers({
|
||||
store: { getSettings: () => ({}) } as never,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mocked custody path uses only these Store methods.
|
||||
store: { getSettings: () => ({}), removeRuntimeWorkspaceSessionPartition: vi.fn() } as never,
|
||||
getUserDataPath: () => '/tmp/orca-user-data',
|
||||
invalidateTransport: () => Promise.resolve()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
WorkspaceSessionState,
|
||||
WorkspaceSessionPatch
|
||||
} from '../../shared/workspace-session-state-types'
|
||||
import type { KnownRuntimeEnvironment } from '../../shared/runtime-environments'
|
||||
import type * as RuntimeEnvironmentStore from '../../shared/runtime-environment-store'
|
||||
|
||||
const { handlers, clearStorage, authority } = vi.hoisted(() => ({
|
||||
authority: { userDataPath: '', reads: 0 },
|
||||
handlers: new Map<string, (...args: unknown[]) => unknown>(),
|
||||
clearStorage: vi.fn(async () => ({ clearedPartitions: [], livePartitions: [] }))
|
||||
}))
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => authority.userDataPath || tmpdir(),
|
||||
getName: () => 'orca-audit',
|
||||
getVersion: () => '0.0.0',
|
||||
isPackaged: false,
|
||||
on() {},
|
||||
whenReady: () => Promise.resolve()
|
||||
},
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => false,
|
||||
encryptString: (value: string) => Buffer.from(value),
|
||||
decryptString: (value: Buffer) => value.toString()
|
||||
},
|
||||
ipcMain: {
|
||||
on: (name: string, handler: (...args: unknown[]) => unknown) => handlers.set(name, handler),
|
||||
handle: (name: string, handler: (...args: unknown[]) => unknown) => handlers.set(name, handler)
|
||||
},
|
||||
BrowserWindow: { getAllWindows: () => [] }
|
||||
}))
|
||||
vi.mock('../browser/browser-route-partition-storage-runtime', () => ({
|
||||
clearBrowserRoutePartitionStorageForEnvironment: clearStorage
|
||||
}))
|
||||
|
||||
vi.mock('../../shared/runtime-environment-store', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof RuntimeEnvironmentStore>()
|
||||
return {
|
||||
...original,
|
||||
listEnvironments: (...args: Parameters<typeof original.listEnvironments>) => {
|
||||
authority.reads++
|
||||
return original.listEnvironments(...args)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const { Store } = await import('../persistence/loading-store/store')
|
||||
const { registerRuntimeEnvironmentConnectivityHandlers } =
|
||||
await import('./runtime-environment-connectivity-handlers')
|
||||
const { registerSessionHandlers } = await import('./session')
|
||||
const { registerRendererShutdownCheckpointHandler } = await import('./renderer-shutdown-checkpoint')
|
||||
const {
|
||||
addEnvironmentFromPairingCode,
|
||||
listEnvironments,
|
||||
updateEnvironmentFromPairingCode,
|
||||
getEnvironmentStorePath
|
||||
} = await import('../../shared/runtime-environment-store')
|
||||
const { encodePairingOffer } = await import('../../shared/pairing')
|
||||
const { getDefaultWorkspaceSession } = await import('../../shared/constants')
|
||||
const { toRuntimeExecutionHostId } = await import('../../shared/execution-host')
|
||||
const cleanups: (() => void)[] = []
|
||||
beforeEach(() => {
|
||||
authority.reads = 0
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
afterEach(() => {
|
||||
for (const cleanup of cleanups.splice(0)) {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
function fixture() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-partition-audit-'))
|
||||
authority.userDataPath = dir
|
||||
const dataFile = join(dir, 'orca-data.json')
|
||||
const store = new Store({ dataFile })
|
||||
const invalidateTransport = vi.fn(async () => {})
|
||||
registerRuntimeEnvironmentConnectivityHandlers({
|
||||
store,
|
||||
getUserDataPath: () => dir,
|
||||
invalidateTransport
|
||||
})
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These tests invoke only session writes; the retirement channel's runtime is never accessed.
|
||||
registerSessionHandlers(store, undefined as never)
|
||||
registerRendererShutdownCheckpointHandler(store)
|
||||
cleanups.push(() => {
|
||||
store.flush()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
return { dir, dataFile, store, invalidateTransport }
|
||||
}
|
||||
|
||||
function pair(dir: string, name: string) {
|
||||
return addEnvironmentFromPairingCode(dir, {
|
||||
name,
|
||||
pairingCode: encodePairingOffer({
|
||||
v: 2,
|
||||
endpoint: 'ws://192.0.2.10:6768',
|
||||
deviceToken: 'audit-inert',
|
||||
publicKeyB64: Buffer.alloc(32, 1).toString('base64')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function session(id: string): WorkspaceSessionState {
|
||||
const worktreeId = `repo-a::/audit/${id}`
|
||||
return {
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: {
|
||||
[worktreeId]: [
|
||||
{
|
||||
id: `tab-${id}`,
|
||||
worktreeId,
|
||||
ptyId: null,
|
||||
title: 'Audit',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function unpair(
|
||||
dir: string,
|
||||
store: InstanceType<typeof Store>,
|
||||
environment: KnownRuntimeEnvironment
|
||||
) {
|
||||
handlers.get('runtimeEnvironments:remove')!(null, { selector: environment.id })
|
||||
expect(listEnvironments(dir).some((entry) => entry.id === environment.id)).toBe(false)
|
||||
expect(store.getWorkspaceSessionHostIds()).not.toContain(toRuntimeExecutionHostId(environment.id))
|
||||
authority.reads = 0
|
||||
}
|
||||
|
||||
async function invokeWrite(
|
||||
channel: string,
|
||||
state: WorkspaceSessionState | WorkspaceSessionPatch,
|
||||
hostId?: string
|
||||
) {
|
||||
const event: { returnValue?: unknown } = {}
|
||||
if (channel === 'app:stage-before-unload-sync') {
|
||||
handlers.get(channel)!(event, { sessions: [{ state, hostId }], ui: {} })
|
||||
return handlers.get('app:await-before-unload-checkpoint')!()
|
||||
}
|
||||
await handlers.get(channel)!(event, state, hostId)
|
||||
return event.returnValue
|
||||
}
|
||||
|
||||
describe('renderer session partition admission', () => {
|
||||
it.each(['session:set', 'session:patch', 'session:set-sync', 'app:stage-before-unload-sync'])(
|
||||
'rejects late %s after actual GUI unpair',
|
||||
async (channel) => {
|
||||
const { dir, store } = fixture()
|
||||
const environment = pair(dir, 'retired')
|
||||
const hostId = toRuntimeExecutionHostId(environment.id)
|
||||
store.setWorkspaceSession(session('before'), hostId)
|
||||
unpair(dir, store, environment)
|
||||
await invokeWrite(
|
||||
channel,
|
||||
channel === 'session:patch' ? { activeTabId: 'late' } : session('late'),
|
||||
hostId
|
||||
)
|
||||
expect(store.getWorkspaceSessionHostIds()).not.toContain(hostId)
|
||||
expect(authority.reads).toBe(1)
|
||||
}
|
||||
)
|
||||
|
||||
it('consults disk for the first valid runtime partition and not its next 64 scalar patches', async () => {
|
||||
const { dir, store } = fixture()
|
||||
const environment = pair(dir, 'live')
|
||||
const hostId = toRuntimeExecutionHostId(environment.id)
|
||||
await invokeWrite('session:set', session('created'), hostId)
|
||||
expect(authority.reads).toBe(1)
|
||||
for (let index = 0; index < 64; index++) {
|
||||
await invokeWrite('session:patch', { activeTabId: `tab-${index}` }, hostId)
|
||||
}
|
||||
expect(authority.reads).toBe(1)
|
||||
expect(store.getWorkspaceSession(hostId).activeTabId).toBe('tab-63')
|
||||
})
|
||||
|
||||
it('preserves local and direct SSH writes without consulting pairing authority', async () => {
|
||||
const { store } = fixture()
|
||||
for (const hostId of [undefined, 'local', 'ssh:unpaired-ssh']) {
|
||||
await invokeWrite('session:set', session(hostId ?? 'omitted'), hostId)
|
||||
expect(Object.keys(store.getWorkspaceSession(hostId).tabsByWorktree)).toHaveLength(1)
|
||||
}
|
||||
expect(authority.reads).toBe(0)
|
||||
})
|
||||
|
||||
it('retains disconnected and repaired same-ID partitions', async () => {
|
||||
const { dir, store } = fixture()
|
||||
const environment = pair(dir, 'repair')
|
||||
const hostId = toRuntimeExecutionHostId(environment.id)
|
||||
await invokeWrite('session:set', session('before'), hostId)
|
||||
handlers.get('runtimeEnvironments:disconnect')!(null, { selector: environment.id })
|
||||
const pairingCode = encodePairingOffer({
|
||||
v: 2,
|
||||
endpoint: 'ws://192.0.2.11:6768',
|
||||
deviceToken: 'audit-repair',
|
||||
publicKeyB64: Buffer.alloc(32, 2).toString('base64')
|
||||
})
|
||||
const repaired = updateEnvironmentFromPairingCode(dir, environment.id, { pairingCode })
|
||||
expect(repaired.id).toBe(environment.id)
|
||||
authority.reads = 0
|
||||
await invokeWrite('session:patch', { activeTabId: 'after-repair' }, hostId)
|
||||
expect(authority.reads).toBe(0)
|
||||
expect(store.getWorkspaceSession(hostId).activeTabId).toBe('after-repair')
|
||||
})
|
||||
|
||||
it('keeps a newly paired same-name ID and rejects the old ID even when used as another pair name', async () => {
|
||||
const { dir, store } = fixture()
|
||||
const prior = pair(dir, 'same-name')
|
||||
const oldHost = toRuntimeExecutionHostId(prior.id)
|
||||
store.setWorkspaceSession(session('old'), oldHost)
|
||||
unpair(dir, store, prior)
|
||||
const next = pair(dir, 'same-name')
|
||||
pair(dir, prior.id)
|
||||
expect(next.id).not.toBe(prior.id)
|
||||
await invokeWrite('session:set', session('old-late'), oldHost)
|
||||
await invokeWrite('session:set', session('new'), toRuntimeExecutionHostId(next.id))
|
||||
expect(store.getWorkspaceSessionHostIds()).not.toContain(oldHost)
|
||||
expect(store.getWorkspaceSessionHostIds()).toContain(toRuntimeExecutionHostId(next.id))
|
||||
})
|
||||
|
||||
it('keeps existing historical runtime partitions writable without assuming their suffix is a paired ID', async () => {
|
||||
const { store } = fixture()
|
||||
store.setWorkspaceSession(session('legacy'), 'runtime:reported-runtime-id')
|
||||
await invokeWrite(
|
||||
'session:patch',
|
||||
{ activeTabId: 'still-owned' },
|
||||
'runtime:reported-runtime-id'
|
||||
)
|
||||
expect(authority.reads).toBe(0)
|
||||
expect(store.getWorkspaceSession('runtime:reported-runtime-id').activeTabId).toBe('still-owned')
|
||||
})
|
||||
|
||||
it('persists unrelated shutdown sessions and UI while reporting an unreadable new-host authority', async () => {
|
||||
const { dir, store, dataFile } = fixture()
|
||||
const environment = pair(dir, 'new')
|
||||
const hostId = toRuntimeExecutionHostId(environment.id)
|
||||
const knownHost = 'runtime:known-existing'
|
||||
store.setWorkspaceSession(session('known-before'), knownHost)
|
||||
writeFileSync(getEnvironmentStorePath(dir), '{broken')
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const event: { returnValue?: unknown } = {}
|
||||
handlers.get('app:stage-before-unload-sync')!(event, {
|
||||
sessions: [
|
||||
{ state: session('unverifiable'), hostId },
|
||||
{ state: session('local-after'), hostId: 'local' },
|
||||
{ state: session('ssh-after'), hostId: 'ssh:target' },
|
||||
{ state: session('known-after'), hostId: knownHost }
|
||||
],
|
||||
ui: { sidebarWidth: 477 }
|
||||
})
|
||||
expect(event.returnValue).toEqual({ ok: false })
|
||||
await expect(handlers.get('app:await-before-unload-checkpoint')!()).resolves.toEqual({
|
||||
ok: false
|
||||
})
|
||||
expect(store.getWorkspaceSessionHostIds()).not.toContain(hostId)
|
||||
const disk = JSON.parse(readFileSync(dataFile, 'utf8'))
|
||||
expect(disk.ui.sidebarWidth).toBe(477)
|
||||
expect(Object.keys(disk.workspaceSession.tabsByWorktree)).toEqual([
|
||||
'repo-a::/audit/local-after'
|
||||
])
|
||||
expect(Object.keys(disk.workspaceSessionsByHostId['ssh:target'].tabsByWorktree)).toEqual([
|
||||
'repo-a::/audit/ssh-after'
|
||||
])
|
||||
expect(Object.keys(disk.workspaceSessionsByHostId[knownHost].tabsByWorktree)).toEqual([
|
||||
'repo-a::/audit/known-after'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps existing no-flush behavior for actual staging errors', async () => {
|
||||
const { store } = fixture()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.spyOn(store, 'stageWorkspaceSessionBeforeUnload').mockImplementation(() => {
|
||||
throw new Error('actual-stage-error')
|
||||
})
|
||||
const flush = vi.spyOn(store, 'flushPendingOrThrowAsync')
|
||||
const event: { returnValue?: unknown } = {}
|
||||
handlers.get('app:stage-before-unload-sync')!(event, {
|
||||
sessions: [{ state: session('local') }],
|
||||
ui: {}
|
||||
})
|
||||
expect(event.returnValue).toEqual({ ok: false })
|
||||
expect(flush).not.toHaveBeenCalled()
|
||||
await expect(handlers.get('app:await-before-unload-checkpoint')!()).resolves.toEqual({
|
||||
ok: false
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('bounded paired mirror retirement', () => {
|
||||
it('keeps 32 pair/unpair cycles absent in memory, disk, and reload', async () => {
|
||||
const { dir, store, dataFile, invalidateTransport } = fixture()
|
||||
const retiredHosts: string[] = []
|
||||
for (let index = 0; index < 32; index++) {
|
||||
const environment = pair(dir, `cycle-${index}`)
|
||||
const hostId = toRuntimeExecutionHostId(environment.id)
|
||||
retiredHosts.push(hostId)
|
||||
await invokeWrite('session:set', session(`cycle-${index}`), hostId)
|
||||
expect(store.getWorkspaceSessionHostIds()).toContain(hostId)
|
||||
unpair(dir, store, environment)
|
||||
await invokeWrite('session:patch', { activeTabId: 'late' }, hostId)
|
||||
}
|
||||
expect(listEnvironments(dir)).toEqual([])
|
||||
expect(invalidateTransport).toHaveBeenCalledTimes(32)
|
||||
expect(store.getWorkspaceSessionHostIds()).toEqual(['local'])
|
||||
store.flushOrThrow()
|
||||
const disk = JSON.parse(readFileSync(dataFile, 'utf8'))
|
||||
for (const hostId of retiredHosts) {
|
||||
expect(disk.workspaceSessionsByHostId[hostId]).toBeUndefined()
|
||||
}
|
||||
const reloaded = new Store({ dataFile })
|
||||
expect(reloaded.getWorkspaceSessionHostIds()).toEqual(['local'])
|
||||
reloaded.freezeWrites()
|
||||
})
|
||||
})
|
||||
|
||||
describe('removal failure ordering', () => {
|
||||
it('replies to the legacy sync channel and flushes unrelated state when admission fails', async () => {
|
||||
const { dir, store, dataFile } = fixture()
|
||||
const environment = pair(dir, 'unreadable')
|
||||
const hostId = toRuntimeExecutionHostId(environment.id)
|
||||
const knownHost = 'runtime:known-existing'
|
||||
store.setWorkspaceSession(session('known-before'), knownHost)
|
||||
store.setWorkspaceSession(session('local-pending'))
|
||||
writeFileSync(getEnvironmentStorePath(dir), '{broken')
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
await expect(invokeWrite('session:set-sync', session('unverifiable'), hostId)).resolves.toBe(
|
||||
false
|
||||
)
|
||||
expect(store.getWorkspaceSessionHostIds()).not.toContain(hostId)
|
||||
const disk = JSON.parse(readFileSync(dataFile, 'utf8'))
|
||||
expect(Object.keys(disk.workspaceSession.tabsByWorktree)).toEqual([
|
||||
'repo-a::/audit/local-pending'
|
||||
])
|
||||
const readsAfterFailure = authority.reads
|
||||
await expect(invokeWrite('session:set-sync', session('known-after'), knownHost)).resolves.toBe(
|
||||
true
|
||||
)
|
||||
expect(authority.reads).toBe(readsAfterFailure)
|
||||
expect(Object.keys(store.getWorkspaceSession(knownHost).tabsByWorktree)).toEqual([
|
||||
'repo-a::/audit/known-after'
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves Store write failures on the legacy sync channel', async () => {
|
||||
const { store } = fixture()
|
||||
vi.spyOn(store, 'setWorkspaceSession').mockImplementationOnce(() => {
|
||||
throw new Error('controlled Store write failure')
|
||||
})
|
||||
await expect(invokeWrite('session:set-sync', session('local'), 'local')).rejects.toThrow(
|
||||
'controlled Store write failure'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps pairing and transport intact if custody cannot be established', () => {
|
||||
const base = fixture()
|
||||
const environment = pair(base.dir, 'custody-failure')
|
||||
const hostId = toRuntimeExecutionHostId(environment.id)
|
||||
base.store.setWorkspaceSession(session('before'), hostId)
|
||||
vi.spyOn(base.store, 'getRepos').mockImplementation(() => {
|
||||
throw new Error('controlled custody read failure')
|
||||
})
|
||||
expect(() =>
|
||||
handlers.get('runtimeEnvironments:remove')!(null, { selector: environment.id })
|
||||
).toThrow('controlled custody read failure')
|
||||
expect(listEnvironments(base.dir).some((entry) => entry.id === environment.id)).toBe(true)
|
||||
expect(base.invalidateTransport).not.toHaveBeenCalled()
|
||||
expect(base.store.getWorkspaceSessionHostIds()).toContain(hostId)
|
||||
})
|
||||
|
||||
it('starts transport and browser retirement even if exact Store partition deletion fails', async () => {
|
||||
const base = fixture()
|
||||
const environment = pair(base.dir, 'store-deletion-failure')
|
||||
const hostId = toRuntimeExecutionHostId(environment.id)
|
||||
base.store.setWorkspaceSession(session('before'), hostId)
|
||||
clearStorage.mockClear()
|
||||
vi.spyOn(base.store, 'removeRuntimeWorkspaceSessionPartition').mockImplementation(() => {
|
||||
throw new Error('controlled partition deletion failure')
|
||||
})
|
||||
expect(() =>
|
||||
handlers.get('runtimeEnvironments:remove')!(null, { selector: environment.id })
|
||||
).toThrow('controlled partition deletion failure')
|
||||
expect(listEnvironments(base.dir)).toEqual([])
|
||||
expect(base.invalidateTransport).toHaveBeenCalledWith(environment.id)
|
||||
await vi.waitFor(() => expect(clearStorage).toHaveBeenCalledWith(environment.id))
|
||||
expect(base.store.getWorkspaceSessionHostIds()).toContain(hostId)
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,7 @@
|
||||
vi.mock('../runtime/runtime-workspace-session-namespace-custody', () => ({
|
||||
hasMainOwnedRuntimeSessionNamespace: () => false
|
||||
}))
|
||||
|
||||
import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -81,6 +85,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
let activeRuntimeEnvironmentId: string | null
|
||||
let store: {
|
||||
getSettings: () => { activeRuntimeEnvironmentId: string | null }
|
||||
removeRuntimeWorkspaceSessionPartition: ReturnType<typeof vi.fn>
|
||||
updateSettings: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
@@ -89,6 +94,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
activeRuntimeEnvironmentId = null
|
||||
store = {
|
||||
getSettings: () => ({ activeRuntimeEnvironmentId }),
|
||||
removeRuntimeWorkspaceSessionPartition: vi.fn(),
|
||||
updateSettings: vi.fn((updates: { activeRuntimeEnvironmentId: string | null }) => {
|
||||
activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId
|
||||
})
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
vi.mock('../runtime/runtime-workspace-session-namespace-custody', () => ({
|
||||
hasMainOwnedRuntimeSessionNamespace: () => false
|
||||
}))
|
||||
|
||||
import type { RuntimeHostStatusSnapshot } from '../../shared/runtime-host-status'
|
||||
import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
@@ -101,6 +105,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
let activeRuntimeEnvironmentId: string | null
|
||||
let store: {
|
||||
getSettings: () => { activeRuntimeEnvironmentId: string | null }
|
||||
removeRuntimeWorkspaceSessionPartition: ReturnType<typeof vi.fn>
|
||||
updateSettings: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
@@ -109,6 +114,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
activeRuntimeEnvironmentId = null
|
||||
store = {
|
||||
getSettings: () => ({ activeRuntimeEnvironmentId }),
|
||||
removeRuntimeWorkspaceSessionPartition: vi.fn(),
|
||||
updateSettings: vi.fn((updates: { activeRuntimeEnvironmentId: string | null }) => {
|
||||
activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId
|
||||
})
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
vi.mock('../runtime/runtime-workspace-session-namespace-custody', () => ({
|
||||
hasMainOwnedRuntimeSessionNamespace: () => false
|
||||
}))
|
||||
|
||||
import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -89,6 +93,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
let activeRuntimeEnvironmentId: string | null
|
||||
let store: {
|
||||
getSettings: () => { activeRuntimeEnvironmentId: string | null }
|
||||
removeRuntimeWorkspaceSessionPartition: ReturnType<typeof vi.fn>
|
||||
updateSettings: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
@@ -97,6 +102,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
activeRuntimeEnvironmentId = null
|
||||
store = {
|
||||
getSettings: () => ({ activeRuntimeEnvironmentId }),
|
||||
removeRuntimeWorkspaceSessionPartition: vi.fn(),
|
||||
updateSettings: vi.fn((updates: { activeRuntimeEnvironmentId: string | null }) => {
|
||||
activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId
|
||||
})
|
||||
|
||||
+19
-4
@@ -1,3 +1,4 @@
|
||||
import { canCreateRendererSessionPartition } from './renderer-workspace-session-admission'
|
||||
import { ipcMain } from 'electron'
|
||||
import type { Store } from '../persistence'
|
||||
import type {
|
||||
@@ -21,11 +22,15 @@ export function registerSessionHandlers(store: Store): void {
|
||||
})
|
||||
|
||||
ipcMain.handle('session:set', (_event, args: WorkspaceSessionState, hostId?: string | null) => {
|
||||
store.setWorkspaceSession(args, hostId)
|
||||
if (canCreateRendererSessionPartition(store, hostId)) {
|
||||
store.setWorkspaceSession(args, hostId)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('session:patch', (_event, args: WorkspaceSessionPatch, hostId?: string | null) => {
|
||||
store.patchWorkspaceSession(args, hostId)
|
||||
if (canCreateRendererSessionPartition(store, hostId)) {
|
||||
store.patchWorkspaceSession(args, hostId)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('session:flush', () => {
|
||||
@@ -39,9 +44,19 @@ export function registerSessionHandlers(store: Store): void {
|
||||
// data (including terminal scrollback buffers) is persisted to disk
|
||||
// before the window closes — regardless of before-quit ordering.
|
||||
ipcMain.on('session:set-sync', (event, args: WorkspaceSessionState, hostId?: string | null) => {
|
||||
store.setWorkspaceSession(args, hostId)
|
||||
let admitted = false
|
||||
let admissionOk = true
|
||||
try {
|
||||
admitted = canCreateRendererSessionPartition(store, hostId)
|
||||
} catch (error) {
|
||||
console.error('[session] Failed to establish runtime session partition authority:', error)
|
||||
admissionOk = false
|
||||
}
|
||||
if (admitted) {
|
||||
store.setWorkspaceSession(args, hostId)
|
||||
}
|
||||
store.flush()
|
||||
event.returnValue = true
|
||||
event.returnValue = admissionOk
|
||||
})
|
||||
|
||||
ipcMain.on(
|
||||
|
||||
@@ -4,6 +4,7 @@ import { sanitizeWorkspaceSessionTerminalRetirements } from '../../runtime/mobil
|
||||
import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
normalizeExecutionHostId,
|
||||
parseExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../../shared/execution-host'
|
||||
import { getDefaultWorkspaceSession } from '../../../shared/constants'
|
||||
@@ -80,6 +81,23 @@ export class SessionHostPartitionOperations {
|
||||
return [...hostIds]
|
||||
}
|
||||
|
||||
removeRuntimeWorkspaceSessionPartition(hostId: ExecutionHostId): boolean {
|
||||
if (
|
||||
parseExecutionHostId(hostId)?.kind !== 'runtime' ||
|
||||
!hasPersistedWorkspaceSession(this, hostId)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const partitions = {
|
||||
...this[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId
|
||||
}
|
||||
delete partitions[hostId]
|
||||
this[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId = partitions
|
||||
invalidateLocalWorktreeMetadataPruneInputs()
|
||||
scheduleSave(this[sessionHostPartitionOperationsContext].scheduling)
|
||||
return true
|
||||
}
|
||||
|
||||
readTerminalScrollbackSnapshot(ref: string): string | null {
|
||||
return readTerminalScrollbackSnapshotSync(
|
||||
ref,
|
||||
|
||||
@@ -75,6 +75,9 @@ export class RuntimeLegacyWorkerTerminalRecoveryPersistence {
|
||||
return dispatchIds
|
||||
} catch (error) {
|
||||
for (const [hostId, original] of originals) {
|
||||
if (store.getWorkspaceSessionHostIds?.().includes(hostId) === false) {
|
||||
continue
|
||||
}
|
||||
const stagedSession = staged.get(hostId)
|
||||
const current = store.getWorkspaceSession(hostId)
|
||||
if (!stagedSession || !current) {
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../shared/constants'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
import { RuntimeLegacyWorkerTerminalRecoveryPersistence } from './runtime-legacy-worker-terminal-recovery-persistence'
|
||||
import type { LegacyWorkerRecoveryCandidate } from './runtime-legacy-worker-terminal-recovery-types'
|
||||
import { RuntimeWorkspaceSessionController } from './runtime-workspace-session-controller'
|
||||
import { hasMainOwnedRuntimeSessionNamespace } from './runtime-workspace-session-namespace-custody'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => tmpdir(),
|
||||
getName: () => 'orca-test',
|
||||
getVersion: () => '0.0.0-test',
|
||||
isPackaged: false,
|
||||
on() {},
|
||||
whenReady: () => Promise.resolve()
|
||||
},
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => false,
|
||||
encryptString: (value: string) => Buffer.from(value),
|
||||
decryptString: (value: Buffer) => value.toString()
|
||||
},
|
||||
ipcMain: { on() {}, handle() {} },
|
||||
BrowserWindow: { getAllWindows: () => [] }
|
||||
}))
|
||||
|
||||
const { Store } = await import('../persistence/loading-store/store')
|
||||
const cleanups: (() => void)[] = []
|
||||
const HOST = 'runtime:historical-self-stamp'
|
||||
const WORKTREE = 'repo-a::/audit/worktree'
|
||||
const TAB = 'legacy-worker-tab'
|
||||
const LEAF = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const PANE = `${TAB}:${LEAF}`
|
||||
const INCARNATION = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
||||
|
||||
afterEach(() => {
|
||||
for (const cleanup of cleanups.splice(0)) {
|
||||
cleanup()
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function fixture(hostId: ExecutionHostId, folder = false) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-session-namespace-'))
|
||||
const dataFile = join(dir, 'orca-data.json')
|
||||
const state = getDefaultPersistedState(dir)
|
||||
if (folder) {
|
||||
state.projectGroups = [
|
||||
{
|
||||
id: 'group-a',
|
||||
name: 'Audit group',
|
||||
parentPath: '/audit',
|
||||
parentGroupId: null,
|
||||
createdFrom: 'manual',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
]
|
||||
state.folderWorkspaces = [
|
||||
{
|
||||
id: 'folder-a',
|
||||
projectGroupId: 'group-a',
|
||||
name: 'Audit folder',
|
||||
folderPath: '/audit',
|
||||
executionHostId: hostId,
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 1,
|
||||
lastActivityAt: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
]
|
||||
} else {
|
||||
state.repos = [
|
||||
{
|
||||
id: 'repo-a',
|
||||
path: '/audit',
|
||||
displayName: 'Audit',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
executionHostId: hostId,
|
||||
kind: 'folder'
|
||||
}
|
||||
]
|
||||
}
|
||||
writeFileSync(dataFile, JSON.stringify(state))
|
||||
const store = new Store({ dataFile })
|
||||
if (folder) {
|
||||
const declaredOwner = store.getFolderWorkspace('folder-a')
|
||||
if (!declaredOwner) {
|
||||
throw new Error('Folder fixture failed to load')
|
||||
}
|
||||
expect(declaredOwner.executionHostId).toBeUndefined()
|
||||
// This exercises explicit in-memory custody; load deliberately strips renderer host stamps.
|
||||
declaredOwner.executionHostId = hostId
|
||||
}
|
||||
cleanups.push(() => {
|
||||
store.flush()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
const controller = new RuntimeWorkspaceSessionController({
|
||||
getStore: () => store,
|
||||
resolveFolderConnectionId: () => null,
|
||||
hasRuntimeOwnedPtyCandidate: () => false
|
||||
})
|
||||
return { store, controller, workspaceId: folder ? 'folder:folder-a' : WORKTREE }
|
||||
}
|
||||
|
||||
function session(workspaceId = WORKTREE): WorkspaceSessionState {
|
||||
return {
|
||||
...getDefaultWorkspaceSession(),
|
||||
activeTabId: TAB,
|
||||
tabsByWorktree: {
|
||||
[workspaceId]: [
|
||||
{
|
||||
id: TAB,
|
||||
worktreeId: workspaceId,
|
||||
ptyId: 'pty-a',
|
||||
title: 'Audit',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
[TAB]: {
|
||||
root: { type: 'leaf', leafId: LEAF },
|
||||
activeLeafId: LEAF,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF]: 'pty-a' }
|
||||
}
|
||||
},
|
||||
terminalPtyIncarnationsByPaneKey: { [PANE]: INCARNATION },
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
[PANE]: {
|
||||
paneKey: PANE,
|
||||
tabId: TAB,
|
||||
worktreeId: workspaceId,
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'audit-codex' },
|
||||
prompt: 'continue',
|
||||
state: 'working',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1,
|
||||
origin: 'live'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function candidate(): LegacyWorkerRecoveryCandidate {
|
||||
return {
|
||||
dispatchId: 'dispatch-a',
|
||||
dispatchStatus: 'dispatched',
|
||||
contractVersion: 1,
|
||||
taskId: 'task-a',
|
||||
worktreeId: WORKTREE,
|
||||
terminalHandle: 'term-a',
|
||||
paneKey: PANE,
|
||||
tabId: TAB,
|
||||
leafId: LEAF,
|
||||
processIncarnation: `pty-a:${INCARNATION}`,
|
||||
ptyId: 'pty-a',
|
||||
incarnationId: INCARNATION
|
||||
}
|
||||
}
|
||||
|
||||
describe('main workspace session namespace custody', () => {
|
||||
it.each([false, true])(
|
||||
'preserves a main-declared namespace before its first session write with folder=%s',
|
||||
(folder) => {
|
||||
const { store, controller, workspaceId } = fixture(HOST, folder)
|
||||
expect(store.getWorkspaceSessionHostIds()).not.toContain(HOST)
|
||||
expect(controller.getHostId(workspaceId)).toBe(HOST)
|
||||
expect(hasMainOwnedRuntimeSessionNamespace(store, HOST)).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([false, true])('follows the actual unique persisted alias with folder=%s', (folder) => {
|
||||
const { store, controller, workspaceId } = fixture('runtime:new-stamp', folder)
|
||||
store.setWorkspaceSession(session(workspaceId), HOST)
|
||||
expect(controller.getHostId(workspaceId)).toBe(HOST)
|
||||
expect(hasMainOwnedRuntimeSessionNamespace(store, HOST)).toBe(true)
|
||||
store.setWorkspaceSession(session(workspaceId), 'runtime:second-alias')
|
||||
expect(controller.getHostId(workspaceId)).toBe('runtime:new-stamp')
|
||||
expect(hasMainOwnedRuntimeSessionNamespace(store, HOST)).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['local', 'ssh:direct-target'] as const)(
|
||||
'does not adopt same-ID tabs from %s',
|
||||
(preferred) => {
|
||||
const { store, controller } = fixture(preferred)
|
||||
store.setWorkspaceSession(session(), HOST)
|
||||
expect(controller.getHostId(WORKTREE)).toBe(preferred)
|
||||
expect(hasMainOwnedRuntimeSessionNamespace(store, HOST)).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it('re-evaluates current catalog custody and removes only the exact runtime partition', () => {
|
||||
const { store } = fixture(HOST)
|
||||
for (const hostId of [HOST, 'runtime:sibling', 'local', 'ssh:direct-target']) {
|
||||
store.setWorkspaceSession(session(), hostId)
|
||||
}
|
||||
expect(hasMainOwnedRuntimeSessionNamespace(store, HOST)).toBe(true)
|
||||
store.removeProjectForHost('repo-a', HOST)
|
||||
expect(hasMainOwnedRuntimeSessionNamespace(store, HOST)).toBe(false)
|
||||
expect(store.removeRuntimeWorkspaceSessionPartition(HOST)).toBe(true)
|
||||
expect(store.removeRuntimeWorkspaceSessionPartition(HOST)).toBe(false)
|
||||
expect(store.removeRuntimeWorkspaceSessionPartition('local')).toBe(false)
|
||||
expect(store.removeRuntimeWorkspaceSessionPartition('ssh:direct-target')).toBe(false)
|
||||
expect(store.getWorkspaceSessionHostIds()).toEqual([
|
||||
'local',
|
||||
'runtime:sibling',
|
||||
'ssh:direct-target'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('legacy recovery rollback partition presence', () => {
|
||||
it.each(['local', 'ssh:direct-target', HOST] as const)(
|
||||
'restores adopted state in existing %s',
|
||||
async (hostId) => {
|
||||
const { store, controller } = fixture(hostId)
|
||||
store.setWorkspaceSession(session(), hostId)
|
||||
vi.spyOn(store, 'flushPendingOrThrowAsync').mockRejectedValue(
|
||||
new Error('controlled write failure')
|
||||
)
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const recovery = new RuntimeLegacyWorkerTerminalRecoveryPersistence(
|
||||
() => store,
|
||||
() => {
|
||||
throw new Error('Unexpected database access')
|
||||
},
|
||||
(id) => controller.tryGetHostId(id)
|
||||
)
|
||||
await expect(
|
||||
recovery.persist([{ candidate: candidate(), resolution: 'adopted' }])
|
||||
).resolves.toEqual(new Set())
|
||||
expect(store.getWorkspaceSession(hostId).sleepingAgentSessionsByPaneKey?.[PANE]?.prompt).toBe(
|
||||
'continue'
|
||||
)
|
||||
expect(store.getWorkspaceSession(hostId).terminalLayoutsByTabId[TAB]).toBeDefined()
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['adopted', 'exited'] as const)(
|
||||
'keeps explicit partition removal after a failed %s write',
|
||||
async (resolution) => {
|
||||
const { store, controller } = fixture(HOST)
|
||||
store.setWorkspaceSession(session(), HOST)
|
||||
let rejectFlush: (reason: Error) => void = () => {
|
||||
throw new Error('Flush not started')
|
||||
}
|
||||
vi.spyOn(store, 'flushPendingOrThrowAsync').mockReturnValue(
|
||||
new Promise<void>((_resolve, reject) => {
|
||||
rejectFlush = reject
|
||||
})
|
||||
)
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const recovery = new RuntimeLegacyWorkerTerminalRecoveryPersistence(
|
||||
() => store,
|
||||
() => {
|
||||
throw new Error('Unexpected database access')
|
||||
},
|
||||
(id) => controller.tryGetHostId(id)
|
||||
)
|
||||
const work = recovery.persist([{ candidate: candidate(), resolution }])
|
||||
store.removeProjectForHost('repo-a', HOST)
|
||||
expect(store.removeRuntimeWorkspaceSessionPartition(HOST)).toBe(true)
|
||||
rejectFlush(new Error('controlled write failure'))
|
||||
await expect(work).resolves.toEqual(new Set())
|
||||
expect(store.getWorkspaceSessionHostIds()).not.toContain(HOST)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { RuntimeWorkspaceSessionController } from './runtime-workspace-session-controller'
|
||||
import { inferFolderWorkspacePathConnection } from '../project-groups/folder-workspace-path-status'
|
||||
import { getRepoExecutionHostId, parseExecutionHostId } from '../../shared/execution-host'
|
||||
import { getRepoIdFromWorktreeId } from '../../shared/worktree/id'
|
||||
import { parseWorkspaceKey } from '../../shared/workspace-scope'
|
||||
import type { Store } from '../persistence'
|
||||
|
||||
export function hasMainOwnedRuntimeSessionNamespace(store: Store, hostId: string): boolean {
|
||||
if (parseExecutionHostId(hostId)?.kind !== 'runtime') {
|
||||
return false
|
||||
}
|
||||
const repos = store.getRepos()
|
||||
const folders = store.getFolderWorkspaces()
|
||||
if (repos.some((repo) => getRepoExecutionHostId(repo) === hostId)) {
|
||||
return true
|
||||
}
|
||||
if (folders.some((folder) => parseExecutionHostId(folder.executionHostId)?.id === hostId)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const controller = new RuntimeWorkspaceSessionController({
|
||||
getStore: () => store,
|
||||
resolveFolderConnectionId: (workspace) => {
|
||||
const connection = inferFolderWorkspacePathConnection({
|
||||
folderPath: workspace.folderPath,
|
||||
projectGroupId: workspace.projectGroupId,
|
||||
connectionId: workspace.connectionId ?? null,
|
||||
projectGroups: store.getProjectGroups(),
|
||||
repos
|
||||
})
|
||||
if (connection.kind === 'ambiguous') {
|
||||
throw new Error('folder_workspace_connection_ambiguous')
|
||||
}
|
||||
return connection.kind === 'ssh' ? connection.connectionId : null
|
||||
},
|
||||
hasRuntimeOwnedPtyCandidate: () => false
|
||||
})
|
||||
for (const workspaceId of Object.keys(store.getWorkspaceSession(hostId).tabsByWorktree)) {
|
||||
const scope = parseWorkspaceKey(workspaceId)
|
||||
const catalogHost =
|
||||
scope?.type === 'folder'
|
||||
? folders.find((folder) => folder.id === scope.folderWorkspaceId)?.executionHostId
|
||||
: (() => {
|
||||
const repo = store.getRepo(
|
||||
getRepoIdFromWorktreeId(scope?.type === 'worktree' ? scope.worktreeId : workspaceId)
|
||||
)
|
||||
return repo ? getRepoExecutionHostId(repo) : null
|
||||
})()
|
||||
// Only a current runtime-owned catalog row can exercise the controller's legacy alias fallback.
|
||||
if (
|
||||
parseExecutionHostId(catalogHost)?.kind === 'runtime' &&
|
||||
controller.tryGetHostId(workspaceId) === hostId
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user