feat(orcad): boot the Orca runtime on plain Node (#15968)

* refactor(host): resolve the app root through the port in fork-reachable modules

`parcel-watcher-entry-path.ts` and `session-scanner-service-entry-path.ts` read the
app root via `require('electron').app` inside a try/catch that already returns null
when Electron is absent. They were therefore correct under plain Node at runtime and
only failed the *static* text check — which is real, not pedantic: the comment in
`ports/port-scan-command-client.ts:19` records that the plain-node-entry-guard fails
on that literal text, try/catch or not.

`hasAppEnvironment() ? getAppEnvironment() : null` gives the identical "no app root
here" answer without the text. That restores `hasAppEnvironment`, which an earlier
commit in this stack deleted as unused — it now has the caller it was waiting for.

Ratchet baseline 27 → 25.

Verified: 74 files / 458 tests; `pnpm typecheck` clean; `oxlint` clean.

* feat(orcad): boot the Orca runtime on plain Node

Closes the last two Electron couplings and makes `orcad` a working artifact:
a 4.43 MB Node bundle that boots, pairs, registers a repo, creates a real git
worktree and round-trips a PTY — with zero `require("electron")`.

Ratchet 2 -> 0, so `config/runtime-electron-baseline.txt` is now empty and its
test asserts exactly that: any reachable electron import is a regression.

- speech: inject the service factories, so importing ModelManager for its type
  no longer drags Electron's streaming net.request into the graph
- filesystem-watcher: add a WorktreeWatcherRemoval port. Every entry in those
  maps arrives through an ipcMain handler carrying a renderer sender, so a host
  with no renderer has nothing to close, restore or forget — the inert default
  is what the desktop code does against empty maps, not a stub hiding work
- user-data-path / profile-storage-paths: resolve userData through
  AppEnvironment. These surfaced only once orcad pulled the store in

Both host ports now anchor to a realm-global symbol. `vi.resetModules()` gives
the re-imported graph a fresh module copy, so a binding installed before the
reset silently read back as uninstalled.

The acceptance smoke drives both hosts through one code path (`--target
orcad|electron`) and seeds its own git repo, so it is hermetic and asserts the
same contract of each. Wired into PR CI.

* test(smoke): remove the seeded workspace container, not just the worktree

* test(smoke): surface the server's stderr when it dies before ready

* fix(smoke): build node-pty for Node before booting orcad in CI

* fix(smoke): drive the CLI built from this checkout, not one on PATH

* docs(ratchet): say the baseline must stay empty, not merely shrink

* build(orcad): externalize only the native modules actually in the graph
This commit is contained in:
Neil
2026-08-22 21:47:46 -07:00
committed by GitHub
parent dee48498b2
commit 03fcfdfb92
34 changed files with 603 additions and 123 deletions
+6
View File
@@ -119,6 +119,12 @@ jobs:
- name: Enforce runtime Electron-import ratchet
run: pnpm run check:runtime-electron-ratchet
# Why both: the ratchet proves nothing reachable from the runtime imports electron,
# which is a property of the import graph. This proves the Node artifact it enables
# actually boots, pairs, creates a worktree and round-trips a real PTY.
- name: Boot orcad and round-trip a terminal
run: pnpm run smoke:orcad-terminal
- name: Verify bundled skill guides
run: pnpm run verify:bundled-skill-guides
+3 -4
View File
@@ -1,7 +1,6 @@
# Modules reachable from the Orca runtime that import `electron`.
# Generated by config/scripts/check-runtime-electron-ratchet.mjs.
# This list may only SHRINK. Adding an entry means the runtime got less
# portable; migrate the module behind a host port instead (src/main/host/).
# This list is EMPTY and must stay that way: the runtime boots on plain Node
# (see `pnpm run build:orcad`). Any entry means the runtime got less portable;
# migrate the module behind a host port instead (src/main/host/).
src/main/ipc/filesystem-watcher.ts
src/main/speech/model-manager.ts
+8 -10
View File
@@ -14,21 +14,19 @@ import process from 'node:process'
const ROOT = join(import.meta.dirname, '..', '..')
const OUT_DIR = join(ROOT, 'out', 'orcad')
const ENTRY = join(ROOT, 'src', 'main', 'orcad', 'orcad-entry.ts')
const ENTRY = join(ROOT, 'src/main/orcad/main.ts')
// Native addons must exist on the host; they cannot be bundled.
// `electron` is external so a residual import fails loudly at require() time rather
// than silently bundling the npm package's installer shim, which is what happened the
// first time and made the bundle look clean while it was not.
const EXTERNAL = [
'electron',
'node-pty',
'@parcel/watcher',
'better-sqlite3',
'keytar',
'fsevents',
'cpu-features'
]
// Why only these: measured, not guessed. `node-pty` is a hard `require.resolve` — orcad
// exits at startup without it. `@parcel/watcher` is a guarded dynamic import, so the
// server boots without it but every watch install fails. `fsevents` is macOS-only and
// optional upstream. better-sqlite3 / keytar / cpu-features were externalized here
// defensively and appear nowhere in the graph; listing them implied a shipping burden
// that does not exist.
const EXTERNAL = ['electron', 'node-pty', '@parcel/watcher', 'fsevents']
/** Why: the UMD build's relative dynamic requires do not bundle. Same fix build-relay.mjs uses. */
const jsoncParserEsm = {
@@ -112,8 +112,9 @@ function renderBaseline(files) {
return [
'# Modules reachable from the Orca runtime that import `electron`.',
'# Generated by config/scripts/check-runtime-electron-ratchet.mjs.',
'# This list may only SHRINK. Adding an entry means the runtime got less',
'# portable; migrate the module behind a host port instead (src/main/host/).',
'# This list is EMPTY and must stay that way: the runtime boots on plain Node',
'# (see `pnpm run build:orcad`). Any entry means the runtime got less portable;',
'# migrate the module behind a host port instead (src/main/host/).',
'',
...files
].join('\n')
@@ -45,9 +45,12 @@ describe('the checked-in baseline', () => {
expect(diffAgainstBaseline(current, baseline)).toEqual({ added: [], removed: [] })
}, 120_000)
it('only lists modules under src/, so a node_modules path cannot pad the count', () => {
// Why an exact-empty assertion now: the reachable set reached zero, so "may only
// shrink" has no room left and any entry at all is a regression. This is strictly
// stronger than the old under-src/ check, which only stopped a node_modules path from
// padding a non-empty count.
it('stays empty, so nothing reachable from the runtime imports electron', () => {
const baseline = readBaseline(readFileSync('config/runtime-electron-baseline.txt', 'utf8'))
expect(baseline.length).toBeGreaterThan(0)
expect(baseline.filter((entry) => !entry.startsWith('src/'))).toEqual([])
expect(baseline).toEqual([])
})
})
+195 -49
View File
@@ -20,14 +20,15 @@
* - the server exits when asked.
*/
import { spawn, spawnSync } from 'node:child_process'
import { mkdtempSync, rmSync } from 'node:fs'
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { dirname, join, resolve } from 'node:path'
import { randomBytes } from 'node:crypto'
import process from 'node:process'
const projectDir = resolve(import.meta.dirname, '../..')
const serveEntry = join(projectDir, 'out', 'main', 'index.js')
const ORCAD_ENTRY = join(projectDir, 'out', 'orcad', 'orcad.js')
const READY_TIMEOUT_MS = 120_000
const OUTPUT_TIMEOUT_MS = 30_000
const SHUTDOWN_TIMEOUT_MS = 15_000
@@ -43,14 +44,30 @@ function fail(message) {
process.exitCode = 1
}
/**
* Prefer the CLI built from this checkout over whatever `orca` is on PATH: it is the
* version under test, and a CI runner has no installed Orca app to fall back on.
*/
function resolveCli() {
const built = join(projectDir, 'out', 'cli', 'index.js')
return existsSync(built)
? { command: process.execPath, prefix: [built] }
: { command: 'orca', prefix: [] }
}
/** The `orca` CLI, driven with an explicit pairing code so it targets this server only. */
function orca(pairingCode, args) {
const result = spawnSync('orca', [...args, '--pairing-code', pairingCode, '--json'], {
encoding: 'utf8',
// Why not shell:true — argument encoding is handled by spawnSync; a shell would
// re-split the pairing code, which is base64url and can contain '='.
shell: false
})
const cli = resolveCli()
const result = spawnSync(
cli.command,
[...cli.prefix, ...args, '--pairing-code', pairingCode, '--json'],
{
encoding: 'utf8',
// Why not shell:true — argument encoding is handled by spawnSync; a shell would
// re-split the pairing code, which is base64url and can contain '='.
shell: false
}
)
if (result.error) {
throw new Error(`orca ${args[0]} failed to spawn: ${result.error.message}`)
}
@@ -70,10 +87,17 @@ function orca(pairingCode, args) {
function waitForReady(child) {
return new Promise((resolvePromise, rejectPromise) => {
let buffered = ''
let serverErr = ''
const timer = setTimeout(
() => rejectPromise(new Error(`no ready payload within ${READY_TIMEOUT_MS}ms`)),
READY_TIMEOUT_MS
)
// Why read stderr at all: an unread pipe can fill and block the child, and without it
// a boot failure surfaces only as "exited with 1", which says nothing actionable.
child.stderr.setEncoding('utf8')
child.stderr.on('data', (chunk) => {
serverErr += chunk
})
child.stdout.setEncoding('utf8')
child.stdout.on('data', (chunk) => {
buffered += chunk
@@ -95,7 +119,13 @@ function waitForReady(child) {
})
child.on('exit', (code) => {
clearTimeout(timer)
rejectPromise(new Error(`server exited with ${code} before signalling ready`))
rejectPromise(
new Error(
`server exited with ${code} before signalling ready${
serverErr.trim() ? `:\n${serverErr.trim()}` : ' (no stderr)'
}`
)
)
})
})
}
@@ -125,50 +155,127 @@ async function waitForNonce(pairingCode, terminalHandle, nonce) {
return false
}
/**
* The two hosts this acceptance drives.
*
* Everything after boot — pairing, worktree list, terminal create, the nonce round trip —
* is identical for both. That is the point: the Node artifact has to satisfy the same
* contract as the Electron server, proven by the same code rather than a parallel test
* that could drift into asserting less.
*/
function resolveLaunch(userDataDir) {
// Why a flag and not just an env var: package scripts have to set this on Windows too,
// and `FOO=bar cmd` is not portable there.
const flagIndex = process.argv.indexOf('--target')
const target =
flagIndex !== -1 ? process.argv[flagIndex + 1] : (process.env.ORCA_SMOKE_TARGET ?? 'electron')
if (target === 'orcad') {
return {
label: `orcad (${ORCAD_ENTRY})`,
command: process.execPath,
args: [ORCAD_ENTRY, '--port', String(PORT), '--json'],
env: { ORCA_USER_DATA: userDataDir }
}
}
if (target !== 'electron') {
throw new Error(
`--target (or ORCA_SMOKE_TARGET) must be 'electron' or 'orcad', got '${target}'`
)
}
const serveArgs = [
serveEntry,
'--serve',
'--serve-port',
String(PORT),
'--serve-json',
`--user-data-dir=${userDataDir}`
]
const override = process.env.ORCA_SMOKE_ELECTRON
return {
label: `electron (${serveEntry})`,
command: override ?? 'npx',
args: override ? serveArgs : ['electron', ...serveArgs],
env: {}
}
}
/** A throwaway git repo with one commit, so `repo add` has something real to register. */
function seedGitRepo() {
const dir = mkdtempSync(join(tmpdir(), 'orca-smoke-repo-'))
writeFileSync(join(dir, 'README.md'), '# orca smoke\n')
const git = (...args) => {
const result = spawnSync('git', args, { cwd: dir, encoding: 'utf8' })
if (result.status !== 0) {
throw new Error(`git ${args.join(' ')} failed: ${result.stderr || result.stdout}`)
}
}
git('init', '-b', 'main')
git('config', 'user.email', 'smoke@orca.test')
git('config', 'user.name', 'Orca Smoke')
git('add', '-A')
git('commit', '-m', 'seed')
return dir
}
async function main() {
const userDataDir = mkdtempSync(join(tmpdir(), 'orca-serve-smoke-'))
log(`booting ${serveEntry} on port ${PORT} with userData ${userDataDir}`)
const launch = resolveLaunch(userDataDir)
log(`booting ${launch.label} on port ${PORT} with userData ${userDataDir}`)
const child = spawn(
process.env.ORCA_SMOKE_ELECTRON ?? 'npx',
process.env.ORCA_SMOKE_ELECTRON
? [
serveEntry,
'--serve',
'--serve-port',
String(PORT),
'--serve-json',
`--user-data-dir=${userDataDir}`
]
: [
'electron',
serveEntry,
'--serve',
'--serve-port',
String(PORT),
'--serve-json',
`--user-data-dir=${userDataDir}`
],
{ stdio: ['ignore', 'pipe', 'pipe'] }
)
// Why tracked out here: the worktree lands in the real workspaces root, not the temp
// profile, so the finally block has to remove it explicitly or every run leaks one.
let seeded = null
let pairing = null
const child = spawn(launch.command, launch.args, {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, ...launch.env }
})
try {
const ready = await waitForReady(child)
log(`ready: ${ready.advertisedEndpoint}`)
const pairingCode = pairingCodeFrom(ready)
pairing = pairingCode
const worktrees = orca(pairingCode, ['worktree', 'list'])?.worktrees ?? []
if (worktrees.length === 0) {
throw new Error('paired client saw no worktrees; cannot create a terminal')
// Why seed instead of using whatever the profile already holds: a hermetic repo makes
// this runnable on a clean CI box, keeps the assertion deterministic, and exercises
// repo.add + worktree.create rather than assuming someone else registered a worktree.
const repoPath = seedGitRepo()
seeded = { repoPath }
log(`seeded repo at ${repoPath}`)
const repo = orca(pairingCode, ['repo', 'add', '--path', repoPath])?.repo
if (!repo?.id) {
throw new Error('repo.add returned no repo id')
}
log(`paired client sees ${worktrees.length} worktree(s)`)
const terminal = orca(pairingCode, [
'terminal',
const worktreeName = `smoke-${randomBytes(4).toString('hex')}`
const created = orca(pairingCode, [
'worktree',
'create',
'--worktree',
worktrees[0].id
])?.terminal
'--repo',
`id:${repo.id}`,
'--name',
worktreeName,
'--setup',
'skip'
])?.worktree
if (!created?.id) {
throw new Error('worktree.create returned no worktree id')
}
seeded.worktreeId = created.id
log(`created worktree ${created.id}`)
// Why `show` and not membership in `list`: list is capped, and the Electron target
// reads a shared dev profile that can already hold more worktrees than the cap. The
// point is that the server persisted and can resolve THIS worktree.
const shown = orca(pairingCode, ['worktree', 'show', '--worktree', created.id])?.worktree
if (shown?.id !== created.id) {
throw new Error('worktree.create succeeded but worktree.show cannot resolve it')
}
log(`server resolves ${shown.id}`)
const terminal = orca(pairingCode, ['terminal', 'create', '--worktree', created.id])?.terminal
if (!terminal?.handle) {
throw new Error('terminal.create returned no handle')
}
@@ -196,16 +303,55 @@ async function main() {
} catch (error) {
fail(error instanceof Error ? error.message : String(error))
} finally {
child.kill('SIGTERM')
const exited = await Promise.race([
new Promise((r) => child.on('exit', () => r(true))),
new Promise((r) => setTimeout(() => r(false), SHUTDOWN_TIMEOUT_MS))
])
if (!exited) {
child.kill('SIGKILL')
fail(`server did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of SIGTERM`)
// Why before SIGTERM: worktree removal is a server operation, so it needs the server.
if (seeded?.worktreeId && pairing) {
const cleanupCli = resolveCli()
const removed = spawnSync(
cleanupCli.command,
[
...cleanupCli.prefix,
'worktree',
'rm',
'--worktree',
seeded.worktreeId,
'--pairing-code',
pairing,
'--force',
'--json'
],
{ encoding: 'utf8' }
)
// Why the parent too: `worktree rm` removes the worktree directory, leaving the
// empty `<workspaces>/<repo-name>/` container behind. Every run would leak one.
const worktreePath = seeded.worktreeId.split('::')[1]
if (removed.status === 0 && worktreePath) {
rmSync(dirname(worktreePath), { recursive: true, force: true })
}
if (removed.status !== 0) {
log(
`WARN: could not remove seeded worktree ${seeded.worktreeId}: ` +
`${(removed.stderr?.trim() || removed.stdout?.trim() || '').replace(/\s+/g, ' ')} (exit ${removed.status})`
)
}
}
// Why the exitCode guard: a server that died during boot has already exited, and
// waiting for a second 'exit' that will never fire reported a bogus shutdown failure
// stacked on top of the real error.
if (child.exitCode === null && child.signalCode === null) {
child.kill('SIGTERM')
const exited = await Promise.race([
new Promise((r) => child.on('exit', () => r(true))),
new Promise((r) => setTimeout(() => r(false), SHUTDOWN_TIMEOUT_MS))
])
if (!exited) {
child.kill('SIGKILL')
fail(`server did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of SIGTERM`)
}
}
rmSync(userDataDir, { recursive: true, force: true })
if (seeded?.repoPath) {
rmSync(seeded.repoPath, { recursive: true, force: true })
}
}
if (!process.exitCode) {
+2
View File
@@ -30,6 +30,8 @@
"check:reliability-gates": "node config/scripts/check-reliability-gates.mjs",
"check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs",
"check:runtime-electron-ratchet": "node config/scripts/check-runtime-electron-ratchet.mjs",
"build:orcad": "node config/scripts/build-orcad.mjs",
"smoke:orcad-terminal": "node config/scripts/ensure-native-runtime.mjs --runtime=node && pnpm run build:cli && pnpm run build:orcad && node config/scripts/runtime-serve-terminal-smoke.mjs --target orcad",
"smoke:serve-terminal": "node config/scripts/runtime-serve-terminal-smoke.mjs",
"check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs",
"generate:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --write",
+3
View File
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'
import type { Repo } from '../../shared/repo-types'
import { toRuntimeExecutionHostId } from '../../shared/execution-host'
import { AutomationService } from './service'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
const testState = { dir: '' }
@@ -21,6 +22,8 @@ vi.mock('electron', () => ({
async function createStore() {
vi.resetModules()
// Why: userData resolves through AppEnvironment; point it at this file's temp dir.
installFakeAppEnvironment({ getPath: () => testState.dir })
const { Store, initDataPath } = await import('../persistence')
initDataPath()
return new Store()
@@ -0,0 +1,9 @@
import { ModelManager } from '../speech/model-manager'
import { SttService } from '../speech/stt-service'
import type { SpeechServiceFactories } from '../speech/speech-runtime-service'
/** The desktop speech factories. Importing this file is what pulls Electron's net in. */
export const electronSpeechServiceFactories: SpeechServiceFactories = {
createModelManager: (customModelsDir) => new ModelManager(customModelsDir),
createSttService: (models) => new SttService(models)
}
+8 -1
View File
@@ -29,6 +29,9 @@ import { electronRuntimeBrowserCommandsFactory } from './host/electron-browser-c
import { setRuntimeBrowserCommandsFactory } from './runtime/runtime-browser-commands-factory'
import { electronHttpClient } from './host/electron-http-client'
import { setMainHttpClient } from './network/http-client'
import { electronSpeechServiceFactories } from './host/electron-speech-services'
import { setSpeechServiceFactories } from './speech/speech-runtime-service'
import { setWorktreeWatcherRemoval } from './ipc/worktree-watcher-removal'
import { setSecretStore } from '../shared/secret-store'
import { ElectronSecretStore } from './host/electron-secret-store'
import { initSessionParseCachePersistence } from './ai-vault/session-parse-cache-persistence'
@@ -67,7 +70,7 @@ import {
isCodexPaneHomeRouteProvenAwayFromSharedHome,
reconcileCodexPaneAccountsWithLivePtys
} from './codex/codex-pane-account-registry'
import { closeAllWatchers } from './ipc/filesystem-watcher'
import { closeAllWatchers, desktopWorktreeWatcherRemoval } from './ipc/filesystem-watcher'
import { disposeWorktreeBaseDirectoryWatchers } from './ipc/worktree-base-directory-watcher'
import { stopFolderRepoGitUpgradeWatch } from './ipc/folder-repo-git-upgrade'
import { registerCoreHandlers } from './ipc/register-core-handlers'
@@ -902,6 +905,10 @@ if (hasSingleInstanceLock) {
// falls back to the platform default, which is a real behavioural difference (proxy
// read from the environment, Node's user agent) rather than a transparent swap.
setMainHttpClient(electronHttpClient)
// Why here: constructing the speech services is what pulls Electron's streaming net
// request in. A host without them rejects speech calls rather than pretending.
setSpeechServiceFactories(electronSpeechServiceFactories)
setWorktreeWatcherRemoval(desktopWorktreeWatcherRemoval)
// Why: couple to dev-parent only for electron-vite desktop runs; `orca serve`'s parent (CLI shim/background shell) isn't the intended server lifetime.
const shouldCoupleToDevParent = is.dev && !isServeMode
installDevParentDisconnectQuit(shouldCoupleToDevParent)
+14
View File
@@ -39,6 +39,7 @@ import {
} from './watcher-removal-drain'
// Why: suppress high-churn dirs at the watcher level (separate from the File Explorer display filter, which only hides rows).
import { WATCHER_IGNORE_DIRS, buildParcelWatcherIgnoreOptions } from './filesystem-watcher-ignore'
import type { WorktreeWatcherRemoval } from './worktree-watcher-removal'
// ── Per-root watcher state ───────────────────────────────────────────
// WatchedRoot/WatcherSubscription live in filesystem-watcher-wsl.ts so native and WSL watchers share one shape.
@@ -1959,3 +1960,16 @@ export async function closeAllWatchers(): Promise<void> {
}
remoteWatchers.clear()
}
/** The desktop binding for {@link WorktreeWatcherRemoval}. Installed during startup. */
export const desktopWorktreeWatcherRemoval: WorktreeWatcherRemoval = {
closeLocal: (worktreePath, deadline) =>
deadline
? closeLocalWatcherForWorktreePath(worktreePath, deadline)
: closeLocalWatcherForWorktreePath(worktreePath),
restoreLocal: restoreLocalWatcherAfterFailedRemoval,
forgetLocal: forgetLocalWatcherRemovalSnapshot,
closeRemote: closeRemoteWatcherForWorktreePath,
restoreRemote: restoreRemoteWatcherAfterFailedRemoval,
forgetRemote: forgetRemoteWatcherRemovalSnapshot
}
@@ -21,7 +21,6 @@ const {
vi.mock('electron', () => ({
app: {
exit: vi.fn(),
getPath: () => '/tmp/orca-user-data',
relaunch: vi.fn()
},
ipcMain: {
@@ -56,9 +55,13 @@ vi.mock('../orca-profiles/profile-cloud-service', () => ({
}))
import { registerOrcaProfileHandlers } from './orca-profiles'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
describe('registerOrcaProfileHandlers auth channels', () => {
beforeEach(() => {
// Why the port and per-test: userData resolves through AppEnvironment now, and
// the global setup's beforeEach reinstates its own fake before this runs.
installFakeAppEnvironment({ getPath: () => '/tmp/orca-user-data' })
handlers.clear()
createCloudLinkedOrcaProfileMock.mockReset()
connectCurrentOrcaProfileMock.mockReset()
+5 -2
View File
@@ -30,8 +30,7 @@ vi.mock('electron', () => ({
app: {
exit: appExitMock,
quit: appQuitMock,
relaunch: appRelaunchMock,
getPath: () => '/tmp/orca-user-data'
relaunch: appRelaunchMock
},
ipcMain: {
handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => {
@@ -68,9 +67,13 @@ vi.mock('../orca-profiles/profile-project-transfer', () => ({
}))
import { registerOrcaProfileHandlers } from './orca-profiles'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
describe('registerOrcaProfileHandlers', () => {
beforeEach(() => {
// Why the port and per-test: userData resolves through AppEnvironment now, and
// the global setup's beforeEach reinstates its own fake before this runs.
installFakeAppEnvironment({ getPath: () => '/tmp/orca-user-data' })
vi.useFakeTimers()
handlers.clear()
appExitMock.mockReset()
+42
View File
@@ -0,0 +1,42 @@
import type { WatcherRemovalDeadline } from './watcher-removal-drain'
/**
* Removal-time coordination for the renderer-facing filesystem watchers.
*
* Worktree removal has to close any watcher holding the directory open, then either
* restore it (removal failed) or drop the snapshot (removal succeeded). The desktop
* implementation lives in `./filesystem-watcher`, where that bookkeeping is inseparable
* from the `WebContents` map it suspends and replays.
*
* Why the default is inert rather than a throw: every entry in those maps arrives through
* an `ipcMain` handler carrying a renderer `sender`. A host with no renderer never
* installs one, so there is nothing to close, restore, or forget — the no-op is what the
* desktop code itself would do against empty maps, not a silent stub hiding lost work.
*/
export type WorktreeWatcherRemoval = {
closeLocal(worktreePath: string, deadline?: WatcherRemovalDeadline): Promise<void>
restoreLocal(worktreePath: string): Promise<void>
forgetLocal(worktreePath: string): void
closeRemote(connectionId: string, worktreePath: string): Promise<void>
restoreRemote(connectionId: string, worktreePath: string): Promise<void>
forgetRemote(connectionId: string, worktreePath: string): void
}
const inert: WorktreeWatcherRemoval = {
closeLocal: async () => {},
restoreLocal: async () => {},
forgetLocal: () => {},
closeRemote: async () => {},
restoreRemote: async () => {},
forgetRemote: () => {}
}
let current: WorktreeWatcherRemoval = inert
export function setWorktreeWatcherRemoval(next: WorktreeWatcherRemoval | null): void {
current = next ?? inert
}
export function getWorktreeWatcherRemoval(): WorktreeWatcherRemoval {
return current
}
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
@@ -12,11 +13,10 @@ import {
const testState = { dir: '' }
vi.mock('electron', () => ({
app: {
getPath: () => testState.dir
}
}))
// Why the port and not vi.mock('electron'): profile path resolution reads AppEnvironment
// now, so an electron mock would be inert and every case would share the global fake's
// one temp dir instead of its own.
installFakeAppEnvironment({ getPath: () => testState.dir })
async function loadProfileIndexStore() {
vi.resetModules()
@@ -30,6 +30,8 @@ function readJson(path: string): unknown {
describe('profile index store', () => {
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-profile-test-'))
// Why re-install per test: the global setup's beforeEach reinstates its own fake.
installFakeAppEnvironment({ getPath: () => testState.dir })
})
afterEach(() => {
@@ -1,4 +1,4 @@
import { app } from 'electron'
import { getAppEnvironment } from '../../shared/app-environment'
import { join } from 'node:path'
const LEGACY_DATA_FILE_NAME = 'orca-data.json'
@@ -13,12 +13,12 @@ export const LEGACY_BACKUP_COUNT = 5
let profileUserDataPath: string | null = null
export function initOrcaProfilePaths(): void {
profileUserDataPath = app.getPath('userData')
profileUserDataPath = getAppEnvironment().getPath('userData')
}
export function getProfileUserDataPath(): string {
if (!profileUserDataPath) {
profileUserDataPath = app.getPath('userData')
profileUserDataPath = getAppEnvironment().getPath('userData')
}
return profileUserDataPath
}
+8
View File
@@ -0,0 +1,8 @@
/** Executable entry for `orcad`. See `./orcad-entry.ts`. */
import process from 'node:process'
import { main } from './orcad-entry'
main().catch((error: unknown) => {
console.error('orcad: failed to start:', error)
process.exit(1)
})
+159 -9
View File
@@ -14,6 +14,7 @@ import { join } from 'node:path'
import process from 'node:process'
import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environment'
import { setSecretStore, type SecretStore } from '../../shared/secret-store'
import type { ServeReadiness } from '../server/serve-readiness'
/** XDG-ish data root. `$ORCA_USER_DATA` wins so a smoke test can isolate state. */
function resolveUserDataPath(): string {
@@ -83,16 +84,56 @@ export function installOrcadHostAdapters(): void {
setSecretStore(createNodeSecretStore())
}
/** Boot the runtime and serve RPC. Returns once the transport is listening. */
export async function startOrcad(options: { port?: number } = {}): Promise<void> {
export type OrcadOptions = {
port?: number
json?: boolean
noPairing?: boolean
pairingAddress?: string
}
export type OrcadHandle = {
readiness: ServeReadiness
stop(): Promise<void>
}
/**
* Boot the runtime and serve RPC. Resolves once the transport is listening and the
* readiness payload has been published, mirroring the desktop `--serve` contract byte
* for byte so the same harnesses can drive either host.
*/
export async function startOrcad(options: OrcadOptions = {}): Promise<OrcadHandle> {
installOrcadHostAdapters()
const { OrcaRuntimeService } = await import('../runtime/orca-runtime')
const { OrcaRuntimeRpcServer } = await import('../runtime/runtime-rpc')
const { registerPtyHandlers } = await import('../ipc/pty')
const { registerHeadlessPtyRuntime, getLocalPtyProvider, getSshPtyProvider } =
await import('../ipc/pty')
const { getAppEnvironment } = await import('../../shared/app-environment')
const { resolveAdvertisedPairingEndpoint } = await import('../runtime/pairing-endpoint')
const { ServeReadinessPublisher } = await import('../server/serve-readiness')
const { Store } = await import('../persistence/loading-store/store')
const { ensureActiveOrcaProfile, initOrcaProfilePaths } =
await import('../orca-profiles/profile-index-store')
const { initSshHostKeyStoreFile } = await import('../ssh/ssh-host-key-store')
const runtime = new OrcaRuntimeService(null, undefined, {
const userDataPath = getAppEnvironment().getPath('userData')
initOrcaProfilePaths()
const profile = ensureActiveOrcaProfile(userDataPath)
// Why a real Store: without one every persistence-backed RPC throws `runtime_unavailable`
// and the read paths that use `this.store?.x ?? []` quietly answer "empty" instead —
// a server that pairs and lists nothing looks healthy and is not.
const store = new Store({ dataFile: profile.dataFile })
// Why: every SSH connect consults this sidecar. Left unbound it reports nothing trusted,
// which is safe but silently discards accept records on every launch.
initSshHostKeyStoreFile(profile.dataFile)
const runtime = new OrcaRuntimeService(store, undefined, {
// Why lazy: a daemon swap replaces the provider after construction, so an eager
// reference would freeze the pre-daemon one.
getLocalProvider: () => getLocalPtyProvider(),
// Why: destructive worktree removal refuses to run without a provider to stop
// processes through — correctly, since it cannot otherwise verify the tree is idle.
getSshProvider: (connectionId) => getSshPtyProvider(connectionId),
// Why false: this host does not run the terminal daemon, so persistent local PTYs
// cannot be recovered. The constructor defaults this to true, which would claim a
// capability orcad does not have.
@@ -103,16 +144,125 @@ export async function startOrcad(options: { port?: number } = {}): Promise<void>
getDesktopWindowStatus: () => 'blocked'
})
// Why null: no renderer. This installs the RuntimePtyController that terminal.create
// spawns through — the whole reason this module had to stop importing electron.
registerPtyHandlers(null, runtime)
// Why the headless entry point rather than registerPtyHandlers(null, …): this is the
// same call `--serve` makes, and it threads the store through. Without the store the
// handlers install fine and every terminal.create then fails at persistence time.
//
// Codex-home and Claude-auth preparation are left unset: both are desktop account
// flows. A launch that needs one fails with its own message rather than silently
// spawning an unauthenticated agent.
registerHeadlessPtyRuntime(runtime, undefined, () => store.getSettings(), undefined, store)
// Why: same post-registration reconciliation `--serve` performs. Skipping it leaves
// restored orchestration rows claiming an authority this host never took over.
await runtime.refreshRestoredOrchestrationAuthority()
await runtime.reconcileLegacyWorkerTerminals()
const rpc = new OrcaRuntimeRpcServer({
runtime,
userDataPath: getAppEnvironment().getPath('userData'),
userDataPath,
enableWebSocket: true,
exposeNetworkByDefault: true,
...(options.port !== undefined ? { wsPort: options.port, preferPinnedWsPort: true } : {})
} as never)
})
await rpc.start()
const boundEndpoint = rpc.getWebSocketEndpoint()
const advertised = boundEndpoint
? resolveAdvertisedPairingEndpoint(boundEndpoint, options.pairingAddress)
: null
const offer = options.noPairing
? ({
available: false,
reason: 'disabled_by_operator',
guidance: 'Restart without --no-pairing to create a client pairing offer.'
} as const)
: rpc.createPairingOffer({
address: options.pairingAddress,
name: `CLI ${new Date().toLocaleDateString()}`,
scope: 'runtime'
})
const readiness: ServeReadiness = {
runtimeId: runtime.getRuntimeId(),
boundEndpoint,
advertisedEndpoint: advertised?.ok ? advertised.endpoint : null,
// Why 'settled': the WSL CLI reconciliation barrier is a desktop-launch concern.
// orcad never runs it, so there is no pending repair a client could race.
managedWslCliReconciliation: 'settled',
pairing: offer.available
? {
available: true,
url: offer.pairingUrl,
endpoint: offer.endpoint,
deviceId: offer.deviceId,
webClientUrl: offer.webClientUrl,
scope: 'runtime',
qr: null
}
: offer
}
await new ServeReadinessPublisher().publish(readiness, {
mode: options.json ? 'json' : 'human'
})
return {
readiness,
stop: async () => {
await rpc.stop()
}
}
}
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 === '--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
}
export async function main(argv: string[] = process.argv.slice(2)): Promise<void> {
const handle = await startOrcad(parseArgs(argv))
let stopping = false
const shutdown = (signal: NodeJS.Signals): void => {
if (stopping) {
return
}
stopping = true
handle
.stop()
.then(() => process.exit(0))
// Why not rethrow: we are already tearing down on a signal, and an exit code is
// the only thing a supervisor can act on.
.catch((error) => {
console.error(`orcad: shutdown after ${signal} failed:`, error)
process.exit(1)
})
}
process.on('SIGINT', () => shutdown('SIGINT'))
process.on('SIGTERM', () => shutdown('SIGTERM'))
}
@@ -13,6 +13,7 @@ import type * as NodeFsPromises from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import type { SshRemotePtyLeaseState } from '../shared/ssh-types'
import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup'
const testState = { dir: '' }
@@ -177,6 +178,9 @@ async function createStore(dir: string): Promise<TestStore> {
testState.dir = dir
vi.resetModules()
const { Store, initDataPath } = await import('./persistence')
// Why here: userData resolves through AppEnvironment, and this must point at this
// file's temp dir rather than the global fake's shared one, after resetModules.
installFakeAppEnvironment({ getPath: () => testState.dir })
initDataPath()
return new Store() as unknown as TestStore
}
@@ -12,6 +12,7 @@ import type { Project, ProjectHostSetup } from '../shared/project-types'
import type { Repo } from '../shared/repo-types'
import { getDefaultPersistedState } from '../shared/constants'
import { toRuntimeExecutionHostId } from '../shared/execution-host'
import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup'
const testState = { dir: '' }
@@ -52,6 +53,9 @@ async function createStoreFromState(state: Record<string, unknown>) {
)
vi.resetModules()
const { Store, initDataPath } = await import('./persistence')
// Why here: userData resolves through AppEnvironment, and this must point at this
// file's temp dir rather than the global fake's shared one, after resetModules.
installFakeAppEnvironment({ getPath: () => testState.dir })
initDataPath()
return new Store()
}
@@ -3,6 +3,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { randomUUID } from 'node:crypto'
import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup'
type FailureMode = 'availability-throws' | 'encryption-throws' | 'unavailable'
@@ -56,6 +57,9 @@ async function createStore() {
describeUnavailable: () => null
})
const { Store, initDataPath } = await import('./persistence')
// Why here: userData resolves through AppEnvironment, and this must point at this
// file's temp dir rather than the global fake's shared one, after resetModules.
installFakeAppEnvironment({ getPath: () => testState.dir })
initDataPath()
return new Store()
}
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from 'node:fs'
import type * as NodeFsPromises from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup'
const testState = { dir: '' }
const cipherState = { available: true }
@@ -53,6 +54,9 @@ async function createStore() {
describeUnavailable: () => null
})
const { Store, initDataPath } = await import('./persistence')
// Why here: userData resolves through AppEnvironment, and this must point at this
// file's temp dir rather than the global fake's shared one, after resetModules.
installFakeAppEnvironment({ getPath: () => testState.dir })
initDataPath()
return new Store()
}
@@ -10,6 +10,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, mkdtempSync, writeFileSync
import { dirname, join } from 'node:path'
import { tmpdir } from 'node:os'
import { randomUUID } from 'node:crypto'
import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup'
const testState = { dir: '' }
@@ -63,6 +64,9 @@ async function createStore() {
describeUnavailable: () => null
})
const { Store, initDataPath } = await import('./persistence')
// Why here: userData resolves through AppEnvironment, and this must point at this
// file's temp dir rather than the global fake's shared one, after resetModules.
installFakeAppEnvironment({ getPath: () => testState.dir })
initDataPath()
return new Store()
}
@@ -8,6 +8,7 @@ import { readFileSync, rmSync, mkdtempSync, statSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { randomUUID } from 'node:crypto'
import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup'
const testState = { dir: '' }
@@ -58,6 +59,9 @@ async function createStore() {
describeUnavailable: () => null
})
const { Store, initDataPath } = await import('./persistence')
// Why here: userData resolves through AppEnvironment, and this must point at this
// file's temp dir rather than the global fake's shared one, after resetModules.
installFakeAppEnvironment({ getPath: () => testState.dir })
initDataPath()
return new Store()
}
+5
View File
@@ -1,6 +1,7 @@
import { mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { vi } from 'vitest'
import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup'
import type { Project, ProjectHostSetup } from '../shared/project-types'
import type { Repo } from '../shared/repo-types'
import type { TerminalTab } from '../shared/terminal-tab-types'
@@ -13,6 +14,10 @@ export const testState = { dir: '' }
/** Reset modules and dynamically import Store so the data-file path picks up the current testState.dir */
export async function createStore() {
vi.resetModules()
// Why here and not a per-file vi.mock('electron'): the data-file path resolves through
// AppEnvironment now, and it must point at this test's dir rather than the global
// fake's shared one. Re-installed after resetModules so the fresh graph sees it.
installFakeAppEnvironment({ getPath: () => testState.dir })
const { Store, initDataPath } = await import('./persistence')
initDataPath()
return new Store()
+4
View File
@@ -12,6 +12,7 @@ import {
readDataFile,
makeRepo
} from './persistence-test-harness'
import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup'
// Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config.
const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({
@@ -50,6 +51,9 @@ async function createStore() {
describeUnavailable: () => null
})
const { Store, initDataPath } = await import('./persistence')
// Why here: userData resolves through AppEnvironment, and this must point at this
// file's temp dir rather than the global fake's shared one, after resetModules.
installFakeAppEnvironment({ getPath: () => testState.dir })
initDataPath()
return new Store()
}
@@ -18,6 +18,7 @@ import {
makeSessionWithTerminalBuffers,
makeSessionWithBrowserHistory
} from './persistence-session-fixtures'
import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup'
// Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config.
const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({
@@ -188,6 +189,9 @@ describe('Store', () => {
mkdirSync(profileDataDirectory, { recursive: true })
vi.resetModules()
// Why: the legacy snapshot dir hangs off userData, which resolves through
// AppEnvironment — without this it points at the global fake's shared dir.
installFakeAppEnvironment({ getPath: () => testState.dir })
const { Store, initDataPath } = await import('./persistence')
initDataPath()
const store = new Store({ dataFile: profileDataFile })
@@ -218,6 +222,9 @@ describe('Store', () => {
writeFileSync(join(legacySnapshotDir, `${ref}.bin`), 'legacy-scrollback', 'utf-8')
vi.resetModules()
// Why: the legacy snapshot dir hangs off userData, which resolves through
// AppEnvironment — without this it points at the global fake's shared dir.
installFakeAppEnvironment({ getPath: () => testState.dir })
const { Store, initDataPath } = await import('./persistence')
initDataPath()
const store = new Store({ dataFile: profileDataFile })
@@ -8,6 +8,7 @@ import { createRetiredNameLookup } from '../shared/worktree/retired-name-registr
import type { SshTarget } from '../shared/ssh-types'
import { MAX_RETIREMENT_NAMESPACES } from './worktree-retirement-namespace'
import { getRuntimeOwnedSshTargetId } from './ssh/ssh-connection-store'
import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup'
const testState = { dir: '' }
@@ -45,6 +46,9 @@ function sshTarget(id: string, overrides: Partial<SshTarget> = {}): SshTarget {
async function reloadStore() {
vi.resetModules()
const { Store, initDataPath } = await import('./persistence')
// Why here: userData resolves through AppEnvironment, and this must point at this
// file's temp dir rather than the global fake's shared one, after resetModules.
installFakeAppEnvironment({ getPath: () => testState.dir })
initDataPath()
return new Store()
}
@@ -1,4 +1,4 @@
import { app } from 'electron'
import { getAppEnvironment } from '../../../shared/app-environment'
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import type { PersistedState } from '../../../shared/persisted-state-types'
@@ -11,7 +11,7 @@ let _dataFile: string | null = null
let _userDataDir: string | null = null
export function initDataPath(): void {
const userDataDir = app.getPath('userData')
const userDataDir = getAppEnvironment().getPath('userData')
_userDataDir = userDataDir
_dataFile = join(userDataDir, 'orca-data.json')
}
@@ -19,7 +19,7 @@ export function initDataPath(): void {
export function getDataFile(): string {
if (!_dataFile) {
// Safety fallback — should not be hit in normal startup.
const userDataDir = app.getPath('userData')
const userDataDir = getAppEnvironment().getPath('userData')
_userDataDir = userDataDir
_dataFile = join(userDataDir, 'orca-data.json')
}
@@ -51,14 +51,14 @@ export function readGithubCacheSnapshot(dataFile: string): PersistedState['githu
}
/**
* Return the userData directory captured at initDataPath() time, before app.setName() can change how app.getPath('userData') resolves.
* Return the userData directory captured at initDataPath() time, before app.setName() can change how getAppEnvironment().getPath('userData') resolves.
*
* Subsystems sharing storage with orca-data.json read this instead of resolving late, which on case-sensitive FS can lose paired devices.
*/
export function getCanonicalUserDataPath(): string {
if (!_userDataDir) {
// Safety fallback — should not be hit in normal startup.
_userDataDir = app.getPath('userData')
_userDataDir = getAppEnvironment().getPath('userData')
}
return _userDataDir
}
@@ -4,6 +4,7 @@ import type * as NodeFs from 'node:fs'
import type * as NodeFsPromises from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup'
// Why these tests exist: will-quit used to run stats.flush() and store.flush() synchronously,
// before preventDefault(). On a stalled network profile mount those fsync/rename syscalls park
@@ -115,6 +116,8 @@ async function createStore(dir: string): Promise<TestStore> {
testState.dir = dir
vi.resetModules()
const { Store, initDataPath } = await import('./persistence')
// Why: userData resolves through AppEnvironment; point it at this file's temp dir.
installFakeAppEnvironment({ getPath: () => testState.dir })
initDataPath()
return new Store() as unknown as TestStore
}
@@ -6,6 +6,7 @@ import { tmpdir } from 'node:os'
// Import from the production source of truth so a filename rename can't silently
// pass these tests against stale names.
import { DEVICE_REGISTRY_FILENAME, E2EE_KEYPAIR_FILENAME } from './mobile-pairing-files'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
// Mutable userData the electron mock resolves. We flip it mid-test to simulate
// app.setName('Orca') changing how app.getPath('userData') resolves (e.g. from
@@ -15,8 +16,12 @@ import { DEVICE_REGISTRY_FILENAME, E2EE_KEYPAIR_FILENAME } from './mobile-pairin
// of whether the test host's filesystem is case-sensitive.
const appState = { userData: '' }
// Why the port for getPath: userData now resolves through AppEnvironment, so an
// electron mock would be inert here. safeStorage is still mocked because the modules
// under test seal through it directly.
installFakeAppEnvironment({ getPath: () => appState.userData })
vi.mock('electron', () => ({
app: { getPath: () => appState.userData },
safeStorage: {
isEncryptionAvailable: () => false,
encryptString: (plaintext: string) => Buffer.from(plaintext, 'utf-8'),
@@ -38,6 +43,8 @@ describe('mobile pairing userData path stability', () => {
lateDir = join(root, 'userdata-late')
mkdirSync(canonicalDir, { recursive: true })
mkdirSync(lateDir, { recursive: true })
// Why re-install: the global setup's beforeEach reinstates its own fake.
installFakeAppEnvironment({ getPath: () => appState.userData })
vi.resetModules()
})
@@ -55,8 +62,9 @@ describe('mobile pairing userData path stability', () => {
appState.userData = lateDir
expect(getCanonicalUserDataPath()).toBe(canonicalDir)
const { app } = await import('electron')
expect(getCanonicalUserDataPath()).not.toBe(app.getPath('userData'))
// Why the port: this is the "resolve late" path the captured value must differ from.
const { getAppEnvironment } = await import('../../shared/app-environment')
expect(getCanonicalUserDataPath()).not.toBe(getAppEnvironment().getPath('userData'))
})
it('writes DeviceRegistry + E2EE keypair under the canonical path, not the late one', async () => {
+12 -8
View File
@@ -136,6 +136,7 @@ import {
setTerminalViewAttributes
} from './terminal-view-attribute-store'
import { clearConfiguredWorktreeSharedDirectoriesCacheForTests } from '../git/worktree-shared-directories'
import { setWorktreeWatcherRemoval } from '../ipc/worktree-watcher-removal'
const ORIGINAL_PLATFORM = process.platform
const ORIGINAL_PLATFORM_DESCRIPTOR = Object.getOwnPropertyDescriptor(process, 'platform')
@@ -219,14 +220,17 @@ const forgetRemoteWatcherRemovalSnapshotMock = vi.hoisted(() => vi.fn())
const scanLocalRepoWorktreesForResolutionMock = vi.hoisted(() => vi.fn())
vi.mock('electron', () => electronMocks)
vi.mock('../ipc/filesystem-watcher', () => ({
closeLocalWatcherForWorktreePath: closeLocalWatcherForWorktreePathMock,
closeRemoteWatcherForWorktreePath: closeRemoteWatcherForWorktreePathMock,
restoreLocalWatcherAfterFailedRemoval: restoreLocalWatcherAfterFailedRemovalMock,
restoreRemoteWatcherAfterFailedRemoval: restoreRemoteWatcherAfterFailedRemovalMock,
forgetLocalWatcherRemovalSnapshot: forgetLocalWatcherRemovalSnapshotMock,
forgetRemoteWatcherRemovalSnapshot: forgetRemoteWatcherRemovalSnapshotMock
}))
// Why install the port instead of mocking ../ipc/filesystem-watcher: the runtime calls
// WorktreeWatcherRemoval now, so a module mock would be inert and every assertion below
// would silently pass against the inert default. Same mocks, same expectations.
setWorktreeWatcherRemoval({
closeLocal: closeLocalWatcherForWorktreePathMock,
closeRemote: closeRemoteWatcherForWorktreePathMock,
restoreLocal: restoreLocalWatcherAfterFailedRemovalMock,
restoreRemote: restoreRemoteWatcherAfterFailedRemovalMock,
forgetLocal: forgetLocalWatcherRemovalSnapshotMock,
forgetRemote: forgetRemoteWatcherRemovalSnapshotMock
})
const {
MOCK_GIT_WORKTREES,
+7 -14
View File
@@ -1136,14 +1136,7 @@ import {
} from '../worktree-removal-repo-owner'
import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch'
import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation'
import {
closeLocalWatcherForWorktreePath,
closeRemoteWatcherForWorktreePath,
forgetLocalWatcherRemovalSnapshot,
forgetRemoteWatcherRemovalSnapshot,
restoreLocalWatcherAfterFailedRemoval,
restoreRemoteWatcherAfterFailedRemoval
} from '../ipc/filesystem-watcher'
import { getWorktreeWatcherRemoval } from '../ipc/worktree-watcher-removal'
import { acquireWatcherRemovalGate } from '../ipc/watcher-removal-gate'
import {
createWatcherRemovalDeadline,
@@ -10234,11 +10227,11 @@ export class OrcaRuntimeService {
const results = await Promise.allSettled([
connectionId
? drainBeforeWatcherRemoval(
closeRemoteWatcherForWorktreePath(connectionId, worktreePath),
getWorktreeWatcherRemoval().closeRemote(connectionId, worktreePath),
deadline,
`remote watcher close for ${worktreePath}`
)
: closeLocalWatcherForWorktreePath(worktreePath, deadline),
: getWorktreeWatcherRemoval().closeLocal(worktreePath, deadline),
drainBeforeWatcherRemoval(
this.fileCommands.closeFileExplorerWatchersForPath(worktreePath, connectionId),
deadline,
@@ -10260,16 +10253,16 @@ export class OrcaRuntimeService {
): Promise<void> => {
await Promise.all([
connectionId
? restoreRemoteWatcherAfterFailedRemoval(connectionId, worktreePath)
: restoreLocalWatcherAfterFailedRemoval(worktreePath),
? getWorktreeWatcherRemoval().restoreRemote(connectionId, worktreePath)
: getWorktreeWatcherRemoval().restoreLocal(worktreePath),
this.fileCommands.restoreFileExplorerWatchersAfterFailedRemoval(worktreePath, connectionId)
])
}
forgetFileWatchersAfterRemoval = (worktreePath: string, connectionId?: string): void => {
if (connectionId) {
forgetRemoteWatcherRemovalSnapshot(connectionId, worktreePath)
getWorktreeWatcherRemoval().forgetRemote(connectionId, worktreePath)
} else {
forgetLocalWatcherRemovalSnapshot(worktreePath)
getWorktreeWatcherRemoval().forgetLocal(worktreePath)
}
this.fileCommands.forgetFileExplorerWatchersAfterRemoval(worktreePath, connectionId)
}
+36 -4
View File
@@ -1,28 +1,60 @@
import { ModelManager } from './model-manager'
import { SttService } from './stt-service'
import type { ModelManager } from './model-manager'
import type { SttService } from './stt-service'
import type { VoiceSettings } from '../../shared/speech-types'
/**
* Lazy accessors for the speech services.
*
* Why the construction is injected: `ModelManager` downloads models through Electron's
* streaming `net.request` — byte-range resume, progress events, a manual idle timeout —
* and `SttService` resolves paths inside the packaged app. Importing either for its
* *type* is free; constructing one is what pulls Electron in.
*
* The desktop installs the factories. A host without them rejects per call rather than
* returning a stub that silently does nothing: speech is a desktop feature, and a
* headless host saying so is more useful than one that appears to transcribe.
*/
type SpeechSettingsStore = {
getSettings(): {
voice?: VoiceSettings
}
}
export type SpeechServiceFactories = {
createModelManager(customModelsDir: string | undefined): ModelManager
createSttService(models: ModelManager): SttService
}
let factories: SpeechServiceFactories | null = null
let modelManager: ModelManager | null = null
let sttService: SttService | null = null
export function setSpeechServiceFactories(next: SpeechServiceFactories | null): void {
factories = next
modelManager = null
sttService = null
}
function requireFactories(): SpeechServiceFactories {
if (!factories) {
throw new Error('speech_unavailable: this host has no speech services')
}
return factories
}
export function getSpeechModelManager(store: SpeechSettingsStore): ModelManager {
if (!modelManager) {
const settings = store.getSettings()
const customDir = settings.voice?.modelsDir || undefined
modelManager = new ModelManager(customDir || undefined)
modelManager = requireFactories().createModelManager(customDir || undefined)
}
return modelManager
}
export function getSpeechSttService(store: SpeechSettingsStore): SttService {
if (!sttService) {
sttService = new SttService(getSpeechModelManager(store))
sttService = requireFactories().createSttService(getSpeechModelManager(store))
}
return sttService
}