From a68b6f35311012846a423654cee72866567b14cd Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 19 Sep 2026 00:12:25 -0700 Subject: [PATCH] fix(orcad): preserve main contracts after runtime rebase --- .../build-profile-lifetime-lock.test.mjs | 2 +- .../ipc/runtime-environment-revision-guard.ts | 5 +- src/main/memory/hydrate-local-pty-registry.ts | 12 -- .../profile-session-owner-transfer.ts | 5 +- src/main/orcad/orcad-command-arguments.ts | 9 +- src/main/orcad/orcad-entry.ts | 103 ++---------------- src/main/orcad/orcad-host-adapters.ts | 64 +++++++++++ src/main/orcad/orcad-launch-options.ts | 45 -------- src/main/orcad/orcad-process-shutdown.ts | 8 ++ .../orcad-live-successor-tab-binding.test.ts | 18 ++- .../rpc/methods/pty-ownership-transfer.ts | 8 +- .../runtime/rpc/rpc-params-type-parity.ts | 31 +++++- 12 files changed, 146 insertions(+), 164 deletions(-) create mode 100644 src/main/orcad/orcad-host-adapters.ts delete mode 100644 src/main/orcad/orcad-launch-options.ts diff --git a/config/scripts/build-profile-lifetime-lock.test.mjs b/config/scripts/build-profile-lifetime-lock.test.mjs index f1eeddb72c1..9a7973259ae 100644 --- a/config/scripts/build-profile-lifetime-lock.test.mjs +++ b/config/scripts/build-profile-lifetime-lock.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { test } from 'node:test' +import { test } from 'vitest' import { buildProfileLifetimeLock } from './build-profile-lifetime-lock.mjs' test('stages only a successful native build and cleans its owned build directory', () => { diff --git a/src/main/ipc/runtime-environment-revision-guard.ts b/src/main/ipc/runtime-environment-revision-guard.ts index bf773c7a87c..03d6169675d 100644 --- a/src/main/ipc/runtime-environment-revision-guard.ts +++ b/src/main/ipc/runtime-environment-revision-guard.ts @@ -15,12 +15,13 @@ export function runtimeEnvironmentRevisionFailure( if (!pairingChanged && !runtimeChanged) { return null } - return runtimeEnvironmentChangedFailure(environment, method) + return runtimeEnvironmentChangedFailure(environment, method, pairingChanged) } export function runtimeEnvironmentChangedFailure( environment: Pick, - method: string + method: string, + pairingChanged = false ): RuntimeRpcResponse { return { id: method, diff --git a/src/main/memory/hydrate-local-pty-registry.ts b/src/main/memory/hydrate-local-pty-registry.ts index 6577e631343..6c70dc4c0e6 100644 --- a/src/main/memory/hydrate-local-pty-registry.ts +++ b/src/main/memory/hydrate-local-pty-registry.ts @@ -2,7 +2,6 @@ import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/ex import { throwIfSignalAborted, waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import { mapSettledWithConcurrency } from '../../shared/map-with-concurrency' import { parsePtySessionId } from '../../shared/pty-session-id-format' -import { folderWorkspaceToWorktree } from '../../shared/folder-workspace-worktree' import { isFolderRepo } from '../../shared/repo-kind' import type { Repo } from '../../shared/repo-types' import { splitWorktreeId, worktreeIdComparisonKey } from '../../shared/worktree/id' @@ -226,17 +225,6 @@ function getVerifiedFolderWorktreeIds( repoCatalog: LocalRepoCatalog ): Set { const verified = new Set() - const folders = store.getFolderWorkspaces() - const counts = new Map() - for (const folder of folders) { - counts.set(folder.id, (counts.get(folder.id) ?? 0) + 1) - } - for (const folder of folders) { - const worktree = folderWorkspaceToWorktree(folder) - if (counts.get(folder.id) === 1 && worktree.hostId === LOCAL_EXECUTION_HOST_ID) { - verified.add(worktree.id) - } - } const metadata = readAllWorktreeMetaForHost(store, LOCAL_EXECUTION_HOST_ID) for (const [worktreeId, meta] of Object.entries(metadata)) { const parsed = splitWorktreeId(worktreeId) diff --git a/src/main/orca-profiles/profile-session-owner-transfer.ts b/src/main/orca-profiles/profile-session-owner-transfer.ts index 2cedba990e7..ff8a99083ca 100644 --- a/src/main/orca-profiles/profile-session-owner-transfer.ts +++ b/src/main/orca-profiles/profile-session-owner-transfer.ts @@ -309,10 +309,7 @@ function mapUnifiedTab(tab: Tab, projection: SessionOwnerProjection): Tab { } function mapTabGroup(group: TabGroup, projection: SessionOwnerProjection): TabGroup { - return { - ...structuredClone(group), - worktreeId: projection.mapWorktreeId(group.worktreeId) - } + return { ...structuredClone(group), worktreeId: projection.mapWorktreeId(group.worktreeId) } } function paneBelongsToTabs(paneKey: string, tabIds: ReadonlySet): boolean { diff --git a/src/main/orcad/orcad-command-arguments.ts b/src/main/orcad/orcad-command-arguments.ts index f4fd748de61..b7644bb2376 100644 --- a/src/main/orcad/orcad-command-arguments.ts +++ b/src/main/orcad/orcad-command-arguments.ts @@ -1,4 +1,11 @@ -import type { OrcadOptions } from './orcad-entry' +export type OrcadOptions = { + port?: number + json?: boolean + noPairing?: boolean + pairingAddress?: string + /** Literal IP to bind. Defaults to loopback; see orcad-bind-address.ts. */ + bind?: string +} /** * orcad's flags. A value-taking flag consumes the next token whatever it looks diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index d34f6546c98..4d46f6ede1e 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -13,34 +13,27 @@ */ import process from 'node:process' import { join } from 'node:path' -import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environment' -import { setSecretStore, type SecretStore } from '../../shared/secret-store' import type { ServeReadiness } from '../server/serve-readiness' +import type { OrcadManagedStopRequestContext } from './orcad-managed-stop-request' +import { installOrcadHostAdapters, runOrcadQuitHandlers } from './orcad-host-adapters' +export { installOrcadHostAdapters } from './orcad-host-adapters' import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' import { startOrcadBrowserProvider } from './orcad-browser-startup' import { OrcadRuntimeLifetime } from './orcad-runtime-lifetime' import { recoverOrcadRuntimeTerminals } from './orcad-runtime-terminal-recovery' import { configureOrcadHostDecommission } from './orcad-host-decommission' -import { parseArgs, type OrcadOptions } from './orcad-launch-options' -export { parseArgs, type OrcadOptions } from './orcad-launch-options' -import { resolveOrcadInstallRoot, resolveOrcadPath, resolveUserDataPath } from './orcad-app-paths' -import { - describeOrcadBindExposure, - OrcadBindAddressError, - resolveOrcadBindHost -} from './orcad-bind-address' -import { acquireOrcadInstanceLock, OrcadInstanceLockError } from './orcad-instance-lock' -import type { OrcadManagedStopRequestContext } from './orcad-managed-stop-request' -import { - installOrcadProcessShutdown, - ORCAD_EXIT_CONFIGURATION, - ORCAD_EXIT_FAILED -} from './orcad-process-shutdown' +import { parseArgs, type OrcadOptions } from './orcad-command-arguments' +export { parseArgs, type OrcadOptions } from './orcad-command-arguments' +import { resolveOrcadInstallRoot, resolveUserDataPath } from './orcad-app-paths' +import { describeOrcadBindExposure, resolveOrcadBindHost } from './orcad-bind-address' +import { acquireOrcadInstanceLock } from './orcad-instance-lock' +import { installOrcadProcessShutdown } from './orcad-process-shutdown' export { ORCAD_EXIT_OK, ORCAD_EXIT_FAILED, ORCAD_EXIT_CONFIGURATION, - ORCAD_SHUTDOWN_DEADLINE_MS + ORCAD_SHUTDOWN_DEADLINE_MS, + resolveOrcadExitCode } from './orcad-process-shutdown' import { isPtyOwnershipTransferMutationEnabled } from '../../shared/pty-ownership-transfer-release-gate' import { initializeProfileLifetimeAdmission } from '../ssh/profile-lifetime-admission' @@ -49,66 +42,6 @@ import { type AiVaultSearchSettings } from '../../shared/ai-vault-search-settings' -let runOrcadQuitHandlers = (): void => {} - -function createNodeAppEnvironment(): AppEnvironment { - const quitHandlers: (() => void)[] = [] - // The main signal handler awaits runtime and browser teardown before process.exit. - // Keep will-quit callbacks synchronous, but never let them pre-empt that async barrier. - runOrcadQuitHandlers = (): void => { - const errors: unknown[] = [] - for (const handler of quitHandlers.splice(0)) { - try { - handler() - } catch (error) { - errors.push(error) - } - } - if (errors.length) { - throw new AggregateError(errors, 'orcad_quit_handlers_failed') - } - } - return { - getPath: resolveOrcadPath, - getAppPath: () => resolveOrcadInstallRoot(), - getVersion: () => process.env.ORCA_VERSION ?? '0.0.0-orcad', - // Why still true: consumers read this as "production build, not a dev checkout" — - // it gates HTTPS-only skill downloads, the real CLI command name, and shell-PATH - // hydration. Answering false to satisfy a path resolver would relax a security - // posture. Layout questions must ask whether the app root is an asar archive - // instead (see parcel-watcher-entry-path.ts). - isPackaged: () => true, - onWillQuit: (handler) => quitHandlers.push(handler), - exit: (code = 0) => process.exit(code), - // Why []: there are no Chromium processes on this host to measure. - getAppMetrics: () => [] - } -} - -/** - * Why not silently plaintext: `isEncryptionAvailable() === false` already makes every - * caller fall back to unsealed storage, which is a security posture, not a detail. - * `describeProtectionGap()` gives the reason a client can surface. - */ -function createNodeSecretStore(): SecretStore { - return { - isEncryptionAvailable: () => false, - encryptString: () => { - throw new Error('orcad_secret_sealing_unavailable') - }, - decryptString: () => { - throw new Error('orcad_secret_sealing_unavailable') - }, - describeProtectionGap: () => - 'This host has no OS keyring, so credentials are stored unencrypted. Pair from a desktop to manage secrets, or install and unlock a keyring.' - } -} - -export function installOrcadHostAdapters(): void { - setAppEnvironment(createNodeAppEnvironment()) - setSecretStore(createNodeSecretStore()) -} - export type OrcadHandle = { readiness: ServeReadiness managedStop: OrcadManagedStopRequestContext @@ -415,20 +348,6 @@ async function startOrcadRuntime( } } -/** - * Exit codes a supervisor can act on. Closed set — see docs/reference/orcad-operations.md. - * - * `ORCAD_EXIT_CONFIGURATION` is the load-bearing one: a data root owned by someone else, or - * held by another orcad, is not fixed by restarting. Restarting on it is the crash-loop the - * supervision contract has to prevent, so systemd's `RestartPreventExitStatus` needs a code - * that means "do not retry" and nothing else does. - */ -export function resolveOrcadExitCode(error: unknown): number { - return error instanceof OrcadInstanceLockError || error instanceof OrcadBindAddressError - ? ORCAD_EXIT_CONFIGURATION - : ORCAD_EXIT_FAILED -} - export async function main(argv: string[] = process.argv.slice(2)): Promise { const handle = await startOrcad(parseArgs(argv)) installOrcadProcessShutdown(handle, resolveOrcadInstallRoot(), handle.managedStop) diff --git a/src/main/orcad/orcad-host-adapters.ts b/src/main/orcad/orcad-host-adapters.ts new file mode 100644 index 00000000000..b980fba6118 --- /dev/null +++ b/src/main/orcad/orcad-host-adapters.ts @@ -0,0 +1,64 @@ +import process from 'node:process' +import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environment' +import { setSecretStore, type SecretStore } from '../../shared/secret-store' +import { resolveOrcadInstallRoot, resolveOrcadPath } from './orcad-app-paths' + +export let runOrcadQuitHandlers = (): void => {} + +function createNodeAppEnvironment(): AppEnvironment { + const quitHandlers: (() => void)[] = [] + // The main signal handler awaits runtime and browser teardown before process.exit. + // Keep will-quit callbacks synchronous, but never let them pre-empt that async barrier. + runOrcadQuitHandlers = (): void => { + const errors: unknown[] = [] + for (const handler of quitHandlers.splice(0)) { + try { + handler() + } catch (error) { + errors.push(error) + } + } + if (errors.length) { + throw new AggregateError(errors, 'orcad_quit_handlers_failed') + } + } + return { + getPath: resolveOrcadPath, + getAppPath: () => resolveOrcadInstallRoot(), + getVersion: () => process.env.ORCA_VERSION ?? '0.0.0-orcad', + // Why still true: consumers read this as "production build, not a dev checkout" — + // it gates HTTPS-only skill downloads, the real CLI command name, and shell-PATH + // hydration. Answering false to satisfy a path resolver would relax a security + // posture. Layout questions must ask whether the app root is an asar archive + // instead (see parcel-watcher-entry-path.ts). + isPackaged: () => true, + onWillQuit: (handler) => quitHandlers.push(handler), + exit: (code = 0) => process.exit(code), + // Why []: there are no Chromium processes on this host to measure. + getAppMetrics: () => [] + } +} + +/** + * Why not silently plaintext: `isEncryptionAvailable() === false` already makes every + * caller fall back to unsealed storage, which is a security posture, not a detail. + * `describeProtectionGap()` gives the reason a client can surface. + */ +function createNodeSecretStore(): SecretStore { + return { + isEncryptionAvailable: () => false, + encryptString: () => { + throw new Error('orcad_secret_sealing_unavailable') + }, + decryptString: () => { + throw new Error('orcad_secret_sealing_unavailable') + }, + describeProtectionGap: () => + 'This host has no OS keyring, so credentials are stored unencrypted. Pair from a desktop to manage secrets, or install and unlock a keyring.' + } +} + +export function installOrcadHostAdapters(): void { + setAppEnvironment(createNodeAppEnvironment()) + setSecretStore(createNodeSecretStore()) +} diff --git a/src/main/orcad/orcad-launch-options.ts b/src/main/orcad/orcad-launch-options.ts deleted file mode 100644 index 6b6f7748910..00000000000 --- a/src/main/orcad/orcad-launch-options.ts +++ /dev/null @@ -1,45 +0,0 @@ -export type OrcadOptions = { - port?: number - json?: boolean - noPairing?: boolean - pairingAddress?: string - /** Literal IP to bind. Defaults to loopback; see orcad-bind-address.ts. */ - bind?: string -} - -export function parseArgs(argv: string[]): OrcadOptions { - const options: OrcadOptions = {} - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i] - if (arg === '--port') { - const raw = argv[i + 1] - const port = Number(raw) - if (!Number.isInteger(port) || port < 0 || port > 65535) { - throw new Error(`--port expects an integer 0-65535, got ${raw ?? "''"}`) - } - options.port = port - i += 1 - } else if (arg === '--json') { - options.json = true - } else if (arg === '--no-pairing') { - options.noPairing = true - } else if (arg === '--bind') { - const value = argv[i + 1] - if (value === undefined) { - throw new Error('--bind expects a value') - } - options.bind = value - i += 1 - } else if (arg === '--pairing-address') { - const value = argv[i + 1] - if (!value) { - throw new Error('--pairing-address expects a value') - } - options.pairingAddress = value - i += 1 - } else { - throw new Error(`Unknown argument: ${arg}`) - } - } - return options -} diff --git a/src/main/orcad/orcad-process-shutdown.ts b/src/main/orcad/orcad-process-shutdown.ts index c88e00c5318..277a60a0afb 100644 --- a/src/main/orcad/orcad-process-shutdown.ts +++ b/src/main/orcad/orcad-process-shutdown.ts @@ -4,12 +4,20 @@ import { type OrcadStopRequestListener } from './orcad-stop-request-listener' import type { OrcadManagedStopRequestContext } from './orcad-managed-stop-request' +import { OrcadBindAddressError } from './orcad-bind-address' +import { OrcadInstanceLockError } from './orcad-instance-lock' export const ORCAD_EXIT_OK = 0 export const ORCAD_EXIT_FAILED = 1 export const ORCAD_EXIT_CONFIGURATION = 78 export const ORCAD_SHUTDOWN_DEADLINE_MS = 15_000 +export function resolveOrcadExitCode(error: unknown): number { + return error instanceof OrcadInstanceLockError || error instanceof OrcadBindAddressError + ? ORCAD_EXIT_CONFIGURATION + : ORCAD_EXIT_FAILED +} + export function installOrcadProcessShutdown( handle: { stop(): Promise }, installRoot: string, diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-live-successor-tab-binding.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-live-successor-tab-binding.test.ts index 9193e210154..5b2625333b0 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-live-successor-tab-binding.test.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-live-successor-tab-binding.test.ts @@ -39,7 +39,18 @@ it.each(['folder', 'worktree'] as const)('admits the real %s reconnect binding w const sessions = { getWorkspaceSession: () => state.workspaceSession } as ConstructorParameters[1] - const writer = new PtyBindingPersistenceOperations({ state, flushOrThrow }, sessions) + const writer = new PtyBindingPersistenceOperations( + { + state, + flushOrThrow, + writeTimer: null, + pendingWrite: null, + writeGeneration: 1, + quitFlushStarted: false, + lastDurableWriteGeneration: 0 + }, + sessions + ) const { identity, surfaceBinding } = f.binding expect( writer.persistPtyBinding({ @@ -52,7 +63,10 @@ it.each(['folder', 'worktree'] as const)('admits the real %s reconnect binding w }) ).toBe(true) expect(flushOrThrow).toHaveBeenCalledOnce() - expect(Object.values(state.workspaceSession.tabsByWorktree).flat()[0].ptyId).toBe(f.tab.ptyId) + // A split-pane rebind must not let a later leaf steal the tab row's first-pane PTY. + expect(Object.values(state.workspaceSession.tabsByWorktree).flat()[0].ptyId).toBe( + Object.values(f.original.workspaceSession.tabsByWorktree).flat()[0].ptyId + ) expect(() => assertOrcadLiveSuccessorTabBindingChange( f.before, diff --git a/src/main/runtime/rpc/methods/pty-ownership-transfer.ts b/src/main/runtime/rpc/methods/pty-ownership-transfer.ts index 4fcf6c64d83..06f48b3f970 100644 --- a/src/main/runtime/rpc/methods/pty-ownership-transfer.ts +++ b/src/main/runtime/rpc/methods/pty-ownership-transfer.ts @@ -22,7 +22,7 @@ import { } from '../../../../shared/pty-ownership-transfer-control-wire' import { parsePtyOwnershipTransferReconnectRekeyRequest } from '../../../../shared/pty-ownership-transfer-reconnect-rekey-wire' import type { RuntimePtyOwnershipTransferSourceAdapter } from '../../../providers/runtime-pty-ownership-transfer-source-adapter' -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod, type RpcTypedMethod } from '../core' import { parsePtyOwnershipTransferSourceGrantRequest } from '../../../../shared/pty-ownership-transfer-source-grant' const RuntimeOwnedPtyPreflight = z.object({ @@ -153,8 +153,8 @@ export const PTY_OWNERSHIP_TRANSFER_METHODS = [ PTY_TRANSFER_SOURCE_STREAM_METHOD ] -function sourceMethod( - name: string, +function sourceMethod( + name: TName, params: z.ZodType, parse: (value: unknown) => T, invoke: ( @@ -162,7 +162,7 @@ function sourceMethod( request: T, binding: Parameters[1] ) => unknown -): RpcMethod { +): RpcTypedMethod { return defineMethod({ name, params, diff --git a/src/main/runtime/rpc/rpc-params-type-parity.ts b/src/main/runtime/rpc/rpc-params-type-parity.ts index 263a7fe38cd..d6600eb38bd 100644 --- a/src/main/runtime/rpc/rpc-params-type-parity.ts +++ b/src/main/runtime/rpc/rpc-params-type-parity.ts @@ -8,7 +8,36 @@ import type { ALL_RPC_METHODS } from './methods' type RegisteredMethod = (typeof ALL_RPC_METHODS)[number] // These schemas reach into src/main and have no shared catalog entry. -type UncataloguedMethod = 'emulator.install' | 'orchestration.send' | 'orchestration.taskUpdate' +type UncataloguedMethod = + | 'emulator.install' + | 'orcad.migration.abortCatalog' + | 'orcad.migration.catalogState' + | 'orcad.migration.commitCatalog' + | 'orcad.migration.importCatalog' + | 'orcad.migration.stageCatalog' + | 'orcad.terminalCensus' + | 'orchestration.send' + | 'orchestration.taskUpdate' + | 'pty.ownershipTransfer.abortSource' + | 'pty.ownershipTransfer.acknowledgeOutputSource' + | 'pty.ownershipTransfer.attachSource' + | 'pty.ownershipTransfer.capturedDestinationCapabilities' + | 'pty.ownershipTransfer.commitSource' + | 'pty.ownershipTransfer.controlSource' + | 'pty.ownershipTransfer.grantSource' + | 'pty.ownershipTransfer.inputSource' + | 'pty.ownershipTransfer.inspectCapturedCatalogActivation' + | 'pty.ownershipTransfer.inspectCapturedCatalogOutputCoverage' + | 'pty.ownershipTransfer.preflightSource' + | 'pty.ownershipTransfer.prepareCapturedDestination' + | 'pty.ownershipTransfer.prepareSource' + | 'pty.ownershipTransfer.publishSource' + | 'pty.ownershipTransfer.rekeyReconnectSource' + | 'pty.ownershipTransfer.replaySource' + | 'pty.ownershipTransfer.retireCapturedSourceDelivery' + | 'pty.ownershipTransfer.retireInputSource' + | 'pty.ownershipTransfer.statusSource' + | 'pty.ownershipTransfer.streamSource' type IsAny = 0 extends 1 & T ? true : false