mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user