mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
perf: checkpoint startup boundaries and bundle diagnostics
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
+27
@@ -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"]
|
||||
}
|
||||
Generated
+16
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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]])
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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({})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user