mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 16:02:15 +00:00
fix(orcad): preserve main contracts after runtime rebase
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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<KnownRuntimeEnvironment, 'runtimeId'>,
|
||||
method: string
|
||||
method: string,
|
||||
pairingChanged = false
|
||||
): RuntimeRpcResponse<never> {
|
||||
return {
|
||||
id: method,
|
||||
|
||||
@@ -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<string> {
|
||||
const verified = new Set<string>()
|
||||
const folders = store.getFolderWorkspaces()
|
||||
const counts = new Map<string, number>()
|
||||
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)
|
||||
|
||||
@@ -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<string>): boolean {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> {
|
||||
const handle = await startOrcad(parseArgs(argv))
|
||||
installOrcadProcessShutdown(handle, resolveOrcadInstallRoot(), handle.managedStop)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<void> },
|
||||
installRoot: string,
|
||||
|
||||
+16
-2
@@ -39,7 +39,18 @@ it.each(['folder', 'worktree'] as const)('admits the real %s reconnect binding w
|
||||
const sessions = {
|
||||
getWorkspaceSession: () => state.workspaceSession
|
||||
} as ConstructorParameters<typeof PtyBindingPersistenceOperations>[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,
|
||||
|
||||
@@ -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<T>(
|
||||
name: string,
|
||||
function sourceMethod<TName extends string, T>(
|
||||
name: TName,
|
||||
params: z.ZodType,
|
||||
parse: (value: unknown) => T,
|
||||
invoke: (
|
||||
@@ -162,7 +162,7 @@ function sourceMethod<T>(
|
||||
request: T,
|
||||
binding: Parameters<RuntimePtyOwnershipTransferSourceAdapter['prepare']>[1]
|
||||
) => unknown
|
||||
): RpcMethod {
|
||||
): RpcTypedMethod<TName, z.ZodType, unknown> {
|
||||
return defineMethod({
|
||||
name,
|
||||
params,
|
||||
|
||||
@@ -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<T> = 0 extends 1 & T ? true : false
|
||||
|
||||
|
||||
Reference in New Issue
Block a user