perf: checkpoint startup boundaries and bundle diagnostics

This commit is contained in:
Neil
2026-07-30 02:17:50 -07:00
parent ef55429f3d
commit a27c2b3387
296 changed files with 19211 additions and 1419 deletions
+3
View File
@@ -251,6 +251,9 @@ jobs:
done
exit "$status"
- name: Check Electron bundle budgets
run: pnpm run check:electron-bundle-budgets
- name: Project web client from renderer build
run: pnpm run build:web-from-renderer
@@ -0,0 +1,158 @@
import type { Plugin } from 'vite'
const COMPLETION_MARKER = '__ORCA_BOOTSTRAP_MARK_EVALUATION_COMPLETE__'
export function createStartupDiagnosticsBootstrapPrefix(chunkName: string): string {
return `
;(() => {
const env = typeof process !== 'undefined' ? process.env : undefined
const mode = env?.ORCA_STARTUP_DIAGNOSTICS
if (mode !== '1' && mode !== 'trace') {
return
}
const processNow = () => {
try {
return globalThis.performance.now()
} catch {
return null
}
}
const enteredAt = processNow()
const safeJson = (value) => {
try {
return JSON.stringify(value)
} catch {
return '"<unserializable>"'
}
}
let closeSync
let diagnosticFileDescriptor
let openSync
let writeSync
try {
const fs = require('node:fs')
closeSync = fs.closeSync
openSync = fs.openSync
writeSync = fs.writeSync
} catch {
closeSync = undefined
openSync = undefined
writeSync = undefined
}
const diagnosticFile = env?.ORCA_STARTUP_DIAGNOSTICS_FILE
if (typeof diagnosticFile === 'string' && diagnosticFile.length > 0 && typeof openSync === 'function') {
try {
diagnosticFileDescriptor = openSync(diagnosticFile, 'a', 0o600)
} catch {
diagnosticFileDescriptor = undefined
}
}
const writeLine = (message) => {
try {
const line = message.endsWith('\\n') ? message : message + '\\n'
if (typeof writeSync === 'function') {
writeSync(2, line)
if (typeof diagnosticFileDescriptor === 'number') {
writeSync(diagnosticFileDescriptor, line)
}
}
} catch {
// Diagnostics must never affect startup.
}
}
const chunkName = ${JSON.stringify(chunkName)}
writeLine('[bootstrap] bundle-enter t=' + safeJson(enteredAt) + ' clock="process-performance-now-ms" chunk=' + safeJson(chunkName) + ' pid=' + process.pid + ' ppid=' + process.ppid + ' execPath=' + safeJson(process.execPath) + ' argv=' + safeJson(process.argv) + ' electronRunAsNode=' + safeJson(env?.ELECTRON_RUN_AS_NODE ?? null))
globalThis.${COMPLETION_MARKER} = () => {
writeLine('[bootstrap] bundle-evaluation-complete t=' + safeJson(processNow()) + ' clock="process-performance-now-ms" chunk=' + safeJson(chunkName))
}
if (!globalThis.__ORCA_BOOTSTRAP_EXIT_LOG_INSTALLED__) {
globalThis.__ORCA_BOOTSTRAP_EXIT_LOG_INSTALLED__ = true
process.once('exit', (code) => {
writeLine('[bootstrap] process-exit code=' + code)
if (typeof closeSync === 'function' && typeof diagnosticFileDescriptor === 'number') {
try {
closeSync(diagnosticFileDescriptor)
} catch {
// Diagnostics must never affect shutdown.
}
}
})
process.on('uncaughtExceptionMonitor', (error, origin) => {
const message = error && typeof error === 'object' && 'stack' in error ? error.stack : error
writeLine('[bootstrap] uncaught-exception origin=' + safeJson(origin) + ' error=' + safeJson(String(message)))
})
process.on('unhandledRejection', (reason) => {
const message = reason && typeof reason === 'object' && 'stack' in reason ? reason.stack : reason
writeLine('[bootstrap] unhandled-rejection error=' + safeJson(String(message)))
})
}
if (mode === 'trace' && !globalThis.__ORCA_BOOTSTRAP_REQUIRE_TRACE_INSTALLED__) {
globalThis.__ORCA_BOOTSTRAP_REQUIRE_TRACE_INSTALLED__ = true
try {
const Module = require('node:module')
const originalLoad = Module._load
const parsedTraceLimit = Number(env?.ORCA_STARTUP_DIAGNOSTICS_TRACE_LIMIT ?? 20000)
const traceLimit = Number.isFinite(parsedTraceLimit) && parsedTraceLimit > 0 ? parsedTraceLimit : 20000
let traceLineCount = 0
let traceLimitReported = false
const writeTraceLine = (message) => {
if (traceLineCount >= traceLimit) {
if (!traceLimitReported) {
traceLimitReported = true
writeLine('[bootstrap] require-trace-limit-reached limit=' + safeJson(traceLimit))
}
return
}
traceLineCount += 1
writeLine(message)
}
Module._load = function (request, parent, isMain) {
const parentName = parent && parent.filename ? parent.filename : null
writeTraceLine('[bootstrap] require-start request=' + safeJson(request) + ' parent=' + safeJson(parentName) + ' isMain=' + safeJson(Boolean(isMain)))
try {
const result = Reflect.apply(originalLoad, this, arguments)
writeTraceLine('[bootstrap] require-ok request=' + safeJson(request))
return result
} catch (error) {
const message = error && typeof error === 'object' && 'stack' in error ? error.stack : error
writeTraceLine('[bootstrap] require-error request=' + safeJson(request) + ' error=' + safeJson(String(message)))
throw error
}
}
} catch (error) {
writeLine('[bootstrap] require-trace-install-error error=' + safeJson(String(error)))
}
}
})();
`
}
export function createStartupDiagnosticsBootstrapSuffix(): string {
return `
;(() => {
try {
globalThis.${COMPLETION_MARKER}?.()
} catch {
// Diagnostics must never affect startup.
}
})();
`
}
export function createStartupDiagnosticsBootstrapPlugin(): Plugin {
return {
name: 'orca-startup-diagnostics-bootstrap',
generateBundle(_options, bundle) {
const mainChunk = bundle['index.js']
if (!mainChunk || mainChunk.type !== 'chunk') {
return
}
// Why: source diagnostics run after generated imports; wrap the final entry instead.
mainChunk.code =
createStartupDiagnosticsBootstrapPrefix(mainChunk.fileName) +
mainChunk.code +
createStartupDiagnosticsBootstrapSuffix()
}
}
}
+30
View File
@@ -0,0 +1,30 @@
{
"version": 1,
"entries": {
"electron-main": {
"maxRawBytes": 825109,
"maxJavaScriptFiles": 1,
"maxCssFiles": 0
},
"electron-preload": {
"maxRawBytes": 132000,
"maxJavaScriptFiles": 1,
"maxCssFiles": 0
},
"renderer-index": {
"maxRawBytes": 8466347,
"maxJavaScriptFiles": 293,
"maxCssFiles": 2
},
"renderer-popout": {
"maxRawBytes": 4537948,
"maxJavaScriptFiles": 78,
"maxCssFiles": 2
},
"renderer-web": {
"maxRawBytes": 4384876,
"maxJavaScriptFiles": 34,
"maxCssFiles": 1
}
}
}
@@ -0,0 +1,223 @@
import { readFileSync } from 'node:fs'
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
import { pathToFileURL } from 'node:url'
import { gzipSync } from 'node:zlib'
const TOP_CONTRIBUTOR_COUNT = 10
const RENDERER_ENTRIES = [
{ id: 'renderer-index', manifestKey: 'index.html' },
{ id: 'renderer-popout', manifestKey: 'popout.html' },
{ id: 'renderer-web', manifestKey: 'web-index.html' }
]
function displayPath(filePath) {
return filePath.split(sep).join('/')
}
function readArtifact(projectRoot, projectPath) {
const absolutePath = join(projectRoot, ...projectPath.split('/'))
try {
return readFileSync(absolutePath)
} catch (error) {
if (error?.code === 'ENOENT') {
throw new Error(`Missing build artifact: ${projectPath}`)
}
throw error
}
}
function resolveRendererArtifact(projectRoot, manifestPath) {
if (
isAbsolute(manifestPath) ||
/^[A-Za-z]:[\\/]/.test(manifestPath) ||
manifestPath.split(/[\\/]/).includes('..')
) {
throw new Error(`Invalid renderer artifact path: ${manifestPath}`)
}
const rendererRoot = join(projectRoot, 'out', 'renderer')
const absolutePath = resolve(rendererRoot, ...manifestPath.split('/'))
const relativePath = relative(projectRoot, absolutePath)
return {
absolutePath,
reportPath: displayPath(relativePath)
}
}
function measureFile(buffer, reportPath, type) {
return {
path: reportPath,
type,
rawBytes: buffer.byteLength,
gzipBytes: gzipSync(buffer).byteLength
}
}
function comparePaths(left, right) {
if (left < right) {
return -1
}
if (left > right) {
return 1
}
return 0
}
function summarizeEntry(id, entryFile, files) {
const contributors = [...files].sort(
(left, right) => right.rawBytes - left.rawBytes || comparePaths(left.path, right.path)
)
return {
id,
entryFile,
rawBytes: contributors.reduce((total, file) => total + file.rawBytes, 0),
gzipBytes: contributors.reduce((total, file) => total + file.gzipBytes, 0),
javascriptFiles: contributors.filter((file) => file.type === 'javascript').length,
cssFiles: contributors.filter((file) => file.type === 'css').length,
topContributors: contributors.slice(0, TOP_CONTRIBUTOR_COUNT)
}
}
function collectRendererFiles(manifest, entryKey) {
const visitedChunks = new Set()
const javascriptFiles = new Set()
const cssFiles = new Set()
function visit(chunkKey) {
if (visitedChunks.has(chunkKey)) {
return
}
visitedChunks.add(chunkKey)
const chunk = manifest[chunkKey]
if (!chunk) {
throw new Error(`Missing manifest chunk "${chunkKey}" reached from "${entryKey}"`)
}
if (typeof chunk.file !== 'string') {
throw new Error(`Manifest chunk "${chunkKey}" has no output file`)
}
javascriptFiles.add(chunk.file)
for (const cssFile of chunk.css ?? []) {
cssFiles.add(cssFile)
}
for (const importedChunk of chunk.imports ?? []) {
visit(importedChunk)
}
}
visit(entryKey)
return { javascriptFiles, cssFiles }
}
function readRendererArtifact(absolutePath, reportPath) {
try {
return readFileSync(absolutePath)
} catch (error) {
if (error?.code === 'ENOENT') {
throw new Error(`Missing build artifact: ${reportPath}`)
}
throw error
}
}
function measureRendererEntry(projectRoot, manifest, { id, manifestKey }) {
const { javascriptFiles, cssFiles } = collectRendererFiles(manifest, manifestKey)
const files = []
for (const manifestPath of [...javascriptFiles].sort()) {
const { absolutePath, reportPath } = resolveRendererArtifact(projectRoot, manifestPath)
files.push(
measureFile(readRendererArtifact(absolutePath, reportPath), reportPath, 'javascript')
)
}
for (const manifestPath of [...cssFiles].sort()) {
const { absolutePath, reportPath } = resolveRendererArtifact(projectRoot, manifestPath)
files.push(measureFile(readRendererArtifact(absolutePath, reportPath), reportPath, 'css'))
}
return summarizeEntry(id, manifestKey, files)
}
export function createElectronBundleEntryReport(projectRoot = process.cwd()) {
projectRoot = resolve(projectRoot)
const manifestPath = 'out/renderer/.vite/manifest.json'
const manifestBuffer = readArtifact(projectRoot, manifestPath)
let manifest
try {
manifest = JSON.parse(manifestBuffer.toString('utf8'))
} catch {
throw new Error(`Invalid JSON build artifact: ${manifestPath}`)
}
const singleFileEntries = [
{ id: 'electron-main', path: 'out/main/index.js' },
{ id: 'electron-preload', path: 'out/preload/index.js' }
].map(({ id, path }) =>
summarizeEntry(id, path, [measureFile(readArtifact(projectRoot, path), path, 'javascript')])
)
return {
version: 1,
entries: [
...singleFileEntries,
...RENDERER_ENTRIES.map((entry) => measureRendererEntry(projectRoot, manifest, entry))
]
}
}
export function findElectronBundleBudgetFailures(report, budgets) {
const failures = []
for (const entry of report.entries) {
const budget = budgets.entries?.[entry.id]
if (!budget) {
failures.push(`${entry.id}: missing checked-in budget`)
continue
}
const checks = [
['raw bytes', entry.rawBytes, budget.maxRawBytes],
['JavaScript files', entry.javascriptFiles, budget.maxJavaScriptFiles],
['CSS files', entry.cssFiles, budget.maxCssFiles]
]
for (const [label, actual, limit] of checks) {
if (!Number.isSafeInteger(limit) || limit < 0) {
failures.push(`${entry.id}: invalid ${label} budget`)
} else if (actual > limit) {
failures.push(`${entry.id}: ${label} ${actual} exceeds budget ${limit}`)
}
}
}
return failures
}
export function runElectronBundleBudgetCheck({
projectRoot = process.cwd(),
budgetsPath = join(projectRoot, 'config', 'electron-bundle-budgets.json'),
writeReport = console.log,
writeError = console.error
} = {}) {
try {
const report = createElectronBundleEntryReport(projectRoot)
const budgets = JSON.parse(readFileSync(budgetsPath, 'utf8'))
const failures = findElectronBundleBudgetFailures(report, budgets)
writeReport(JSON.stringify(report, null, 2))
for (const failure of failures) {
writeError(`Bundle budget exceeded: ${failure}`)
}
return failures.length === 0 ? 0 : 1
} catch (error) {
writeError(error instanceof Error ? error.message : String(error))
return 1
}
}
const isMain = process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url
if (isMain) {
process.exitCode = runElectronBundleBudgetCheck()
}
@@ -0,0 +1,177 @@
import { mkdir, mkdtemp, rm, unlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { gzipSync } from 'node:zlib'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
createElectronBundleEntryReport,
findElectronBundleBudgetFailures,
runElectronBundleBudgetCheck
} from './electron-bundle-entry-report.mjs'
let projectRoot
async function writeProjectFile(projectPath, contents) {
const absolutePath = join(projectRoot, ...projectPath.split('/'))
await mkdir(dirname(absolutePath), { recursive: true })
await writeFile(absolutePath, contents)
}
async function writeFixtureBuild() {
const files = {
'out/main/index.js': 'main-entry',
'out/preload/index.js': 'preload-entry',
'out/renderer/assets/index.js': 'index-entry',
'out/renderer/assets/first.js': 'first-chunk',
'out/renderer/assets/shared.js': 'shared-chunk',
'out/renderer/assets/popout.js': 'popout-entry',
'out/renderer/assets/web.js': 'web-entry',
'out/renderer/assets/index.css': 'index-style',
'out/renderer/assets/shared.css': 'shared-style'
}
const manifest = {
'index.html': {
file: 'assets/index.js',
imports: ['_first.js', '_shared.js'],
dynamicImports: ['_dynamic.js'],
css: ['assets/index.css']
},
'_first.js': {
file: 'assets/first.js',
imports: ['_shared.js'],
css: ['assets/shared.css']
},
'_shared.js': {
file: 'assets/shared.js',
css: ['assets/shared.css']
},
'popout.html': {
file: 'assets/popout.js',
imports: ['_shared.js']
},
'web-index.html': {
file: 'assets/web.js',
imports: ['_shared.js']
},
'_dynamic.js': {
file: 'assets/not-written-dynamic.js'
}
}
for (const [projectPath, contents] of Object.entries(files)) {
await writeProjectFile(projectPath, contents)
}
await writeProjectFile('out/renderer/.vite/manifest.json', JSON.stringify(manifest))
return files
}
function byteLength(value) {
return Buffer.byteLength(value)
}
function gzipLength(value) {
return gzipSync(value).byteLength
}
beforeEach(async () => {
projectRoot = await mkdtemp(join(tmpdir(), 'orca-electron-bundle-report-'))
})
afterEach(async () => {
await rm(projectRoot, { recursive: true, force: true })
})
describe('Electron bundle entry report', () => {
it('follows transitive static imports and ignores dynamic imports', async () => {
const files = await writeFixtureBuild()
const report = createElectronBundleEntryReport(projectRoot)
const entry = report.entries.find(({ id }) => id === 'renderer-index')
const expectedPaths = [
'out/renderer/assets/index.js',
'out/renderer/assets/first.js',
'out/renderer/assets/shared.js',
'out/renderer/assets/index.css',
'out/renderer/assets/shared.css'
]
expect(entry.javascriptFiles).toBe(3)
expect(entry.cssFiles).toBe(2)
expect(entry.rawBytes).toBe(
expectedPaths.reduce((total, projectPath) => total + byteLength(files[projectPath]), 0)
)
expect(entry.gzipBytes).toBe(
expectedPaths.reduce((total, projectPath) => total + gzipLength(files[projectPath]), 0)
)
})
it('deduplicates shared JavaScript and CSS within each entry', async () => {
await writeFixtureBuild()
const report = createElectronBundleEntryReport(projectRoot)
const entry = report.entries.find(({ id }) => id === 'renderer-index')
const paths = entry.topContributors.map(({ path }) => path)
expect(paths.filter((path) => path.endsWith('shared.js'))).toHaveLength(1)
expect(paths.filter((path) => path.endsWith('shared.css'))).toHaveLength(1)
})
it('fails clearly when a referenced production artifact is missing', async () => {
await writeFixtureBuild()
await unlink(join(projectRoot, 'out', 'renderer', 'assets', 'shared.js'))
expect(() => createElectronBundleEntryReport(projectRoot)).toThrow(/shared\.js/)
})
it('reports raw-size and chunk-count budget failures', async () => {
await writeFixtureBuild()
const report = createElectronBundleEntryReport(projectRoot)
const budgets = {
entries: Object.fromEntries(
report.entries.map((entry) => [
entry.id,
{
maxRawBytes: entry.rawBytes,
maxJavaScriptFiles: entry.javascriptFiles,
maxCssFiles: entry.cssFiles
}
])
)
}
budgets.entries['renderer-index'].maxRawBytes -= 1
budgets.entries['renderer-index'].maxJavaScriptFiles -= 1
expect(findElectronBundleBudgetFailures(report, budgets)).toEqual([
expect.stringContaining('raw bytes'),
expect.stringContaining('JavaScript files')
])
})
it('returns a nonzero gate result when a checked-in budget is exceeded', async () => {
await writeFixtureBuild()
const budgetsPath = join(projectRoot, 'budgets.json')
const report = createElectronBundleEntryReport(projectRoot)
const budgets = {
entries: Object.fromEntries(
report.entries.map((entry) => [
entry.id,
{
maxRawBytes: entry.id === 'electron-main' ? 0 : entry.rawBytes,
maxJavaScriptFiles: entry.javascriptFiles,
maxCssFiles: entry.cssFiles
}
])
)
}
const errors = []
await writeFile(budgetsPath, JSON.stringify(budgets))
expect(
runElectronBundleBudgetCheck({
projectRoot,
budgetsPath,
writeReport: () => {},
writeError: (message) => errors.push(message)
})
).toBe(1)
expect(errors).toEqual([expect.stringContaining('electron-main')])
})
})
@@ -91,6 +91,20 @@ describe('PR workflow parallelism', () => {
expect(packageJson.scripts['build:release']).toContain('pnpm run build:web-from-renderer')
})
it('checks bundle budgets after the Electron build without rebuilding', () => {
const steps = workflow.jobs.package.steps
const buildIndex = steps.findIndex((step) => step.name === 'Build package inputs')
const budgetIndex = steps.findIndex((step) => step.name === 'Check Electron bundle budgets')
const webIndex = steps.findIndex(
(step) => step.name === 'Project web client from renderer build'
)
expect(steps[budgetIndex].run).toBe('pnpm run check:electron-bundle-budgets')
expect(buildIndex).toBeLessThan(budgetIndex)
expect(budgetIndex).toBeLessThan(webIndex)
expect(packageJson.scripts['check:electron-bundle-budgets']).not.toContain('build:')
})
it('restores the pnpm store before dependency installation', () => {
const steps = dependencyAction.runs.steps
const pnpmIndex = steps.findIndex((step) => step.name === 'Setup pnpm')
@@ -0,0 +1,111 @@
import vm from 'node:vm'
import { describe, expect, it, vi } from 'vitest'
import {
createStartupDiagnosticsBootstrapPrefix,
createStartupDiagnosticsBootstrapSuffix
} from '../../build-plugins/startup-diagnostics-bootstrap'
function executeBootstrap(mode: string | undefined, applicationCode = '') {
const lines: string[] = []
const order: string[] = []
const originalLoad = vi.fn()
const moduleApi = { _load: originalLoad }
const requireModule = vi.fn((id: string) => {
if (id === 'node:fs') {
return {
closeSync: vi.fn(),
openSync: vi.fn(),
writeSync: (_fd: number, line: string) => {
lines.push(line.trimEnd())
order.push(line.includes('bundle-enter') ? 'bundle-enter' : 'evaluation-complete')
}
}
}
if (id === 'node:module') {
return moduleApi
}
throw new Error(`Unexpected require: ${id}`)
})
const now = vi.fn().mockReturnValueOnce(4.25).mockReturnValueOnce(19.75)
const processApi = {
argv: ['electron', 'app with spaces'],
env: mode ? { ORCA_STARTUP_DIAGNOSTICS: mode } : {},
execPath: '/Applications/Orca App',
on: vi.fn(),
once: vi.fn(),
pid: 12,
ppid: 7
}
const record = (event: string) => order.push(event)
const source =
createStartupDiagnosticsBootstrapPrefix('index.js') +
applicationCode +
createStartupDiagnosticsBootstrapSuffix()
const run = () =>
vm.runInNewContext(source, {
console,
performance: { now },
process: processApi,
record,
require: requireModule
})
return { lines, moduleApi, now, order, originalLoad, requireModule, run }
}
describe('startup diagnostics bootstrap', () => {
it('wraps synchronous entry evaluation with ordered process-clock timestamps', () => {
const execution = executeBootstrap('1', "record('application-evaluated');")
execution.run()
expect(execution.order).toEqual([
'bundle-enter',
'application-evaluated',
'evaluation-complete'
])
expect(execution.lines[0]).toContain(
'[bootstrap] bundle-enter t=4.25 clock="process-performance-now-ms"'
)
expect(execution.lines[1]).toContain(
'[bootstrap] bundle-evaluation-complete t=19.75 clock="process-performance-now-ms"'
)
})
it('does not emit evaluation completion when entry evaluation throws', () => {
const execution = executeBootstrap('1', "throw new Error('entry failed');")
expect(execution.run).toThrow('entry failed')
expect(execution.lines.some((line) => line.includes('bundle-evaluation-complete'))).toBe(false)
})
it('never loads or patches Module in normal diagnostics mode', () => {
const execution = executeBootstrap('1')
execution.run()
expect(execution.requireModule).toHaveBeenCalledWith('node:fs')
expect(execution.requireModule).not.toHaveBeenCalledWith('node:module')
expect(execution.moduleApi._load).toBe(execution.originalLoad)
})
it('installs require tracing only in trace mode', () => {
const execution = executeBootstrap('trace')
execution.run()
expect(execution.requireModule).toHaveBeenCalledWith('node:module')
expect(execution.moduleApi._load).not.toBe(execution.originalLoad)
})
it('does no diagnostic work when disabled', () => {
const execution = executeBootstrap(undefined, "record('application-evaluated');")
execution.run()
expect(execution.order).toEqual(['application-evaluated'])
expect(execution.requireModule).not.toHaveBeenCalled()
expect(execution.now).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,766 @@
# Orca Performance Architecture Review
- **Date:** 2026-07-29
- **Revision reviewed:** `ef55429f3d` (`origin/main` at the start of the review)
- **Scope:** Electron main/preload/renderer, shared/runtime/CLI, desktop host
services, mobile, build outputs, and the performance diagnostics and benchmark
suite
## Executive conclusion
Orca's next large performance gains will not come from adding another memo or
virtualizing another list. The codebase already does those things well. The
highest-leverage work is to reduce duplicated ownership and make runtime
capabilities genuinely lazy.
The two strongest opportunities are:
1. **Build a minimal startup kernel and app shell.** The production main entry is
an eager 8.15 MB JavaScript file, while the primary renderer entry statically
reaches 11.10 MB across 304 JavaScript chunks. Terminal, onboarding, provider,
plugin, account, and integration graphs are reachable before their surfaces
are used.
2. **Give each host one authoritative repository-intelligence service.** Git
status, upstream state, worktree identity, checks eligibility, and related
projections are still fetched through partially independent pipelines.
Coalescing within a single function does not prevent separate consumers from
launching overlapping local, WSL, SSH, or runtime work.
Four follow-ups are worthwhile after those foundations:
- partition the renderer store by lifecycle and update frequency;
- cold-park inactive mobile terminal WebViews;
- make the mobile workspace catalog a revisioned, replayable stream;
- partition durable persistence when real profiles, not synthetic restored tabs,
show serialization or shutdown stalls.
The first three recommendations reinforce one another. Domain stores create the
renderer's lazy boundaries; a capability kernel creates the main-process
boundaries; repository intelligence removes the largest remaining repeated
subprocess/network pipeline.
## Ranked opportunities
| Rank | Opportunity | Primary win | Confidence | Effort |
| --------- | ------------------------------------------------------ | ---------------------------------------- | --------------------------------------------- | ------------ |
| P0 | Minimal startup kernel and renderer app shell | cold start, first paint, memory | High | Large |
| P0 | Host-owned repository intelligence | Git CPU, SSH latency, consistency | High | Large |
| P1 | Lifecycle-partitioned renderer stores | startup graph, memory, update isolation | High for startup; Medium for steady-state CPU | Large |
| P1 | Mobile terminal hot set and cold parking | mobile memory/GPU/process pressure | High | Medium–Large |
| P2 | Revisioned mobile workspace stream | foreground network/CPU, remote latency | High | Medium–Large |
| P2 | Partitioned durable persistence | large-profile save/shutdown tail latency | Medium | Large |
| Quick win | Repair false dynamic boundaries and add bundle budgets | startup/package weight | High | Small–Medium |
“Confidence” describes the evidence that the cost exists, not a promise of a
specific speedup. Only an A/B implementation can establish savings.
## Measured baseline
### Codebase and build
The architectural sweep covered 5,913 non-test TypeScript/TSX files. Approximate
production line counts were 361k in `src/main`, 585k in the renderer, 81k shared,
9k preload, 14k CLI, and 110k mobile. Generated code and tests were excluded
from those figures.
`pnpm run build:electron-vite` produced:
| Surface | Static startup graph | Raw bytes | Gzip bytes |
| -------------------- | -------------------: | ---------: | ---------: |
| Electron main entry | one primary entry | 8,149,600 | 1,701,691 |
| Electron preload | one entry | 130,769 | 20,658 |
| Main renderer window | 304 JS + 3 CSS | 11,104,115 | 2,472,847 |
| Dashboard popout | 81 JS + 3 CSS | 5,893,311 | 1,296,803 |
| Web entry | 33 JS + 1 CSS | 4,361,095 | 928,572 |
The renderer figures are transitive **static** imports from the Vite manifest;
they exclude dynamic imports and large workers such as the 11.35 MB TypeScript
worker.
Largest chunks in the main renderer startup graph:
| Chunk role | Raw bytes |
| -------------------------------- | --------: |
| Root Zustand store | 1,950,409 |
| `App` | 1,582,599 |
| terminal user-input graph | 1,347,224 |
| onboarding inline-terminal graph | 1,321,452 |
| JSX runtime group | 663,192 |
| client group | 465,648 |
| shared library group | 343,714 |
| worktree card group | 294,115 |
The names are build chunk labels, not a claim that every byte belongs to the
named source module. The important fact is that each entire chunk is in the
static entry graph.
### Startup benchmark
A three-iteration empty-state production run reported these medians:
| Phase | Median |
| ---------------------------------- | -----: |
| spawn → Electron app ready | 456 ms |
| app ready → services initialized | 43 ms |
| window created → `did-finish-load` | 159 ms |
| spawn → `did-finish-load` | 768 ms |
| spawn → workspace ready | 911 ms |
The first run after the build was materially colder:
| Phase | First run |
| -------------------------------- | --------: |
| spawn → Electron app ready | 2,387 ms |
| app ready → services initialized | 238 ms |
| spawn → `did-finish-load` | 2,886 ms |
| spawn → workspace ready | 3,024 ms |
The harness does not isolate Electron boot from main-bundle parse, module link,
and top-level evaluation. It therefore supports “startup is a target,” but not
“the bundle costs exactly N milliseconds.” The proposed rollout requires an A/B
split build and finer milestones.
### Phase 0 startup clock attribution
`ORCA_STARTUP_DIAGNOSTICS=1` now emits these additional one-shot boundaries:
| Event | Meaning | Clock |
| ---------------------------------------- | --------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| `[bootstrap] bundle-enter` | Earliest executable generated main-entry boundary | Main-process `performance.now()` in `t` |
| `[bootstrap] bundle-evaluation-complete` | Generated main entry and its synchronous dependencies finished evaluation | Main-process `performance.now()` in `t` |
| `[startup] renderer-first-react-commit` | Main-window root layout effect ran after its first React commit | Main-process receipt in `t`; renderer clock in `rendererT` |
| `[startup] renderer-shell-painted` | A second animation-frame callback ran, so a paint opportunity followed the commit | Main-process receipt in `t`; renderer clock in `rendererT` |
The bootstrap and `[startup]` `t` values share the Electron main process's
monotonic clock. `rendererT` values share only the main-window renderer clock,
so they are used for commit-to-paint duration but not subtracted from main
timestamps. Popout and web roots do not emit these renderer milestones.
JavaScript cannot separately time parsing or linking before its first
executable statement. `spawnToBundleEnterMs` uses the parent harness's
complete-line receipt and still includes Electron boot, bundle parse/link, and
stderr delivery; it is an upper-bound attribution, not a parse-only timer.
`synchronousBundleAndDependencyEvaluationMs` uses the main-process clock from
bundle entry to the generated evaluation-complete footer. The remaining
explicit phases separate evaluation-complete to app-ready, app-ready to
services initialized, services initialized to first React commit, and first
commit to the post-commit shell paint opportunity.
Validation on the review worktree's fresh production build:
| Boundary or phase | One-run isolated macOS fixture |
| ------------------------------------------------------------ | -----------------------------: |
| `bundle-enter` main clock | 257.24 ms |
| `bundle-evaluation-complete` main clock | 454.72 ms |
| `app-ready` main clock | 529.7 ms |
| `services-initialized` main clock | 815.8 ms |
| `renderer-first-react-commit` main receipt | 1,101.0 ms |
| `renderer-shell-painted` main receipt | 1,151.1 ms |
| spawn → bundle-enter line receipt | 1,814.6 ms |
| synchronous bundle/dependency evaluation | 197.48 ms |
| app-ready → services initialized | 286.1 ms |
| services initialized → first React commit receipt | 285.2 ms |
| first commit → post-commit paint opportunity, renderer clock | 55 ms |
All 21 numeric derived phases were non-negative. The portable evidence is in
[`phase0-startup-attribution-2026-07-29.md`](../../tools/benchmarks/results/phase0-startup-attribution-2026-07-29.md).
Against the pre-instrumentation `out/` snapshot, the measured static startup
surfaces changed by +697 raw/+286 gzip bytes for main, +29/+3 for preload, and
+1,464/+425 for the main renderer (+2,190 raw/+714 gzip total). No budget was
raised.
The production build, bundle-budget check, focused 16-test suite, Node and web
typechecks, targeted oxlint/oxfmt, max-lines ratchet, and `git diff --check`
passed. The live run was one cold macOS iteration from an unpackaged production
Electron build; it is causal evidence, not a representative latency baseline.
Main IPC receipt includes message latency, and double animation-frame
scheduling establishes a post-commit paint opportunity rather than compositor
proof.
### Restored-state scaling
Synthetic restored-local-tab profiles did **not** establish persistence as a
startup bottleneck:
| Restored tabs | State file | JSON parse | store load | `did-finish-load` |
| ------------: | ---------: | ---------: | ---------: | ----------------: |
| 200 | 104 KB | 0 ms | 5 ms | 758 ms |
| 1,000 | 516 KB | 1 ms | 8 ms | 759 ms |
These are three-run medians and warm-cache dominated. They show that tab count
alone is not enough to justify an urgent persistence rewrite.
### Zustand selector fan-out
`pnpm bench:zustand-selector-fanout` measured 2,500 subscribers across 2,000
unrelated writes:
- 5,000,000 selector executions;
- 29.65 ms median total;
- 0.0148 ms per write;
- zero render invalidations.
Store partitioning should therefore be justified first by dependency isolation,
ownership, and real high-frequency workloads—not by the synthetic selector
loop alone.
## Architecture map
| Layer | Current ownership | Main performance implication |
| ------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| Electron main | one composition root constructs most services | broad eager module graph before first window |
| Preload | narrow IPC bridge | comparatively small; not a priority |
| Renderer | one `App` plus one ~40-slice Zustand root | all entries inherit broad state; desktop entries also inherit terminal/integration dependencies |
| Runtime/CLI | `OrcaRuntimeService` and RPC methods expose host operations | good remote abstraction, but several data domains still lack one snapshot owner |
| Terminal | daemon/main headless authority plus renderer/mobile presentation | desktop is mature; mobile retains every xterm WebView |
| Persistence | one durable state document plus targeted sidecars | good write safety, but whole-document serialization remains |
| Mobile | RPC snapshots plus event streams and safety polls | reliable convergence, but polling compensates for non-replayable events |
## P0 — Minimal startup kernel and renderer app shell
### Evidence
The main entry statically imports persistence, AI Vault cache persistence,
terminal providers, telemetry, runtime/RPC, updater, accounts, browser,
automation, plugins, and many other capabilities from one composition root.
See [`src/main/index.ts`](../../src/main/index.ts#L1). It constructs the account,
runtime, automation, plugin, browser, rate-limit, and integration services
before the `services-initialized` milestone in the `app.whenReady()` path
([`src/main/index.ts`](../../src/main/index.ts#L1866)).
The build config has separate entries for workers, the terminal daemon, plugin
host, and watchdogs, which is good, but the primary main entry remains 8.15 MB
([`electron.vite.config.ts`](../../electron.vite.config.ts#L191)).
The renderer root imports `App` directly
([`src/renderer/src/main.tsx`](../../src/renderer/src/main.tsx#L5)), and `App`
retains several terminal modules while only the main `Terminal` component is
lazy ([`src/renderer/src/App.tsx`](../../src/renderer/src/App.tsx#L53),
[`src/renderer/src/App.tsx`](../../src/renderer/src/App.tsx#L337)). The store is
also an eager root dependency of the main, popout, and web entries.
This explains otherwise surprising build results:
- a 1.35 MB terminal-labelled chunk is static for both the main window and
popout;
- a 1.32 MB onboarding inline-terminal chunk is static for the main window;
- the web entry pays 1.95 MB for the same desktop store;
- the popout pays 5.89 MB before rendering a much narrower surface.
Some apparent main-process dynamic boundaries are also defeated by static
imports. For example, `ssh-connection.ts` dynamically imports SFTP/deploy
modules, while other provider and relay modules import the same implementations
statically. Rollup must keep those modules reachable in eager capability graphs.
### Target architecture
Create explicit startup stages with enforced dependency rules:
1. **Process bootstrap:** process flags, crash guards, profile path, single
instance, and startup diagnostics only.
2. **Window kernel:** durable settings required for theme/window behavior,
minimal i18n, IPC router, runtime identity, and a window-ready gate.
3. **Session restore:** only the workspace/session projections required to draw
the initial shell and reconnect visible terminals.
4. **Post-first-paint services:** telemetry, updater, tray enrichments, account
reconciliation, hook repair, plugin discovery, caches, and background heals.
5. **On-demand capability modules:** SSH/SFTP, AI Vault scanners, browser
automation, emulator, speech, hosted-review providers, plugin workers, and
settings-only flows.
For the renderer, give each HTML entry its own bootstrap state:
- a small durable shell/settings/workspace projection;
- dynamic terminal activation for terminal surfaces;
- dynamic onboarding/setup code only when shown;
- a popout-specific dashboard store adapter;
- a web-specific runtime/session adapter.
Lazy React components are not sufficient if a root store, selector, event
registry, or shared component statically imports the same capability.
Dependency-boundary tests should fail when a shell module imports a
terminal-only, onboarding-only, provider-only, or settings-only module.
### Implementation sequence
1. Add build output budgets and save a machine-readable static-entry report.
2. Instrument `bundle-enter`, kernel ready, store ready, first React commit,
shell painted, visible terminal requested, and visible terminal revealed.
3. Extract capability registration from `src/main/index.ts` behind async
factories without changing IPC contracts.
4. Split renderer state as described in the P1 section.
5. Move one low-risk capability at a time, starting with settings/onboarding
and inactive provider integrations.
6. Only then defer terminal modules, preserving restored-terminal correctness
and measuring reveal latency separately.
### Success gates
Provisional goals for the first architectural tranche:
- main eager entry below 3 MB raw;
- main-window static renderer graph below 5 MB raw and materially fewer than
304 requests;
- popout and web entries no longer import the desktop terminal/store graph;
- 25–40% improvement in a repeatable cold-start protocol on representative
macOS and Windows hardware;
- no regression in warm workspace ready, terminal restore, first terminal
reveal, crash recovery, or serve-mode startup.
The byte and percentage thresholds are starting SLOs, not estimates of savings.
### Risks and compatibility
- Keep local, WSL, SSH, relay, and serve-mode registration deterministic even
when modules load late.
- A late IPC module must return a bounded “initializing” result or await one
shared initialization promise; it must not expose partially constructed
services.
- Windows startup and native-module resolution need packaged-app tests because
dynamic chunk paths differ inside ASAR.
- Folder workspaces must not initialize Git-only capabilities merely to draw
the shell.
## P0 — Host-owned repository intelligence
### Evidence
The active renderer status path is already thoughtfully event-driven. It uses
file, terminal, and push signals, a 3-second activity floor, a 60-second safety
interval, visibility gates, slow-task backoff, and one scheduler
([`useGitStatusPolling.ts`](../../src/renderer/src/components/right-sidebar/useGitStatusPolling.ts#L23),
[`useGitStatusPolling.ts`](../../src/renderer/src/components/right-sidebar/useGitStatusPolling.ts#L103)).
The Checks panel nevertheless owns another status snapshot. Global status-map
changes intentionally act only as invalidation signals, after which Checks
calls `getRuntimeGitStatus()` and may separately call
`getRuntimeGitUpstreamStatus()`
([`ChecksPanel.tsx`](../../src/renderer/src/components/right-sidebar/ChecksPanel.tsx#L1498),
[`ChecksPanel.tsx`](../../src/renderer/src/components/right-sidebar/ChecksPanel.tsx#L1513)).
Main-process status deduplication has a cancellation-shaped gap. `getStatus()`
joins identical concurrent requests only when no `AbortSignal` is present; a
signalled request goes straight to `runGetStatus()`
([`src/main/git/status.ts`](../../src/main/git/status.ts#L214)). The active poll
passes a signal, while some private snapshot consumers do not. They therefore
cannot share one underlying read. The IPC handler creates per-renderer
cancellation controllers and forwards those signals to local or SSH providers
([`src/main/ipc/filesystem.ts`](../../src/main/ipc/filesystem.ts#L1091)).
There are strong caches for individual operations—line stats, submodule paths,
upstream-name resolution, negative upstream results, and worktree scans. The
remaining problem is ownership above those caches. Source Control, Files,
Checks, review creation, cleanup, branch identity, and runtime/mobile clients
need projections of the same repository state but can still initiate different
pipelines.
This is most expensive on SSH/WSL/runtime hosts, where one logical refresh can
mean multiple RPC round trips plus several Git subprocesses.
### Target architecture
Create a repository-intelligence service in the process that owns Git execution
for each host. Maintain a versioned snapshot per repository/worktree:
```ts
type RepositorySnapshot = {
revision: number
generatedAt: number
repositoryIdentity: { head: string | null; branch: string | null }
status: { entries: GitStatusEntry[]; didHitLimit: boolean; lineStatsState: string }
upstream: GitUpstreamStatus | null
conflicts: GitConflictOperation | null
worktreeGraphVersion: number
freshness: Record<RepositoryProjection, Freshness>
}
```
Key the owner by execution host, repository identity, worktree path, and
relevant Git options—not path alone. Host scope must distinguish native, each
WSL distro, each SSH provider/session, and each runtime/relay connection.
Consumers subscribe to projections. Invalidation sources enqueue refresh
reasons:
- `.git` and worktree metadata watchers;
- terminal command completion/change signals;
- Orca mutations such as commit, checkout, merge, push, and cleanup;
- provider/reconnect events;
- slow safety reconciliation.
A consumer cancellation should release its **lease**, not cancel a shared
underlying Git read still needed by other consumers. Mutations should retain
the existing pre/post invalidation fences and increment a generation so
pre-mutation results cannot repopulate the snapshot.
### Rollout
1. Wrap the current status/upstream functions behind the service without
changing subprocess commands.
2. Migrate Checks and the active polling hook to snapshot projections.
3. Migrate Source Control, creation eligibility, cleanup, and hosted-review
consumers.
4. Publish revisioned snapshots through runtime RPC for mobile/web.
5. Add optional worktree-graph and conflict projections after status ownership
is stable.
### Success gates
- One physical status pipeline per host/worktree/generation under concurrent
Source Control + Checks + polling requests.
- No duplicate upstream probe for consumers of the same snapshot.
- Cancellation of one renderer or panel does not cancel work leased elsewhere.
- Subprocess and RPC counts fall materially in a large-repo/slow-SSH benchmark.
- Mutation read-after-write tests remain exact.
- Preferred and fallback commands run in the Git 2.25 compatibility matrix.
### Risks and compatibility
- Do not let a TTL substitute for explicit mutation invalidation.
- Do not share capability state across WSL distros, SSH hosts, or runtime
incarnations.
- A folder workspace needs a cheap non-Git snapshot and no Git probes.
- Provider-neutral review fields belong in the snapshot; GitHub-specific
details do not.
## P1 — Lifecycle-partitioned renderer stores
### Evidence
The root Zustand store eagerly creates roughly 40 slices, including workspaces,
terminals, UI, settings, GitHub, hosted review, Linear, Jira, editor, usage,
browser, SSH, runtime, agent status, dictation, cleanup, and generation state
([`src/renderer/src/store/index.ts`](../../src/renderer/src/store/index.ts#L52)).
Store slices cross capability boundaries:
- the terminal slice imports parked-watcher, shutdown-buffer, and runtime
terminal-stream modules
([`src/renderer/src/store/slices/terminals.ts`](../../src/renderer/src/store/slices/terminals.ts#L74));
- the worktree slice imports parked-terminal cleanup
([`src/renderer/src/store/slices/worktrees.ts`](../../src/renderer/src/store/slices/worktrees.ts#L42)).
That graph is inherited by all three renderer entries. This is a stronger
reason to partition than selector CPU: the synthetic selector benchmark found
only 0.0148 ms per unrelated write for 2,500 subscribers.
### Target domains
Use a small shell coordinator over independently importable stores:
1. **Durable shell:** appearance, keybindings, active route, window state.
2. **Workspace catalog:** repos, projects, worktrees, runtime ownership.
3. **Session:** tabs, editor/browser descriptors, active surface.
4. **Terminal runtime:** layouts, PTY bindings, stream metadata, parking.
5. **Agent live state:** status, unread/completion state, high-frequency clocks.
6. **Repository state:** projections from the repository-intelligence service.
7. **Integrations:** provider caches and feature-specific mutations.
The shell may know stable identifiers and narrow interfaces. It must not import
implementation modules from terminal, onboarding, editor workers, browser, or
provider domains.
Avoid a flag day. Introduce domain facades, mirror a slice into its new store,
move readers, then make the old slice a compatibility adapter until no imports
remain. Preserve reference gates and the existing listener census.
### Success gates
- Main, popout, and web entries import only stores they render.
- A terminal output/status update does not execute selectors in settings,
provider caches, or workspace catalog.
- Session persistence and runtime graph publication subscribe to explicit
domain projections.
- Existing no-op reference behavior and crash-diagnostic collection-size
reporting remain covered.
## P1 — Mobile terminal hot set and cold parking
### Evidence
The mobile session maps every terminal record to `TerminalPaneView`
([`mobile/app/h/[hostId]/session/[worktreeId].tsx`](../../mobile/app/h/%5BhostId%5D/session/%5BworktreeId%5D.tsx#L4726)).
Each pane contains a `TerminalWebView`. Inactive panes deliberately remain
mounted and are hidden with `opacity: 0` to preserve xterm state
([`mobile/src/session/TerminalPaneView.tsx`](../../mobile/src/session/TerminalPaneView.tsx#L61)).
Mobile already subscribes only to the active terminal and batches WebView
writes at roughly 20 Hz. The remaining scaling cost is retained WebView/xterm
instances, layout state, JavaScript heaps, textures, and native WebView
resources for every terminal in the session.
Desktop has already demonstrated the safer model: bounded hot retention,
headless authority, byte watching, cold parking, and replay on reveal.
### Target architecture
- Keep the active terminal and, initially, the two most recently used terminals
warm.
- Retain terminal identity, unread state, modes, and replay cursor without a
WebView.
- On park, release the WebView after a grace period and record the last applied
stream sequence.
- On reveal, create one WebView, request an authoritative snapshot plus replay
after that sequence, apply theme/scale, fit, then subscribe live.
- If continuity cannot be proven, request a full snapshot rather than exposing
a partially replayed terminal.
### Success gates
- Native/JS/GPU memory is approximately bounded as terminal count grows.
- Ten- and fifty-terminal fixtures keep no more than the configured hot set.
- Warm switch remains visually immediate; cold reveal gets an explicit p95
budget on iOS and Android.
- Split panes, selection, OSC/query replies, SSH/runtime terminals, background
reconnect, and Take Back remain correct.
## P2 — Revisioned, replayable mobile workspace catalog
### Evidence
Mobile subscribes to `runtime.clientEvents.subscribe` but also performs a full
`worktree.ps` refresh every three seconds while foregrounded
([`mobile/src/worktree/host-worktree-refresh.ts`](../../mobile/src/worktree/host-worktree-refresh.ts#L5)).
The comments document the reason: events are not queued while disconnected,
and some desktop repo edits have not reached the runtime stream
([`mobile/src/worktree/host-worktree-refresh.ts`](../../mobile/src/worktree/host-worktree-refresh.ts#L42)).
For 200 repos and 5,000 worktrees, the existing no-op equality gate is already
effective: five polls spent about 5.84 ms building sections and 3.96 ms
comparing equality, versus 27.95 ms in the older unconditional rebuild path.
The remaining opportunity is to avoid the request and snapshot work, especially
over relay/SSH paths.
### Target architecture
Publish a host catalog with a monotonically increasing revision:
- `catalog.get({ afterRevision })` returns no change, a delta, or a snapshot;
- `catalog.subscribe({ fromRevision })` replays retained deltas, then emits
live events;
- reconnect detects gaps and requests one snapshot;
- repo metadata and worktree topology share a transaction boundary or declare
independent revisions.
Once replay/gap recovery is proven, move the full foreground poll from three
seconds to a much slower safety interval. Keep foreground refresh and
reconnection reconciliation.
This stream can later carry repository-intelligence revisions without copying
full Git status into every catalog event.
## P2 — Partitioned durable persistence
### Evidence
Persistence is already substantially optimized:
- writes are async, debounced, and serialized;
- mutation bursts have a bounded max wait;
- byte-identical state skips disk writes;
- secrets are normalized so randomized encryption does not defeat the hash;
- durable rename/fsync and backups protect against corruption;
- active-view and GitHub cache paths are narrower;
- renderer session payload construction is patch/reference gated.
The remaining whole-document work happens before async I/O:
`buildStateToSave()` synchronously clones the durable state, encrypts secret
slots, `JSON.stringify`s the full document, performs substitutions, and hashes
it on the main thread
([`src/main/persistence.ts`](../../src/main/persistence.ts#L3624)). Shutdown uses a
synchronous durable write
([`src/main/persistence.ts`](../../src/main/persistence.ts#L3732)).
The synthetic 1,000-tab profile was only 516 KB and loaded cheaply, so this is
not a P0/P1 finding on current evidence.
### Trigger and target design
Before implementation, capture real large-profile metrics:
- serialized bytes by top-level domain;
- stringify/hash duration;
- async fsync/rename duration;
- shutdown flush duration;
- save frequency and no-op rate;
- renderer session-payload build duration.
Escalate when p95 main-thread serialization exceeds a visible-jank budget or
shutdown flush becomes a reliability problem. Then partition by ownership:
- settings/profile;
- workspace topology;
- session/tab layout;
- automation/history;
- provider caches;
- per-host runtime state.
Use versioned envelopes and an atomic manifest or small journal so a partial
generation can be recovered. Preserve secret handling, backup rotation, and
cross-platform rename/fsync behavior.
## Quick wins that support the architecture
### Enforce static-entry budgets in CI
Parse `out/renderer/.vite/manifest.json` after production builds and report
static raw/gzip bytes, chunk count, and top contributors for each HTML entry.
Also budget `out/main/index.js` and preload. Fail only on ratcheted regression
limits at first; tighten after the capability split.
### Repair false dynamic boundaries
Generate a build-time list of modules that are both statically and dynamically
imported. Start with the SFTP and SSH relay modules currently reached both ways:
- `ssh-filesystem-provider-sftp.ts`;
- `sftp-upload.ts`;
- `ssh-relay-deploy-helpers.ts`.
Move shared types/constants into small leaf modules, and make eager call sites
load implementations through a capability factory. Verify packaged ASAR paths
on all desktop platforms.
### Add parse/evaluation attribution
Current startup milestones begin after the main bundle is already executing.
Add build banner timings and capability-factory timings so Electron boot,
bundle parse/link, top-level evaluation, service construction, and async
initialization can be compared separately. A trace mode should remain
diagnostic-only because require tracing changes startup behavior.
## Existing strengths — do not redo these
The review found many areas where the obvious recommendation is already
implemented:
- **Desktop terminals:** daemon ownership, main headless history, hidden-tab
cold parking, hot retention, parked byte watchers, WebGL disposal, reveal and
resource benchmarks.
- **Large UI collections:** worktree sidebar, source-control files, diffs,
reviews, search, AI Vault, and other heavy lists use virtualization where it
matters.
- **Git refresh mechanics:** event/file/push signals, visibility gates,
safety polling, slow-task backoff, huge-repo limits, worktree scan
coalescing, and several narrow caches.
- **File watching:** Parcel watcher isolation prevents native watcher faults
from taking down the main process and includes SSH/WSL-specific behavior.
- **Plugin runtime:** discovery is separated from worker activation; workers
are bounded and idle-reaped.
- **AI Vault:** scans share one cache owner, incremental parse state and
persisted parse caches exist, and OpenCode SQLite work is isolated in a
worker entry.
- **Browser panes:** inactive worktree guests are parked unless
automation-visible; diagnostics account for guest/parked state.
- **Mobile terminal delivery:** active-only subscription and batched WebView
writes already address stream CPU.
- **Mobile workspace rendering:** equality-gated publication avoids the former
unconditional rebuild cost.
- **Persistence:** async durable writes, no-op hashing, sidecars, patch-based
session updates, and shutdown safety are all meaningful improvements.
- **Diagnostics:** startup, main-thread jank, idle CPU, terminal scale/parking,
memory, workspace switch, and selector-fanout harnesses provide unusually
good foundations for performance work.
Generic recommendations such as “virtualize the sidebar,” “unmount every
inactive desktop terminal,” “poll Git less,” or “move plugin workers out of the
main process” would duplicate existing work and risk regressions.
## Suggested delivery roadmap
### Phase 0 — measurement and guardrails
- Commit static-entry reports and ratcheted budgets.
- Add startup parse/evaluation and first-commit milestones.
- Add subprocess/RPC counters to the large-repo and slow-SSH benchmarks.
- Add mobile WebView count and per-process memory diagnostics.
### Phase 1 — dependency boundaries
- Extract a minimal main kernel and post-paint capability registry.
- Introduce shell/workspace/session renderer stores.
- Remove terminal and onboarding dependencies from non-terminal entries.
- Repair the SFTP/SSH false dynamic boundaries.
### Phase 2 — repository authority
- Add the host-scoped snapshot service.
- Migrate active status and Checks first.
- Migrate remaining desktop consumers, then runtime/mobile projections.
- Preserve mutation fences and add Git 2.25 real-binary coverage.
### Phase 3 — retained-resource scaling
- Ship mobile terminal hot retention behind a flag.
- Measure memory and reveal p95 on both mobile platforms.
- Add revisioned catalog replay, then relax three-second polling.
### Phase 4 — evidence-triggered persistence work
- Profile large real states.
- Partition only the domains that breach serialization or shutdown budgets.
## Coverage and methodology
This was an architectural performance review rather than a line-by-line
correctness audit. It combined:
- repository-wide production-file, synchronous-I/O, interval, worker, watcher,
and process scans;
- main startup and service-wiring inspection;
- renderer entry, Zustand, React, IPC, and large-list inspection;
- Git status, worktree, upstream, provider, SSH/WSL/runtime flow tracing;
- desktop and mobile terminal lifecycle inspection;
- persistence and session-hydration inspection;
- browser, AI Vault, plugin, automation, resource, and background-service
inspection;
- preload, shared RPC/runtime, CLI, mobile transport, and build configuration
review;
- a production build plus startup, restored-state, selector-fanout, and mobile
workspace-refresh benchmarks;
- review of the existing renderer memory profile and performance harnesses.
Measured facts are labeled with byte counts or benchmark results. Proposed
impacts are qualitative unless an A/B implementation exists. The review did
not use production telemetry, a Windows/Linux build host, a physical mobile
device, or a controlled slow-SSH lab, so rollout gates explicitly require
those environments.
## Benchmark commands
```bash
pnpm run build:electron-vite
node tools/benchmarks/startup-time-bench.mjs \
--label big-picture-audit \
--iterations 3 \
--files 0 \
--state-profile none \
--linger-ms 300 \
--timeout-ms 120000
node tools/benchmarks/startup-time-bench.mjs \
--label big-picture-restored-200 \
--iterations 3 \
--files 0 \
--state-profile restored-local-tabs \
--session-tabs 200 \
--linger-ms 300 \
--timeout-ms 120000
node tools/benchmarks/startup-time-bench.mjs \
--label big-picture-restored-1000 \
--iterations 3 \
--files 0 \
--state-profile restored-local-tabs \
--session-tabs 1000 \
--linger-ms 300 \
--timeout-ms 120000
pnpm bench:zustand-selector-fanout
pnpm --dir mobile repro:workspace-picker-lag
```
Generated build output and local benchmark JSON files are ignored by Git. The
material measurements are reproduced in this report so it remains
self-contained.
+1 -131
View File
@@ -4,6 +4,7 @@ import { defineConfig, type UserConfig } from 'electron-vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { createPlainNodeEntryGuardPlugin } from './build-plugins/plain-node-entry-guard'
import { createStartupDiagnosticsBootstrapPlugin } from './build-plugins/startup-diagnostics-bootstrap'
import packageJson from './package.json' with { type: 'json' }
const BUNDLED_MAIN_DEPENDENCIES = new Set(['@xterm/headless', '@xterm/addon-serialize'])
@@ -50,137 +51,6 @@ const ORCA_DIAGNOSTICS_TOKEN_URL_LITERAL =
? JSON.stringify(orcaDiagnosticsTokenUrl)
: 'null'
function createStartupDiagnosticsBanner(chunkName: string): string {
return `
;(() => {
const env = typeof process !== 'undefined' ? process.env : undefined
const mode = env?.ORCA_STARTUP_DIAGNOSTICS
if (mode !== '1' && mode !== 'trace') {
return
}
const safeJson = (value) => {
try {
return JSON.stringify(value)
} catch {
return '"<unserializable>"'
}
}
let closeSync
let diagnosticFileDescriptor
let openSync
let writeSync
try {
const fs = require('node:fs')
closeSync = fs.closeSync
openSync = fs.openSync
writeSync = fs.writeSync
} catch {
closeSync = undefined
openSync = undefined
writeSync = undefined
}
const diagnosticFile = env?.ORCA_STARTUP_DIAGNOSTICS_FILE
if (typeof diagnosticFile === 'string' && diagnosticFile.length > 0 && typeof openSync === 'function') {
try {
diagnosticFileDescriptor = openSync(diagnosticFile, 'a', 0o600)
} catch {
diagnosticFileDescriptor = undefined
}
}
const writeLine = (message) => {
try {
const line = message.endsWith('\\n') ? message : message + '\\n'
if (typeof writeSync === 'function') {
writeSync(2, line)
if (typeof diagnosticFileDescriptor === 'number') {
writeSync(diagnosticFileDescriptor, line)
}
}
} catch {
// Diagnostics must never affect startup.
}
}
const chunkName = ${JSON.stringify(chunkName)}
writeLine('[bootstrap] bundle-enter chunk=' + safeJson(chunkName) + ' pid=' + process.pid + ' ppid=' + process.ppid + ' execPath=' + safeJson(process.execPath) + ' argv=' + safeJson(process.argv) + ' electronRunAsNode=' + safeJson(env?.ELECTRON_RUN_AS_NODE ?? null))
if (!globalThis.__ORCA_BOOTSTRAP_EXIT_LOG_INSTALLED__) {
globalThis.__ORCA_BOOTSTRAP_EXIT_LOG_INSTALLED__ = true
process.once('exit', (code) => {
writeLine('[bootstrap] process-exit code=' + code)
if (typeof closeSync === 'function' && typeof diagnosticFileDescriptor === 'number') {
try {
closeSync(diagnosticFileDescriptor)
} catch {
// Diagnostics must never affect shutdown.
}
}
})
process.on('uncaughtExceptionMonitor', (error, origin) => {
const message = error && typeof error === 'object' && 'stack' in error ? error.stack : error
writeLine('[bootstrap] uncaught-exception origin=' + safeJson(origin) + ' error=' + safeJson(String(message)))
})
process.on('unhandledRejection', (reason) => {
const message = reason && typeof reason === 'object' && 'stack' in reason ? reason.stack : reason
writeLine('[bootstrap] unhandled-rejection error=' + safeJson(String(message)))
})
}
if (mode === 'trace' && !globalThis.__ORCA_BOOTSTRAP_REQUIRE_TRACE_INSTALLED__) {
globalThis.__ORCA_BOOTSTRAP_REQUIRE_TRACE_INSTALLED__ = true
try {
const Module = require('node:module')
const originalLoad = Module._load
const parsedTraceLimit = Number(env?.ORCA_STARTUP_DIAGNOSTICS_TRACE_LIMIT ?? 20000)
const traceLimit = Number.isFinite(parsedTraceLimit) && parsedTraceLimit > 0 ? parsedTraceLimit : 20000
let traceLineCount = 0
let traceLimitReported = false
const writeTraceLine = (message) => {
if (traceLineCount >= traceLimit) {
if (!traceLimitReported) {
traceLimitReported = true
writeLine('[bootstrap] require-trace-limit-reached limit=' + safeJson(traceLimit))
}
return
}
traceLineCount += 1
writeLine(message)
}
Module._load = function (request, parent, isMain) {
const parentName = parent && parent.filename ? parent.filename : null
writeTraceLine('[bootstrap] require-start request=' + safeJson(request) + ' parent=' + safeJson(parentName) + ' isMain=' + safeJson(Boolean(isMain)))
try {
const result = Reflect.apply(originalLoad, this, arguments)
writeTraceLine('[bootstrap] require-ok request=' + safeJson(request))
return result
} catch (error) {
const message = error && typeof error === 'object' && 'stack' in error ? error.stack : error
writeTraceLine('[bootstrap] require-error request=' + safeJson(request) + ' error=' + safeJson(String(message)))
throw error
}
}
} catch (error) {
writeLine('[bootstrap] require-trace-install-error error=' + safeJson(String(error)))
}
}
})();
`
}
function createStartupDiagnosticsBootstrapPlugin() {
return {
name: 'orca-startup-diagnostics-bootstrap',
generateBundle(_options, bundle) {
const mainChunk = bundle['index.js']
if (!mainChunk || mainChunk.type !== 'chunk') {
return
}
// Why: source-level startup diagnostics run after Rollup's generated
// prelude and require() list. Mutate the final emitted chunk so macOS
// launch failures can identify the earliest JS boundary reached.
mainChunk.code = createStartupDiagnosticsBanner(mainChunk.fileName) + mainChunk.code
}
}
}
export const electronViteConfig: UserConfig = {
main: {
build: {
@@ -1783,6 +1783,7 @@ export default function SessionScreen() {
terminalTabs,
terminalsRef.current
)
const terminalRecordCount = mergedTerminalsForActive.length
terminalsRef.current = mergedTerminalsForActive
setTerminals((prev) =>
terminalRecordsEqual(prev, mergedTerminalsForActive) ? prev : mergedTerminalsForActive
@@ -1858,7 +1859,7 @@ export default function SessionScreen() {
pendingActiveTerminalHandleRef.current = null
}
}
diagnostics.tabsApplied(result, nextTabs, active, selectionSource)
diagnostics.tabsApplied(result, nextTabs, terminalRecordCount, active, selectionSource)
activeSessionTabTypeRef.current = active?.type ?? null
activeSessionTabIdRef.current = active?.id ?? null
setActiveSessionTabId(active?.id ?? null)
@@ -2934,7 +2935,6 @@ export default function SessionScreen() {
terminalGestureInputInFlightRef.current.delete(handle)
}
}, [])
const handleTerminalWebReady = useCallback(
(handle: string) => {
const wasAlreadyReady = webReadyHandlesRef.current.has(handle)
@@ -4737,6 +4737,8 @@ export default function SessionScreen() {
void saveTerminalTextScale(scale)
}}
onRef={setTerminalWebViewRef}
diagnostics={terminalDiagnosticsRef.current}
terminalRecordsLoaded={terminalsLoaded}
onWebReady={handleTerminalWebReady}
onSelectionMode={handleSelectionMode}
onSelectionCopy={handleSelectionCopy}
+1
View File
@@ -19,6 +19,7 @@
},
"dependencies": {
"@noble/hashes": "1.8.0",
"@orca/expo-process-memory": "file:./packages/expo-process-memory",
"@orca/expo-two-way-audio": "file:./packages/expo-two-way-audio",
"@react-native-async-storage/async-storage": "^2.2.0",
"@xterm/addon-unicode11": "0.10.0-beta.285",
@@ -0,0 +1,15 @@
apply plugin: 'com.android.library'
group = 'expo.modules.processmemory'
version = '0.0.1'
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useCoreDependencies()
useExpoPublishing()
useDefaultAndroidSdkVersions()
android {
namespace "expo.modules.processmemory"
}
@@ -0,0 +1,2 @@
<manifest>
</manifest>
@@ -0,0 +1,27 @@
package expo.modules.processmemory
import android.os.Debug
import android.os.Process
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class ExpoProcessMemoryModule : Module() {
override fun definition() = ModuleDefinition {
Name("ExpoProcessMemory")
Function("getProcessMemory") {
val memoryInfo = Debug.MemoryInfo()
Debug.getMemoryInfo(memoryInfo)
val totalPssKibibytes = memoryInfo.totalPss
require(totalPssKibibytes >= 0) { "Process memory is unavailable" }
mapOf(
"metric" to "proportional-set-size",
"value" to totalPssKibibytes.toDouble(),
"unit" to "kibibytes",
"processRole" to "app",
"pid" to Process.myPid(),
"sampledAtMs" to System.currentTimeMillis().toDouble()
)
}
}
}
@@ -0,0 +1,9 @@
{
"platforms": ["ios", "android"],
"ios": {
"modules": ["ExpoProcessMemoryModule"]
},
"android": {
"modules": ["expo.modules.processmemory.ExpoProcessMemoryModule"]
}
}
@@ -0,0 +1,19 @@
require 'json'
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
Pod::Spec.new do |s|
s.name = 'ExpoProcessMemory'
s.version = package['version']
s.summary = package['description']
s.description = package['description']
s.license = package['license']
s.author = 'Orca contributors'
s.homepage = 'https://github.com/stablyai/orca'
s.platforms = { :ios => '13.4' }
s.swift_version = '5.4'
s.source = { git: 'https://github.com/stablyai/orca.git' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.source_files = '**/*.{h,m,swift}'
end
@@ -0,0 +1,32 @@
import Darwin
import ExpoModulesCore
import Foundation
public class ExpoProcessMemoryModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoProcessMemory")
Function("getProcessMemory") { () throws -> [String: Any] in
var info = task_vm_info_data_t()
var count = mach_msg_type_number_t(
MemoryLayout<task_vm_info_data_t>.stride / MemoryLayout<integer_t>.stride
)
let status = withUnsafeMutablePointer(to: &info) { pointer in
pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count)
}
}
guard status == KERN_SUCCESS else {
throw NSError(domain: "ExpoProcessMemory", code: Int(status))
}
return [
"metric": "physical-footprint",
"value": Double(info.phys_footprint),
"unit": "bytes",
"processRole": "app",
"pid": ProcessInfo.processInfo.processIdentifier,
"sampledAtMs": Date().timeIntervalSince1970 * 1_000
]
}
}
}
@@ -0,0 +1,17 @@
{
"name": "@orca/expo-process-memory",
"version": "0.0.1",
"private": true,
"description": "Native app-process memory diagnostics for Orca mobile",
"license": "MIT",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"typecheck": "pnpm --dir ../.. exec tsc --noEmit"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
}
@@ -0,0 +1,8 @@
import { requireNativeModule } from 'expo-modules-core'
import type { NativeProcessMemoryMeasurement } from './process-memory-measurement'
type ExpoProcessMemoryModule = {
getProcessMemory(): NativeProcessMemoryMeasurement
}
export default requireNativeModule<ExpoProcessMemoryModule>('ExpoProcessMemory')
@@ -0,0 +1,2 @@
export { default } from './ExpoProcessMemoryModule'
export type { NativeProcessMemoryMeasurement } from './process-memory-measurement'
@@ -0,0 +1,8 @@
export type NativeProcessMemoryMeasurement = Readonly<{
metric: 'physical-footprint' | 'proportional-set-size'
value: number
unit: 'bytes' | 'kibibytes'
processRole: 'app'
pid: number
sampledAtMs: number
}>
@@ -0,0 +1,9 @@
{
"extends": "expo-module-scripts/tsconfig.base",
"compilerOptions": {
"outDir": "./build",
"module": "esnext",
"moduleResolution": "bundler"
},
"include": ["./src"]
}
+16
View File
@@ -14,6 +14,9 @@ importers:
'@noble/hashes':
specifier: 1.8.0
version: 1.8.0
'@orca/expo-process-memory':
specifier: file:./packages/expo-process-memory
version: file:packages/expo-process-memory(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
'@orca/expo-two-way-audio':
specifier: file:./packages/expo-two-way-audio
version: file:packages/expo-two-way-audio(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
@@ -1894,6 +1897,13 @@ packages:
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16}
'@orca/expo-process-memory@file:packages/expo-process-memory':
resolution: {directory: packages/expo-process-memory, type: directory}
peerDependencies:
expo: '*'
react: '*'
react-native: '*'
'@orca/expo-two-way-audio@file:packages/expo-two-way-audio':
resolution: {directory: packages/expo-two-way-audio, type: directory}
peerDependencies:
@@ -8859,6 +8869,12 @@ snapshots:
'@noble/hashes@1.8.0': {}
'@orca/expo-process-memory@file:packages/expo-process-memory(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)':
dependencies:
expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3)
react: 19.2.6
react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)
'@orca/expo-two-way-audio@file:packages/expo-two-way-audio(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)':
dependencies:
expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3)
@@ -0,0 +1,121 @@
import { describe, expect, it, vi } from 'vitest'
import {
captureMobileProcessMemorySnapshot,
normalizeNativeProcessMemoryBytes,
type NativeProcessMemoryMeasurement
} from './mobile-process-memory-diagnostics'
const IOS_MEASUREMENT: NativeProcessMemoryMeasurement = {
metric: 'physical-footprint',
value: 12_345,
unit: 'bytes',
processRole: 'app',
pid: 41,
sampledAtMs: 1_722_000_000_000
}
const ANDROID_MEASUREMENT: NativeProcessMemoryMeasurement = {
metric: 'proportional-set-size',
value: 2_048,
unit: 'kibibytes',
processRole: 'app',
pid: 42,
sampledAtMs: 1_722_000_000_001
}
describe('mobile process-memory diagnostics', () => {
it('normalizes native byte and KiB units to bytes', () => {
expect(normalizeNativeProcessMemoryBytes(IOS_MEASUREMENT)).toBe(12_345)
expect(normalizeNativeProcessMemoryBytes(ANDROID_MEASUREMENT)).toBe(2_097_152)
})
it('rejects invalid native byte values', () => {
expect(() =>
normalizeNativeProcessMemoryBytes({ ...IOS_MEASUREMENT, value: Number.NaN })
).toThrow()
expect(() => normalizeNativeProcessMemoryBytes({ ...IOS_MEASUREMENT, value: -1 })).toThrow()
})
it.each([
['ios', IOS_MEASUREMENT, 12_345, 'physical-footprint'],
['android', ANDROID_MEASUREMENT, 2_097_152, 'proportional-set-size']
] as const)(
'attributes a supported %s app-process sample',
async (platform, raw, bytes, metric) => {
const snapshot = await captureMobileProcessMemorySnapshot(platform, () => raw)
expect(snapshot).toMatchObject({
platform,
supportStatus: 'supported',
processRole: 'app',
pid: raw.pid,
metric,
bytes,
byteUnit: 'bytes',
sampledAtMs: raw.sampledAtMs,
webContentProcessAttribution: 'unsupported-unattributed',
limitation: 'public-sandbox-api-unavailable',
errorKind: null
})
}
)
it('reports unsupported platforms without calling native code', async () => {
const readNative = vi.fn()
const snapshot = await captureMobileProcessMemorySnapshot('web', readNative, () => 77)
expect(readNative).not.toHaveBeenCalled()
expect(snapshot).toMatchObject({
platform: 'other',
supportStatus: 'unsupported',
bytes: null,
sampledAtMs: 77
})
})
it('turns native failures into a bounded error without copying messages', async () => {
const snapshot = await captureMobileProcessMemorySnapshot(
'ios',
() => {
throw new Error('/private/worktree credential failed')
},
() => 88
)
expect(snapshot).toMatchObject({
supportStatus: 'error',
bytes: null,
sampledAtMs: 88,
errorKind: 'native-error'
})
expect(JSON.stringify(snapshot)).not.toContain('credential')
})
it('rejects a native metric attributed to the wrong platform', async () => {
const snapshot = await captureMobileProcessMemorySnapshot('ios', () => ANDROID_MEASUREMENT)
expect(snapshot.supportStatus).toBe('error')
expect(snapshot.bytes).toBeNull()
})
it('uses a fixed privacy-safe schema', async () => {
const snapshot = await captureMobileProcessMemorySnapshot('ios', () => IOS_MEASUREMENT)
expect(Object.keys(snapshot).sort()).toEqual(
[
'byteUnit',
'bytes',
'errorKind',
'limitation',
'metric',
'pid',
'platform',
'processRole',
'sampledAtMs',
'supportStatus',
'webContentProcessAttribution'
].sort()
)
})
})
@@ -0,0 +1,107 @@
import type { NativeProcessMemoryMeasurement } from '@orca/expo-process-memory'
export type { NativeProcessMemoryMeasurement } from '@orca/expo-process-memory'
export type MobileProcessMemoryPlatform = 'ios' | 'android' | 'other'
export type MobileProcessMemorySnapshot = Readonly<{
platform: MobileProcessMemoryPlatform
supportStatus: 'supported' | 'unsupported' | 'error'
processRole: 'app'
pid: number | null
metric: NativeProcessMemoryMeasurement['metric'] | null
bytes: number | null
byteUnit: 'bytes'
sampledAtMs: number
webContentProcessAttribution: 'unsupported-unattributed'
limitation: 'public-sandbox-api-unavailable'
errorKind: 'native-error' | null
}>
type NativeMemoryReader = () =>
| NativeProcessMemoryMeasurement
| Promise<NativeProcessMemoryMeasurement>
const BYTE_MULTIPLIER: Record<NativeProcessMemoryMeasurement['unit'], number> = {
bytes: 1,
kibibytes: 1024
}
export function normalizeNativeProcessMemoryBytes(
measurement: NativeProcessMemoryMeasurement
): number {
const bytes = measurement.value * BYTE_MULTIPLIER[measurement.unit]
if (!Number.isFinite(bytes) || bytes < 0 || !Number.isSafeInteger(Math.round(bytes))) {
throw new Error('Invalid native process-memory measurement')
}
return Math.round(bytes)
}
export async function captureMobileProcessMemorySnapshot(
platform: string,
readNative: NativeMemoryReader,
now: () => number = Date.now
): Promise<MobileProcessMemorySnapshot> {
const normalizedPlatform: MobileProcessMemoryPlatform =
platform === 'ios' || platform === 'android' ? platform : 'other'
const base = {
platform: normalizedPlatform,
processRole: 'app' as const,
byteUnit: 'bytes' as const,
webContentProcessAttribution: 'unsupported-unattributed' as const,
limitation: 'public-sandbox-api-unavailable' as const
}
if (normalizedPlatform === 'other') {
return {
...base,
supportStatus: 'unsupported',
pid: null,
metric: null,
bytes: null,
sampledAtMs: now(),
errorKind: null
}
}
try {
const measurement = await readNative()
const expectedMetric =
normalizedPlatform === 'ios' ? 'physical-footprint' : 'proportional-set-size'
const expectedUnit = normalizedPlatform === 'ios' ? 'bytes' : 'kibibytes'
if (
measurement.processRole !== 'app' ||
measurement.metric !== expectedMetric ||
measurement.unit !== expectedUnit ||
!Number.isFinite(measurement.sampledAtMs)
) {
throw new Error('Invalid native process-memory attribution')
}
return {
...base,
supportStatus: 'supported',
pid: Number.isSafeInteger(measurement.pid) && measurement.pid > 0 ? measurement.pid : null,
metric: measurement.metric,
bytes: normalizeNativeProcessMemoryBytes(measurement),
sampledAtMs: measurement.sampledAtMs,
errorKind: null
}
} catch {
return {
...base,
supportStatus: 'error',
pid: null,
metric: null,
bytes: null,
sampledAtMs: now(),
errorKind: 'native-error'
}
}
}
export async function readMobileProcessMemorySnapshot(
platform: string
): Promise<MobileProcessMemorySnapshot> {
return captureMobileProcessMemorySnapshot(platform, async () => {
const { default: nativeModule } = await import('@orca/expo-process-memory')
return nativeModule.getProcessMemory()
})
}
+35 -2
View File
@@ -1,6 +1,7 @@
import { useCallback } from 'react'
import { StyleSheet, View } from 'react-native'
import { useCallback, useEffect, useRef } from 'react'
import { Platform, StyleSheet, View } from 'react-native'
import { TerminalWebView } from '../terminal/TerminalWebView'
import type { MobileTerminalDiagnostics } from './mobile-terminal-diagnostics'
import type {
MobileTerminalTheme,
TerminalKeyboardAvoidanceMetrics,
@@ -15,6 +16,8 @@ type TerminalPaneViewProps = {
terminalTheme?: MobileTerminalTheme
textScale: number
onRef: (handle: string, ref: TerminalWebViewHandle | null) => void
diagnostics: MobileTerminalDiagnostics
terminalRecordsLoaded: boolean
onWebReady: (handle: string) => void
onSelectionMode: (handle: string, active: boolean) => void
onSelectionCopy: (handle: string, text: string) => void
@@ -37,6 +40,8 @@ export function TerminalPaneView({
terminalTheme,
textScale,
onRef,
diagnostics,
terminalRecordsLoaded,
onWebReady,
onSelectionMode,
onSelectionCopy,
@@ -51,6 +56,12 @@ export function TerminalPaneView({
onOpenUrl,
onTextScaleChange
}: TerminalPaneViewProps) {
const diagnosticsMountLifecycleRef = useRef<
ReturnType<MobileTerminalDiagnostics['createWebViewMountLifecycle']> | undefined
>(undefined)
if (diagnosticsMountLifecycleRef.current === undefined) {
diagnosticsMountLifecycleRef.current = diagnostics.createWebViewMountLifecycle()
}
const setRef = useCallback(
(ref: TerminalWebViewHandle | null) => {
onRef(handle, ref)
@@ -58,6 +69,28 @@ export function TerminalPaneView({
[handle, onRef]
)
useEffect(() => {
const lifecycle = diagnosticsMountLifecycleRef.current
if (!lifecycle) {
return
}
lifecycle.mounted(active)
return lifecycle.unmounted
}, [diagnostics])
useEffect(() => {
const lifecycle = diagnosticsMountLifecycleRef.current
if (lifecycle) {
lifecycle.activityChanged(active)
if (terminalRecordsLoaded) {
void diagnostics.sampleProcessMemoryOnce(Platform.OS, {
terminalRecordsLoaded,
renderedTerminalPaneCount: 1
})
}
}
}, [active, diagnostics, terminalRecordsLoaded])
return (
<View
// Why: inactive terminal WebViews stay mounted to preserve xterm state,
@@ -43,4 +43,78 @@ describe('mobile terminal diagnostics', () => {
expect(log).toHaveBeenCalledTimes(2)
log.mockRestore()
})
it('waits for a rendered terminal pane, then samples once across effect replay', async () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const readProcessMemory = vi.fn(async () => ({
platform: 'ios' as const,
supportStatus: 'supported' as const,
processRole: 'app' as const,
pid: 41,
metric: 'physical-footprint' as const,
bytes: 12_345,
byteUnit: 'bytes' as const,
sampledAtMs: 77,
webContentProcessAttribution: 'unsupported-unattributed' as const,
limitation: 'public-sandbox-api-unavailable' as const,
errorKind: null
}))
const diagnostics = new MobileTerminalDiagnostics(true, readProcessMemory)
await diagnostics.sampleProcessMemoryOnce('ios', {
terminalRecordsLoaded: false,
renderedTerminalPaneCount: 1
})
await diagnostics.sampleProcessMemoryOnce('ios', {
terminalRecordsLoaded: true,
renderedTerminalPaneCount: 0
})
await Promise.all(
Array.from({ length: 2 }, () =>
diagnostics.sampleProcessMemoryOnce('ios', {
terminalRecordsLoaded: true,
renderedTerminalPaneCount: 1
})
)
)
expect(readProcessMemory).toHaveBeenCalledOnce()
expect(log).toHaveBeenCalledOnce()
log.mockRestore()
})
it('does not load native process-memory diagnostics while disabled', async () => {
const readProcessMemory = vi.fn()
const diagnostics = new MobileTerminalDiagnostics(false, readProcessMemory)
await diagnostics.sampleProcessMemoryOnce('ios', {
terminalRecordsLoaded: true,
renderedTerminalPaneCount: 1
})
expect(readProcessMemory).not.toHaveBeenCalled()
})
it('keeps actual terminal records distinct from terminal and total tab counts', () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const diagnostics = new MobileTerminalDiagnostics(true)
const tabs = [
{ id: 'ready', type: 'terminal', terminal: 'pty-1', isActive: true },
{ id: 'pending', type: 'terminal', terminal: null, isActive: false },
{ id: 'browser', type: 'browser', isActive: false }
]
diagnostics.tabsApplied({ snapshotVersion: 1, tabs }, tabs, 3, tabs[0], 'snapshot')
expect(log).toHaveBeenCalledWith(
'[terminal-diagnostic]',
'webview-count-snapshot',
expect.objectContaining({
terminalRecordCount: 3,
terminalTabCount: 2,
tabCount: 3
})
)
log.mockRestore()
})
})
@@ -1,3 +1,10 @@
import {
MobileTerminalProcessMemoryDiagnostics,
type MobileTerminalProcessMemoryReadiness,
type MobileTerminalProcessMemoryReader
} from './mobile-terminal-process-memory-diagnostics'
import { MobileTerminalWebViewCountDiagnostics } from './mobile-terminal-webview-count-diagnostics'
const MOBILE_TERMINAL_DIAGNOSTIC_TAG = '[terminal-diagnostic]'
type MobileTerminalDiagnosticValue = string | number | boolean | null | undefined
@@ -37,13 +44,17 @@ export function getMobileTerminalDiagnosticErrorName(error: unknown): string {
return typeof error
}
export function isMobileTerminalDiagnosticsEnabled(): boolean {
return typeof __DEV__ === 'undefined' || __DEV__
}
export function logMobileTerminalDiagnostic(
event: string,
details: MobileTerminalDiagnosticDetails = {}
): void {
// Why: lifecycle diagnostics are intentionally available for HMR repros,
// but high-frequency WebView events must not add production log overhead.
if (typeof __DEV__ !== 'undefined' && !__DEV__) {
if (!isMobileTerminalDiagnosticsEnabled()) {
return
}
// Keep this structured and content-free so users can safely share a filtered log.
@@ -57,6 +68,22 @@ export class MobileTerminalDiagnostics {
private lastAppliedTabsSignature: string | null = null
private lastTabsFetchStartAt = 0
private tabsFetchSkipLogged = false
private readonly processMemory: MobileTerminalProcessMemoryDiagnostics
private readonly webViewCounts: MobileTerminalWebViewCountDiagnostics
constructor(
enabled = isMobileTerminalDiagnosticsEnabled(),
readProcessMemory?: MobileTerminalProcessMemoryReader
) {
this.processMemory = new MobileTerminalProcessMemoryDiagnostics(
enabled,
(snapshot) => logMobileTerminalDiagnostic('process-memory-snapshot', snapshot),
readProcessMemory
)
this.webViewCounts = new MobileTerminalWebViewCountDiagnostics(enabled, (snapshot) => {
logMobileTerminalDiagnostic('webview-count-snapshot', snapshot)
})
}
clearTerminalCache(): void {
this.streamGateByHandle.clear()
@@ -69,6 +96,7 @@ export class MobileTerminalDiagnostics {
this.lastAppliedTabsSignature = null
this.lastTabsFetchStartAt = 0
this.tabsFetchSkipLogged = false
this.webViewCounts.resetRoute()
}
terminalUnsubscribed(handle: string): void {
@@ -171,9 +199,15 @@ export class MobileTerminalDiagnostics {
tabsApplied(
snapshot: DiagnosticTabsSnapshot,
tabs: readonly DiagnosticTab[],
terminalRecordCount: number,
activeTab: DiagnosticTab | null,
selectionSource: string
): void {
this.webViewCounts.sessionSnapshot({
terminalRecordCount,
terminalTabCount: tabs.filter((tab) => tab.type === 'terminal').length,
tabCount: tabs.length
})
const activeHandle =
activeTab?.type === 'terminal' && typeof activeTab.terminal === 'string'
? activeTab.terminal
@@ -267,6 +301,17 @@ export class MobileTerminalDiagnostics {
})
}
createWebViewMountLifecycle() {
return this.webViewCounts.createMountLifecycle()
}
async sampleProcessMemoryOnce(
platform: string,
readiness: MobileTerminalProcessMemoryReadiness
): Promise<void> {
await this.processMemory.sampleOnce(platform, readiness)
}
private logTabs(
event: 'tabs-applied' | 'tabs-fetch-success',
snapshot: DiagnosticTabsSnapshot,
@@ -0,0 +1,41 @@
import {
readMobileProcessMemorySnapshot,
type MobileProcessMemorySnapshot
} from '../diagnostics/mobile-process-memory-diagnostics'
export type MobileTerminalProcessMemoryReadiness = Readonly<{
terminalRecordsLoaded: boolean
renderedTerminalPaneCount: number
}>
export type MobileTerminalProcessMemoryReader = (
platform: string
) => Promise<MobileProcessMemorySnapshot>
type SnapshotEmitter = (snapshot: MobileProcessMemorySnapshot) => void
export class MobileTerminalProcessMemoryDiagnostics {
private sampleStarted = false
constructor(
private readonly enabled: boolean,
private readonly emit: SnapshotEmitter,
private readonly readProcessMemory: MobileTerminalProcessMemoryReader = readMobileProcessMemorySnapshot
) {}
async sampleOnce(
platform: string,
readiness: MobileTerminalProcessMemoryReadiness
): Promise<void> {
if (
!this.enabled ||
!readiness.terminalRecordsLoaded ||
readiness.renderedTerminalPaneCount < 1 ||
this.sampleStarted
) {
return
}
this.sampleStarted = true
this.emit(await this.readProcessMemory(platform))
}
}
@@ -0,0 +1,149 @@
import { describe, expect, it, vi } from 'vitest'
import {
MobileTerminalWebViewCountDiagnostics,
type MobileTerminalWebViewCountSnapshot
} from './mobile-terminal-webview-count-diagnostics'
function createDiagnostics() {
const snapshots: MobileTerminalWebViewCountSnapshot[] = []
return {
diagnostics: new MobileTerminalWebViewCountDiagnostics(true, (snapshot) =>
snapshots.push(snapshot)
),
snapshots
}
}
describe('mobile terminal WebView count diagnostics', () => {
it.each([10, 50])('aggregates a %i-terminal retained set', (terminalCount) => {
const { diagnostics, snapshots } = createDiagnostics()
diagnostics.sessionSnapshot({
terminalRecordCount: terminalCount,
terminalTabCount: terminalCount,
tabCount: terminalCount + 2
})
for (let index = 0; index < terminalCount; index += 1) {
diagnostics.createMountLifecycle()!.mounted(index === terminalCount - 1)
}
expect(snapshots.at(-1)).toEqual({
boundary: 'mount',
mountedWebViewCount: terminalCount,
activeMountedWebViewCount: 1,
inactiveMountedWebViewCount: terminalCount - 1,
terminalRecordCount: terminalCount,
terminalTabCount: terminalCount,
tabCount: terminalCount + 2
})
})
it('replays setup-cleanup-setup with one retained mount', () => {
const { diagnostics, snapshots } = createDiagnostics()
const lifecycle = diagnostics.createMountLifecycle()!
lifecycle.mounted(true)
lifecycle.unmounted()
lifecycle.mounted(true)
lifecycle.mounted(true)
lifecycle.activityChanged(true)
expect(snapshots.map((snapshot) => snapshot.boundary)).toEqual(['mount', 'unmount', 'mount'])
expect(snapshots.at(-1)?.mountedWebViewCount).toBe(1)
expect(diagnostics.retainedEntryCount()).toBe(1)
})
it('tracks active changes independently of mount state', () => {
const { diagnostics, snapshots } = createDiagnostics()
const firstLifecycle = diagnostics.createMountLifecycle()!
const secondLifecycle = diagnostics.createMountLifecycle()!
firstLifecycle.mounted(true)
secondLifecycle.mounted(false)
firstLifecycle.activityChanged(false)
secondLifecycle.activityChanged(true)
expect(snapshots.at(-1)).toMatchObject({
boundary: 'activity-change',
mountedWebViewCount: 2,
activeMountedWebViewCount: 1,
inactiveMountedWebViewCount: 1
})
})
it('clears mounted identities and totals at a route reset', () => {
const { diagnostics, snapshots } = createDiagnostics()
const lifecycle = diagnostics.createMountLifecycle()!
diagnostics.sessionSnapshot({
terminalRecordCount: 2,
terminalTabCount: 3,
tabCount: 4
})
lifecycle.mounted(true)
diagnostics.resetRoute()
lifecycle.unmounted()
expect(diagnostics.retainedEntryCount()).toBe(0)
expect(snapshots.at(-1)).toEqual({
boundary: 'route-reset',
mountedWebViewCount: 0,
activeMountedWebViewCount: 0,
inactiveMountedWebViewCount: 0,
terminalRecordCount: 0,
terminalTabCount: 0,
tabCount: 0
})
})
it('ignores a late old unmount after reset and a new same-handle pane mount', () => {
const { diagnostics, snapshots } = createDiagnostics()
const oldPaneLifecycle = diagnostics.createMountLifecycle()!
oldPaneLifecycle.mounted(true)
diagnostics.resetRoute()
const newPaneLifecycle = diagnostics.createMountLifecycle()!
newPaneLifecycle.mounted(true)
const snapshotCount = snapshots.length
oldPaneLifecycle.unmounted()
expect(snapshots).toHaveLength(snapshotCount)
expect(snapshots.at(-1)).toMatchObject({
boundary: 'mount',
mountedWebViewCount: 1,
activeMountedWebViewCount: 1
})
expect(diagnostics.retainedEntryCount()).toBe(1)
})
it('allocates no retained entries or emissions while disabled', () => {
const emit = vi.fn()
const diagnostics = new MobileTerminalWebViewCountDiagnostics(false, emit)
expect(diagnostics.createMountLifecycle()).toBeNull()
diagnostics.sessionSnapshot({
terminalRecordCount: 1,
terminalTabCount: 1,
tabCount: 1
})
diagnostics.resetRoute()
expect(diagnostics.retainedEntryCount()).toBe(0)
expect(emit).not.toHaveBeenCalled()
})
it('emits only bounded aggregate fields', () => {
const { diagnostics, snapshots } = createDiagnostics()
diagnostics.createMountLifecycle()!.mounted(true)
expect(Object.keys(snapshots[0]).sort()).toEqual(
[
'activeMountedWebViewCount',
'boundary',
'inactiveMountedWebViewCount',
'mountedWebViewCount',
'tabCount',
'terminalRecordCount',
'terminalTabCount'
].sort()
)
})
})
@@ -0,0 +1,126 @@
export type MobileTerminalSurfaceTotals = Readonly<{
terminalRecordCount: number
terminalTabCount: number
tabCount: number
}>
export type MobileTerminalWebViewCountSnapshot = MobileTerminalSurfaceTotals &
Readonly<{
boundary: 'mount' | 'unmount' | 'activity-change' | 'session-snapshot' | 'route-reset'
mountedWebViewCount: number
activeMountedWebViewCount: number
inactiveMountedWebViewCount: number
}>
type SnapshotEmitter = (snapshot: MobileTerminalWebViewCountSnapshot) => void
declare const mobileTerminalWebViewMountIdentityBrand: unique symbol
type MobileTerminalWebViewMountIdentity = Readonly<{
[mobileTerminalWebViewMountIdentityBrand]: true
}>
export type MobileTerminalWebViewMountLifecycle = Readonly<{
mounted: (active: boolean) => void
activityChanged: (active: boolean) => void
unmounted: () => void
}>
const EMPTY_TOTALS: MobileTerminalSurfaceTotals = {
terminalRecordCount: 0,
terminalTabCount: 0,
tabCount: 0
}
export class MobileTerminalWebViewCountDiagnostics {
private readonly mountedActivityByIdentity: Map<
MobileTerminalWebViewMountIdentity,
boolean
> | null
private totals = EMPTY_TOTALS
constructor(
enabled: boolean,
private readonly emit: SnapshotEmitter
) {
this.mountedActivityByIdentity = enabled ? new Map() : null
}
private createMountIdentity(): MobileTerminalWebViewMountIdentity | null {
return this.mountedActivityByIdentity ? ({} as MobileTerminalWebViewMountIdentity) : null
}
createMountLifecycle(): MobileTerminalWebViewMountLifecycle | null {
const identity = this.createMountIdentity()
return identity
? {
mounted: (active) => this.mounted(identity, active),
activityChanged: (active) => this.activityChanged(identity, active),
unmounted: () => this.unmounted(identity)
}
: null
}
private mounted(identity: MobileTerminalWebViewMountIdentity, active: boolean): void {
const mounted = this.mountedActivityByIdentity
if (!mounted || mounted.has(identity)) {
return
}
mounted.set(identity, active)
this.emitSnapshot('mount')
}
private activityChanged(identity: MobileTerminalWebViewMountIdentity, active: boolean): void {
const mounted = this.mountedActivityByIdentity
if (!mounted || !mounted.has(identity) || mounted.get(identity) === active) {
return
}
mounted.set(identity, active)
this.emitSnapshot('activity-change')
}
private unmounted(identity: MobileTerminalWebViewMountIdentity): void {
if (!this.mountedActivityByIdentity?.delete(identity)) {
return
}
this.emitSnapshot('unmount')
}
sessionSnapshot(totals: MobileTerminalSurfaceTotals): void {
if (
!this.mountedActivityByIdentity ||
(this.totals.terminalRecordCount === totals.terminalRecordCount &&
this.totals.terminalTabCount === totals.terminalTabCount &&
this.totals.tabCount === totals.tabCount)
) {
return
}
this.totals = totals
this.emitSnapshot('session-snapshot')
}
resetRoute(): void {
if (!this.mountedActivityByIdentity) {
return
}
this.mountedActivityByIdentity.clear()
this.totals = EMPTY_TOTALS
this.emitSnapshot('route-reset')
}
retainedEntryCount(): number {
return this.mountedActivityByIdentity?.size ?? 0
}
private emitSnapshot(boundary: MobileTerminalWebViewCountSnapshot['boundary']): void {
const activity = [...(this.mountedActivityByIdentity?.values() ?? [])]
const activeMountedWebViewCount = activity.filter(Boolean).length
this.emit({
boundary,
mountedWebViewCount: activity.length,
activeMountedWebViewCount,
inactiveMountedWebViewCount: activity.length - activeMountedWebViewCount,
...this.totals
})
}
}
+1
View File
@@ -27,6 +27,7 @@
"test:repro:remote-agent-session": "pnpm run build:cli && pnpm run build:electron-vite && node config/scripts/remote-agent-session-authority-repro.mjs",
"check:reliability-gates": "node config/scripts/check-reliability-gates.mjs",
"check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs",
"check:electron-bundle-budgets": "node config/scripts/electron-bundle-entry-report.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",
"verify:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --check",
@@ -0,0 +1,21 @@
import type { BrowserManager } from './browser-manager'
export type BrowserKernelWindowDependencies = {
browserManager: BrowserManager
isAllowedSessionPartition: (partition: string) => boolean
}
let dependencies: BrowserKernelWindowDependencies | null = null
export function setBrowserKernelWindowDependencies(
nextDependencies: BrowserKernelWindowDependencies
): void {
dependencies = nextDependencies
}
export function getBrowserKernelWindowDependencies(): BrowserKernelWindowDependencies {
if (!dependencies) {
throw new Error('Browser kernel must be initialized before creating or attaching a window')
}
return dependencies
}
@@ -0,0 +1,35 @@
import { join } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
let userDataPath = ''
vi.mock('electron', () => ({
app: { getPath: () => userDataPath }
}))
describe('Claude usage file path', () => {
const earlyUserDataPath = join('early-root', 'profile')
const lateUserDataPath = join('late-root', 'profile')
beforeEach(() => {
userDataPath = earlyUserDataPath
vi.resetModules()
})
it('keeps the path captured before the Electron app name changes', async () => {
const { getClaudeUsageFilePath, initClaudeUsagePath } = await import('./claude-usage-file-path')
initClaudeUsagePath()
userDataPath = lateUserDataPath
expect(getClaudeUsageFilePath()).toBe(join(earlyUserDataPath, 'orca-claude-usage.json'))
})
it('caches the fallback path when startup initialization was skipped', async () => {
const { getClaudeUsageFilePath } = await import('./claude-usage-file-path')
expect(getClaudeUsageFilePath()).toBe(join(earlyUserDataPath, 'orca-claude-usage.json'))
userDataPath = lateUserDataPath
expect(getClaudeUsageFilePath()).toBe(join(earlyUserDataPath, 'orca-claude-usage.json'))
})
})
@@ -0,0 +1,16 @@
import { app } from 'electron'
import { join } from 'node:path'
let claudeUsageFilePath: string | null = null
export function initClaudeUsagePath(): void {
// Why: app.setName can change userData casing, so capture the configured path before app readiness.
claudeUsageFilePath = join(app.getPath('userData'), 'orca-claude-usage.json')
}
export function getClaudeUsageFilePath(): string {
if (!claudeUsageFilePath) {
claudeUsageFilePath = join(app.getPath('userData'), 'orca-claude-usage.json')
}
return claudeUsageFilePath
}
+6 -19
View File
@@ -1,7 +1,6 @@
/* eslint-disable max-lines -- Why: this store is the single main-process owner for Claude usage persistence, scan gating, and query semantics. Keeping those policy decisions together avoids split-brain range/scope logic across multiple files. */
import { app } from 'electron'
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { dirname } from 'node:path'
import type {
ClaudeUsageBreakdownKind,
ClaudeUsageBreakdownRow,
@@ -18,6 +17,9 @@ import type { Store } from '../persistence'
import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata'
import type { ClaudeUsagePersistedState } from './types'
import { createWorktreeRefs, getSessionProjectLabel, scanClaudeUsageFiles } from './scanner'
import { getClaudeUsageFilePath } from './claude-usage-file-path'
export { initClaudeUsagePath } from './claude-usage-file-path'
// Why: v5 widens Claude ownership keys (message-id / uuid fallbacks). Older
// caches either lack ownership or used narrower keys and can under/over-count
@@ -26,10 +28,6 @@ const SCHEMA_VERSION = 5
const STALE_MS = 5 * 60_000
const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000
// Why: capture the path after configureDevUserDataPath() but before app.setName()
// mutates Electron's derived userData location, matching the persistence/store pattern.
let _claudeUsageFile: string | null = null
type ClaudeModelPricing = {
input: number
output: number
@@ -128,17 +126,6 @@ function getDefaultState(): ClaudeUsagePersistedState {
}
}
export function initClaudeUsagePath(): void {
_claudeUsageFile = join(app.getPath('userData'), 'orca-claude-usage.json')
}
function getClaudeUsageFile(): string {
if (!_claudeUsageFile) {
_claudeUsageFile = join(app.getPath('userData'), 'orca-claude-usage.json')
}
return _claudeUsageFile
}
function hasClaudeModelVersion(model: string, family: string, version: string): boolean {
const normalized = model.replace(/\./g, '-')
return new RegExp(`${family}-${version}(?:$|[^0-9])`).test(normalized)
@@ -339,7 +326,7 @@ export class ClaudeUsageStore {
private load(): ClaudeUsagePersistedState {
try {
const usageFile = getClaudeUsageFile()
const usageFile = getClaudeUsageFilePath()
if (!existsSync(usageFile)) {
return getDefaultState()
}
@@ -376,7 +363,7 @@ export class ClaudeUsageStore {
}
private writeToDisk(): void {
const usageFile = getClaudeUsageFile()
const usageFile = getClaudeUsageFilePath()
const dir = dirname(usageFile)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
@@ -0,0 +1,35 @@
import { join } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
let userDataPath = ''
vi.mock('electron', () => ({
app: { getPath: () => userDataPath }
}))
describe('Codex usage file path', () => {
const earlyUserDataPath = join('early-root', 'profile')
const lateUserDataPath = join('late-root', 'profile')
beforeEach(() => {
userDataPath = earlyUserDataPath
vi.resetModules()
})
it('keeps the path captured before the Electron app name changes', async () => {
const { getCodexUsageFilePath, initCodexUsagePath } = await import('./codex-usage-file-path')
initCodexUsagePath()
userDataPath = lateUserDataPath
expect(getCodexUsageFilePath()).toBe(join(earlyUserDataPath, 'orca-codex-usage.json'))
})
it('caches the fallback path when startup initialization was skipped', async () => {
const { getCodexUsageFilePath } = await import('./codex-usage-file-path')
expect(getCodexUsageFilePath()).toBe(join(earlyUserDataPath, 'orca-codex-usage.json'))
userDataPath = lateUserDataPath
expect(getCodexUsageFilePath()).toBe(join(earlyUserDataPath, 'orca-codex-usage.json'))
})
})
@@ -0,0 +1,16 @@
import { app } from 'electron'
import { join } from 'node:path'
let codexUsageFilePath: string | null = null
export function initCodexUsagePath(): void {
// Why: app.setName can change userData casing, so capture the configured path before app readiness.
codexUsageFilePath = join(app.getPath('userData'), 'orca-codex-usage.json')
}
export function getCodexUsageFilePath(): string {
if (!codexUsageFilePath) {
codexUsageFilePath = join(app.getPath('userData'), 'orca-codex-usage.json')
}
return codexUsageFilePath
}
+6 -17
View File
@@ -1,6 +1,5 @@
/* eslint-disable max-lines -- Why: this store owns Codex analytics persistence, scan policy, and renderer query semantics. Keeping them together prevents the Codex range/scope rules from drifting away from the scanner’s event model. */
import { app } from 'electron'
import { dirname, join } from 'node:path'
import { dirname } from 'node:path'
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import type {
CodexUsageBreakdownKind,
@@ -18,6 +17,9 @@ import type { Store } from '../persistence'
import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata'
import type { CodexUsagePersistedState } from './types'
import { createWorktreeRefs, scanCodexUsageFiles } from './scanner'
import { getCodexUsageFilePath } from './codex-usage-file-path'
export { initCodexUsagePath } from './codex-usage-file-path'
// Why: v5 keys Codex ownership on raw token_count identity without session id
// so forks that rewrite session_meta still match. Older caches used session-
@@ -26,8 +28,6 @@ const SCHEMA_VERSION = 5
const STALE_MS = 5 * 60_000
const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000
let _codexUsageFile: string | null = null
type TieredPrice = { threshold: number; price: number }
type CodexModelPricing = {
input: number
@@ -161,17 +161,6 @@ export function normalizePersistedState(state: CodexUsagePersistedState): CodexU
}
}
export function initCodexUsagePath(): void {
_codexUsageFile = join(app.getPath('userData'), 'orca-codex-usage.json')
}
function getCodexUsageFile(): string {
if (!_codexUsageFile) {
_codexUsageFile = join(app.getPath('userData'), 'orca-codex-usage.json')
}
return _codexUsageFile
}
function stripParenthesizedReasoningTier(model: string): string | null {
const match = model.match(/^(.*)\(([^()]*)\)$/)
if (!match) {
@@ -374,7 +363,7 @@ export class CodexUsageStore {
private load(): CodexUsagePersistedState {
try {
const usageFile = getCodexUsageFile()
const usageFile = getCodexUsageFilePath()
if (!existsSync(usageFile)) {
return getDefaultState()
}
@@ -394,7 +383,7 @@ export class CodexUsageStore {
}
private writeToDisk(): void {
const usageFile = getCodexUsageFile()
const usageFile = getCodexUsageFilePath()
const dir = dirname(usageFile)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
@@ -0,0 +1,73 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { writeStartupDiagnosticLine } = vi.hoisted(() => ({
writeStartupDiagnosticLine: vi.fn()
}))
vi.mock('../startup/startup-diagnostics', () => ({ writeStartupDiagnosticLine }))
import {
MAIN_THREAD_DIAGNOSTICS_ENV,
drainRemoteRpcRequestStats,
drainSubprocessSpawnStats,
recordRemoteRpcRequest,
recordSubprocessSpawn,
startMainThreadChurnProbe
} from './main-thread-churn-probe'
function lastReport(): Record<string, unknown> {
const line = writeStartupDiagnosticLine.mock.calls.at(-1)?.[0]
expect(line).toMatch(/^\[main-thread\] /)
return JSON.parse(line.slice('[main-thread] '.length))
}
describe('main-thread churn report windows', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.stubEnv(MAIN_THREAD_DIAGNOSTICS_ENV, '1')
})
afterEach(() => {
vi.clearAllTimers()
vi.useRealTimers()
vi.unstubAllEnvs()
writeStartupDiagnosticLine.mockReset()
drainSubprocessSpawnStats()
drainRemoteRpcRequestStats()
})
it('drains subprocess and RPC counts into the same deterministic window', () => {
startMainThreadChurnProbe()
recordSubprocessSpawn('git', ['status'], 2)
recordRemoteRpcRequest('git.status')
recordRemoteRpcRequest('git.status')
vi.advanceTimersByTime(5_000)
expect(lastReport()).toMatchObject({
spawnCount: 1,
spawns: { 'git status': { count: 1, blockMsTotal: 2, blockMsMax: 2 } },
rpcCount: 2,
rpcs: { 'git.status': { count: 2 } }
})
vi.advanceTimersByTime(5_000)
expect(lastReport()).toMatchObject({
spawnCount: 0,
spawns: {},
rpcCount: 0,
rpcs: {}
})
})
it('does not schedule reporting while diagnostics are disabled', () => {
vi.stubEnv(MAIN_THREAD_DIAGNOSTICS_ENV, '')
startMainThreadChurnProbe()
vi.advanceTimersByTime(5_000)
expect(vi.getTimerCount()).toBe(0)
expect(writeStartupDiagnosticLine).not.toHaveBeenCalled()
})
})
@@ -1,15 +1,19 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
MAIN_THREAD_DIAGNOSTICS_ENV,
classifyRemoteRpcMethod,
classifySubprocessCommand,
drainRemoteRpcRequestStats,
drainSubprocessSpawnStats,
isMainThreadDiagnosticsEnabled,
recordRemoteRpcRequest,
recordSubprocessSpawn
} from './main-thread-churn-probe'
afterEach(() => {
vi.unstubAllEnvs()
drainSubprocessSpawnStats()
drainRemoteRpcRequestStats()
})
describe('classifySubprocessCommand', () => {
@@ -72,3 +76,30 @@ describe('recordSubprocessSpawn', () => {
expect(drainSubprocessSpawnStats()).toEqual({})
})
})
describe('recordRemoteRpcRequest', () => {
it('keeps only stable protocol method buckets', () => {
expect(classifyRemoteRpcMethod('git.status')).toBe('git.status')
expect(classifyRemoteRpcMethod('fs.workspaceSpaceScan')).toBe('fs.workspaceSpaceScan')
expect(classifyRemoteRpcMethod('/home/alice/private-repo')).toBe('other')
expect(classifyRemoteRpcMethod(`git.${'x'.repeat(80)}`)).toBe('other')
})
it('is disabled without allocating retained stats', () => {
vi.stubEnv(MAIN_THREAD_DIAGNOSTICS_ENV, '')
recordRemoteRpcRequest('git.status')
expect(drainRemoteRpcRequestStats()).toEqual({})
})
it('aggregates once per method and drain resets the window', () => {
vi.stubEnv(MAIN_THREAD_DIAGNOSTICS_ENV, '1')
recordRemoteRpcRequest('git.status')
recordRemoteRpcRequest('git.status')
recordRemoteRpcRequest('git.history')
expect(drainRemoteRpcRequestStats()).toEqual({
'git.status': { count: 2 },
'git.history': { count: 1 }
})
expect(drainRemoteRpcRequestStats()).toEqual({})
})
})
@@ -81,6 +81,8 @@ export type SubprocessSpawnStats = {
}
const spawnStatsByCommand = new Map<string, SubprocessSpawnStats>()
export type RemoteRpcRequestStats = { count: number }
const rpcStatsByMethod = new Map<string, RemoteRpcRequestStats>()
/**
* Record one subprocess spawn from the main process. `blockMs` is how long
@@ -119,6 +121,38 @@ export function drainSubprocessSpawnStats(): Record<string, SubprocessSpawnStats
return drained
}
export function classifyRemoteRpcMethod(method: string): string {
if (method.length > 80 || !/^[A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z][A-Za-z0-9]*)+$/.test(method)) {
return 'other'
}
return method
}
/**
* Record one outbound request at a remote host/provider RPC boundary.
* No-op unless ORCA_MAIN_THREAD_DIAGNOSTICS=1.
*/
export function recordRemoteRpcRequest(method: string): void {
if (!isMainThreadDiagnosticsEnabled()) {
return
}
const key = classifyRemoteRpcMethod(method)
const stats = rpcStatsByMethod.get(key)
if (stats) {
stats.count++
} else {
rpcStatsByMethod.set(key, { count: 1 })
}
}
export function drainRemoteRpcRequestStats(): Record<string, RemoteRpcRequestStats> {
const drained = Object.fromEntries(
[...rpcStatsByMethod].map(([key, stats]) => [key, { count: stats.count }])
)
rpcStatsByMethod.clear()
return drained
}
/**
* Timestamped marker line for correlating a specific main-process activity
* (e.g. an updater check) with the probe's stall windows and with macOS
@@ -138,8 +172,8 @@ export function writeMainThreadDiagnosticMarker(marker: string): void {
* Long-running main-process jank probe for benchmarks and field diagnosis of
* issue #7576. Every 5s emits one `[main-thread] {json}` stderr line with the
* window's worst event-loop stall, stall counts over 50/250ms, and drained
* subprocess spawn stats. Unlike the startup stall probe this never stops:
* the churn it measures (git status polling, updater retries) is steady-state.
* subprocess and remote RPC stats. Unlike the startup stall probe this never
* stops: the churn it measures is steady-state.
*/
export function startMainThreadChurnProbe(): void {
if (!isMainThreadDiagnosticsEnabled()) {
@@ -166,15 +200,20 @@ export function startMainThreadChurnProbe(): void {
if (now - lastReport < REPORT_EVERY_MS) {
return
}
const windowDurationMs = now - lastReport
lastReport = now
const spawns = drainSubprocessSpawnStats()
const rpcs = drainRemoteRpcRequestStats()
const report = {
t: Math.round(now),
windowDurationMs: Math.round(windowDurationMs),
maxGapMs: Math.max(0, Math.round(windowMaxGapMs)),
gapsOver50Ms,
gapsOver250Ms,
spawnCount: Object.values(spawns).reduce((sum, s) => sum + s.count, 0),
spawns
spawns,
rpcCount: Object.values(rpcs).reduce((sum, rpc) => sum + rpc.count, 0),
rpcs
}
windowMaxGapMs = 0
gapsOver50Ms = 0
+519 -410
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -23,7 +23,9 @@ vi.mock('../cli/wsl-cli-registration-registry', () => ({
recordWslCliRegistrationInstalled: recordInstalledMock,
recordWslCliRegistrationRemoved: recordRemovedMock
}))
vi.mock('../persistence', () => ({ getCanonicalUserDataPath: () => '/canonical-user-data' }))
vi.mock('../persistence-data-path', () => ({
getCanonicalUserDataPath: () => '/canonical-user-data'
}))
vi.mock('../startup/hydrate-shell-path', () => ({
hydrateShellPath: vi.fn(async () => ({ ok: false })),
mergePathSegments: vi.fn()
+1 -1
View File
@@ -7,7 +7,7 @@ import {
} from '../cli/wsl-cli-registration-registry'
import { WslCliInstaller } from '../cli/wsl-cli-installer'
import { runSerializedWslCliRegistrationOperation } from '../cli/wsl-cli-registration-operation'
import { getCanonicalUserDataPath } from '../persistence'
import { getCanonicalUserDataPath } from '../persistence-data-path'
import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path'
import { getDefaultWslDistro } from '../wsl'
@@ -0,0 +1,102 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { PairingOffer } from '../../shared/pairing'
import {
MAIN_THREAD_DIAGNOSTICS_ENV,
drainRemoteRpcRequestStats
} from '../diagnostics/main-thread-churn-probe'
const { oneShotRequest, dedicatedSubscription, cachedRequest, sharedRequest, sharedSubscription } =
vi.hoisted(() => ({
oneShotRequest: vi.fn(),
dedicatedSubscription: vi.fn(),
cachedRequest: vi.fn(),
sharedRequest: vi.fn(),
sharedSubscription: vi.fn()
}))
vi.mock('../../shared/remote-runtime-client', () => ({
sendRemoteRuntimeRequest: oneShotRequest,
subscribeRemoteRuntimeRequest: dedicatedSubscription
}))
vi.mock('../../shared/remote-runtime-request-connection', () => ({
RemoteRuntimeRequestConnection: class {
request = cachedRequest
close(): void {}
}
}))
vi.mock('../../shared/remote-runtime-shared-control-connection', () => ({
RemoteRuntimeSharedControlConnection: class {
request = sharedRequest
subscribe = sharedSubscription
close(): void {}
getDiagnostics(): null {
return null
}
reconnectNow(): void {}
retryNow(): boolean {
return false
}
}
}))
import {
closeAllRemoteRuntimeRequestConnections,
sendRemoteRuntimeConnectionRequest,
sendRemoteRuntimeOneShotRequest,
sendRemoteRuntimeSharedControlRequest,
subscribeRemoteRuntimeDedicatedRequest,
subscribeRemoteRuntimeSharedControlRequest
} from './runtime-environment-request-connections'
const pairing: PairingOffer = {
v: 2,
endpoint: 'ws://127.0.0.1:6768',
deviceToken: 'secret',
publicKeyB64: 'secret'
}
afterEach(() => {
closeAllRemoteRuntimeRequestConnections()
drainRemoteRpcRequestStats()
vi.unstubAllEnvs()
vi.resetAllMocks()
})
describe('runtime environment RPC diagnostics', () => {
it('counts each selected outbound transport once, including rejected requests', async () => {
vi.stubEnv(MAIN_THREAD_DIAGNOSTICS_ENV, '1')
oneShotRequest.mockResolvedValue({ ok: true })
cachedRequest.mockRejectedValue(new Error('cached failure'))
sharedRequest.mockResolvedValue({ ok: true })
dedicatedSubscription.mockResolvedValue({ requestId: 'dedicated' })
sharedSubscription.mockResolvedValue({ requestId: 'shared' })
await sendRemoteRuntimeOneShotRequest(pairing, 'status.get', undefined, 1_000)
await expect(
sendRemoteRuntimeConnectionRequest('environment-1', pairing, 'terminal.send', {}, 1_000)
).rejects.toThrow('cached failure')
await sendRemoteRuntimeSharedControlRequest('environment-1', pairing, 'git.status', {}, 1_000)
await subscribeRemoteRuntimeDedicatedRequest(pairing, 'terminal.multiplex', {}, 1_000, {
onResponse: vi.fn(),
onError: vi.fn()
})
await subscribeRemoteRuntimeSharedControlRequest(
'environment-1',
pairing,
'clientEvents.subscribe',
{},
1_000,
{ onResponse: vi.fn(), onError: vi.fn() }
)
expect(drainRemoteRpcRequestStats()).toEqual({
'status.get': { count: 1 },
'terminal.send': { count: 1 },
'git.status': { count: 1 },
'terminal.multiplex': { count: 1 },
'clientEvents.subscribe': { count: 1 }
})
})
})
@@ -1,11 +1,21 @@
import type { PairingOffer } from '../../shared/pairing'
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
import type {
RuntimeOrchestrationEnvelope,
RuntimeRpcResponse
} from '../../shared/runtime-rpc-envelope'
import {
sendRemoteRuntimeRequest,
subscribeRemoteRuntimeRequest,
type RemoteRuntimeSubscription,
type RemoteRuntimeSubscriptionCallbacks
} from '../../shared/remote-runtime-client'
import { RemoteRuntimeRequestConnection } from '../../shared/remote-runtime-request-connection'
import { RemoteRuntimeSharedControlConnection } from '../../shared/remote-runtime-shared-control-connection'
import type {
RemoteRuntimeSharedConnectionDiagnostics,
RemoteRuntimeSharedSubscription
} from '../../shared/remote-runtime-shared-control-types'
import { recordRemoteRpcRequest } from '../diagnostics/main-thread-churn-probe'
type CachedRuntimeConnection = {
pairingKey: string
@@ -20,6 +30,17 @@ type CachedSharedControlConnection = {
const requestConnections = new Map<string, CachedRuntimeConnection>()
const sharedControlConnections = new Map<string, CachedSharedControlConnection>()
export function sendRemoteRuntimeOneShotRequest<TResult>(
pairing: PairingOffer,
method: string,
params: unknown,
timeoutMs: number,
envelope?: RuntimeOrchestrationEnvelope
): Promise<RuntimeRpcResponse<TResult>> {
recordRemoteRpcRequest(method)
return sendRemoteRuntimeRequest(pairing, method, params, timeoutMs, envelope)
}
export function sendRemoteRuntimeConnectionRequest<TResult>(
environmentId: string,
pairing: PairingOffer,
@@ -37,6 +58,7 @@ export function sendRemoteRuntimeConnectionRequest<TResult>(
}
requestConnections.set(environmentId, cached)
}
recordRemoteRpcRequest(method)
return cached.connection.request(method, params, timeoutMs)
}
@@ -63,9 +85,21 @@ export function sendRemoteRuntimeSharedControlRequest<TResult>(
params: unknown,
timeoutMs: number
): Promise<RuntimeRpcResponse<TResult>> {
recordRemoteRpcRequest(method)
return getSharedControlConnection(environmentId, pairing).request(method, params, timeoutMs)
}
export function subscribeRemoteRuntimeDedicatedRequest<TResult>(
pairing: PairingOffer,
method: string,
params: unknown,
timeoutMs: number,
callbacks: RemoteRuntimeSubscriptionCallbacks<TResult>
): Promise<RemoteRuntimeSubscription> {
recordRemoteRpcRequest(method)
return subscribeRemoteRuntimeRequest(pairing, method, params, timeoutMs, callbacks)
}
export function subscribeRemoteRuntimeSharedControlRequest<TResult>(
environmentId: string,
pairing: PairingOffer,
@@ -79,6 +113,7 @@ export function subscribeRemoteRuntimeSharedControlRequest<TResult>(
onClose?: () => void
}
): Promise<RemoteRuntimeSharedSubscription> {
recordRemoteRpcRequest(method)
return getSharedControlConnection(environmentId, pairing).subscribe(
method,
params,
@@ -1,11 +1,11 @@
import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version'
import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client'
import { markEnvironmentUsed } from '../../shared/runtime-environment-store'
import type {
getPreferredPairingOffer,
KnownRuntimeEnvironment
} from '../../shared/runtime-environments'
import type { RuntimeStatus } from '../../shared/runtime-types'
import { sendRemoteRuntimeOneShotRequest } from './runtime-environment-request-connections'
const sharedControlSupport = new Map<string, { cacheKey: string; check: Promise<boolean> }>()
@@ -30,7 +30,7 @@ export async function supportsSharedControl(
}
let resolvedCacheKey = cacheKey
const check = (async () => {
const response = await sendRemoteRuntimeRequest<RuntimeStatus>(
const response = await sendRemoteRuntimeOneShotRequest<RuntimeStatus>(
pairing,
'status.get',
undefined,
@@ -5,17 +5,15 @@ import type {
RuntimeRpcResponse
} from '../../shared/runtime-rpc-envelope'
import type { RuntimeStatus } from '../../shared/runtime-types'
import {
sendRemoteRuntimeRequest,
subscribeRemoteRuntimeRequest,
type RemoteRuntimeSubscription
} from '../../shared/remote-runtime-client'
import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-client'
import { withRemoteRuntimeTailscaleHint } from '../../shared/remote-runtime-tailscale-hint'
import { enqueueRuntimeCall } from './runtime-environment-call-queue'
import {
reconnectRemoteRuntimeSharedControlConnection,
sendRemoteRuntimeConnectionRequest,
sendRemoteRuntimeOneShotRequest,
sendRemoteRuntimeSharedControlRequest,
subscribeRemoteRuntimeDedicatedRequest,
subscribeRemoteRuntimeSharedControlRequest
} from './runtime-environment-request-connections'
import { attachRemoteControlDiagnostics } from './runtime-environment-status-diagnostics'
@@ -40,7 +38,7 @@ export async function getRuntimeEnvironmentStatus(
const pairing = getPreferredPairingOffer(environment)
let response: RuntimeRpcResponse<RuntimeStatus>
try {
response = await sendRemoteRuntimeRequest<RuntimeStatus>(
response = await sendRemoteRuntimeOneShotRequest<RuntimeStatus>(
pairing,
'status.get',
undefined,
@@ -106,7 +104,7 @@ export async function callRuntimeEnvironment(
endpoint = pairing.endpoint
const effectiveTimeoutMs = timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS
if (envelope) {
const response = await sendRemoteRuntimeRequest(
const response = await sendRemoteRuntimeOneShotRequest(
pairing,
method,
params,
@@ -144,7 +142,12 @@ export async function callRuntimeEnvironment(
}
// Why: startup/control-plane RPCs use the proven one-shot path so repo
// hydration cannot be coupled to a stale terminal-control connection.
const response = await sendRemoteRuntimeRequest(pairing, method, params, effectiveTimeoutMs)
const response = await sendRemoteRuntimeOneShotRequest(
pairing,
method,
params,
effectiveTimeoutMs
)
markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response)
return response
})
@@ -221,7 +224,7 @@ export async function subscribeRuntimeEnvironment(
callbacksWithMarkUsed
)
}
return await subscribeRemoteRuntimeRequest(
return await subscribeRemoteRuntimeDedicatedRequest(
pairing,
method,
params,
@@ -56,8 +56,10 @@ vi.mock('../../shared/remote-runtime-client', () => ({
}))
vi.mock('./runtime-environment-request-connections', () => ({
sendRemoteRuntimeOneShotRequest: sendRemoteRuntimeRequestMock,
sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock,
sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock,
subscribeRemoteRuntimeDedicatedRequest: subscribeRemoteRuntimeRequestMock,
subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock,
getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock,
reconnectRemoteRuntimeSharedControlConnection: reconnectRemoteRuntimeSharedControlConnectionMock,
+3 -1
View File
@@ -9,7 +9,9 @@ const watchOptions: { onPrompt: () => void }[] = []
vi.mock('./codex-accounts/fs-utils', () => ({
writeFileAtomically: (...args: unknown[]) => writeFileAtomically(...args)
}))
vi.mock('./persistence', () => ({ getCanonicalUserDataPath: () => '/tmp/orca-tcc-notice-test' }))
vi.mock('./persistence-data-path', () => ({
getCanonicalUserDataPath: () => '/tmp/orca-tcc-notice-test'
}))
vi.mock('./macos-tcc-prompt-watch', () => ({
MacosTccPromptWatch: class {
constructor(options: { onPrompt: () => void }) {
+1 -1
View File
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import type { BrowserWindow } from 'electron'
import { writeFileAtomically } from './codex-accounts/fs-utils'
import { getCanonicalUserDataPath } from './persistence'
import { getCanonicalUserDataPath } from './persistence-data-path'
import { MacosTccPromptWatch } from './macos-tcc-prompt-watch'
/**
@@ -0,0 +1,36 @@
import { join } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
let userDataPath = ''
vi.mock('electron', () => ({
app: { getPath: () => userDataPath }
}))
describe('OpenCode usage file path', () => {
const earlyUserDataPath = join('early-root', 'profile')
const lateUserDataPath = join('late-root', 'profile')
beforeEach(() => {
userDataPath = earlyUserDataPath
vi.resetModules()
})
it('keeps the path captured before the Electron app name changes', async () => {
const { getOpenCodeUsageFilePath, initOpenCodeUsagePath } =
await import('./opencode-usage-file-path')
initOpenCodeUsagePath()
userDataPath = lateUserDataPath
expect(getOpenCodeUsageFilePath()).toBe(join(earlyUserDataPath, 'orca-opencode-usage.json'))
})
it('caches the fallback path when startup initialization was skipped', async () => {
const { getOpenCodeUsageFilePath } = await import('./opencode-usage-file-path')
expect(getOpenCodeUsageFilePath()).toBe(join(earlyUserDataPath, 'orca-opencode-usage.json'))
userDataPath = lateUserDataPath
expect(getOpenCodeUsageFilePath()).toBe(join(earlyUserDataPath, 'orca-opencode-usage.json'))
})
})
@@ -0,0 +1,16 @@
import { app } from 'electron'
import { join } from 'node:path'
let openCodeUsageFilePath: string | null = null
export function initOpenCodeUsagePath(): void {
// Why: app.setName can change userData casing, so capture the configured path before app readiness.
openCodeUsageFilePath = join(app.getPath('userData'), 'orca-opencode-usage.json')
}
export function getOpenCodeUsageFilePath(): string {
if (!openCodeUsageFilePath) {
openCodeUsageFilePath = join(app.getPath('userData'), 'orca-opencode-usage.json')
}
return openCodeUsageFilePath
}
+6 -17
View File
@@ -1,6 +1,5 @@
/* eslint-disable max-lines -- Why: this store owns OpenCode analytics persistence, scan policy, and renderer query semantics. Keeping range/scope queries next to scan persistence prevents UI totals from drifting from the SQLite projection. */
import { app } from 'electron'
import { dirname, join } from 'node:path'
import { dirname } from 'node:path'
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import type {
OpenCodeUsageBreakdownKind,
@@ -17,14 +16,15 @@ import type { Store } from '../persistence'
import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata'
import type { OpenCodeUsageDailyAggregate, OpenCodeUsagePersistedState } from './types'
import { createWorktreeRefs, scanOpenCodeUsageDatabases } from './scanner'
import { getOpenCodeUsageFilePath } from './opencode-usage-file-path'
export { initOpenCodeUsagePath } from './opencode-usage-file-path'
// Why: v2 adds per-database session ownership (stale sibling-copy dedupe).
// Older caches were built without it and can carry doubled sessions (#8006).
const SCHEMA_VERSION = 2
const STALE_MS = 5 * 60_000
let _openCodeUsageFile: string | null = null
function getDefaultState(): OpenCodeUsagePersistedState {
return {
schemaVersion: SCHEMA_VERSION,
@@ -59,17 +59,6 @@ export function normalizePersistedState(
}
}
export function initOpenCodeUsagePath(): void {
_openCodeUsageFile = join(app.getPath('userData'), 'orca-opencode-usage.json')
}
function getOpenCodeUsageFile(): string {
if (!_openCodeUsageFile) {
_openCodeUsageFile = join(app.getPath('userData'), 'orca-opencode-usage.json')
}
return _openCodeUsageFile
}
function getRangeCutoff(range: OpenCodeUsageRange): string | null {
if (range === 'all') {
return null
@@ -160,7 +149,7 @@ export class OpenCodeUsageStore {
private load(): OpenCodeUsagePersistedState {
try {
const usageFile = getOpenCodeUsageFile()
const usageFile = getOpenCodeUsageFilePath()
if (!existsSync(usageFile)) {
return getDefaultState()
}
@@ -180,7 +169,7 @@ export class OpenCodeUsageStore {
}
private writeToDisk(): void {
const usageFile = getOpenCodeUsageFile()
const usageFile = getOpenCodeUsageFilePath()
const dir = dirname(usageFile)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
+93
View File
@@ -0,0 +1,93 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as SecureFile from '../shared/secure-file'
import { E2EE_KEYPAIR_FILENAME, DEVICE_REGISTRY_FILENAME } from './runtime/mobile-pairing-files'
const pathMocks = vi.hoisted(() => ({
appState: { userData: '' },
hardenExistingSecureFile: vi.fn()
}))
vi.mock('electron', () => ({
app: { getPath: () => pathMocks.appState.userData },
safeStorage: {
isEncryptionAvailable: () => false,
encryptString: (plaintext: string) => Buffer.from(plaintext, 'utf8'),
decryptString: (ciphertext: Buffer) => ciphertext.toString('utf8')
}
}))
vi.mock('../shared/secure-file', async (importOriginal) => ({
...(await importOriginal<typeof SecureFile>()),
hardenExistingSecureFile: pathMocks.hardenExistingSecureFile
}))
describe('persistence data path state', () => {
let root: string
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'orca-persistence-path-'))
pathMocks.hardenExistingSecureFile.mockReset()
vi.resetModules()
})
afterEach(() => {
rmSync(root, { recursive: true, force: true })
vi.resetModules()
})
it('keeps the fallback data file and userData path in one captured state', async () => {
const fallbackPath = join(root, 'fallback-user-data')
const latePath = join(root, 'late-user-data')
pathMocks.appState.userData = fallbackPath
const paths = await import('./persistence-data-path')
expect(paths.getPersistenceDataFilePath()).toBe(join(fallbackPath, 'orca-data.json'))
pathMocks.appState.userData = latePath
expect(paths.getCanonicalUserDataPath()).toBe(fallbackPath)
expect(paths.getPersistenceDataFilePath()).toBe(join(fallbackPath, 'orca-data.json'))
paths.initDataPath()
expect(paths.getCanonicalUserDataPath()).toBe(latePath)
expect(paths.getPersistenceDataFilePath()).toBe(join(latePath, 'orca-data.json'))
})
it('shares the initialized state through persistence compatibility re-exports', async () => {
const canonicalPath = join(root, 'canonical-user-data')
pathMocks.appState.userData = canonicalPath
const paths = await import('./persistence-data-path')
const persistence = await import('./persistence')
expect(persistence.initDataPath).toBe(paths.initDataPath)
expect(persistence.getCanonicalUserDataPath).toBe(paths.getCanonicalUserDataPath)
persistence.initDataPath()
pathMocks.appState.userData = join(root, 'late-user-data')
expect(paths.getCanonicalUserDataPath()).toBe(canonicalPath)
expect(paths.getPersistenceDataFilePath()).toBe(join(canonicalPath, 'orca-data.json'))
})
it('migrates and hardens the complete mobile credential pair', async () => {
const sourcePath = join(root, 'late-user-data')
const canonicalPath = join(root, 'canonical-user-data')
mkdirSync(sourcePath, { recursive: true })
mkdirSync(canonicalPath, { recursive: true })
const devices = '{"devices":"source"}'
const keypair = '{"keypair":"source"}'
writeFileSync(join(sourcePath, DEVICE_REGISTRY_FILENAME), devices)
writeFileSync(join(sourcePath, E2EE_KEYPAIR_FILENAME), keypair)
pathMocks.appState.userData = canonicalPath
const paths = await import('./persistence-data-path')
paths.initDataPath()
paths.migrateMobilePairingDataToCanonicalUserDataPath(sourcePath)
const deviceTarget = join(canonicalPath, DEVICE_REGISTRY_FILENAME)
const keypairTarget = join(canonicalPath, E2EE_KEYPAIR_FILENAME)
expect(readFileSync(deviceTarget, 'utf8')).toBe(devices)
expect(readFileSync(keypairTarget, 'utf8')).toBe(keypair)
expect(pathMocks.hardenExistingSecureFile.mock.calls).toEqual([[deviceTarget], [keypairTarget]])
})
})
+52
View File
@@ -0,0 +1,52 @@
import { app } from 'electron'
import { copyFileSync, existsSync, mkdirSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { hardenExistingSecureFile } from '../shared/secure-file'
import { MOBILE_PAIRING_USERDATA_FILES } from './runtime/mobile-pairing-files'
let dataFile: string | null = null
let userDataDir: string | null = null
// Why: capture after dev redirection but before app.setName changes path casing on case-sensitive filesystems.
export function initDataPath(): void {
userDataDir = app.getPath('userData')
dataFile = join(userDataDir, 'orca-data.json')
}
export function getPersistenceDataFilePath(): string {
if (!dataFile) {
userDataDir = app.getPath('userData')
dataFile = join(userDataDir, 'orca-data.json')
}
return dataFile
}
export function getCanonicalUserDataPath(): string {
userDataDir ??= app.getPath('userData')
return userDataDir
}
export function migrateMobilePairingDataToCanonicalUserDataPath(sourceUserDataDir: string): void {
const targetUserDataDir = getCanonicalUserDataPath()
if (resolve(sourceUserDataDir) === resolve(targetUserDataDir)) {
return
}
const migrations = MOBILE_PAIRING_USERDATA_FILES.map((fileName) => ({
sourcePath: join(sourceUserDataDir, fileName),
targetPath: join(targetUserDataDir, fileName)
}))
if (
migrations.some(({ sourcePath }) => !existsSync(sourcePath)) ||
migrations.some(({ targetPath }) => existsSync(targetPath))
) {
return
}
mkdirSync(targetUserDataDir, { recursive: true })
for (const { sourcePath, targetPath } of migrations) {
copyFileSync(sourcePath, targetPath)
// Why: copied credentials must be current-user-only on every platform, including Windows ACLs.
hardenExistingSecureFile(targetPath)
}
}
+7 -67
View File
@@ -1,5 +1,5 @@
/* eslint-disable max-lines -- Why: persistence keeps schema defaults, migration, and load/save/flush in one file so the storage contract reviews as a unit. */
import { app, safeStorage } from 'electron'
import { safeStorage } from 'electron'
import {
readFileSync,
writeFileSync,
@@ -79,14 +79,18 @@ import {
buildWorkspaceRunContext
} from '../shared/task-source-context'
import type { MigrationUnsupportedPtyEntry } from '../shared/agent-status-types'
import { MOBILE_PAIRING_USERDATA_FILES } from './runtime/mobile-pairing-files'
import { normalizePersistedMobileClientTabSelections } from './runtime/client-session-tab-selection-persistence'
import { sanitizeWorkspaceSessionTerminalRetirements } from './runtime/mobile-session-terminal-persistence-retirement'
import {
removeRepoFromHostWorkspaceSessions,
removeRepoFromWorkspaceSession
} from './orca-profiles/profile-project-session-state'
import { hardenExistingSecureFile } from '../shared/secure-file'
import { getPersistenceDataFilePath as getDataFile } from './persistence-data-path'
export {
getCanonicalUserDataPath,
initDataPath,
migrateMobilePairingDataToCanonicalUserDataPath
} from './persistence-data-path'
import {
LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS,
type RemovedSshTargetTombstone,
@@ -329,27 +333,6 @@ function retireLegacyInstructionsForClearedTextActionRecipes(
return changed ? { ...sourceControlAi, instructionsByOperation } : sourceControlAi
}
// Why capture once (not a module const, not per-call): a const resolves before configureDevUserDataPath() redirects userData (dev/prod collide);
// per-call resolves after app.setName('Orca') flips path case and loses data on case-sensitive FS. index.ts calls initDataPath() at the right moment.
let _dataFile: string | null = null
let _userDataDir: string | null = null
export function initDataPath(): void {
const userDataDir = app.getPath('userData')
_userDataDir = userDataDir
_dataFile = join(userDataDir, 'orca-data.json')
}
function getDataFile(): string {
if (!_dataFile) {
// Safety fallback — should not be hit in normal startup.
const userDataDir = app.getPath('userData')
_userDataDir = userDataDir
_dataFile = join(userDataDir, 'orca-data.json')
}
return _dataFile
}
// Why a sidecar: githubCache refreshes every poll and would rewrite the whole multi-MB orca-data.json each cycle.
// Snapshotted best-effort at quit for instant badges next launch; safe to lose.
function getGithubCacheFile(dataFile = getDataFile()): string {
@@ -434,49 +417,6 @@ function readGithubCacheSnapshot(dataFile: string): PersistedState['githubCache'
return null
}
/**
* Return the userData directory captured at initDataPath() time, before app.setName() can change how app.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')
}
return _userDataDir
}
/**
* Copy legacy mobile pairing credentials into the canonical userData directory.
*
* Copies the registry and E2EE keypair forward as a pair so an update doesn't force a re-pair or mix devices with the wrong key.
*/
export function migrateMobilePairingDataToCanonicalUserDataPath(sourceUserDataDir: string): void {
const targetUserDataDir = getCanonicalUserDataPath()
if (resolve(sourceUserDataDir) === resolve(targetUserDataDir)) {
return
}
const migrations = MOBILE_PAIRING_USERDATA_FILES.map((fileName) => ({
sourcePath: join(sourceUserDataDir, fileName),
targetPath: join(targetUserDataDir, fileName)
}))
if (migrations.some(({ sourcePath }) => !existsSync(sourcePath))) {
return
}
if (migrations.some(({ targetPath }) => existsSync(targetPath))) {
return
}
mkdirSync(targetUserDataDir, { recursive: true })
for (const { sourcePath, targetPath } of migrations) {
copyFileSync(sourcePath, targetPath)
// Why: copyFileSync drops Windows ACLs, so re-assert current-user-only on these credential copies (device tokens, E2EE key).
hardenExistingSecureFile(targetPath)
}
}
// Why (issue #1158): keep 5 rolling backups at >=1h spacing so a corrupt/empty write leaves an earlier copy recoverable.
const BACKUP_COUNT = 5
const BACKUP_MIN_INTERVAL_MS = 60 * 60 * 1000
@@ -1,5 +1,5 @@
import type { SFTPWrapper } from 'ssh2'
import { uploadFile as uploadFileViaSftp } from '../ssh/sftp-upload'
import { loadSftpUploadCapability } from '../ssh/sftp-upload-capability'
import type { FileUploadSession } from './types'
export type SftpFactory = () => Promise<SFTPWrapper>
@@ -24,12 +24,13 @@ export async function openSshFileUploadSession(
if (!createSftp) {
throw new Error('Remote file upload is unavailable. Reconnect the SSH target and retry.')
}
const { uploadFile } = await loadSftpUploadCapability()
const sftp = await createSftp()
return {
// Why: one session covers the whole import so normal SSH keeps its prior
// channel count even when a directory contains many files.
uploadFile: (sourcePath, destinationPath, options) =>
uploadFileViaSftp(sftp, sourcePath, destinationPath, options),
uploadFile(sftp, sourcePath, destinationPath, options),
close: () => sftp.end()
}
}
@@ -0,0 +1,73 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
function createMockMux() {
return {
request: vi.fn(),
onNotification: vi.fn(() => () => {})
}
}
afterEach(() => {
vi.doUnmock('./ssh-filesystem-download')
vi.resetModules()
})
describe('SshFilesystemProvider download boundary', () => {
it('does not load the SFTP download capability for raw transfers', async () => {
let capabilityLoaded = false
vi.doMock('./ssh-filesystem-download', () => {
capabilityLoaded = true
return {
downloadFileViaSftp: vi.fn(),
downloadFolderViaSftp: vi.fn()
}
})
const { SshFilesystemProvider } = await import('./ssh-filesystem-provider')
const downloadFile = vi.fn().mockResolvedValue(undefined)
const provider = new SshFilesystemProvider('conn-1', createMockMux() as never, undefined, {
downloadFile
})
expect(capabilityLoaded).toBe(false)
await provider.downloadFile('/remote/archive.zip', '/local/archive.zip')
expect(capabilityLoaded).toBe(false)
expect(downloadFile).toHaveBeenCalledWith('/remote/archive.zip', '/local/archive.zip')
})
it('loads folder downloads on demand and preserves Windows path mapping', async () => {
const downloadFolderViaSftp = vi.fn().mockResolvedValue(undefined)
let capabilityLoads = 0
vi.doMock('./ssh-filesystem-download', () => {
capabilityLoads += 1
return {
downloadFileViaSftp: vi.fn(),
downloadFolderViaSftp
}
})
const { SshFilesystemProvider } = await import('./ssh-filesystem-provider')
const createSftp = vi.fn()
const provider = new SshFilesystemProvider(
'conn-1',
createMockMux() as never,
createSftp,
undefined,
getRemoteHostPlatform('win32-x64')
)
const controller = new AbortController()
expect(capabilityLoads).toBe(0)
await provider.downloadFolder!('C:\\remote\\src', 'C:\\local\\src', {
signal: controller.signal
})
expect(capabilityLoads).toBe(1)
expect(downloadFolderViaSftp).toHaveBeenCalledWith(
createSftp,
'C:\\remote\\src',
'C:\\local\\src',
{ signal: controller.signal, windowsRemotePaths: true }
)
})
})
@@ -1,5 +1,4 @@
import type { FileEntryWithStats, SFTPWrapper, Stats } from 'ssh2'
import type { FileStat } from './types'
const ABORTED_SFTP_OPERATION_GRACE_MS = 5_000
@@ -55,34 +54,6 @@ function waitForSftpCallback<T>(
})
}
export function fileStatFromSftpStats(stats: Stats): FileStat {
let type: FileStat['type'] = 'file'
if (stats.isDirectory()) {
type = 'directory'
} else if (stats.isSymbolicLink()) {
type = 'symlink'
}
const maybeNlink = (stats as Stats & { nlink?: unknown }).nlink
return {
size: stats.size,
type,
mtime: stats.mtime * 1000,
...(typeof maybeNlink === 'number' ? { nlink: maybeNlink } : {})
}
}
export function lstatViaSftp(sftp: SFTPWrapper, filePath: string): Promise<FileStat> {
return new Promise((resolve, reject) => {
sftp.lstat(filePath, (err, stats) => {
if (err) {
reject(err)
return
}
resolve(fileStatFromSftpStats(stats))
})
})
}
export function fastGetViaSftp(
sftp: SFTPWrapper,
sourcePath: string,
@@ -0,0 +1,101 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
function createMockMux() {
return {
request: vi.fn(),
onNotification: vi.fn(() => () => {})
}
}
afterEach(() => {
vi.doUnmock('../ssh/sftp-upload')
vi.resetModules()
})
describe('SshFilesystemProvider upload boundary', () => {
it('does not load the SFTP upload capability for raw transfers', async () => {
let capabilityLoaded = false
vi.doMock('../ssh/sftp-upload', () => {
capabilityLoaded = true
return {
uploadBuffer: vi.fn(),
uploadFile: vi.fn()
}
})
const { SshFilesystemProvider } = await import('./ssh-filesystem-provider')
const writeBuffer = vi.fn().mockResolvedValue(undefined)
const rawSession = { uploadFile: vi.fn(), close: vi.fn() }
const provider = new SshFilesystemProvider('conn-1', createMockMux() as never, undefined, {
writeBuffer,
openFileUploadSession: vi.fn().mockResolvedValue(rawSession)
})
await provider.writeFileBase64Chunk('/remote/image.png', 'cG5n', true)
await expect(provider.openFileUploadSession()).resolves.toBe(rawSession)
expect(capabilityLoaded).toBe(false)
expect(writeBuffer).toHaveBeenCalledWith('/remote/image.png', Buffer.from('png'), {
append: true,
exclusive: false
})
})
it('loads binary SFTP uploads on demand and preserves write flags', async () => {
const uploadBuffer = vi.fn().mockResolvedValue(undefined)
let capabilityLoads = 0
vi.doMock('../ssh/sftp-upload', () => {
capabilityLoads += 1
return {
uploadBuffer,
uploadFile: vi.fn()
}
})
const { SshFilesystemProvider } = await import('./ssh-filesystem-provider')
const sftp = { end: vi.fn() }
const provider = new SshFilesystemProvider(
'conn-1',
createMockMux() as never,
vi.fn().mockResolvedValue(sftp) as never
)
expect(capabilityLoads).toBe(0)
await provider.writeFileBase64Chunk('/remote/image.png', 'cG5n', true)
expect(capabilityLoads).toBe(1)
expect(uploadBuffer).toHaveBeenCalledWith(sftp, Buffer.from('png'), '/remote/image.png', {
append: true,
exclusive: false
})
expect(sftp.end).toHaveBeenCalledOnce()
})
it('loads file SFTP uploads when a session opens and keeps its session semantics', async () => {
const uploadFile = vi.fn().mockResolvedValue(undefined)
let capabilityLoads = 0
vi.doMock('../ssh/sftp-upload', () => {
capabilityLoads += 1
return {
uploadBuffer: vi.fn(),
uploadFile
}
})
const { SshFilesystemProvider } = await import('./ssh-filesystem-provider')
const sftp = { end: vi.fn() }
const provider = new SshFilesystemProvider(
'conn-1',
createMockMux() as never,
vi.fn().mockResolvedValue(sftp) as never
)
expect(capabilityLoads).toBe(0)
const session = await provider.openFileUploadSession()
await session.uploadFile('/local/image.png', '/remote/image.png', { exclusive: true })
session.close()
expect(capabilityLoads).toBe(1)
expect(uploadFile).toHaveBeenCalledWith(sftp, '/local/image.png', '/remote/image.png', {
exclusive: true
})
expect(sftp.end).toHaveBeenCalledOnce()
})
})
+12 -9
View File
@@ -1,13 +1,9 @@
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import { isMethodNotFoundError, readFileViaStream } from '../ssh/ssh-filesystem-stream-reader'
import { uploadBuffer } from '../ssh/sftp-upload'
import { lstatViaSftp } from './ssh-filesystem-provider-sftp'
import {
downloadFileViaSftp,
downloadFolderViaSftp,
type SftpFactory
} from './ssh-filesystem-download'
import { loadSftpUploadCapability } from '../ssh/sftp-upload-capability'
import type { SftpFactory } from './ssh-filesystem-download'
import { openSshFileUploadSession, type SshRawTransferOptions } from './ssh-filesystem-file-upload'
import { lstatViaSftp } from './ssh-filesystem-sftp-stat'
import {
closeSshFilesystemWatch,
registerSshFilesystemWatch,
@@ -52,11 +48,13 @@ export class SshFilesystemProvider implements IFilesystemProvider {
// omitting this method makes folder capability truthful at the provider boundary.
// windowsRemotePaths is provider-owned (from host platform), not a caller option.
const windowsRemotePaths = hostPlatform ? isWindowsRemoteHost(hostPlatform) : undefined
this.downloadFolder = (sourcePath, destinationPath, options) =>
downloadFolderViaSftp(createSftp, sourcePath, destinationPath, {
this.downloadFolder = async (sourcePath, destinationPath, options) => {
const { downloadFolderViaSftp } = await import('./ssh-filesystem-download')
await downloadFolderViaSftp(createSftp, sourcePath, destinationPath, {
...options,
windowsRemotePaths
})
}
}
this.unsubscribeNotifications = mux.onNotification((method, params) =>
@@ -136,6 +134,10 @@ export class SshFilesystemProvider implements IFilesystemProvider {
await this.rawTransfer.downloadFile(sourcePath, destinationPath)
return
}
if (!this.createSftp) {
throw new Error('Remote file download is unavailable. Reconnect the SSH target and retry.')
}
const { downloadFileViaSftp } = await import('./ssh-filesystem-download')
await downloadFileViaSftp(this.createSftp, sourcePath, destinationPath)
}
@@ -210,6 +212,7 @@ export class SshFilesystemProvider implements IFilesystemProvider {
try {
// Why: relay fs.writeFile is text-only. SFTP writes the decoded bytes
// directly so runtime uploads do not corrupt images, PDFs, or archives.
const { uploadBuffer } = await loadSftpUploadCapability()
await uploadBuffer(sftp, contents, filePath, {
append,
exclusive: !append
@@ -0,0 +1,30 @@
import type { SFTPWrapper, Stats } from 'ssh2'
import type { FileStat } from './types'
export function fileStatFromSftpStats(stats: Stats): FileStat {
let type: FileStat['type'] = 'file'
if (stats.isDirectory()) {
type = 'directory'
} else if (stats.isSymbolicLink()) {
type = 'symlink'
}
const maybeNlink = (stats as Stats & { nlink?: unknown }).nlink
return {
size: stats.size,
type,
mtime: stats.mtime * 1000,
...(typeof maybeNlink === 'number' ? { nlink: maybeNlink } : {})
}
}
export function lstatViaSftp(sftp: SFTPWrapper, filePath: string): Promise<FileStat> {
return new Promise((resolve, reject) => {
sftp.lstat(filePath, (err, stats) => {
if (err) {
reject(err)
return
}
resolve(fileStatFromSftpStats(stats))
})
})
}
+3 -1
View File
@@ -15,7 +15,9 @@ const { appMock, getCanonicalUserDataPathMock } = vi.hoisted(() => ({
}))
vi.mock('electron', () => ({ app: appMock }))
vi.mock('./persistence', () => ({ getCanonicalUserDataPath: getCanonicalUserDataPathMock }))
vi.mock('./persistence-data-path', () => ({
getCanonicalUserDataPath: getCanonicalUserDataPathMock
}))
describe('serve update handoff', () => {
let root: string
+1 -1
View File
@@ -8,7 +8,7 @@ import {
type ServeSupervisorMessage,
type ServeUpdateHandoffState
} from '../shared/serve-update-handoff'
import { getCanonicalUserDataPath } from './persistence'
import { getCanonicalUserDataPath } from './persistence-data-path'
function getConfiguredHandoffPath(): string | null {
const configuredPath = process.env[SERVE_UPDATE_HANDOFF_PATH_ENV]
+19
View File
@@ -0,0 +1,19 @@
import type {
mkdirSftp,
uploadBuffer,
uploadDirectory,
uploadFile,
writeStringViaSftp
} from './sftp-upload'
export type SftpUploadCapability = {
mkdirSftp: typeof mkdirSftp
uploadBuffer: typeof uploadBuffer
uploadDirectory: typeof uploadDirectory
uploadFile: typeof uploadFile
writeStringViaSftp: typeof writeStringViaSftp
}
export function loadSftpUploadCapability(): Promise<SftpUploadCapability> {
return import('./sftp-upload')
}
@@ -0,0 +1,119 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
MAIN_THREAD_DIAGNOSTICS_ENV,
drainRemoteRpcRequestStats
} from '../diagnostics/main-thread-churn-probe'
import { SshChannelMultiplexer, type MultiplexerTransport } from './ssh-channel-multiplexer'
import { encodeFrame, MessageType } from './relay-protocol'
function responseFrame(
id: number,
seq: number,
result?: unknown,
error?: { code: number; message: string }
): Buffer {
return encodeFrame(
MessageType.Regular,
seq,
0,
Buffer.from(JSON.stringify({ jsonrpc: '2.0', id, ...(error ? { error } : { result }) }))
)
}
function createTransport(): MultiplexerTransport & { receive: (data: Buffer) => void } {
let receive = (_data: Buffer): void => {}
return {
write: () => {},
onData: (callback) => {
receive = callback
},
onClose: () => {},
receive: (data) => receive(data)
}
}
describe('synthetic slow-SSH RPC benchmark', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.stubEnv(MAIN_THREAD_DIAGNOSTICS_ENV, '1')
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllEnvs()
vi.restoreAllMocks()
drainRemoteRpcRequestStats()
})
it('reports controlled latency and counts each outcome once', async () => {
const syntheticLatencyMs = 125
const transport = createTransport()
const mux = new SshChannelMultiplexer(transport)
const abortController = new AbortController()
const startedAt = Date.now()
const success = mux.request('git.status')
const remoteError = mux.request('git.history')
const cancellation = mux.request('git.diff', {}, { signal: abortController.signal })
const preAbortedController = new AbortController()
preAbortedController.abort()
const preAborted = mux.request('git.status', {}, { signal: preAbortedController.signal })
const outcomesPromise = Promise.allSettled([success, remoteError, cancellation, preAborted])
setTimeout(() => {
transport.receive(responseFrame(1, 1, { entries: [] }))
transport.receive(responseFrame(2, 2, undefined, { code: -32_000, message: 'synthetic' }))
abortController.abort()
}, syntheticLatencyMs)
await vi.advanceTimersByTimeAsync(syntheticLatencyMs)
const outcomes = await outcomesPromise
const rpcs = drainRemoteRpcRequestStats()
const report = {
mode: 'synthetic-ssh-rpc-latency',
latencyKind: 'controlled-in-memory-transport',
syntheticLatencyMs,
elapsedMs: Date.now() - startedAt,
rpcCount: Object.values(rpcs).reduce((sum, stats) => sum + stats.count, 0),
rpcs
}
console.info(`[synthetic-ssh-rpc] ${JSON.stringify(report)}`)
expect(outcomes.map((outcome) => outcome.status)).toEqual([
'fulfilled',
'rejected',
'rejected',
'rejected'
])
expect(report).toEqual({
mode: 'synthetic-ssh-rpc-latency',
latencyKind: 'controlled-in-memory-transport',
syntheticLatencyMs: 125,
elapsedMs: 125,
rpcCount: 3,
rpcs: {
'git.status': { count: 1 },
'git.history': { count: 1 },
'git.diff': { count: 1 }
}
})
mux.dispose()
})
it('counts a failed send attempt but not a disposed request', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
const transport = createTransport()
transport.write = () => {
throw new Error('synthetic write failure')
}
const mux = new SshChannelMultiplexer(transport)
await expect(mux.request('git.status')).rejects.toThrow('SSH connection lost')
expect(drainRemoteRpcRequestStats()).toEqual({ 'git.status': { count: 1 } })
mux.dispose()
await expect(mux.request('git.status')).rejects.toThrow('Multiplexer disposed')
expect(drainRemoteRpcRequestStats()).toEqual({})
})
})
+2
View File
@@ -14,6 +14,7 @@ import {
type JsonRpcResponse,
type JsonRpcNotification
} from './relay-protocol'
import { recordRemoteRpcRequest } from '../diagnostics/main-thread-churn-probe'
export type MultiplexerTransport = {
write: (data: Buffer) => void
@@ -216,6 +217,7 @@ export class SshChannelMultiplexer {
options.signal.addEventListener('abort', onAbort, { once: true })
}
this.pendingRequests.set(id, { resolve, reject, timer, cleanup })
recordRemoteRpcRequest(method)
this.sendMessage(msg)
})
}
+5 -4
View File
@@ -50,6 +50,7 @@ import {
createLinkedSshFileTransferSignal,
raceSftpFileTransferWithAbort
} from './ssh-file-transfer-abort'
import { loadSftpUploadCapability } from './sftp-upload-capability'
export type { SshConnectionCallbacks } from './ssh-connection-utils'
type SshRemoteFileOptions = {
@@ -433,7 +434,7 @@ export class SshConnection {
const transfer = (async (): Promise<void> => {
const targetDir = await resolveSftpTransferPathIfMapped(sftp, remoteDir, options)
linkedSignal.signal.throwIfAborted()
const { uploadDirectory } = await import('./ssh-relay-deploy-helpers')
const { uploadDirectory } = await loadSftpUploadCapability()
await uploadDirectory(sftp, localDir, targetDir)
})()
await raceSftpFileTransferWithAbort(transfer, linkedSignal.signal, (onClose) => {
@@ -480,7 +481,7 @@ export class SshConnection {
async openFileUploadSession(options?: SshRemoteFileOptions): Promise<FileUploadSession> {
if (!this.useSystemSshTransport) {
const sftp = await this.sftp()
const { uploadFile } = await import('./sftp-upload')
const { uploadFile } = await loadSftpUploadCapability()
return {
uploadFile: (localPath, remotePath, uploadOptions) =>
uploadFile(sftp, localPath, remotePath, uploadOptions),
@@ -531,7 +532,7 @@ export class SshConnection {
const write = (async (): Promise<void> => {
const targetPath = await resolveSftpTransferPathIfMapped(sftp, remotePath, options)
linkedSignal.signal.throwIfAborted()
const { writeStringViaSftp } = await import('./sftp-upload')
const { writeStringViaSftp } = await loadSftpUploadCapability()
await writeStringViaSftp(sftp, targetPath, contents)
})()
await raceSftpFileTransferWithAbort(write, linkedSignal.signal, (onClose) => {
@@ -561,7 +562,7 @@ export class SshConnection {
if (!this.useSystemSshTransport) {
const sftp = await this.sftp()
try {
const { uploadBuffer } = await import('./sftp-upload')
const { uploadBuffer } = await loadSftpUploadCapability()
await uploadBuffer(sftp, contents, remotePath, options)
} finally {
sftp.end()
+22 -1
View File
@@ -3,10 +3,31 @@ import { createSshOperationAbortError } from './ssh-connection-utils'
import { RELAY_SENTINEL, RELAY_SENTINEL_TIMEOUT_MS } from './relay-protocol'
import type { MultiplexerTransport } from './ssh-channel-multiplexer'
import { buildRelayVersionMismatchError } from './ssh-relay-handshake-mismatch'
import { loadSftpUploadCapability, type SftpUploadCapability } from './sftp-upload-capability'
export { uploadFile, uploadDirectory, mkdirSftp } from './sftp-upload'
export { execCommand, isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command'
export async function uploadFile(
...args: Parameters<SftpUploadCapability['uploadFile']>
): Promise<void> {
const { uploadFile: upload } = await loadSftpUploadCapability()
await upload(...args)
}
export async function uploadDirectory(
...args: Parameters<SftpUploadCapability['uploadDirectory']>
): Promise<void> {
const { uploadDirectory: upload } = await loadSftpUploadCapability()
await upload(...args)
}
export async function mkdirSftp(
...args: Parameters<SftpUploadCapability['mkdirSftp']>
): Promise<void> {
const { mkdirSftp: mkdir } = await loadSftpUploadCapability()
await mkdir(...args)
}
// ── Sentinel detection ────────────────────────────────────────────────
const MAX_RELAY_STARTUP_BUFFER_BYTES = 64 * 1024
@@ -0,0 +1,122 @@
import { EventEmitter } from 'node:events'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getRemoteHostPlatform } from './ssh-remote-platform'
afterEach(() => {
vi.doUnmock('./sftp-upload')
vi.doUnmock('./sftp-namespace-resolution')
vi.resetModules()
})
function createSftp() {
const sftp = new EventEmitter() as EventEmitter & { end: ReturnType<typeof vi.fn> }
sftp.end = vi.fn(() => sftp.emit('close'))
return sftp
}
describe('relay install SFTP transfers', () => {
it('keeps system SSH transfers outside the SFTP upload capability', async () => {
let capabilityLoaded = false
vi.doMock('./sftp-upload', () => {
capabilityLoaded = true
return {
uploadDirectory: vi.fn(),
writeStringViaSftp: vi.fn()
}
})
const { uploadRelayDirectory, writeRelayFile } = await import('./ssh-relay-install-transfers')
const uploadDirectory = vi.fn().mockResolvedValue(undefined)
const writeFile = vi.fn().mockResolvedValue(undefined)
const conn = { uploadDirectory, writeFile }
const hostPlatform = getRemoteHostPlatform('linux-x64')
const signal = new AbortController().signal
await uploadRelayDirectory(conn as never, '/local/relay', '/shell/relay', hostPlatform, {
signal
})
await writeRelayFile(conn as never, hostPlatform, '/shell/relay/.version', '1.2.3', { signal })
expect(capabilityLoaded).toBe(false)
expect(uploadDirectory).toHaveBeenCalledWith('/local/relay', '/shell/relay', {
hostPlatform,
signal,
sftpNamespace: undefined
})
expect(writeFile).toHaveBeenCalledWith('/shell/relay/.version', '1.2.3', {
hostPlatform,
signal,
sftpNamespace: undefined
})
})
it('loads fallback transfers on demand after resolving the SFTP namespace', async () => {
const uploadDirectory = vi.fn().mockResolvedValue(undefined)
const writeStringViaSftp = vi.fn().mockResolvedValue(undefined)
let capabilityLoads = 0
vi.doMock('./sftp-upload', () => {
capabilityLoads += 1
return { uploadDirectory, writeStringViaSftp }
})
const resolveSftpTransferPathIfMapped = vi
.fn()
.mockResolvedValueOnce('/mapped/relay')
.mockResolvedValueOnce('/mapped/relay/.version')
vi.doMock('./sftp-namespace-resolution', () => ({
resolveSftpTransferPathIfMapped
}))
const { uploadRelayDirectory, writeRelayFile } = await import('./ssh-relay-install-transfers')
const firstSftp = createSftp()
const secondSftp = createSftp()
const conn = {
sftp: vi.fn().mockResolvedValueOnce(firstSftp).mockResolvedValueOnce(secondSftp)
}
const hostPlatform = getRemoteHostPlatform('linux-x64')
expect(capabilityLoads).toBe(0)
await uploadRelayDirectory(conn as never, '/local/relay', '/shell/relay', hostPlatform)
await writeRelayFile(conn as never, hostPlatform, '/shell/relay/.version', '1.2.3')
expect(capabilityLoads).toBe(1)
expect(uploadDirectory).toHaveBeenCalledWith(firstSftp, '/local/relay', '/mapped/relay')
expect(writeStringViaSftp).toHaveBeenCalledWith(secondSftp, '/mapped/relay/.version', '1.2.3')
expect(firstSftp.end).toHaveBeenCalledOnce()
expect(secondSftp.end).toHaveBeenCalledOnce()
})
it('closes the fallback SFTP session when an upload is aborted', async () => {
let startUpload: (() => void) | undefined
const uploadStarted = new Promise<void>((resolve) => {
startUpload = resolve
})
vi.doMock('./sftp-upload', () => ({
uploadDirectory: vi.fn(() => {
startUpload?.()
return new Promise<void>(() => {})
}),
writeStringViaSftp: vi.fn()
}))
vi.doMock('./sftp-namespace-resolution', () => ({
resolveSftpTransferPathIfMapped: vi.fn().mockResolvedValue('/mapped/relay')
}))
const { uploadRelayDirectory } = await import('./ssh-relay-install-transfers')
const sftp = createSftp()
const conn = { sftp: vi.fn().mockResolvedValue(sftp) }
const controller = new AbortController()
const transfer = uploadRelayDirectory(
conn as never,
'/local/relay',
'/shell/relay',
getRemoteHostPlatform('linux-x64'),
{ signal: controller.signal }
)
await uploadStarted
controller.abort()
await expect(transfer).rejects.toMatchObject({
name: 'AbortError',
sshChannelCloseConfirmed: true
})
expect(sftp.end).toHaveBeenCalledOnce()
})
})
+2 -1
View File
@@ -5,7 +5,7 @@
import type { SFTPWrapper } from 'ssh2'
import type { SshConnection } from './ssh-connection'
import { writeStringViaSftp } from './sftp-upload'
import { loadSftpUploadCapability } from './sftp-upload-capability'
import { uploadDirectory } from './ssh-relay-deploy-helpers'
import { raceSftpFileTransferWithAbort } from './ssh-file-transfer-abort'
import {
@@ -65,6 +65,7 @@ export async function writeRelayFile(
sftpNamespace: options?.sftpNamespace
})
options?.signal?.throwIfAborted()
const { writeStringViaSftp } = await loadSftpUploadCapability()
await writeStringViaSftp(sftp, targetPath, contents)
})
}
@@ -8,7 +8,7 @@ vi.mock('electron', () => ({
getAppPath: () => '/host/app'
}
}))
vi.mock('../persistence', () => ({
vi.mock('../persistence-data-path', () => ({
getCanonicalUserDataPath: () => '/host/user-data'
}))
@@ -8,7 +8,7 @@ import { app } from 'electron'
import { spawn as nodeSpawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { getCanonicalUserDataPath } from '../persistence'
import { getCanonicalUserDataPath } from '../persistence-data-path'
import { parseRemoteCliArgs } from './ssh-remote-cli-args'
import { clampOrchestrationAskTimeoutMs } from '../../shared/orchestration-ask-timeout'
import {
+1 -1
View File
@@ -7,7 +7,7 @@ vi.mock('electron', () => ({
getAppPath: () => '/host/app'
}
}))
vi.mock('../persistence', () => ({
vi.mock('../persistence-data-path', () => ({
getCanonicalUserDataPath: () => '/host/user-data'
}))
@@ -0,0 +1,152 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import ts from 'typescript-api'
import { describe, expect, it } from 'vitest'
const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8')
const sourceFile = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false)
const capabilityModule = './startup/account-runtime-coordination-startup-capability'
const capabilityImport = `await import('${capabilityModule}')`
function findImport(moduleSpecifier: string): ts.ImportDeclaration | undefined {
return sourceFile.statements
.filter(ts.isImportDeclaration)
.find(
(declaration) => (declaration.moduleSpecifier as ts.StringLiteral).text === moduleSpecifier
)
}
describe('account-runtime coordination startup boundary', () => {
it('removes every candidate eager index value behind one owner', () => {
for (const moduleSpecifier of [
'./minimax/minimax-cookie-store',
'./rate-limits/claude-rate-limit-target',
'./rate-limits/codex-rate-limit-target',
'./rate-limits/account-runtime-target-sync',
'./claude-accounts/runtime-selection',
'./claude-accounts/live-pty-gate'
]) {
expect(findImport(moduleSpecifier)).toBeUndefined()
}
expect(findImport('./startup/account-runtime-coordination-startup-owner')).toBeDefined()
expect(findImport(capabilityModule)).toBeUndefined()
expect(source.split(capabilityImport)).toHaveLength(2)
})
it('installs after crash/hang and before its first consumer or Store construction', () => {
const readyIndex = source.indexOf('void app.whenReady().then(async () => {')
const crashInstallIndex = source.indexOf(
'installCrashHangRuntimeStartupCapability(crashHangRuntime)',
readyIndex
)
const importIndex = source.indexOf(capabilityImport, crashInstallIndex)
const factoryIndex = source.indexOf(
'const accountRuntimeCoordination = createAccountRuntimeCoordinationStartupCapability()',
importIndex
)
const installIndex = source.indexOf(
'installAccountRuntimeCoordinationStartupCapability(accountRuntimeCoordination)',
factoryIndex
)
const crashStoreIndex = source.indexOf(
'crashReports = crashHangRuntime.CrashReportStore.fromUserData()',
installIndex
)
const storeIndex = source.indexOf('store = createStoreStartupCapability(', crashStoreIndex)
expect(importIndex).toBeGreaterThan(crashInstallIndex)
expect(factoryIndex).toBeGreaterThan(importIndex)
expect(installIndex).toBeGreaterThan(factoryIndex)
expect(crashStoreIndex).toBeGreaterThan(installIndex)
expect(storeIndex).toBeGreaterThan(crashStoreIndex)
})
it('preserves live-PTY hydration and account target ordering', () => {
const storeIndex = source.indexOf('store = createStoreStartupCapability(')
const attachIndex = source.indexOf(
'accountRuntimeCoordination.attachClaudeLivePtyPersistence(store)',
storeIndex
)
const drainIndex = source.indexOf(
'accountRuntimeCoordination.onLiveClaudePtysDrained(() => {',
attachIndex
)
const persistedIndex = source.indexOf(
'const persistedClaudePtyIds = store.getClaudeLivePtySessionIds()',
drainIndex
)
const seedIndex = source.indexOf(
'accountRuntimeCoordination.seedLiveClaudePtysFromPersistence(persistedClaudePtyIds)',
persistedIndex
)
const accountServicesIndex = source.indexOf(
'const accountServices = createAccountServicesStartupCapability(',
seedIndex
)
const codexTargetIndex = source.indexOf(
'accountRuntimeCoordination.getInitialCodexRateLimitTarget(store.getSettings())',
accountServicesIndex
)
const claudeTargetIndex = source.indexOf(
'accountRuntimeCoordination.getInitialClaudeRateLimitTarget(store.getSettings())',
codexTargetIndex
)
const syncIndex = source.indexOf(
'accountRuntimeCoordination.createAccountRuntimeTargetSettingsSync(',
claudeTargetIndex
)
expect(attachIndex).toBeGreaterThan(storeIndex)
expect(drainIndex).toBeGreaterThan(attachIndex)
expect(persistedIndex).toBeGreaterThan(drainIndex)
expect(seedIndex).toBeGreaterThan(persistedIndex)
expect(accountServicesIndex).toBeGreaterThan(seedIndex)
expect(codexTargetIndex).toBeGreaterThan(accountServicesIndex)
expect(claudeTargetIndex).toBeGreaterThan(codexTargetIndex)
expect(syncIndex).toBeGreaterThan(claudeTargetIndex)
})
it('preserves deferred MiniMax and inactive Claude resolver identities', () => {
const miniMaxResolverIndex = source.indexOf('rateLimits.setMiniMaxConfigResolver(() => {')
const inactiveResolverIndex = source.indexOf(
'rateLimits.setInactiveClaudeAccountsResolver(() => {',
miniMaxResolverIndex
)
const inactiveResolverEnd = source.indexOf(
'rateLimits.setInactiveCodexAccountsResolver(() => {',
inactiveResolverIndex
)
const inactiveResolver = source.slice(inactiveResolverIndex, inactiveResolverEnd)
expect(source.slice(miniMaxResolverIndex, inactiveResolverIndex)).toContain(
'getAccountRuntimeCoordinationStartupCapability().readMiniMaxSessionCookie()'
)
expect(
inactiveResolver.match(
/getAccountRuntimeCoordinationStartupCapability\(\)\.normalizeClaudeRuntimeSelection/g
)
).toHaveLength(2)
})
it('keeps unrelated path, WSL, and restart-preservation leaves eager', () => {
expect(findImport('../shared/cross-platform-path')).toBeDefined()
expect(findImport('./wsl')).toBeDefined()
expect(findImport('./agent-auth-restart-preservation')).toBeDefined()
const readyIndex = source.indexOf('void app.whenReady().then(async () => {')
const preReadySource = source.slice(0, readyIndex)
for (const value of [
'readMiniMaxSessionCookie(',
'getInitialClaudeRateLimitTarget(',
'getInitialCodexRateLimitTarget(',
'createAccountRuntimeTargetSettingsSync(',
'normalizeClaudeRuntimeSelection(',
'attachClaudeLivePtyPersistence(',
'onLiveClaudePtysDrained(',
'seedLiveClaudePtysFromPersistence('
]) {
expect(preReadySource).not.toContain(value)
}
})
})
@@ -0,0 +1,41 @@
import { describe, expect, it, vi } from 'vitest'
const accountRuntimeMocks = vi.hoisted(() => ({
attachClaudeLivePtyPersistence: vi.fn(),
createAccountRuntimeTargetSettingsSync: vi.fn(),
getInitialClaudeRateLimitTarget: vi.fn(),
getInitialCodexRateLimitTarget: vi.fn(),
normalizeClaudeRuntimeSelection: vi.fn(),
onLiveClaudePtysDrained: vi.fn(),
readMiniMaxSessionCookie: vi.fn(),
seedLiveClaudePtysFromPersistence: vi.fn()
}))
vi.mock('../claude-accounts/live-pty-gate', () => ({
attachClaudeLivePtyPersistence: accountRuntimeMocks.attachClaudeLivePtyPersistence,
onLiveClaudePtysDrained: accountRuntimeMocks.onLiveClaudePtysDrained,
seedLiveClaudePtysFromPersistence: accountRuntimeMocks.seedLiveClaudePtysFromPersistence
}))
vi.mock('../claude-accounts/runtime-selection', () => ({
normalizeClaudeRuntimeSelection: accountRuntimeMocks.normalizeClaudeRuntimeSelection
}))
vi.mock('../minimax/minimax-cookie-store', () => ({
readMiniMaxSessionCookie: accountRuntimeMocks.readMiniMaxSessionCookie
}))
vi.mock('../rate-limits/account-runtime-target-sync', () => ({
createAccountRuntimeTargetSettingsSync: accountRuntimeMocks.createAccountRuntimeTargetSettingsSync
}))
vi.mock('../rate-limits/claude-rate-limit-target', () => ({
getInitialClaudeRateLimitTarget: accountRuntimeMocks.getInitialClaudeRateLimitTarget
}))
vi.mock('../rate-limits/codex-rate-limit-target', () => ({
getInitialCodexRateLimitTarget: accountRuntimeMocks.getInitialCodexRateLimitTarget
}))
import { createAccountRuntimeCoordinationStartupCapability } from './account-runtime-coordination-startup-capability'
describe('account-runtime coordination startup capability', () => {
it('returns every original function identity', () => {
expect(createAccountRuntimeCoordinationStartupCapability()).toEqual(accountRuntimeMocks)
})
})
@@ -0,0 +1,34 @@
import {
attachClaudeLivePtyPersistence,
onLiveClaudePtysDrained,
seedLiveClaudePtysFromPersistence
} from '../claude-accounts/live-pty-gate'
import { normalizeClaudeRuntimeSelection } from '../claude-accounts/runtime-selection'
import { readMiniMaxSessionCookie } from '../minimax/minimax-cookie-store'
import { createAccountRuntimeTargetSettingsSync } from '../rate-limits/account-runtime-target-sync'
import { getInitialClaudeRateLimitTarget } from '../rate-limits/claude-rate-limit-target'
import { getInitialCodexRateLimitTarget } from '../rate-limits/codex-rate-limit-target'
export type AccountRuntimeCoordinationStartupCapability = {
attachClaudeLivePtyPersistence: typeof attachClaudeLivePtyPersistence
createAccountRuntimeTargetSettingsSync: typeof createAccountRuntimeTargetSettingsSync
getInitialClaudeRateLimitTarget: typeof getInitialClaudeRateLimitTarget
getInitialCodexRateLimitTarget: typeof getInitialCodexRateLimitTarget
normalizeClaudeRuntimeSelection: typeof normalizeClaudeRuntimeSelection
onLiveClaudePtysDrained: typeof onLiveClaudePtysDrained
readMiniMaxSessionCookie: typeof readMiniMaxSessionCookie
seedLiveClaudePtysFromPersistence: typeof seedLiveClaudePtysFromPersistence
}
export function createAccountRuntimeCoordinationStartupCapability(): AccountRuntimeCoordinationStartupCapability {
return {
attachClaudeLivePtyPersistence,
createAccountRuntimeTargetSettingsSync,
getInitialClaudeRateLimitTarget,
getInitialCodexRateLimitTarget,
normalizeClaudeRuntimeSelection,
onLiveClaudePtysDrained,
readMiniMaxSessionCookie,
seedLiveClaudePtysFromPersistence
}
}
@@ -0,0 +1,37 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
describe('account-runtime coordination startup owner', () => {
beforeEach(() => {
vi.resetModules()
})
it('fails closed before installation', async () => {
const { getAccountRuntimeCoordinationStartupCapability } =
await import('./account-runtime-coordination-startup-owner')
expect(() => getAccountRuntimeCoordinationStartupCapability()).toThrow(
'Account-runtime coordination capability must be initialized before use'
)
})
it('returns the exact installed capability identity', async () => {
const {
getAccountRuntimeCoordinationStartupCapability,
installAccountRuntimeCoordinationStartupCapability
} = await import('./account-runtime-coordination-startup-owner')
const capability = {
attachClaudeLivePtyPersistence: vi.fn(),
createAccountRuntimeTargetSettingsSync: vi.fn(),
getInitialClaudeRateLimitTarget: vi.fn(),
getInitialCodexRateLimitTarget: vi.fn(),
normalizeClaudeRuntimeSelection: vi.fn(),
onLiveClaudePtysDrained: vi.fn(),
readMiniMaxSessionCookie: vi.fn(),
seedLiveClaudePtysFromPersistence: vi.fn()
}
installAccountRuntimeCoordinationStartupCapability(capability as never)
expect(getAccountRuntimeCoordinationStartupCapability()).toBe(capability)
})
})
@@ -0,0 +1,16 @@
import type { AccountRuntimeCoordinationStartupCapability } from './account-runtime-coordination-startup-capability'
let capability: AccountRuntimeCoordinationStartupCapability | null = null
export function installAccountRuntimeCoordinationStartupCapability(
nextCapability: AccountRuntimeCoordinationStartupCapability
): void {
capability = nextCapability
}
export function getAccountRuntimeCoordinationStartupCapability(): AccountRuntimeCoordinationStartupCapability {
if (!capability) {
throw new Error('Account-runtime coordination capability must be initialized before use')
}
return capability
}
@@ -0,0 +1,148 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import ts from 'typescript-api'
import { describe, expect, it } from 'vitest'
const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8')
const sourceFile = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false)
const capabilityImport = "await import('./startup/account-services-startup-capability')"
const capabilityFactory = 'const accountServices = createAccountServicesStartupCapability(store, {'
function findImport(moduleSpecifier: string): ts.ImportDeclaration | undefined {
return sourceFile.statements
.filter(ts.isImportDeclaration)
.find(
(declaration) => (declaration.moduleSpecifier as ts.StringLiteral).text === moduleSpecifier
)
}
describe('account services startup boundary', () => {
it('keeps all five implementations out of the eager main graph', () => {
const serviceModules = [
'./rate-limits/service',
'./codex-accounts/runtime-home-service',
'./codex-accounts/service',
'./claude-accounts/runtime-auth-service',
'./claude-accounts/service'
]
for (const moduleSpecifier of serviceModules) {
expect(findImport(moduleSpecifier)?.importClause?.isTypeOnly).toBe(true)
}
expect(source).not.toMatch(
/new (RateLimitService|CodexRuntimeHomeService|CodexAccountService|ClaudeRuntimeAuthService|ClaudeAccountService)\(/
)
expect(source.split(capabilityImport)).toHaveLength(2)
expect(source.split(capabilityFactory)).toHaveLength(2)
})
it('keeps Codex configuration and migration policy in the composition root', () => {
const factoryIndex = source.indexOf(capabilityFactory)
const configureIndex = source.indexOf('configureCodexRuntimeHome: (runtimeHome) => {')
const laneGateIndex = source.indexOf('runtimeHome.setRealHomeLaneGate(', configureIndex)
const sweepIndex = source.indexOf('setSystemCodexHomeHookSweepSuppressed(', laneGateIndex)
const schedulerIndex = source.indexOf('createCodexSessionMigrationScheduler({', sweepIndex)
const lifecycleIndex = source.indexOf(
'onHostSystemDefaultSelected: codexSessionMigration.requestRun',
schedulerIndex
)
const initialRunIndex = source.indexOf(
'afterCodexAccountCreated: codexSessionMigration.scheduleInitialRun',
lifecycleIndex
)
expect(configureIndex).toBeGreaterThan(factoryIndex)
expect(laneGateIndex).toBeGreaterThan(configureIndex)
expect(sweepIndex).toBeGreaterThan(laneGateIndex)
expect(schedulerIndex).toBeGreaterThan(sweepIndex)
expect(lifecycleIndex).toBeGreaterThan(schedulerIndex)
expect(initialRunIndex).toBeGreaterThan(lifecycleIndex)
})
it('assigns every returned singleton before rate-limit and runtime wiring', () => {
const openCodeUsageIndex = source.indexOf(
'openCodeUsage = await createOpenCodeUsageStoreStartupCapability(store)'
)
const importIndex = source.indexOf(capabilityImport, openCodeUsageIndex)
const factoryIndex = source.indexOf(capabilityFactory)
const assignments = [
'rateLimits = accountServices.rateLimits',
'codexRuntimeHome = accountServices.codexRuntimeHome',
'codexAccounts = accountServices.codexAccounts',
'claudeRuntimeAuth = accountServices.claudeRuntimeAuth',
'claudeAccounts = accountServices.claudeAccounts'
]
let previousIndex = factoryIndex
for (const assignment of assignments) {
const assignmentIndex = source.indexOf(assignment, previousIndex)
expect(assignmentIndex).toBeGreaterThan(previousIndex)
previousIndex = assignmentIndex
}
const resolverIndex = source.indexOf('rateLimits.setCodexHomePathResolver(', previousIndex)
const runtimeIndex = source.indexOf(
'runtimeService.setAccountServices({ claudeAccounts, codexAccounts, rateLimits })',
resolverIndex
)
const initializedIndex = source.indexOf("logStartupMilestone('services-initialized')")
expect(importIndex).toBeGreaterThan(openCodeUsageIndex)
expect(factoryIndex).toBeGreaterThan(importIndex)
expect(resolverIndex).toBeGreaterThan(previousIndex)
expect(runtimeIndex).toBeGreaterThan(resolverIndex)
expect(initializedIndex).toBeGreaterThan(runtimeIndex)
})
it('keeps main-window guards, core IPC, PTY, and auth consumers on the same globals', () => {
const guardMessages = [
'Rate limit service must be initialized before opening the main window',
'Codex account service must be initialized before opening the main window',
'Codex runtime home service must be initialized before opening the main window',
'Claude account service must be initialized before opening the main window',
'Claude runtime auth service must be initialized before opening the main window'
]
for (const message of guardMessages) {
expect(source).toContain(message)
}
const coreHandlersIndex = source.indexOf('registerCoreHandlers(')
const coreCodexIndex = source.indexOf('codexAccounts,', coreHandlersIndex)
const coreClaudeIndex = source.indexOf('claudeAccounts,', coreCodexIndex)
const coreRateLimitsIndex = source.indexOf('rateLimits,', coreClaudeIndex)
expect(coreHandlersIndex).toBeGreaterThanOrEqual(0)
expect(coreCodexIndex).toBeGreaterThan(coreHandlersIndex)
expect(coreClaudeIndex).toBeGreaterThan(coreCodexIndex)
expect(coreRateLimitsIndex).toBeGreaterThan(coreClaudeIndex)
expect(source).toContain('prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch')
expect(source).toContain(
'prepareForClaudeLaunch: (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target)'
)
expect(source).toContain(
'await preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store })'
)
expect(source).toContain('rateLimits.attach(window)')
expect(source).toContain('rateLimits.start({ fetchImmediately: false })')
})
it('keeps live usage, inactive accounts, serve preparation, and quit behavior in index', () => {
const liveStatusIndex = source.indexOf('agentHookServer.setClaudeStatusLineListener(')
const inactiveClaudeIndex = source.indexOf(
'rateLimits.setInactiveClaudeAccountsResolver(',
liveStatusIndex
)
const inactiveCodexIndex = source.indexOf(
'rateLimits.setInactiveCodexAccountsResolver(',
inactiveClaudeIndex
)
const serveIndex = source.indexOf('if (serveOptions) {', inactiveCodexIndex)
const headlessPtyIndex = source.indexOf('registerHeadlessPtyRuntime(', serveIndex)
const stopIndex = source.indexOf('rateLimits?.stop()')
expect(liveStatusIndex).toBeGreaterThanOrEqual(0)
expect(inactiveClaudeIndex).toBeGreaterThan(liveStatusIndex)
expect(inactiveCodexIndex).toBeGreaterThan(inactiveClaudeIndex)
expect(headlessPtyIndex).toBeGreaterThan(serveIndex)
expect(stopIndex).toBeGreaterThan(headlessPtyIndex)
})
})
@@ -0,0 +1,129 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const serviceMocks = vi.hoisted(() => ({
events: [] as string[],
rateLimitConstructor: vi.fn(),
codexRuntimeHomeConstructor: vi.fn(),
codexAccountConstructor: vi.fn(),
claudeRuntimeAuthConstructor: vi.fn(),
claudeAccountConstructor: vi.fn()
}))
vi.mock('../rate-limits/service', () => ({
RateLimitService: class {
constructor() {
serviceMocks.events.push('rate-limits')
serviceMocks.rateLimitConstructor(this)
}
}
}))
vi.mock('../codex-accounts/runtime-home-service', () => ({
CodexRuntimeHomeService: class {
constructor(store: unknown) {
serviceMocks.events.push('codex-runtime-home')
serviceMocks.codexRuntimeHomeConstructor(this, store)
}
}
}))
vi.mock('../codex-accounts/service', () => ({
CodexAccountService: class {
constructor(store: unknown, rateLimits: unknown, runtimeHome: unknown, lifecycle: unknown) {
serviceMocks.events.push('codex-account')
serviceMocks.codexAccountConstructor(this, store, rateLimits, runtimeHome, lifecycle)
}
}
}))
vi.mock('../claude-accounts/runtime-auth-service', () => ({
ClaudeRuntimeAuthService: class {
constructor(store: unknown) {
serviceMocks.events.push('claude-runtime-auth')
serviceMocks.claudeRuntimeAuthConstructor(this, store)
}
}
}))
vi.mock('../claude-accounts/service', () => ({
ClaudeAccountService: class {
constructor(store: unknown, rateLimits: unknown, runtimeAuth: unknown) {
serviceMocks.events.push('claude-account')
serviceMocks.claudeAccountConstructor(this, store, rateLimits, runtimeAuth)
}
}
}))
import type { Store } from '../persistence'
import { createAccountServicesStartupCapability } from './account-services-startup-capability'
describe('account services startup capability', () => {
beforeEach(() => {
serviceMocks.events.length = 0
vi.clearAllMocks()
})
it('constructs, configures, and schedules in the exact startup order', () => {
const store = {} as Store
const lifecycle = { onHostSystemDefaultSelected: vi.fn() }
const afterCodexAccountCreated = vi.fn(() => {
serviceMocks.events.push('after-codex-account')
})
const configureCodexRuntimeHome = vi.fn((_runtimeHome: unknown) => {
serviceMocks.events.push('configure-codex-runtime-home')
return { codexAccountLifecycle: lifecycle, afterCodexAccountCreated }
})
const services = createAccountServicesStartupCapability(store, {
configureCodexRuntimeHome
})
expect(serviceMocks.events).toEqual([
'rate-limits',
'codex-runtime-home',
'configure-codex-runtime-home',
'codex-account',
'after-codex-account',
'claude-runtime-auth',
'claude-account'
])
expect(configureCodexRuntimeHome).toHaveBeenCalledOnce()
expect(configureCodexRuntimeHome).toHaveBeenCalledWith(services.codexRuntimeHome)
expect(afterCodexAccountCreated).toHaveBeenCalledOnce()
})
it('preserves Store and dependency identity across all five live services', () => {
const store = {} as Store
const lifecycle = { onHostSystemDefaultSelected: vi.fn() }
const services = createAccountServicesStartupCapability(store, {
configureCodexRuntimeHome: () => ({
codexAccountLifecycle: lifecycle,
afterCodexAccountCreated: vi.fn()
})
})
expect(serviceMocks.rateLimitConstructor).toHaveBeenCalledWith(services.rateLimits)
expect(serviceMocks.codexRuntimeHomeConstructor).toHaveBeenCalledWith(
services.codexRuntimeHome,
store
)
expect(serviceMocks.codexAccountConstructor).toHaveBeenCalledWith(
services.codexAccounts,
store,
services.rateLimits,
services.codexRuntimeHome,
lifecycle
)
expect(serviceMocks.claudeRuntimeAuthConstructor).toHaveBeenCalledWith(
services.claudeRuntimeAuth,
store
)
expect(serviceMocks.claudeAccountConstructor).toHaveBeenCalledWith(
services.claudeAccounts,
store,
services.rateLimits,
services.claudeRuntimeAuth
)
})
})
@@ -0,0 +1,51 @@
import { ClaudeAccountService } from '../claude-accounts/service'
import { ClaudeRuntimeAuthService } from '../claude-accounts/runtime-auth-service'
import { CodexAccountService, type CodexAccountServiceLifecycle } from '../codex-accounts/service'
import { CodexRuntimeHomeService } from '../codex-accounts/runtime-home-service'
import type { Store } from '../persistence'
import { RateLimitService } from '../rate-limits/service'
type CodexRuntimeHomeStartupConfiguration = {
codexAccountLifecycle: CodexAccountServiceLifecycle
afterCodexAccountCreated: () => void
}
type AccountServicesStartupOptions = {
configureCodexRuntimeHome: (
runtimeHome: CodexRuntimeHomeService
) => CodexRuntimeHomeStartupConfiguration
}
export type AccountServicesStartupCapability = {
rateLimits: RateLimitService
codexRuntimeHome: CodexRuntimeHomeService
codexAccounts: CodexAccountService
claudeRuntimeAuth: ClaudeRuntimeAuthService
claudeAccounts: ClaudeAccountService
}
export function createAccountServicesStartupCapability(
store: Store,
options: AccountServicesStartupOptions
): AccountServicesStartupCapability {
const rateLimits = new RateLimitService()
const codexRuntimeHome = new CodexRuntimeHomeService(store)
const codexConfiguration = options.configureCodexRuntimeHome(codexRuntimeHome)
const codexAccounts = new CodexAccountService(
store,
rateLimits,
codexRuntimeHome,
codexConfiguration.codexAccountLifecycle
)
codexConfiguration.afterCodexAccountCreated()
const claudeRuntimeAuth = new ClaudeRuntimeAuthService(store)
const claudeAccounts = new ClaudeAccountService(store, rateLimits, claudeRuntimeAuth)
return {
rateLimits,
codexRuntimeHome,
codexAccounts,
claudeRuntimeAuth,
claudeAccounts
}
}
@@ -0,0 +1,45 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import ts from 'typescript-api'
import { describe, expect, it } from 'vitest'
const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8')
const sourceFile = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false)
const capabilityImport = "await import('./startup/agent-awake-startup-capability')"
const capabilityFactory = 'agentAwakeService = await createAgentAwakeStartupCapability()'
describe('agent awake startup boundary', () => {
it('keeps the service implementation out of the eager main graph', () => {
const serviceImport = sourceFile.statements
.filter(ts.isImportDeclaration)
.find(
(declaration) =>
(declaration.moduleSpecifier as ts.StringLiteral).text === './agent-awake-service'
)
expect(serviceImport?.importClause?.isTypeOnly).toBe(true)
expect(source).not.toContain('new AgentAwakeService(')
expect(source.split(capabilityImport)).toHaveLength(2)
expect(source.split(capabilityFactory)).toHaveLength(2)
})
it('initializes the service before subscriptions and observable readiness', () => {
const importIndex = source.indexOf(capabilityImport)
const factoryIndex = source.indexOf(capabilityFactory, importIndex)
const settingsIndex = source.indexOf(
'agentAwakeService.setEnabled(store.getSettings().keepComputerAwakeWhileAgentsRun)',
factoryIndex
)
const subscriptionIndex = source.indexOf(
'agentHookServer.subscribeStatusChanges',
settingsIndex
)
const initializedIndex = source.indexOf("logStartupMilestone('services-initialized')")
expect(importIndex).toBeGreaterThanOrEqual(0)
expect(factoryIndex).toBeGreaterThan(importIndex)
expect(settingsIndex).toBeGreaterThan(factoryIndex)
expect(subscriptionIndex).toBeGreaterThan(settingsIndex)
expect(initializedIndex).toBeGreaterThan(subscriptionIndex)
})
})
@@ -0,0 +1,29 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({
powerMonitor: {
on: vi.fn(),
off: vi.fn()
},
powerSaveBlocker: {
start: vi.fn(),
stop: vi.fn(),
isStarted: vi.fn()
}
}))
import { AgentAwakeService } from '../agent-awake-service'
import { createAgentAwakeStartupCapability } from './agent-awake-startup-capability'
describe('agent awake startup capability', () => {
it('returns the live service after initializing runtime statuses as empty', async () => {
const setStatuses = vi.spyOn(AgentAwakeService.prototype, 'setStatuses')
const service = await createAgentAwakeStartupCapability()
expect(service).toBeInstanceOf(AgentAwakeService)
expect(setStatuses).toHaveBeenCalledOnce()
expect(setStatuses).toHaveBeenCalledWith([])
service.dispose()
})
})
@@ -0,0 +1,8 @@
import { AgentAwakeService } from '../agent-awake-service'
export async function createAgentAwakeStartupCapability(): Promise<AgentAwakeService> {
const service = new AgentAwakeService()
// Why: disk-hydrated rows are UI continuity; only this runtime's hook events prevent sleep.
service.setStatuses([])
return service
}
@@ -0,0 +1,33 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import ts from 'typescript-api'
import { describe, expect, it } from 'vitest'
const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8')
const sourceFile = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false)
const capabilityImport = "await import('./startup/agent-browser-startup-capability')"
describe('agent browser startup boundary', () => {
it('keeps the bridge implementation out of the eager main graph', () => {
const staticImports = sourceFile.statements
.filter(ts.isImportDeclaration)
.map((declaration) => (declaration.moduleSpecifier as ts.StringLiteral).text)
expect(staticImports).not.toContain('./browser/agent-browser-bridge')
expect(source).not.toContain('new AgentBrowserBridge(')
expect(source.split(capabilityImport)).toHaveLength(2)
})
it('attaches the bridge before services are observable', () => {
const importIndex = source.indexOf(capabilityImport)
const attachIndex = source.indexOf(
'attachAgentBrowserStartupCapability(runtimeService, browserManager)',
importIndex
)
const initializedIndex = source.indexOf("logStartupMilestone('services-initialized')")
expect(importIndex).toBeGreaterThanOrEqual(0)
expect(attachIndex).toBeGreaterThan(importIndex)
expect(initializedIndex).toBeGreaterThan(attachIndex)
})
})
@@ -0,0 +1,33 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({
app: {
getAppPath: vi.fn(() => '/project'),
getPath: vi.fn(() => '/app')
},
webContents: {
fromId: vi.fn(() => null)
}
}))
import type { BrowserManager } from '../browser/browser-manager'
import { attachAgentBrowserStartupCapability } from './agent-browser-startup-capability'
describe('agent browser startup capability', () => {
it('attaches one bridge and forwards tab changes to mobile sessions', () => {
const browserManager = {} as BrowserManager
const setAgentBrowserBridge = vi.fn()
const notifyMobileSessionTabsChanged = vi.fn()
const bridge = attachAgentBrowserStartupCapability(
{ notifyMobileSessionTabsChanged, setAgentBrowserBridge },
browserManager
)
bridge.setActiveTab(42, 'worktree-1')
expect(setAgentBrowserBridge).toHaveBeenCalledOnce()
expect(setAgentBrowserBridge).toHaveBeenCalledWith(bridge)
expect(notifyMobileSessionTabsChanged).toHaveBeenCalledOnce()
expect(notifyMobileSessionTabsChanged).toHaveBeenCalledWith('worktree-1')
})
})
@@ -0,0 +1,19 @@
import { AgentBrowserBridge } from '../browser/agent-browser-bridge'
import type { BrowserManager } from '../browser/browser-manager'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
type AgentBrowserStartupRuntime = Pick<
OrcaRuntimeService,
'notifyMobileSessionTabsChanged' | 'setAgentBrowserBridge'
>
export function attachAgentBrowserStartupCapability(
runtime: AgentBrowserStartupRuntime,
browserManager: BrowserManager
): AgentBrowserBridge {
const bridge = new AgentBrowserBridge(browserManager, {
onTabsChanged: (worktreeId) => runtime.notifyMobileSessionTabsChanged(worktreeId)
})
runtime.setAgentBrowserBridge(bridge)
return bridge
}
@@ -0,0 +1,126 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import ts from 'typescript-api'
import { describe, expect, it } from 'vitest'
const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8')
const sourceFile = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false)
const capabilityModule = './startup/agent-hook-runtime-startup-capability'
const capabilityImport = `await import('${capabilityModule}')`
function findImport(moduleSpecifier: string): ts.ImportDeclaration | undefined {
return sourceFile.statements
.filter(ts.isImportDeclaration)
.find(
(declaration) => (declaration.moduleSpecifier as ts.StringLiteral).text === moduleSpecifier
)
}
describe('agent-hook runtime startup boundary', () => {
it('removes every eager index value import behind one typed seam', () => {
for (const moduleSpecifier of [
'./agent-hooks/install-telemetry',
'./agent-hooks/managed-agent-hook-controls',
'./agent-hooks/server',
'./agent-hooks/hook-provider-session-invalidation',
'./agent-hooks/wsl-hook-relay-manager',
'./agent-hooks/first-work-branch-rename',
'./agent-hooks/branch-rename-failure-output',
'./agent-hooks/first-work-folder-rename',
'./agent-hooks/migration-unsupported-pty-state'
]) {
expect(findImport(moduleSpecifier)).toBeUndefined()
}
expect(findImport('./startup/agent-hook-runtime-startup-owner')).toBeDefined()
expect(findImport(capabilityModule)?.importClause?.isTypeOnly).toBe(true)
expect(source.split(capabilityImport)).toHaveLength(2)
})
it('installs after retained app-ready capabilities and before every consumer', () => {
const readyIndex = source.indexOf('void app.whenReady().then(async () => {')
const desktopInstallIndex = source.indexOf(
'installDesktopShellStartupCapability(desktopShell)',
readyIndex
)
const importIndex = source.indexOf(capabilityImport, desktopInstallIndex)
const factoryIndex = source.indexOf(
'const agentHooks = createAgentHookRuntimeStartupCapability()',
importIndex
)
const installIndex = source.indexOf(
'installAgentHookRuntimeStartupCapability(agentHooks)',
factoryIndex
)
const browserManagerIndex = source.indexOf(
'const browserManager = browserKernel.browserManager',
installIndex
)
const storeIndex = source.indexOf('store = createStoreStartupCapability(', browserManagerIndex)
const runtimeIndex = source.indexOf('createOrcaRuntimeServiceStartupCapability(', storeIndex)
const terminalStartIndex = source.indexOf('startTerminalRuntimeStartupServices()', runtimeIndex)
expect(importIndex).toBeGreaterThan(desktopInstallIndex)
expect(factoryIndex).toBeGreaterThan(importIndex)
expect(installIndex).toBeGreaterThan(factoryIndex)
expect(browserManagerIndex).toBeGreaterThan(installIndex)
expect(storeIndex).toBeGreaterThan(browserManagerIndex)
expect(runtimeIndex).toBeGreaterThan(storeIndex)
expect(terminalStartIndex).toBeGreaterThan(runtimeIndex)
})
it('preserves the hook-server start contract and runtime identity', () => {
const startupStart = source.indexOf('function startTerminalRuntimeStartupServices():')
const startupEnd = source.indexOf('\nfunction prepareCodexRuntimeHomeForLaunch', startupStart)
const startupBlock = source.slice(startupStart, startupEnd)
const runtimeStart = source.indexOf('createOrcaRuntimeServiceStartupCapability(')
const runtimeEnd = source.indexOf('\n runtime = runtimeService', runtimeStart)
const runtimeBlock = source.slice(runtimeStart, runtimeEnd)
expect(startupBlock).toContain('getAgentHookRuntimeStartupCapability()')
expect(startupBlock).toContain('if (!isAgentStatusHooksEnabled(store?.getSettings()))')
expect(startupBlock).toContain('await agentHookServer.start({')
expect(startupBlock).toContain("env: app.isPackaged ? 'production' : 'development'")
expect(startupBlock).toContain("userDataPath: app.getPath('userData')")
expect(startupBlock).toContain('endpointNamespace: devAgentHookEndpointNamespace')
expect(runtimeBlock).toContain('agentHooks.agentHookServer.ingestTerminalStatus(event)')
expect(runtimeBlock).toContain('agentHooks.agentHookServer.getStatusSnapshot()')
expect(runtimeBlock).toContain('agentHooks.agentHookServer.buildPtyEnv()')
})
it('preserves first-work, migration, managed-install, and listener ordering', () => {
const renameStart = source.indexOf('function maybeAutoRenameBranchOnFirstWorkFromHook(')
const renameEnd = source.indexOf('\nconst devInstanceIdentity', renameStart)
const renameBlock = source.slice(renameStart, renameEnd)
const windowStart = source.indexOf('function openMainWindow(): BrowserWindow')
const windowEnd = source.indexOf('\nfunction sendOpenFeatureTour', windowStart)
const windowBlock = source.slice(windowStart, windowEnd)
const managedIndex = source.indexOf('if (shouldInstallManagedHooks(is.dev))')
const childProcessIndex = source.indexOf("app.on('child-process-gone'", managedIndex)
expect(renameBlock).toContain('maybeAutoRenameBranchOnFirstWork(')
expect(renameBlock).toContain('rememberBranchRenameFailureOutput(worktreeId, null)')
expect(renameBlock).toContain('renameWorktreeFolderOnFirstWork(worktreeId, newLeaf, {')
expect(renameBlock).toContain('moveWorktree')
expect(windowBlock).toContain('agentHookServer.setListener(')
expect(windowBlock).toContain('agentHookServer.setPaneStatusClearListener(')
expect(windowBlock).toContain('setMigrationUnsupportedPtyListener(')
expect(source.slice(managedIndex, childProcessIndex)).toContain(
'agentHooks.runManagedHookInstallers(agentHooks.MANAGED_AGENT_HOOK_INSTALLERS)'
)
expect(source.slice(managedIndex, childProcessIndex)).toContain(
'agentHooks.removeManagedAgentHooks()'
)
})
it('keeps early quit safe and exact post-install teardown identities', () => {
const willQuitIndex = source.indexOf("app.on('will-quit', (e) => {")
const willQuitBlock = source.slice(willQuitIndex)
expect(willQuitBlock).toContain(
'const agentHooks = getAgentHookRuntimeStartupCapabilityIfInstalled()'
)
expect(willQuitBlock).toContain('agentHooks?.agentHookServer.stop()')
expect(willQuitBlock).toContain('agentHooks?.wslHookRelayManager.disposeAll()')
})
})
@@ -0,0 +1,53 @@
import { describe, expect, it, vi } from 'vitest'
const agentHookMocks = vi.hoisted(() => ({
agentHookServer: { start: vi.fn(), stop: vi.fn() },
createHookProviderSessionInvalidator: vi.fn(),
isAgentStatusHooksEnabled: vi.fn(),
MANAGED_AGENT_HOOK_INSTALLERS: [['codex', vi.fn()]] as const,
maybeAutoRenameBranchOnFirstWork: vi.fn(),
rememberBranchRenameFailureOutput: vi.fn(),
removeManagedAgentHooks: vi.fn(),
renameWorktreeFolderOnFirstWork: vi.fn(),
runManagedHookInstallers: vi.fn(),
setMigrationUnsupportedPtyListener: vi.fn(),
wslHookRelayManager: { disposeAll: vi.fn() }
}))
vi.mock('../agent-hooks/branch-rename-failure-output', () => ({
rememberBranchRenameFailureOutput: agentHookMocks.rememberBranchRenameFailureOutput
}))
vi.mock('../agent-hooks/first-work-branch-rename', () => ({
maybeAutoRenameBranchOnFirstWork: agentHookMocks.maybeAutoRenameBranchOnFirstWork
}))
vi.mock('../agent-hooks/first-work-folder-rename', () => ({
renameWorktreeFolderOnFirstWork: agentHookMocks.renameWorktreeFolderOnFirstWork
}))
vi.mock('../agent-hooks/hook-provider-session-invalidation', () => ({
createHookProviderSessionInvalidator: agentHookMocks.createHookProviderSessionInvalidator
}))
vi.mock('../agent-hooks/install-telemetry', () => ({
runManagedHookInstallers: agentHookMocks.runManagedHookInstallers
}))
vi.mock('../agent-hooks/managed-agent-hook-controls', () => ({
isAgentStatusHooksEnabled: agentHookMocks.isAgentStatusHooksEnabled,
MANAGED_AGENT_HOOK_INSTALLERS: agentHookMocks.MANAGED_AGENT_HOOK_INSTALLERS,
removeManagedAgentHooks: agentHookMocks.removeManagedAgentHooks
}))
vi.mock('../agent-hooks/migration-unsupported-pty-state', () => ({
setMigrationUnsupportedPtyListener: agentHookMocks.setMigrationUnsupportedPtyListener
}))
vi.mock('../agent-hooks/server', () => ({
agentHookServer: agentHookMocks.agentHookServer
}))
vi.mock('../agent-hooks/wsl-hook-relay-manager', () => ({
wslHookRelayManager: agentHookMocks.wslHookRelayManager
}))
import { createAgentHookRuntimeStartupCapability } from './agent-hook-runtime-startup-capability'
describe('agent-hook runtime startup capability', () => {
it('returns every original singleton, function, and constant identity', () => {
expect(createAgentHookRuntimeStartupCapability()).toEqual(agentHookMocks)
})
})
@@ -0,0 +1,45 @@
import { rememberBranchRenameFailureOutput } from '../agent-hooks/branch-rename-failure-output'
import { maybeAutoRenameBranchOnFirstWork } from '../agent-hooks/first-work-branch-rename'
import { renameWorktreeFolderOnFirstWork } from '../agent-hooks/first-work-folder-rename'
import { createHookProviderSessionInvalidator } from '../agent-hooks/hook-provider-session-invalidation'
import { runManagedHookInstallers } from '../agent-hooks/install-telemetry'
import {
isAgentStatusHooksEnabled,
MANAGED_AGENT_HOOK_INSTALLERS,
removeManagedAgentHooks
} from '../agent-hooks/managed-agent-hook-controls'
import { setMigrationUnsupportedPtyListener } from '../agent-hooks/migration-unsupported-pty-state'
import { agentHookServer } from '../agent-hooks/server'
import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager'
export type { AgentHookProviderSessionIdentity } from '../agent-hooks/server'
export type AgentHookRuntimeStartupCapability = {
agentHookServer: typeof agentHookServer
createHookProviderSessionInvalidator: typeof createHookProviderSessionInvalidator
isAgentStatusHooksEnabled: typeof isAgentStatusHooksEnabled
MANAGED_AGENT_HOOK_INSTALLERS: typeof MANAGED_AGENT_HOOK_INSTALLERS
maybeAutoRenameBranchOnFirstWork: typeof maybeAutoRenameBranchOnFirstWork
rememberBranchRenameFailureOutput: typeof rememberBranchRenameFailureOutput
removeManagedAgentHooks: typeof removeManagedAgentHooks
renameWorktreeFolderOnFirstWork: typeof renameWorktreeFolderOnFirstWork
runManagedHookInstallers: typeof runManagedHookInstallers
setMigrationUnsupportedPtyListener: typeof setMigrationUnsupportedPtyListener
wslHookRelayManager: typeof wslHookRelayManager
}
export function createAgentHookRuntimeStartupCapability(): AgentHookRuntimeStartupCapability {
return {
agentHookServer,
createHookProviderSessionInvalidator,
isAgentStatusHooksEnabled,
MANAGED_AGENT_HOOK_INSTALLERS,
maybeAutoRenameBranchOnFirstWork,
rememberBranchRenameFailureOutput,
removeManagedAgentHooks,
renameWorktreeFolderOnFirstWork,
runManagedHookInstallers,
setMigrationUnsupportedPtyListener,
wslHookRelayManager
}
}
@@ -0,0 +1,45 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
describe('agent-hook runtime startup owner', () => {
beforeEach(() => {
vi.resetModules()
})
it('fails closed before installation while optional teardown reads null', async () => {
const {
getAgentHookRuntimeStartupCapability,
getAgentHookRuntimeStartupCapabilityIfInstalled
} = await import('./agent-hook-runtime-startup-owner')
expect(getAgentHookRuntimeStartupCapabilityIfInstalled()).toBeNull()
expect(() => getAgentHookRuntimeStartupCapability()).toThrow(
'Agent-hook runtime capability must be initialized before use'
)
})
it('returns the exact installed capability identity', async () => {
const {
getAgentHookRuntimeStartupCapability,
getAgentHookRuntimeStartupCapabilityIfInstalled,
installAgentHookRuntimeStartupCapability
} = await import('./agent-hook-runtime-startup-owner')
const capability = {
agentHookServer: {},
createHookProviderSessionInvalidator: vi.fn(),
isAgentStatusHooksEnabled: vi.fn(),
MANAGED_AGENT_HOOK_INSTALLERS: [],
maybeAutoRenameBranchOnFirstWork: vi.fn(),
rememberBranchRenameFailureOutput: vi.fn(),
removeManagedAgentHooks: vi.fn(),
renameWorktreeFolderOnFirstWork: vi.fn(),
runManagedHookInstallers: vi.fn(),
setMigrationUnsupportedPtyListener: vi.fn(),
wslHookRelayManager: {}
}
installAgentHookRuntimeStartupCapability(capability as never)
expect(getAgentHookRuntimeStartupCapability()).toBe(capability)
expect(getAgentHookRuntimeStartupCapabilityIfInstalled()).toBe(capability)
})
})
@@ -0,0 +1,20 @@
import type { AgentHookRuntimeStartupCapability } from './agent-hook-runtime-startup-capability'
let capability: AgentHookRuntimeStartupCapability | null = null
export function installAgentHookRuntimeStartupCapability(
nextCapability: AgentHookRuntimeStartupCapability
): void {
capability = nextCapability
}
export function getAgentHookRuntimeStartupCapability(): AgentHookRuntimeStartupCapability {
if (!capability) {
throw new Error('Agent-hook runtime capability must be initialized before use')
}
return capability
}
export function getAgentHookRuntimeStartupCapabilityIfInstalled(): AgentHookRuntimeStartupCapability | null {
return capability
}
@@ -0,0 +1,70 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import ts from 'typescript-api'
import { describe, expect, it } from 'vitest'
const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8')
const sourceFile = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false)
const capabilityImport = "await import('./startup/automation-service-startup-capability')"
const capabilityFactory = 'automations = await createAutomationServiceStartupCapability(store, {'
describe('automation service startup boundary', () => {
it('keeps the service implementation out of the eager main graph', () => {
const serviceImport = sourceFile.statements
.filter(ts.isImportDeclaration)
.find(
(declaration) =>
(declaration.moduleSpecifier as ts.StringLiteral).text === './automations/service'
)
expect(serviceImport?.importClause?.isTypeOnly).toBe(true)
expect(source).not.toContain('new AutomationService(')
expect(source.split(capabilityImport)).toHaveLength(2)
expect(source.split(capabilityFactory)).toHaveLength(2)
})
it('attaches the live instance before readiness without moving startup consumers', () => {
const importIndex = source.indexOf(capabilityImport)
const factoryIndex = source.indexOf(capabilityFactory, importIndex)
const attachmentIndex = source.indexOf('runtimeService.setAutomationService(automations)')
const initializedIndex = source.indexOf("logStartupMilestone('services-initialized')")
expect(importIndex).toBeGreaterThanOrEqual(0)
expect(factoryIndex).toBeGreaterThan(importIndex)
expect(attachmentIndex).toBeGreaterThan(factoryIndex)
expect(initializedIndex).toBeGreaterThan(attachmentIndex)
const coreHandlersIndex = source.indexOf('registerCoreHandlers(')
const webContentsIndex = source.indexOf('automations.setWebContents(window.webContents)')
expect(webContentsIndex).toBeGreaterThan(coreHandlersIndex)
expect(source.indexOf('automations.start()', webContentsIndex)).toBeGreaterThan(
webContentsIndex
)
})
it('preserves scheduling inputs, dispatch routing, and committed lifetime', () => {
const factoryIndex = source.indexOf(capabilityFactory)
const attachmentIndex = source.indexOf('runtimeService.setAutomationService', factoryIndex)
const constructionSource = source.slice(factoryIndex, attachmentIndex)
expect(constructionSource).toContain('claudeUsage,')
expect(constructionSource).toContain('codexUsage,')
expect(constructionSource).toContain('allowRemoteHostScheduling: isServeMode')
expect(constructionSource).toContain('headlessDispatcher: isServeMode')
expect(constructionSource).toContain("automation.workspaceMode === 'new_per_run'")
expect(constructionSource).toContain('runtimeService.createManagedWorktree')
expect(constructionSource).toContain('runtimeService.launchAgentTerminal')
expect(constructionSource).toContain('runtimeService.waitForTerminal')
expect(constructionSource).toContain('runtimeService.readTerminal')
expect(constructionSource).toContain("status: 'completed' as const")
expect(constructionSource).toContain("status: 'dispatch_failed' as const")
expect(
source.indexOf('automations.start()', source.indexOf('if (serveOptions)'))
).toBeGreaterThan(
source.indexOf('await runtimeRpc.start()', source.indexOf('if (serveOptions)'))
)
expect(source.split('automations?.stop()')).toHaveLength(2)
expect(source.indexOf('automations?.stop()')).toBeGreaterThan(
source.indexOf("app.on('will-quit'")
)
})
})
@@ -0,0 +1,38 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const serviceMocks = vi.hoisted(() => ({
constructor: vi.fn()
}))
vi.mock('../automations/service', () => ({
AutomationService: class {
constructor(store: unknown, options: unknown) {
serviceMocks.constructor(store, options)
}
}
}))
import { AutomationService } from '../automations/service'
import { createAutomationServiceStartupCapability } from './automation-service-startup-capability'
describe('automation service startup capability', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns the constructed live service with the original inputs', async () => {
const store = { listAutomations: vi.fn() }
const options = {
claudeUsage: { getUsage: vi.fn() },
codexUsage: { getUsage: vi.fn() },
allowRemoteHostScheduling: true,
headlessDispatcher: vi.fn()
}
const service = await createAutomationServiceStartupCapability(store as never, options as never)
expect(serviceMocks.constructor).toHaveBeenCalledOnce()
expect(serviceMocks.constructor).toHaveBeenCalledWith(store, options)
expect(service).toBeInstanceOf(AutomationService)
})
})
@@ -0,0 +1,10 @@
import { AutomationService } from '../automations/service'
type AutomationServiceParameters = ConstructorParameters<typeof AutomationService>
export function createAutomationServiceStartupCapability(
store: AutomationServiceParameters[0],
options: NonNullable<AutomationServiceParameters[1]>
): AutomationService {
return new AutomationService(store, options)
}
@@ -0,0 +1,145 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import ts from 'typescript-api'
import { describe, expect, it } from 'vitest'
const projectRoot = process.cwd()
const source = readFileSync(join(projectRoot, 'src/main/index.ts'), 'utf8')
const sourceFile = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, false)
const createWindowSource = readFileSync(
join(projectRoot, 'src/main/window/createMainWindow.ts'),
'utf8'
)
const attachWindowSource = readFileSync(
join(projectRoot, 'src/main/window/attach-main-window-services.ts'),
'utf8'
)
const capabilityModule = './startup/browser-kernel-startup-capability'
const capabilityImport = `await import('${capabilityModule}')`
const capabilityFactory = 'const browserKernel = createBrowserKernelStartupCapability()'
function findImport(moduleSpecifier: string): ts.ImportDeclaration | undefined {
return sourceFile.statements
.filter(ts.isImportDeclaration)
.find(
(declaration) => (declaration.moduleSpecifier as ts.StringLiteral).text === moduleSpecifier
)
}
describe('browser kernel startup boundary', () => {
it('keeps index type-only and uses one dynamic aggregate capability', () => {
expect(findImport('./browser/browser-manager')?.importClause?.isTypeOnly).toBe(true)
expect(findImport('./browser/browser-session-startup')).toBeUndefined()
expect(source.split(capabilityImport)).toHaveLength(2)
expect(source.split(capabilityFactory)).toHaveLength(2)
})
it('removes browser kernel values from both eager window modules', () => {
for (const windowSource of [createWindowSource, attachWindowSource]) {
expect(windowSource).not.toContain("from '../browser/browser-manager'")
expect(windowSource).not.toContain("from '../browser/browser-session-registry'")
expect(windowSource).toContain("from '../browser/browser-kernel-window-dependencies'")
}
expect(createWindowSource).toContain('const { browserManager, isAllowedSessionPartition } =')
expect(attachWindowSource).toContain(
'const { browserManager } = getBrowserKernelWindowDependencies()'
)
})
it('loads at the earliest ready point before certificate registration and browser use', () => {
const readyIndex = source.indexOf('void app.whenReady().then(async () => {')
const milestoneIndex = source.indexOf("logStartupMilestone('app-ready')", readyIndex)
const importIndex = source.indexOf(capabilityImport, milestoneIndex)
const factoryIndex = source.indexOf(capabilityFactory, importIndex)
const assignmentIndex = source.indexOf(
'browserManagerForShutdown = browserManager',
factoryIndex
)
const watchdogIndex = source.indexOf('installMainThreadHangWatchdog(', assignmentIndex)
const certificateIndex = source.indexOf("app.on(\n 'certificate-error'", watchdogIndex)
expect(milestoneIndex).toBeGreaterThan(readyIndex)
expect(importIndex).toBeGreaterThan(milestoneIndex)
expect(factoryIndex).toBeGreaterThan(importIndex)
expect(assignmentIndex).toBeGreaterThan(factoryIndex)
expect(watchdogIndex).toBeGreaterThan(assignmentIndex)
expect(certificateIndex).toBeGreaterThan(watchdogIndex)
})
it('preserves every certificate callback argument on the returned controller', () => {
const certificateIndex = source.indexOf("app.on(\n 'certificate-error'")
const certificateEnd = source.indexOf('electronApp.setAppUserModelId', certificateIndex)
const certificateBlock = source.slice(certificateIndex, certificateEnd)
expect(certificateBlock).toContain(
'browserKernel.browserCertificateTrustController.handleCertificateError({'
)
for (const argument of [
'event,',
'webContents,',
'url,',
'error,',
'certificate,',
'callback,',
'isMainFrame'
]) {
expect(certificateBlock).toContain(argument)
}
})
it('preserves session initialization inputs and downstream manager identity/order', () => {
const proxyIndex = source.indexOf('await applyElectronProxySettings(store.getSettings())')
const sessionIndex = source.indexOf(
'browserKernel.initializeBrowserSessionsForApp({',
proxyIndex
)
const systemResumeIndex = source.indexOf(
'unsubscribeSystemResumeBroadcast = registerSystemResumeBroadcast()',
sessionIndex
)
const settingsIndex = source.indexOf('browserManager.setSettingsResolver(', systemResumeIndex)
const guestListenerIndex = source.indexOf(
'browserManager.setBrowserGuestStateChangedListener(',
settingsIndex
)
const agentBrowserIndex = source.indexOf(
'attachAgentBrowserStartupCapability(runtimeService, browserManager)',
guestListenerIndex
)
const offscreenIndex = source.indexOf(
'attachOffscreenBrowserStartupCapability(runtime, browserManager)',
agentBrowserIndex
)
expect(sessionIndex).toBeGreaterThan(proxyIndex)
const sessionBlock = source.slice(sessionIndex, systemResumeIndex)
expect(sessionBlock).toContain('orcaProfileId: activeOrcaProfile.profile.id')
expect(sessionBlock).toContain('profileDirectory: activeOrcaProfile.profileDirectory')
expect(settingsIndex).toBeGreaterThan(systemResumeIndex)
expect(guestListenerIndex).toBeGreaterThan(settingsIndex)
expect(agentBrowserIndex).toBeGreaterThan(guestListenerIndex)
expect(offscreenIndex).toBeGreaterThan(agentBrowserIndex)
})
it('keeps headless and desktop readiness plus shutdown clearing after kernel setup', () => {
const factoryIndex = source.indexOf(capabilityFactory)
const serveIndex = source.indexOf('if (serveOptions) {', factoryIndex)
const headlessGraphIndex = source.indexOf(
'runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID',
serveIndex
)
const headlessRpcIndex = source.indexOf('await runtimeRpc.start()', headlessGraphIndex)
const desktopWindowIndex = source.indexOf('Promise.resolve(openMainWindow())', headlessRpcIndex)
const shutdownIndex = source.indexOf("app.on('will-quit'")
const clearListenerIndex = source.indexOf(
'browserManagerForShutdown?.setBrowserGuestStateChangedListener(null)',
shutdownIndex
)
expect(serveIndex).toBeGreaterThan(factoryIndex)
expect(headlessGraphIndex).toBeGreaterThan(serveIndex)
expect(headlessRpcIndex).toBeGreaterThan(headlessGraphIndex)
expect(desktopWindowIndex).toBeGreaterThan(headlessRpcIndex)
expect(clearListenerIndex).toBeGreaterThan(shutdownIndex)
})
})
@@ -0,0 +1,60 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const browserMocks = vi.hoisted(() => ({
browserManager: { marker: 'manager' },
certificateTrustController: { marker: 'certificate-controller' },
initializeBrowserSessionsForApp: vi.fn(),
isAllowedPartition: vi.fn(),
setWindowDependencies: vi.fn()
}))
vi.mock('../browser/browser-manager', () => ({
browserManager: browserMocks.browserManager,
browserCertificateTrustController: browserMocks.certificateTrustController
}))
vi.mock('../browser/browser-session-registry', () => ({
browserSessionRegistry: {
isAllowedPartition: browserMocks.isAllowedPartition
}
}))
vi.mock('../browser/browser-session-startup', () => ({
initializeBrowserSessionsForApp: browserMocks.initializeBrowserSessionsForApp
}))
vi.mock('../browser/browser-kernel-window-dependencies', () => ({
setBrowserKernelWindowDependencies: browserMocks.setWindowDependencies
}))
import { createBrowserKernelStartupCapability } from './browser-kernel-startup-capability'
describe('browser kernel startup capability', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns the exact manager, trust controller, and session initializer identities', () => {
const capability = createBrowserKernelStartupCapability()
expect(capability.browserManager).toBe(browserMocks.browserManager)
expect(capability.browserCertificateTrustController).toBe(
browserMocks.certificateTrustController
)
expect(capability.initializeBrowserSessionsForApp).toBe(
browserMocks.initializeBrowserSessionsForApp
)
})
it('links window consumers to the same manager and session registry', () => {
createBrowserKernelStartupCapability()
expect(browserMocks.setWindowDependencies).toHaveBeenCalledOnce()
const dependencies = browserMocks.setWindowDependencies.mock.calls[0]?.[0]
expect(dependencies.browserManager).toBe(browserMocks.browserManager)
browserMocks.isAllowedPartition.mockReturnValue(true)
expect(dependencies.isAllowedSessionPartition('persist:profile')).toBe(true)
expect(browserMocks.isAllowedPartition).toHaveBeenCalledWith('persist:profile')
})
})

Some files were not shown because too many files have changed in this diff Show More