diff --git a/.playwright/scripts/__tests__/performanceDiagnostics.e2e.mjs b/.playwright/scripts/__tests__/performanceDiagnostics.e2e.mjs
new file mode 100644
index 00000000..6dc33287
--- /dev/null
+++ b/.playwright/scripts/__tests__/performanceDiagnostics.e2e.mjs
@@ -0,0 +1,146 @@
+import { createHash } from 'node:crypto'
+import fs from 'node:fs/promises'
+import { createRequire } from 'node:module'
+import path from 'node:path'
+import { expect, test } from '../fixtures/electron.mjs'
+
+const { unzipSync, strFromU8 } = createRequire(new URL('../../../apps/buddy/package.json', import.meta.url))('fflate/node')
+
+test('performance capture stays local, identifies processes and exports bounded metadata', async ({ buddy }, testInfo) => {
+ const instance = await buddy.createInstance('performance-diagnostics')
+ const { page, app, diagnostics } = await instance.launch()
+ await page.evaluate(() => window.location.hash = '/settings/logs')
+ const header = page.locator('.desktop-settings-page__header')
+ const panel = page.getByTestId('performance-diagnostics')
+ await expect(header.getByRole('button', { name: '性能诊断' })).toBeVisible()
+ await expect(header.getByRole('button', { name: '导出诊断包' })).toBeVisible()
+ await expect(panel).toHaveCount(0)
+ const live = header.getByRole('switch')
+ await expect(live).toBeChecked()
+ await live.click()
+ await expect(live).not.toBeChecked()
+ await expect(page.locator('.application-logs__status')).toContainText('已暂停更新')
+ await live.click()
+ await expect(live).toBeChecked()
+ await testInfo.attach('logs-header', { body: await page.screenshot(), contentType: 'image/png' })
+
+ const extra = await app.evaluate(async ({ BrowserWindow }) => {
+ const window = new BrowserWindow({ show: false })
+ await window.loadURL('data:text/html,
Diagnostic fixture
')
+ return { id: window.id, pid: window.webContents.getOSProcessId() }
+ })
+ await header.getByRole('button', { name: '性能诊断' }).click()
+ await expect(panel).toBeVisible()
+ await expect(panel.getByText('界面 · 当前窗口', { exact: true })).toBeVisible()
+ const collectionDetails = page.getByText('仅记录性能摘要,不含对话、文件内容或原始堆栈。指标不含 FFmpeg 等外部子进程和桌宠。', { exact: true })
+ await panel.getByRole('button', { name: '采集说明', exact: true }).click()
+ await expect(collectionDetails).toBeVisible()
+ await panel.getByRole('button', { name: '采集说明', exact: true }).click()
+ await expect(collectionDetails).not.toBeVisible()
+ const otherRenderer = panel.getByRole('row').filter({ hasText: String(extra.pid) })
+ await expect(otherRenderer).toContainText('仅显示指标', { timeout: 12000 })
+ await expect(otherRenderer.getByRole('button')).toHaveCount(0)
+ await expect(panel.getByRole('button', { name: '分析 5 秒', exact: true })).toHaveCount(3)
+ const wrongTargetRejected = await page.evaluate(async () => {
+ try {
+ await window.lexoraDesktop.app.performance.capture({ target: 'runtime', pid: 0 })
+ return false
+ }
+ catch { return true }
+ })
+ expect(wrongTargetRejected).toBe(true)
+
+ const processNames = panel.locator('tbody tr td:first-child')
+ await expect.poll(() => processNames.allTextContents()).toEqual(['主进程', '任务运行时', '界面 · 当前窗口', '界面', '图形', '网络'])
+ const processPids = await panel.locator('tbody tr td:nth-child(2)').allTextContents()
+ const runtime = panel.getByRole('row').filter({ hasText: '任务运行时' })
+ const runtimePid = (await runtime.locator('td').nth(1).textContent()).trim()
+ await runtime.getByRole('button', { name: '分析 5 秒' }).click()
+ await expect(panel.getByRole('status')).toContainText(`正在分析任务运行时(PID ${runtimePid})`)
+ await expect(panel.getByRole('button', { name: '关闭', exact: true })).toBeDisabled()
+ await expect(panel.getByRole('status')).toContainText(`已完成任务运行时(PID ${runtimePid})的分析`, { timeout: 15000 })
+ expect(await panel.locator('tbody tr td:nth-child(2)').allTextContents()).toEqual(processPids)
+ await testInfo.attach('performance-dialog', { body: await page.screenshot(), contentType: 'image/png' })
+
+ const results = []
+ for (const target of ['main', 'renderer']) {
+ const result = await page.evaluate(async (target) => {
+ const api = window.lexoraDesktop.app.performance
+ const snapshot = await api.snapshot()
+ const pid = target === 'renderer' ? snapshot.rendererPid : snapshot.samples.at(-1).processes.find(item => item.role === 'main').pid
+ return api.capture({ target, pid })
+ }, target)
+ expect(result).toMatchObject({ target, pid: expect.any(Number) })
+ expect(result.durationMs).toBeGreaterThan(4500)
+ expect(result.samples).toBeGreaterThan(0)
+ expect(result.hotspots.length).toBeLessThanOrEqual(20)
+ expect(JSON.stringify(result)).not.toMatch(/file:\/\/|\/home\/|functionName|scriptId|callFrame/)
+ results.push(result)
+ }
+ const snapshot = await page.evaluate(() => window.lexoraDesktop.app.performance.snapshot())
+ expect(snapshot.coverage).toBe('electron-processes')
+ expect(snapshot.samples.length).toBeLessThanOrEqual(60)
+ expect(snapshot.samples.at(-1).logicalCpuCount).toBeGreaterThan(0)
+ const filename = path.join(instance.home, 'diagnostics.zip')
+ await app.evaluate(({ dialog }, filename) => {
+ dialog.showSaveDialog = async () => ({ canceled: false, filePath: filename })
+ }, filename)
+ await panel.getByRole('button', { name: '导出诊断包' }).click()
+ await expect(panel).toHaveCount(0)
+ await page.getByRole('button', { name: '导出 ZIP', exact: true }).click()
+ await expect.poll(async () => fs.stat(filename).then(stat => stat.size).catch(() => 0)).toBeGreaterThan(0)
+ const archive = unzipSync(await fs.readFile(filename))
+ const context = strFromU8(archive['context.jsonl']).trim().split('\n').map(line => JSON.parse(line))
+ expect(context.filter(record => record.cpuProfile).map(record => record.cpuProfile.target).sort()).toEqual(['main', 'renderer', 'runtime'])
+ expect(context.find(record => record.cpuProfile?.target === 'runtime').cpuProfile.pid).toBe(Number(runtimePid))
+ expect(strFromU8(archive['context.jsonl'])).not.toMatch(/functionName|scriptId|callFrame|file:\/\/|\/home\//)
+ const summaryPath = testInfo.outputPath('performance-summary.json')
+ await fs.writeFile(summaryPath, JSON.stringify({ profiles: results, snapshot }, null, 2))
+ await testInfo.attach('performance-summary', { path: summaryPath, contentType: 'application/json' })
+
+ await header.getByRole('button', { name: '性能诊断' }).click()
+ await expect(panel).toBeVisible()
+ await page.keyboard.press('Escape')
+ await expect(panel).toHaveCount(0)
+ await app.evaluate(({ BrowserWindow }, id) => BrowserWindow.fromId(id)?.close(), extra.id)
+ expect(diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([])
+})
+
+test('renderer CPU summaries retain locations from bundled Vue code', async ({ buddy }, testInfo) => {
+ const instance = await buddy.createInstance('renderer-cpu-locations')
+ const { page } = await instance.launch()
+ const { filename, profile } = await page.evaluate(async () => {
+ const url = document.querySelector('link[rel="modulepreload"][href*="/vue.runtime.esm-bundler-"]')?.href
+ if (!url)
+ throw new Error('Bundled Vue runtime was not loaded')
+ const exports = await import(url)
+ const vue = Object.values(exports).find(value => value && typeof value === 'object' && typeof value.reactive === 'function' && typeof value.computed === 'function')
+ if (!vue)
+ throw new Error('Bundled Vue runtime exports were not found')
+ const state = vue.reactive({ count: 0 })
+ const doubled = vue.computed(() => state.count * 2)
+ let observed = 0
+ const timer = setInterval(() => {
+ const until = performance.now() + 20
+ while (performance.now() < until) {
+ state.count++
+ observed = doubled.value
+ }
+ }, 25)
+ try {
+ const api = window.lexoraDesktop.app.performance
+ const { rendererPid } = await api.snapshot()
+ const profile = await api.capture({ target: 'renderer', pid: rendererPid })
+ if (!observed)
+ throw new Error('Reactive workload did not run')
+ return { filename: new URL(url).pathname.split('/').at(-1), profile }
+ }
+ finally {
+ clearInterval(timer)
+ }
+ })
+ const code = createHash('sha256').update(filename).digest('hex').slice(0, 12)
+ expect(profile.hotspots.some(hotspot => hotspot.location.startsWith(`renderer:${code}:`) && hotspot.selfMs > 0)).toBe(true)
+ expect(JSON.stringify(profile)).not.toMatch(/vue\.runtime|functionName|scriptId|callFrame|lexora-app:|file:/)
+ await testInfo.attach('renderer-cpu-locations', { body: JSON.stringify({ filename, profile }, null, 2), contentType: 'application/json' })
+})
diff --git a/apps/buddy/electron.vite.config.ts b/apps/buddy/electron.vite.config.ts
index 50a3bc36..cf0f0606 100644
--- a/apps/buddy/electron.vite.config.ts
+++ b/apps/buddy/electron.vite.config.ts
@@ -38,7 +38,7 @@ export default defineConfig({
build: {
outDir: join(electronOutputRoot, 'main'),
rollupOptions: {
- external: ['electron', '@silvia-odwyer/photon-node'],
+ external: ['electron', '@silvia-odwyer/photon-node', 'fflate/node'],
input: {
'index': fileURLToPath(new URL('./electron/main/index.ts', import.meta.url)),
'buddy-service': fileURLToPath(new URL('./service/src/index.ts', import.meta.url)),
diff --git a/apps/buddy/electron/main/__tests__/desktopDiagnostics.spec.ts b/apps/buddy/electron/main/__tests__/desktopDiagnostics.spec.ts
index 9d3f1e2a..53feaf78 100644
--- a/apps/buddy/electron/main/__tests__/desktopDiagnostics.spec.ts
+++ b/apps/buddy/electron/main/__tests__/desktopDiagnostics.spec.ts
@@ -162,86 +162,71 @@ describe('desktop diagnostics', () => {
appVersion: '0.3.0',
platform: process.platform,
collectorPid: process.pid,
- message: 'first\nsecond',
operationId: 'startup-1',
durationMs: 12,
})
expect(records[0]!.launchId).toBe(records[1]!.launchId)
- expect(records[1]!.error).toMatchObject({ name: 'Error', code: 'EACCES', message: 'failed /workspace token=' })
+ expect(records[1]).toMatchObject({ errorType: 'Error', errorCode: 'EACCES' })
+ expect(records[0]).not.toHaveProperty('message')
+ expect(records[1]).not.toHaveProperty('error')
expect(JSON.stringify(records)).not.toContain('fixture-')
- expect(records[1]!.error).not.toHaveProperty('request')
if (process.platform !== 'win32') {
expect((await stat(directory)).mode & 0o777).toBe(0o700)
expect((await stat(join(directory, 'application.jsonl'))).mode & 0o777).toBe(0o600)
}
})
- it('reassembles UTF-8 and secrets across chunks before redaction', async () => {
+ it('counts split UTF-8 and arbitrary stderr without retaining any content', async () => {
const { directory, logger } = await createLogger()
const output = logger.createWritable('local-service')
- const bytes = Buffer.from('中文 Authorization: Bearer fixture-secret\r\n next line\nlast line')
+ const bytes = Buffer.from('中文 私密用户文本 /project/customer.mov Authorization: Bearer fixture-secret')
for (const byte of bytes)
output.write(Buffer.from([byte]))
await logger.close()
const records = await readRecords(directory)
- expect(records.map(record => record.message)).toEqual([
- '中文 Authorization: ',
- ' next line',
- 'last line',
- ])
- expect(new Set(records.map(record => record.sourceId)).size).toBe(1)
- expect(records.every(record => record.scope === 'local-service')).toBe(true)
+ expect(records).toHaveLength(1)
+ expect(records[0]).toMatchObject({ scope: 'local-service', output: { bytes: bytes.length, chunks: bytes.length } })
+ expect(JSON.stringify(records)).not.toMatch(/私密|customer|fixture-secret/)
+ expect(records[0]).not.toHaveProperty('message')
})
- it('does not emit partial lines during flush and continues writing after flush', async () => {
+ it('aggregates stderr until its fixed interval or EOF without holding output bytes', async () => {
const { directory, logger } = await createLogger()
const output = logger.createWritable('local-service')
- output.write('Authorization: Bea')
+ output.write('private text')
await logger.flush()
expect(logger.status.accepted).toBe(0)
- output.write('rer fixture-secret\n')
- await logger.flush()
- for (let index = 0; index < 4; index++) {
- logger.record({ ...event, message: `next-${index}` })
- await logger.flush()
- }
+ output.end('more text')
await logger.close()
- expect((await readRecords(directory)).map(record => record.message))
- .toEqual(['Authorization: ', 'next-0', 'next-1', 'next-2', 'next-3'])
+ expect((await readRecords(directory))[0]?.output).toEqual({ bytes: 21, chunks: 2 })
})
- it('discards an oversized line in full and resumes at the next newline', async () => {
+ it('does not serialize arbitrary messages, error stacks or nested payloads', async () => {
const { directory, logger } = await createLogger()
- const output = logger.createWritable('native-pet')
- output.write('x'.repeat(MAX_DIAGNOSTIC_RECORD_BYTES))
- output.write('Authorization: Bea')
- output.write('rer fixture-secret\nrecovered\n')
- expect(logger.record({ ...event, message: 'x'.repeat(MAX_DIAGNOSTIC_RECORD_BYTES) })).toBe(false)
+ expect(logger.record({ ...event, message: 'private'.repeat(100000), error: new Error('unlabelled personal text') })).toBe(true)
await logger.close()
const records = await readRecords(directory)
- expect(records.filter(record => record.scope === 'native-pet').map(record => record.message)).toEqual(['recovered'])
- expect(logger.status.dropped).toBe(2)
- expect(records.find(record => record.event === 'recorder.loss')?.recorderLoss).toEqual({ dropped: 2, failed: 0 })
- expect(JSON.stringify(records)).not.toContain('fixture-secret')
+ expect(records[0]).toMatchObject({ errorType: 'Error', errorCode: 'OPERATION_FAILED' })
+ expect(JSON.stringify(records)).not.toMatch(/private|unlabelled/)
})
- it('bounds the pending queue without accumulating a Writable backlog', async () => {
- const { directory, logger } = await createLogger({ maxQueueBytes: MAX_DIAGNOSTIC_RECORD_BYTES })
- const output = logger.createWritable('local-service')
- for (let index = 0; index < 100; index++)
- output.write(`${index} ${'x'.repeat(2000)}\n`)
+ it('rejects a log storm before accessing payloads and bounds the pending queue', async () => {
+ const { logger } = await createLogger({ maxQueueBytes: MAX_DIAGNOSTIC_RECORD_BYTES })
+ expect(logger.record(event)).toBe(true)
+ const payload = { ...event, get error() {
+ throw new Error('must not inspect rejected payload')
+ } }
+ for (let index = 0; index < 10000; index++)
+ expect(logger.record(payload)).toBe(false)
expect(logger.status.pendingBytes).toBeLessThanOrEqual(MAX_DIAGNOSTIC_RECORD_BYTES)
- expect(output.writableLength).toBe(0)
- expect(logger.status.dropped).toBeGreaterThan(0)
+ expect(logger.status.dropped).toBe(10000)
await logger.close()
- const records = await readRecords(directory)
- expect(records.filter(record => record.scope === 'local-service').length + logger.status.dropped).toBe(100)
expect(logger.status.pendingBytes).toBe(0)
})
it('persists a loss summary even when every input was rejected', async () => {
const { directory, logger } = await createLogger()
- logger.record({ ...event, message: 'x'.repeat(MAX_DIAGNOSTIC_RECORD_BYTES) })
+ logger.record({ ...event, event: 'INVALID_EVENT' })
expect(await logger.close()).toMatchObject({ dropped: 1, written: 1 })
expect((await readRecords(directory))[0]).toMatchObject({
event: 'recorder.loss',
@@ -251,7 +236,7 @@ describe('desktop diagnostics', () => {
it('reports bounded close loss once through stderr without writing back to the closed sink', async () => {
const { directory, logger } = await createLogger()
- logger.record({ ...event, message: 'fixture-private'.repeat(MAX_DIAGNOSTIC_RECORD_BYTES) })
+ logger.record({ ...event, event: 'INVALID_EVENT' })
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
await closeDesktopDiagnostics(logger)
await closeDesktopDiagnostics(logger)
@@ -263,8 +248,8 @@ describe('desktop diagnostics', () => {
it('bounds file size and count while retaining the latest complete records', async () => {
const { directory, logger } = await createLogger({ maxFileBytes: MAX_DIAGNOSTIC_RECORD_BYTES, maxFiles: 3 })
- for (let index = 0; index < 8; index++) {
- logger.record({ ...event, operationId: String(index), message: 'x'.repeat(10000) })
+ for (let index = 0; index < 150; index++) {
+ logger.record({ ...event, operationId: String(index), component: 'component'.repeat(10) })
await logger.flush()
}
await logger.close()
@@ -272,8 +257,7 @@ describe('desktop diagnostics', () => {
expect(files.sort()).toEqual(['application.1.jsonl', 'application.2.jsonl', 'application.jsonl'])
for (const file of files)
expect((await stat(join(directory, file))).size).toBeLessThanOrEqual(MAX_DIAGNOSTIC_RECORD_BYTES)
- expect((await readRecords(directory))[0]!.operationId).toBe('7')
- expect((await readRecords(directory, 'application.2.jsonl'))[0]!.operationId).toBe('5')
+ expect((await readRecords(directory)).at(-1)!.operationId).toBe('149')
})
it('starts a clean file after a previous launch ended with an incomplete line', async () => {
@@ -298,7 +282,7 @@ describe('desktop diagnostics', () => {
const output = logger.createWritable('local-service')
const errors: Error[] = []
output.on('error', error => errors.push(error))
- output.write('first\n')
+ logger.record(event)
expect(await logger.flush()).toMatchObject({ failed: 1, written: 0, pendingBytes: 0 })
expect(logger.status.lastError).toBeTruthy()
expect(errors).toEqual([])
@@ -312,15 +296,18 @@ describe('desktop diagnostics', () => {
expect(logger.status.failed).toBe(1)
})
- it('isolates rotation failure and reports it without overwriting existing records', async () => {
+ it('isolates rotation failure and preserves already written records', async () => {
const { directory, logger } = await createLogger({ maxFileBytes: MAX_DIAGNOSTIC_RECORD_BYTES, maxFiles: 2 })
- logger.record({ ...event, message: 'x'.repeat(10000) })
+ logger.record({ ...event, operationId: 'first' })
await logger.flush()
await mkdir(join(directory, 'application.1.jsonl'))
- logger.record({ ...event, message: 'y'.repeat(10000) })
+ for (let index = 0; index < 100 && !logger.status.failed; index++) {
+ logger.record({ ...event, operationId: String(index), component: 'component'.repeat(10) })
+ await logger.flush()
+ }
await logger.close()
expect(logger.status.failed).toBe(1)
- expect((await readRecords(directory))[0]!.message).toBe('x'.repeat(10000))
+ expect((await readRecords(directory))[0]!.operationId).toBe('first')
})
it('waits for captured stream EOF and keeps process generations separate', async () => {
@@ -337,7 +324,7 @@ describe('desktop diagnostics', () => {
second.end('rer fixture-secret')
expect(await closing).toMatchObject({ written: 2, closeTimedOut: false })
const records = await readRecords(directory)
- expect(records.map(record => record.message)).toEqual(['first tail', 'Authorization: '])
+ expect(records.map(record => record.output?.bytes)).toEqual([10, 36])
expect(records[0]!.sourceId).not.toBe(records[1]!.sourceId)
})
@@ -361,7 +348,8 @@ describe('desktop diagnostics', () => {
expect(await logger.close()).toMatchObject({ written: 1, closeTimedOut: false })
expect((await readRecords(directory))[0]).toMatchObject({
event: 'process.stderr_failed',
- error: { message: 'stderr read failed' },
+ errorType: 'Error',
+ errorCode: 'OPERATION_FAILED',
})
})
@@ -391,6 +379,6 @@ describe('desktop diagnostics', () => {
const error = await new Promise(resolve => output.write('late\n', resolve))
expect(error).toBeInstanceOf(Error)
expect(logger.record(event)).toBe(false)
- expect((await readRecords(directory)).map(record => record.message)).toEqual(['tail'])
+ expect((await readRecords(directory)).map(record => record.output?.bytes)).toEqual([4])
})
})
diff --git a/apps/buddy/electron/main/app/DesktopIntegrations.ts b/apps/buddy/electron/main/app/DesktopIntegrations.ts
index 9ad02f80..fee5cc10 100644
--- a/apps/buddy/electron/main/app/DesktopIntegrations.ts
+++ b/apps/buddy/electron/main/app/DesktopIntegrations.ts
@@ -34,6 +34,7 @@ import { createDesktopTray } from '../tray'
import { registerWorkbenchIpc } from '../workbench/registerWorkbenchIpc'
import { WorkbenchStateStore } from '../workbench/WorkbenchStateStore'
import { registerApplicationLogIpc } from './registerApplicationLogIpc'
+import { registerPerformanceIpc } from './registerPerformanceIpc'
import { registerStartupIpc } from './registerStartupIpc'
const browserArtifactEntrySchema = z.object({
@@ -107,7 +108,8 @@ export class DesktopIntegrations {
this.#subscriptions.push(registerWorkbenchIpc(new WorkbenchStateStore(paths.buddyHome), () => windows.window))
this.#subscriptions.push(registerContextPanelIpc(runtime.contextPanel, () => windows.window))
this.#subscriptions.push(registerStartupIpc(this.#environment.startup, () => windows.window))
- this.#subscriptions.push(registerApplicationLogIpc(new ApplicationLogReader(paths.logs, diagnostics.launchId, homedir()), () => windows.window, event => diagnostics.record(event)))
+ this.#subscriptions.push(registerApplicationLogIpc(new ApplicationLogReader(paths.logs, diagnostics.launchId, homedir()), () => windows.window, event => diagnostics.record(event), () => diagnostics.flushWithin(1000)))
+ this.#subscriptions.push(registerPerformanceIpc(runtime, () => windows.window, this.#environment.events.publish))
this.#tray = createDesktopTray({
appName: paths.appName,
iconPath: trayIconPath,
diff --git a/apps/buddy/electron/main/app/DesktopRuntimeHost.ts b/apps/buddy/electron/main/app/DesktopRuntimeHost.ts
index 58efc2b9..c95d74aa 100644
--- a/apps/buddy/electron/main/app/DesktopRuntimeHost.ts
+++ b/apps/buddy/electron/main/app/DesktopRuntimeHost.ts
@@ -25,6 +25,7 @@ import { installAttachmentProtocol } from '../attachmentProtocol'
import { registerBrowserHostRpc } from '../browser/registerBrowserHostRpc'
import { LexoraConfigStore } from '../config/LexoraConfigStore'
import { ContextPanelHost } from '../context-panel/ContextPanelHost'
+import { DesktopPerformanceMonitor } from '../diagnostics/DesktopPerformanceMonitor'
import { registerExtensionAuthoringRpc } from '../extensions/registerExtensionAuthoringRpc'
import { DesktopNetwork } from '../network/DesktopNetwork'
import { registerWebHostRpc } from '../network/registerWebHostRpc'
@@ -39,6 +40,7 @@ import { createCredentialVault } from '../secrets/CredentialVault'
import { registerCredentialHostRpc } from '../secrets/registerCredentialHostRpc'
export class DesktopRuntimeHost {
+ readonly performance: DesktopPerformanceMonitor
inspectExtension: ((id: string) => Promise) | null = null
extensionAgent: ReturnType['agent'] | null = null
readonly contextPanel: ContextPanelHost
@@ -60,6 +62,7 @@ export class DesktopRuntimeHost {
this.#environment = environment
this.#windows = windows
this.#browser = browser
+ this.performance = new DesktopPerformanceMonitor(() => ({ metrics: app.getAppMetrics(), proxy: this.network.activity }), environment.events.publish)
this.configStore = new LexoraConfigStore({ configPath: environment.paths.configPath })
const configDiagnostics = this.configStore.onDidChange(change => environment.events.publish({ event: `settings.${change.kind.replaceAll('-', '_')}`, component: 'desktop.settings', level: change.kind.endsWith('failed') ? 'warn' : 'info', revision: change.revision, operationId: change.operationId, count: change.groups.length }))
this.#subscriptions.push(() => configDiagnostics.dispose())
@@ -161,6 +164,17 @@ export class DesktopRuntimeHost {
environment.diagnostics.record({ ...event, scope: 'local-service' })
},
bindPeer: (peer) => {
+ const sandbox = registerSandboxHostRpc(peer, {
+ buddyHome: environment.paths.buddyHome,
+ proxyUrl: this.#network!.sandboxProxyUrl,
+ ...this.#sandboxOptions(),
+ })
+ sandbox.onDidChange(event => environment.events.publish({
+ event: `sandbox.supervisor.${event.kind}`,
+ level: event.kind === 'cleanup_failed' ? 'warn' : 'info',
+ requestId: event.requestId,
+ sandboxProcess: { phase: event.kind, pid: event.pid, exitCode: event.exitCode },
+ }))
const disposers = [
peer.onRequest(extensionAgentRpc.list, () => this.extensionAgent?.list() ?? []),
peer.onRequest(extensionAgentRpc.invoke, (input, signal) => {
@@ -179,11 +193,7 @@ export class DesktopRuntimeHost {
return this.contextPanel.execute({ action: 'open', target: { kind: 'browser', source } }, 'harness')
}),
registerWebHostRpc(peer, this.#network!.authenticateProxy, this.#network!.assertAvailable),
- registerSandboxHostRpc(peer, {
- buddyHome: environment.paths.buddyHome,
- proxyUrl: this.#network!.sandboxProxyUrl,
- ...this.#sandboxOptions(),
- }),
+ sandbox.dispose,
registerBrowserHostRpc(peer, {
createAdapterLease: input => this.#browser.adapter.issueLease(input),
getHost: () => this.#browser.host,
@@ -241,6 +251,7 @@ export class DesktopRuntimeHost {
powerMonitor.off('unlock-screen', wakeOnUnlock)
})
service.start()
+ this.performance.start()
this.#subscriptions.push(installAttachmentProtocol(service))
this.#subscriptions.push(installRendererProtocol())
}
@@ -252,6 +263,7 @@ export class DesktopRuntimeHost {
}
async stop(): Promise {
+ this.performance.stop()
const failures: unknown[] = []
for (const cleanup of [
() => this.contextPanel.dispose(),
diff --git a/apps/buddy/electron/main/app/__tests__/registerApplicationLogIpc.spec.ts b/apps/buddy/electron/main/app/__tests__/registerApplicationLogIpc.spec.ts
index e1a0167b..203a9703 100644
--- a/apps/buddy/electron/main/app/__tests__/registerApplicationLogIpc.spec.ts
+++ b/apps/buddy/electron/main/app/__tests__/registerApplicationLogIpc.spec.ts
@@ -36,7 +36,7 @@ async function fixture(record: (event: DesktopDiagnosticEvent) => boolean = () =
const sender = { mainFrame: frame }
const window = { webContents: sender, isDestroyed: () => false } as unknown as BrowserWindow
const event = { sender, senderFrame: frame } as unknown as IpcMainInvokeEvent
- registerApplicationLogIpc(new ApplicationLogReader(directory, 'launch-current', '/fixture'), () => window, record)
+ registerApplicationLogIpc(new ApplicationLogReader(directory, 'launch-current', '/fixture'), () => window, record, async () => {})
const handler = native.handlers.get(DESKTOP_IPC_CHANNELS.appLogsExportDiagnostics)!
return { directory, event, handler }
}
diff --git a/apps/buddy/electron/main/app/registerApplicationLogIpc.ts b/apps/buddy/electron/main/app/registerApplicationLogIpc.ts
index e74cae16..868604f3 100644
--- a/apps/buddy/electron/main/app/registerApplicationLogIpc.ts
+++ b/apps/buddy/electron/main/app/registerApplicationLogIpc.ts
@@ -8,7 +8,7 @@ import { DESKTOP_IPC_CHANNELS } from '../../shared/desktopApi'
import { saveApplicationDiagnosticBundle } from '../diagnostics/saveApplicationDiagnosticBundle'
import { assertTrustedSender } from '../ipc'
-export function registerApplicationLogIpc(reader: ApplicationLogReader, getWindow: () => BrowserWindow | null, record: (event: DesktopDiagnosticEvent) => boolean): () => void {
+export function registerApplicationLogIpc(reader: ApplicationLogReader, getWindow: () => BrowserWindow | null, record: (event: DesktopDiagnosticEvent) => boolean, flush: () => Promise): () => void {
let exporting = false
const producers = new WeakMap>()
ipcMain.handle(DESKTOP_IPC_CHANNELS.appLogsReport, async (event, input: unknown) => {
@@ -54,6 +54,7 @@ export function registerApplicationLogIpc(reader: ApplicationLogReader, getWindo
return { status: 'canceled' }
exporting = true
try {
+ await flush()
return await saveApplicationDiagnosticBundle(reader, request, window)
}
catch {
diff --git a/apps/buddy/electron/main/app/registerPerformanceIpc.ts b/apps/buddy/electron/main/app/registerPerformanceIpc.ts
new file mode 100644
index 00000000..3e3959b9
--- /dev/null
+++ b/apps/buddy/electron/main/app/registerPerformanceIpc.ts
@@ -0,0 +1,84 @@
+import type { BrowserWindow } from 'electron'
+import type { ApplicationDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic'
+import type { DesktopRuntimeHost } from './DesktopRuntimeHost'
+import process from 'node:process'
+import { ipcMain } from 'electron'
+import { captureCpuProfile, captureNodeCpuProfile } from '../../../platform/diagnostics/cpuProfile'
+import { safeDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic'
+import { cpuProfileRequestSchema, cpuProfileSummarySchema } from '../../../shared/diagnostics/performanceDiagnostic'
+import { DESKTOP_IPC_CHANNELS } from '../../shared/desktopApi'
+import { assertTrustedSender } from '../ipc'
+
+export function registerPerformanceIpc(runtime: DesktopRuntimeHost, getWindow: () => BrowserWindow | null, reporter: ApplicationDiagnosticReporter): () => void {
+ const record = safeDiagnosticReporter(reporter)
+ const shutdown = new AbortController()
+ let busy = false
+ ipcMain.handle(DESKTOP_IPC_CHANNELS.appPerformanceSnapshot, (event) => {
+ const window = getWindow()
+ assertTrustedSender(event, window)
+ if (!window)
+ throw new Error('CPU_PROFILE_UNAVAILABLE')
+ return { ...runtime.performance.snapshot(), rendererPid: window.webContents.getOSProcessId() }
+ })
+ ipcMain.handle(DESKTOP_IPC_CHANNELS.appPerformanceCapture, async (event, input: unknown) => {
+ const window = getWindow()
+ assertTrustedSender(event, window)
+ const request = cpuProfileRequestSchema.parse(input)
+ const { target, pid } = request
+ if (busy || !window || shutdown.signal.aborted)
+ throw new Error('CPU_PROFILE_UNAVAILABLE')
+ const currentPid = target === 'main' ? process.pid : target === 'runtime' ? runtime.service.state.pid : window.webContents.getOSProcessId()
+ if (pid !== currentPid)
+ throw new Error('CPU_PROFILE_PROCESS_CHANGED')
+ busy = true
+ const operationId = crypto.randomUUID()
+ try {
+ const snapshot = runtime.performance.snapshot()
+ for (const performanceSample of snapshot.samples)
+ record({ event: 'performance.sample', level: 'info', operationId, performanceSample })
+ const cpuProfile = target === 'runtime'
+ ? cpuProfileSummarySchema.parse(await runtime.service.request('diagnostics.profile', request, { signal: shutdown.signal, timeoutMs: 10_000 }))
+ : target === 'main'
+ ? await captureNodeCpuProfile('main', shutdown.signal)
+ : await captureRenderer(window, shutdown.signal)
+ record({ event: 'performance.cpu_profile', level: 'info', operationId, cpuProfile })
+ return cpuProfile
+ }
+ catch {
+ record({ event: 'performance.profile_failed', level: 'warn', operationId, errorCode: 'CPU_PROFILE_FAILED' })
+ throw new Error('CPU_PROFILE_FAILED')
+ }
+ finally {
+ busy = false
+ }
+ })
+ return () => {
+ shutdown.abort()
+ ipcMain.removeHandler(DESKTOP_IPC_CHANNELS.appPerformanceSnapshot)
+ ipcMain.removeHandler(DESKTOP_IPC_CHANNELS.appPerformanceCapture)
+ }
+}
+
+async function captureRenderer(window: BrowserWindow, signal: AbortSignal) {
+ const contents = window.webContents
+ if (contents.debugger.isAttached())
+ throw new Error('CPU_PROFILE_BUSY')
+ const pid = contents.getOSProcessId()
+ contents.debugger.attach('1.3')
+ let attached = true
+ const detached = () => {
+ attached = false
+ }
+ contents.debugger.once('detach', detached)
+ return captureCpuProfile({
+ send: (method, params) => contents.debugger.sendCommand(method, params),
+ close: () => {
+ contents.debugger.removeListener('detach', detached)
+ if (attached && !contents.isDestroyed() && contents.debugger.isAttached())
+ contents.debugger.detach()
+ },
+ }, 'renderer', pid, (url) => {
+ const match = /^lexora-app:\/\/renderer\/assets\/([\w-]+(?:\.[\w-]+)*\.js)$/.exec(url)
+ return match?.[1] ?? null
+ }, signal)
+}
diff --git a/apps/buddy/electron/main/desktopDiagnostics.ts b/apps/buddy/electron/main/desktopDiagnostics.ts
index ca549896..cc39659e 100644
--- a/apps/buddy/electron/main/desktopDiagnostics.ts
+++ b/apps/buddy/electron/main/desktopDiagnostics.ts
@@ -5,9 +5,10 @@ import { Buffer } from 'node:buffer'
import { randomUUID } from 'node:crypto'
import process from 'node:process'
import { APPLICATION_LOG_MAX_FILE_BYTES, APPLICATION_LOG_MAX_FILES } from '../../shared/diagnostics/applicationLog'
+import { DiagnosticAdmission } from '../../shared/diagnostics/DiagnosticAdmission'
import { DiagnosticFile } from './diagnostics/diagnosticFile'
import { captureDiagnosticOutput, createDiagnosticOutput } from './diagnostics/diagnosticOutput'
-import { encodeDiagnosticRecord, MAX_DIAGNOSTIC_RECORD_BYTES, redactDiagnosticText } from './diagnostics/diagnosticRecord'
+import { encodeDiagnosticRecord, MAX_DIAGNOSTIC_RECORD_BYTES } from './diagnostics/diagnosticRecord'
export type { DesktopDiagnosticEvent, DesktopDiagnosticRecord, DesktopDiagnosticScope } from './diagnostics/diagnosticRecord'
@@ -57,6 +58,7 @@ export class DesktopDiagnosticLogger {
}
#queue: Buffer[] = []
+ readonly #admission = new DiagnosticAdmission()
#sequence = 0
#settled = 0
#retryAt = 0
@@ -102,7 +104,7 @@ export class DesktopDiagnosticLogger {
createWritable(scope: DesktopDiagnosticScope, options: Partial> = {}): Writable {
const sourceId = randomUUID()
const output = createDiagnosticOutput(
- message => this.#enqueue({ scope, sourceId, event: 'process.stderr', level: 'warn', ...options, message }),
+ output => this.#enqueue({ scope, sourceId, event: 'process.stderr', level: 'warn', ...options, output }),
() => this.#drop(),
)
if (this.#status.state !== 'open') {
@@ -193,10 +195,13 @@ export class DesktopDiagnosticLogger {
return this.status
}
- #enqueue(input: DesktopDiagnosticEvent): boolean {
+ #enqueue(input: DesktopDiagnosticEvent, internal = false): boolean {
if (this.#status.state === 'closed')
return false
- if (performance.now() < this.#retryAt)
+ const now = performance.now()
+ if (now < this.#retryAt || this.#status.pendingBytes + MAX_DIAGNOSTIC_RECORD_BYTES > this.#maxQueueBytes)
+ return this.#drop()
+ if (!internal && !this.#admission.take())
return this.#drop()
const line = encodeDiagnosticRecord(input, this.#context, ++this.#sequence, performance.now() - this.#startedAt, this.#userHome)
if (!line || this.#status.pendingBytes + line.length > this.#maxQueueBytes)
@@ -261,7 +266,7 @@ export class DesktopDiagnosticLogger {
level: 'warn',
event: 'recorder.loss',
recorderLoss: { dropped: this.#status.dropped, failed: this.#status.failed },
- })
+ }, true)
}
#drop(): false {
@@ -271,8 +276,7 @@ export class DesktopDiagnosticLogger {
}
#noteError(error: unknown): void {
- const message = error instanceof Error ? error.message : 'Diagnostic I/O failure'
- this.#status.lastError = redactDiagnosticText(message, this.#userHome).slice(0, 1024)
+ this.#status.lastError = error instanceof Error && 'code' in error && typeof error.code === 'string' && /^E[A-Z0-9_]{1,32}$/.test(error.code) ? error.code : 'DIAGNOSTIC_IO_FAILED'
}
#resolveFlushes(): void {
diff --git a/apps/buddy/electron/main/diagnostics/DesktopPerformanceMonitor.ts b/apps/buddy/electron/main/diagnostics/DesktopPerformanceMonitor.ts
new file mode 100644
index 00000000..6c7c7f84
--- /dev/null
+++ b/apps/buddy/electron/main/diagnostics/DesktopPerformanceMonitor.ts
@@ -0,0 +1,117 @@
+import type { ApplicationDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic'
+import type { PerformanceSample, ProcessRole } from '../../../shared/diagnostics/performanceDiagnostic'
+import { cpus } from 'node:os'
+import { safeDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic'
+import { performanceSampleSchema } from '../../../shared/diagnostics/performanceDiagnostic'
+
+export const PERFORMANCE_INTERVAL_MS = 5000
+const HISTORY_LIMIT = 60
+
+export class DesktopPerformanceMonitor {
+ readonly #read: () => { metrics: Electron.ProcessMetric[], proxy: PerformanceSample['proxy'] }
+ readonly #record: ApplicationDiagnosticReporter
+ readonly #logicalCpuCount: number
+ readonly #startedAt = performance.now()
+ #timer: ReturnType | undefined
+ #history: PerformanceSample[] = []
+ #previous = new Map()
+ #lastAt = 0
+ #nextIncidentAt = 0
+ #incident: { id: string, remaining: number } | undefined
+
+ constructor(read: () => { metrics: Electron.ProcessMetric[], proxy: PerformanceSample['proxy'] }, record: ApplicationDiagnosticReporter, logicalCpuCount = cpus().length) {
+ this.#read = read
+ this.#record = safeDiagnosticReporter(record)
+ this.#logicalCpuCount = logicalCpuCount
+ }
+
+ start(): void {
+ if (this.#timer)
+ return
+ this.sample()
+ this.#timer = setInterval(() => this.sample(), PERFORMANCE_INTERVAL_MS)
+ this.#timer.unref()
+ }
+
+ stop(): void {
+ clearInterval(this.#timer)
+ this.#timer = undefined
+ this.#previous.clear()
+ this.#history = []
+ this.#incident = undefined
+ }
+
+ snapshot() {
+ return { samples: structuredClone(this.#history), coverage: 'electron-processes' as const, intervalMs: PERFORMANCE_INTERVAL_MS }
+ }
+
+ sample(): void {
+ const now = performance.now()
+ try {
+ const { metrics, proxy } = this.#read()
+ const intervalMs = this.#previous.size ? now - this.#lastAt : 0
+ const previous = this.#previous
+ const next = new Map()
+ let sustained = false
+ const processes = metrics.slice(0, 32).map((metric) => {
+ const key = `${metric.pid}:${metric.creationTime}`
+ const before = previous.get(key)
+ const cpu = metric.cpu.cumulativeCPUUsage
+ const occupiedCores = before?.cpu !== undefined && cpu !== undefined && cpu >= before.cpu && intervalMs > 0
+ ? (cpu - before.cpu) * 1000 / intervalMs
+ : null
+ const cpuPercent = occupiedCores !== null && this.#logicalCpuCount > 0 ? occupiedCores * 100 / this.#logicalCpuCount : null
+ const highSince = occupiedCores !== null && occupiedCores >= 0.5 && intervalMs <= PERFORMANCE_INTERVAL_MS * 3 ? before?.highSince ?? now : undefined
+ if (highSince !== undefined && now - highSince >= 30_000)
+ sustained = true
+ next.set(key, { cpu, highSince })
+ return { pid: metric.pid, createdAt: metric.creationTime, role: processRole(metric), cpuPercent, memoryKiB: metric.memory.workingSetSize }
+ })
+ const sample = performanceSampleSchema.parse({ sampledAt: new Date().toISOString(), elapsedMs: now - this.#startedAt, intervalMs, collectionMs: performance.now() - now, logicalCpuCount: this.#logicalCpuCount, processes, proxy, truncated: metrics.length > 32 })
+ this.#lastAt = now
+ this.#previous = next
+ this.#history.push(sample)
+ if (this.#history.length > HISTORY_LIMIT)
+ this.#history.shift()
+ if (sustained && now >= this.#nextIncidentAt && !this.#incident) {
+ this.#incident = { id: crypto.randomUUID(), remaining: 6 }
+ this.#nextIncidentAt = now + 300_000
+ this.#record({ event: 'performance.sustained_cpu', component: 'desktop.performance', level: 'warn', operationId: this.#incident.id })
+ for (const frame of this.#history)
+ this.#recordSample(frame, this.#incident.id)
+ }
+ else if (this.#incident) {
+ this.#recordSample(sample, this.#incident.id)
+ if (--this.#incident.remaining === 0)
+ this.#incident = undefined
+ }
+ }
+ catch {
+ this.#previous.clear()
+ if (now >= this.#nextIncidentAt) {
+ this.#nextIncidentAt = now + 300_000
+ this.#record({ event: 'performance.sample_failed', level: 'warn', errorCode: 'PERFORMANCE_SAMPLE_FAILED' })
+ }
+ }
+ }
+
+ #recordSample(performanceSample: PerformanceSample, operationId: string): void {
+ this.#record({ event: 'performance.sample', component: 'desktop.performance', level: 'info', operationId, performanceSample })
+ }
+}
+
+function processRole(metric: Electron.ProcessMetric): ProcessRole {
+ if (metric.type === 'Browser')
+ return 'main'
+ if (metric.type === 'Tab')
+ return 'renderer'
+ if (metric.type === 'GPU')
+ return 'gpu'
+ if (metric.name === 'Buddy Local Service')
+ return 'runtime'
+ if (metric.name === 'Buddy Shell Sandbox')
+ return 'sandbox'
+ if (metric.serviceName === 'network.mojom.NetworkService')
+ return 'network'
+ return metric.type === 'Utility' ? 'utility' : 'other'
+}
diff --git a/apps/buddy/electron/main/diagnostics/__tests__/DesktopPerformanceMonitor.spec.ts b/apps/buddy/electron/main/diagnostics/__tests__/DesktopPerformanceMonitor.spec.ts
new file mode 100644
index 00000000..90d965ee
--- /dev/null
+++ b/apps/buddy/electron/main/diagnostics/__tests__/DesktopPerformanceMonitor.spec.ts
@@ -0,0 +1,72 @@
+import type { ApplicationDiagnostic } from '../../../../shared/diagnostics/applicationDiagnostic'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { DesktopPerformanceMonitor } from '../DesktopPerformanceMonitor'
+
+afterEach(() => vi.useRealTimers())
+const proxy = { accepted: 1, reportedFailures: 0, opened: 1, closed: 1, active: 0 }
+function metric(cpu = 0, creationTime = 1): Electron.ProcessMetric {
+ return { pid: 42, creationTime, type: 'Utility', name: 'Buddy Local Service', cpu: { percentCPUUsage: 999, cumulativeCPUUsage: cpu, idleWakeupsPerSecond: 0 }, memory: { workingSetSize: 1024, peakWorkingSetSize: 1024 } }
+}
+
+describe('performance evidence', () => {
+ it.each([1, 8, 32])('normalizes interval CPU deltas across %i logical processors and resets reused PIDs', (logicalCpuCount) => {
+ vi.useFakeTimers({ toFake: ['performance', 'setInterval', 'clearInterval'] })
+ let current = metric()
+ const records: ApplicationDiagnostic[] = []
+ const monitor = new DesktopPerformanceMonitor(() => ({ metrics: [current], proxy }), event => records.push(event), logicalCpuCount)
+ monitor.start()
+ expect(monitor.snapshot().samples[0]?.processes[0]?.cpuPercent).toBeNull()
+ current = metric(2.5)
+ vi.advanceTimersByTime(5000)
+ expect(monitor.snapshot().samples.at(-1)?.logicalCpuCount).toBe(logicalCpuCount)
+ expect(monitor.snapshot().samples.at(-1)?.processes[0]).toMatchObject({ cpuPercent: 50 / logicalCpuCount, role: 'runtime', pid: 42 })
+ current = metric(0, 2)
+ vi.advanceTimersByTime(5000)
+ expect(monitor.snapshot().samples.at(-1)?.processes[0]?.cpuPercent).toBeNull()
+ current = { ...current, cpu: { percentCPUUsage: 999, idleWakeupsPerSecond: 0 } }
+ vi.advanceTimersByTime(5000)
+ expect(monitor.snapshot().samples.at(-1)?.processes[0]?.cpuPercent).toBeNull()
+ expect(records).toEqual([])
+ monitor.stop()
+ vi.advanceTimersByTime(10000)
+ expect(monitor.snapshot().samples).toEqual([])
+ })
+
+ it('retains bounded history and before/after evidence without logging each routine sample', () => {
+ vi.useFakeTimers({ toFake: ['performance', 'setInterval', 'clearInterval'] })
+ const records: ApplicationDiagnostic[] = []
+ const monitor = new DesktopPerformanceMonitor(() => ({ metrics: [metric(performance.now() / 1000)], proxy }), event => records.push(event), 32)
+ monitor.start()
+ vi.advanceTimersByTime(30000)
+ expect(records).toEqual([])
+ vi.advanceTimersByTime(5000)
+ expect(records[0]?.event).toBe('performance.sustained_cpu')
+ expect(monitor.snapshot().samples.at(-1)?.processes[0]?.cpuPercent).toBe(3.125)
+ expect(records.filter(record => record.performanceSample)).toHaveLength(8)
+ vi.advanceTimersByTime(30000)
+ expect(records.filter(record => record.performanceSample)).toHaveLength(14)
+ vi.advanceTimersByTime(240000)
+ expect(monitor.snapshot().samples).toHaveLength(60)
+ expect(records.filter(record => record.event === 'performance.sustained_cpu')).toHaveLength(1)
+ monitor.stop()
+ })
+
+ it('bounds process cardinality, excludes names and isolates sampler/observer failures', () => {
+ const monitor = new DesktopPerformanceMonitor(() => ({ metrics: Array.from({ length: 80 }, (_, pid) => ({ ...metric(), pid, name: 'private-hostname' })), proxy }), () => {
+ throw new Error('observer')
+ })
+ monitor.sample()
+ const snapshot = monitor.snapshot()
+ expect(snapshot.samples[0]?.truncated).toBe(true)
+ expect(snapshot.samples[0]?.processes).toHaveLength(32)
+ expect(JSON.stringify(snapshot)).not.toContain('private-hostname')
+ snapshot.samples.splice(0)
+ expect(monitor.snapshot().samples).toHaveLength(1)
+ const broken = new DesktopPerformanceMonitor(() => {
+ throw new Error('private path')
+ }, () => {
+ throw new Error('observer')
+ })
+ expect(() => broken.sample()).not.toThrow()
+ })
+})
diff --git a/apps/buddy/electron/main/diagnostics/__tests__/applicationDiagnosticBundle.spec.ts b/apps/buddy/electron/main/diagnostics/__tests__/applicationDiagnosticBundle.spec.ts
index f18bc50f..fe8d5812 100644
--- a/apps/buddy/electron/main/diagnostics/__tests__/applicationDiagnosticBundle.spec.ts
+++ b/apps/buddy/electron/main/diagnostics/__tests__/applicationDiagnosticBundle.spec.ts
@@ -218,11 +218,40 @@ describe('application diagnostic bundles', () => {
expect(context.every(record => record.launchId === 'launch-current')).toBe(true)
})
+ it('joins sandbox host cleanup with the originating run through request identity', async () => {
+ const { reader } = await fixture([
+ record(1, { event: 'sandbox.requested', level: 'info', requestId: 'request-fixture', runId: 'run-fixture', errorCode: undefined }),
+ record(2, { event: 'sandbox.supervisor.cleanup_failed', level: 'error', requestId: 'request-fixture', runId: undefined, operationId: undefined }),
+ ])
+ const { context } = unpack((await createApplicationDiagnosticBundle(reader, 'current', { launchId: 'launch-current', sequence: 2 }))!.bytes)
+ expect(context.map(record => record.sequence)).toEqual([1, 2])
+ })
+
+ it('joins performance evidence to activity at sample time, preserving run and sandbox context without asserting causation', async () => {
+ const activity = { level: 'info', errorCode: undefined } as const
+ const performance = { ...activity, runId: undefined, operationId: 'performance-fixture', timestamp: '2026-09-24T00:10:00.000Z' }
+ const { reader } = await fixture([
+ record(1, { ...activity, event: 'run.started', timestamp: '2026-09-24T00:01:00.000Z' }),
+ record(2, { ...activity, event: 'sandbox.requested', requestId: 'request-fixture', timestamp: '2026-09-24T00:02:00.000Z' }),
+ record(3, { ...activity, event: 'run.completed', timestamp: '2026-09-24T00:05:10.000Z' }),
+ record(4, { ...activity, event: 'sandbox.supervisor.released', runId: undefined, requestId: 'request-fixture', timestamp: '2026-09-24T00:05:11.000Z' }),
+ record(5, { ...performance, event: 'performance.sample', performanceSample: { sampledAt: '2026-09-24T00:05:00.000Z', elapsedMs: 300000, collectionMs: 1, intervalMs: 5000, logicalCpuCount: 32, truncated: false, processes: [], proxy: { accepted: 0, reportedFailures: 0, opened: 0, closed: 0, active: 0 } } }),
+ record(6, { ...performance, level: 'warn', event: 'performance.sustained_cpu' }),
+ record(7, { ...activity, event: 'run.completed', runId: 'run-earlier', timestamp: '2026-09-24T00:02:00.000Z' }),
+ record(8, { ...activity, event: 'run.started', runId: 'run-later', timestamp: '2026-09-24T00:14:00.000Z' }),
+ ])
+ const { context, errors, manifest } = unpack((await createApplicationDiagnosticBundle(reader, 'current', { launchId: 'launch-current', sequence: 6 }))!.bytes)
+ expect(context.map(record => record.sequence)).toEqual([1, 2, 3, 4, 5, 6])
+ expect(errors).toEqual([])
+ expect(manifest.incidents[0]).toMatchObject({ association: 'identities-and-observation-window', runStartObserved: true, runEndObserved: true })
+ })
+
it('retains cumulative recorder losses per launch while keeping legacy counts unknown', async () => {
const loss = { event: 'recorder.loss', level: 'warn', runId: undefined, errorCode: undefined } as const
const { reader } = await fixture([
record(1, { ...loss, recorderLoss: { dropped: 7, failed: 4 } }),
record(2, { ...loss, recorderLoss: { dropped: 9, failed: 4 } }),
+ record(3, { ...loss, producerInstanceId: 'b3827615-1bd4-4c61-8c1b-f15e6b648c73', recorderLoss: { dropped: 5, failed: 1 } }),
record(1, { ...loss, launchId: 'launch-other', recorderLoss: { dropped: 3, failed: 2 } }),
record(1, { ...loss, launchId: 'launch-legacy', message: 'private loss details' }),
])
@@ -230,6 +259,7 @@ describe('application diagnostic bundles', () => {
expect(manifest.recorder).toMatchObject({ scope: 'scanned-records', lossObserved: true })
expect(manifest.recorder.launches).toEqual(expect.arrayContaining([
{ launchId: 'launch-current', dropped: 9, failed: 4 },
+ { launchId: 'launch-current', producerInstanceId: 'b3827615-1bd4-4c61-8c1b-f15e6b648c73', dropped: 5, failed: 1 },
{ launchId: 'launch-other', dropped: 3, failed: 2 },
{ launchId: 'launch-legacy', dropped: null, failed: null },
]))
diff --git a/apps/buddy/electron/main/diagnostics/__tests__/diagnosticOverhead.bench.ts b/apps/buddy/electron/main/diagnostics/__tests__/diagnosticOverhead.bench.ts
new file mode 100644
index 00000000..6be3edbd
--- /dev/null
+++ b/apps/buddy/electron/main/diagnostics/__tests__/diagnosticOverhead.bench.ts
@@ -0,0 +1,14 @@
+import type { ProcessMetric } from 'electron'
+import { bench, describe } from 'vitest'
+import { DesktopPerformanceMonitor } from '../DesktopPerformanceMonitor'
+
+const metrics: ProcessMetric[] = Array.from({ length: 32 }, (_, pid) => ({ pid, creationTime: 1, type: 'Utility', name: 'Buddy Local Service', cpu: { cumulativeCPUUsage: 0, percentCPUUsage: 0, idleWakeupsPerSecond: 0 }, memory: { workingSetSize: 1024, peakWorkingSetSize: 1024 } }))
+const read = () => ({ metrics, proxy: { accepted: 100, reportedFailures: 0, opened: 100, closed: 100, active: 0 } })
+const monitor = new DesktopPerformanceMonitor(read, () => {})
+
+describe('diagnostic bookkeeping at the 32-process bound (excludes OS reads)', () => {
+ bench('disabled', () => {
+ read()
+ }, { time: 300 })
+ bench('enabled', () => monitor.sample(), { time: 1000 })
+})
diff --git a/apps/buddy/electron/main/diagnostics/applicationDiagnosticBundle.ts b/apps/buddy/electron/main/diagnostics/applicationDiagnosticBundle.ts
index 344eeb76..c3367fbe 100644
--- a/apps/buddy/electron/main/diagnostics/applicationDiagnosticBundle.ts
+++ b/apps/buddy/electron/main/diagnostics/applicationDiagnosticBundle.ts
@@ -1,5 +1,5 @@
import type { ApplicationLogAnchor, ApplicationLogApi, ApplicationLogRecord } from '../../../shared/diagnostics/applicationLog'
-import { strToU8, zipSync } from 'fflate/browser'
+import { strToU8, zip } from 'fflate/node'
import { applicationLogKey, applicationLogRecordSchema } from '../../../shared/diagnostics/applicationLog'
import { isRoutineRpcRecord } from '../../../shared/diagnostics/rpcDiagnosticPolicy'
@@ -47,6 +47,10 @@ const bundleRecordSchema = applicationLogRecordSchema.pick({
failure: true,
providerRequest: true,
recorderLoss: true,
+ performanceSample: true,
+ cpuProfile: true,
+ sandboxProcess: true,
+ output: true,
processExit: true,
loadFailure: true,
recoveryAction: true,
@@ -99,10 +103,11 @@ export async function createApplicationDiagnosticBundle(reader: Pick !isRoutineRpcRecord(record))
const selectedRecord = selected ? records.find(record => applicationLogKey(record) === applicationLogKey(selected)) : undefined
const allErrors = meaningful.filter(record => record.level === 'error')
+ const incidents = meaningful.filter(record => record.level === 'error' || record.event === 'performance.sustained_cpu' || record.event === 'performance.cpu_profile' || record.sandboxProcess?.phase === 'cleanup_failed')
const seeds: ApplicationLogRecord[] = []
const seen = new Set()
- for (const record of selected ? selectedRecord ? [selectedRecord] : [] : allErrors) {
- const key = `${record.launchId}:${record.runId ?? record.operationId ?? 'startup'}`
+ for (const record of selected ? selectedRecord ? [selectedRecord] : [] : incidents) {
+ const key = `${record.launchId}:${record.runId ?? record.operationId ?? record.requestId ?? 'startup'}`
if (seen.has(key))
continue
seen.add(key)
@@ -125,16 +130,17 @@ export async function createApplicationDiagnosticBundle(reader: Pick a.timestamp.localeCompare(b.timestamp) || a.sequence - b.sequence)
const scopedErrors = selected ? candidates.filter(record => record.level === 'error') : allErrors
const errors = scopedErrors.slice(0, limits.errors).reverse()
- const recorderLosses = new Map()
+ const recorderLosses = new Map()
for (const record of records) {
if (record.event !== 'recorder.loss')
continue
- const loss = recorderLosses.get(record.launchId) ?? { launchId: record.launchId, dropped: null, failed: null }
+ const key = `${record.launchId}:${record.producerInstanceId ?? 'collector'}`
+ const loss = recorderLosses.get(key) ?? { launchId: record.launchId, producerInstanceId: record.producerInstanceId, dropped: null, failed: null }
if (record.recorderLoss) {
loss.dropped = Math.max(loss.dropped ?? 0, record.recorderLoss.dropped)
loss.failed = Math.max(loss.failed ?? 0, record.recorderLoss.failed)
}
- recorderLosses.set(record.launchId, loss)
+ recorderLosses.set(key, loss)
}
const coverageReasons = Object.entries({
scan_limit: records.length < first.total,
@@ -162,6 +168,7 @@ export async function createApplicationDiagnosticBundle(reader: Pick record.event === 'run.started'),
@@ -178,17 +185,21 @@ export async function createApplicationDiagnosticBundle(reader: Pick((resolve, reject) => zip({
'manifest.json': strToU8(JSON.stringify(manifest, null, 2)),
'summary.txt': strToU8(summary),
'errors.jsonl': encodeRecords(errors),
'context.jsonl': encodeRecords(context),
- }),
+ }, (error, bytes) => error ? reject(error) : resolve(bytes))),
errorCount: errors.length,
contextCount: context.length,
filename: `lexora-diagnostics-${capturedAt.replace(/[:.]/g, '-')}.zip`,
@@ -198,6 +209,25 @@ export async function createApplicationDiagnosticBundle(reader: Pick record.launchId === seed.launchId)
const runs = new Set()
+ if (isPerformanceIncident(seed)) {
+ const times = [Date.parse(seed.timestamp)]
+ for (const record of launch) {
+ if (record.performanceSample && (applicationLogKey(record) === applicationLogKey(seed) || (seed.operationId && record.operationId === seed.operationId)))
+ times.push(Date.parse(record.performanceSample.sampledAt))
+ }
+ const from = Math.min(...times) - 30_000
+ const to = Math.max(...times) + 30_000
+ for (const record of launch) {
+ const time = Date.parse(record.timestamp)
+ if (record.runId && time >= from && time <= to)
+ runs.add(record.runId)
+ }
+ }
+ const requests = new Set(seed.requestId ? [seed.requestId] : [])
+ for (const record of launch) {
+ if (record.requestId && requests.has(record.requestId) && record.runId)
+ runs.add(record.runId)
+ }
if (seed.runId)
runs.add(seed.runId)
if (!seed.runId && seed.operationId) {
@@ -213,7 +243,12 @@ function incidentRecords(records: ApplicationLogRecord[], seed: ApplicationLogRe
if (record.runId && runs.has(record.runId) && record.operationId)
operations.add(record.operationId)
}
+ for (const record of launch) {
+ if (record.requestId && ((record.runId && runs.has(record.runId)) || (!record.runId && record.operationId && operations.has(record.operationId))))
+ requests.add(record.requestId)
+ }
const linked = (record: ApplicationLogRecord) => applicationLogKey(record) === applicationLogKey(seed)
+ || (record.requestId && requests.has(record.requestId))
|| (record.runId && runs.has(record.runId))
|| (!record.runId && ((record.operationId && operations.has(record.operationId))
|| (record.parentOperationId && operations.has(record.parentOperationId))))
@@ -226,6 +261,10 @@ function incidentRecords(records: ApplicationLogRecord[], seed: ApplicationLogRe
|| (!record.runId && (!record.operationId || /^(?:app|startup|runtime|recorder|component)\./.test(record.event)) && Date.parse(record.timestamp) >= from && Date.parse(record.timestamp) <= to))
}
+function isPerformanceIncident(record: ApplicationLogRecord): boolean {
+ return !!record.performanceSample || !!record.cpuProfile || record.event === 'performance.sustained_cpu'
+}
+
function encodeRecords(records: ApplicationLogRecord[]): Uint8Array {
return strToU8(records.map(record => JSON.stringify(bundleRecordSchema.parse(record))).join('\n') + (records.length ? '\n' : ''))
}
diff --git a/apps/buddy/electron/main/diagnostics/diagnosticOutput.ts b/apps/buddy/electron/main/diagnostics/diagnosticOutput.ts
index 321eaa6e..59ec25b3 100644
--- a/apps/buddy/electron/main/diagnostics/diagnosticOutput.ts
+++ b/apps/buddy/electron/main/diagnostics/diagnosticOutput.ts
@@ -1,52 +1,32 @@
-import { Buffer } from 'node:buffer'
+import type { Buffer } from 'node:buffer'
import { Writable } from 'node:stream'
-import { MAX_DIAGNOSTIC_RECORD_BYTES } from './diagnosticRecord'
-export function createDiagnosticOutput(onLine: (line: string) => void, onDrop: () => void): Writable {
- let pending: Buffer[] = []
+export function createDiagnosticOutput(onOutput: (output: { bytes: number, chunks: number }) => void, onDrop: () => void): Writable {
let bytes = 0
- let discarding = false
-
- function append(chunk: Buffer) {
- if (discarding || !chunk.length)
- return
- bytes += chunk.length
- if (bytes > MAX_DIAGNOSTIC_RECORD_BYTES) {
- pending = []
- bytes = 0
- discarding = true
- onDrop()
- return
- }
- pending.push(Buffer.from(chunk))
- }
-
- function finishLine() {
- if (!discarding && bytes)
- onLine(Buffer.concat(pending, bytes).toString('utf8').replace(/\r$/, ''))
- pending = []
+ let chunks = 0
+ let timer: ReturnType | undefined
+ const flush = () => {
+ clearTimeout(timer)
+ timer = undefined
+ if (chunks)
+ onOutput({ bytes, chunks })
bytes = 0
- discarding = false
+ chunks = 0
}
-
const output = new Writable({
write(chunk: Buffer, _encoding, callback) {
- let start = 0
- for (let end = chunk.indexOf(10); end !== -1; end = chunk.indexOf(10, start)) {
- append(chunk.subarray(start, end))
- finishLine()
- start = end + 1
- }
- append(chunk.subarray(start))
+ bytes = Math.min(Number.MAX_SAFE_INTEGER, bytes + chunk.length)
+ chunks = Math.min(Number.MAX_SAFE_INTEGER, chunks + 1)
+ timer ??= setTimeout(flush, 5000)
+ timer.unref()
callback()
},
final(callback) {
- finishLine()
+ flush()
callback()
},
destroy(error, callback) {
- if (bytes || discarding)
- finishLine()
+ flush()
callback(error)
},
})
diff --git a/apps/buddy/electron/main/diagnostics/diagnosticRecord.ts b/apps/buddy/electron/main/diagnostics/diagnosticRecord.ts
index 66b5d206..4132755e 100644
--- a/apps/buddy/electron/main/diagnostics/diagnosticRecord.ts
+++ b/apps/buddy/electron/main/diagnostics/diagnosticRecord.ts
@@ -1,7 +1,7 @@
import type { ApplicationDiagnostic } from '../../../shared/diagnostics/applicationDiagnostic'
import type { ApplicationLogRecord } from '../../../shared/diagnostics/applicationLog'
import { Buffer } from 'node:buffer'
-import { applicationDiagnosticSchema } from '../../../shared/diagnostics/applicationDiagnostic'
+import { applicationDiagnosticSchema, readDiagnosticError } from '../../../shared/diagnostics/applicationDiagnostic'
export const MAX_DIAGNOSTIC_RECORD_BYTES = 16 * 1024
export type DesktopDiagnosticScope = 'desktop' | 'local-service' | 'native-pet'
@@ -43,9 +43,10 @@ export function encodeDiagnosticRecord(
try {
if (!/^[a-z][a-z\d._-]{0,95}$/.test(input.event))
return null
- const error = input.error instanceof Error ? input.error : undefined
+ const failure = input.error === undefined ? {} : readDiagnosticError(input.error)
const record: DesktopDiagnosticRecord = {
...context,
+ ...failure,
schemaVersion: 1,
timestamp: new Date().toISOString(),
elapsedMs: Math.round(elapsedMs),
@@ -53,21 +54,11 @@ export function encodeDiagnosticRecord(
scope: input.scope,
level: input.level,
event: input.event,
- message: input.message === undefined ? undefined : clean(input.message),
- sourceId: input.sourceId === undefined ? undefined : clean(input.sourceId),
+ sourceId: typeof input.sourceId === 'string' && /^[\w:.-]{1,192}$/.test(input.sourceId) ? input.sourceId : undefined,
sourcePid: typeof input.sourcePid === 'number' && Number.isSafeInteger(input.sourcePid) && input.sourcePid > 0 ? input.sourcePid : undefined,
- operationId: input.operationId === undefined ? undefined : clean(input.operationId),
durationMs: typeof input.durationMs === 'number' && Number.isFinite(input.durationMs) && input.durationMs >= 0 ? input.durationMs : undefined,
- error: error
- ? {
- name: clean(error.name),
- message: clean(error.message),
- stack: error.stack === undefined ? undefined : clean(error.stack),
- code: 'code' in error && typeof error.code === 'string' ? clean(error.code) : undefined,
- }
- : input.error === undefined ? undefined : { name: 'UnknownError', message: 'Non-Error failure' },
}
- for (const key of ['parentOperationId', 'producerInstanceId', 'extensionId', 'workingCopyId', 'markId', 'revision', 'contentVersion', 'savedVersion', 'dirty', 'generation', 'sessionId', 'providerId', 'connectorId', 'automationId', 'occurrenceId', 'toolCallId', 'sourceSequence', 'occurredAt', 'component', 'conversationId', 'spaceId', 'directoryId', 'branchId', 'runId', 'turnId', 'requestId', 'errorCode', 'errorType', 'failure', 'providerRequest', 'recorderLoss', 'processExit', 'loadFailure', 'recoveryAction', 'previousLaunchId', 'count', 'attempt', 'method'] as const) {
+ for (const key of ['operationId', 'parentOperationId', 'producerInstanceId', 'extensionId', 'workingCopyId', 'markId', 'revision', 'contentVersion', 'savedVersion', 'dirty', 'generation', 'sessionId', 'providerId', 'connectorId', 'automationId', 'occurrenceId', 'toolCallId', 'sourceSequence', 'occurredAt', 'component', 'conversationId', 'spaceId', 'directoryId', 'branchId', 'runId', 'turnId', 'requestId', 'errorCode', 'errorType', 'failure', 'providerRequest', 'recorderLoss', 'performanceSample', 'cpuProfile', 'output', 'sandboxProcess', 'processExit', 'loadFailure', 'recoveryAction', 'previousLaunchId', 'count', 'attempt', 'method'] as const) {
const parsed = applicationDiagnosticSchema.shape[key].safeParse(input[key])
if (!parsed.success)
return null
diff --git a/apps/buddy/electron/main/network/DesktopNetwork.ts b/apps/buddy/electron/main/network/DesktopNetwork.ts
index 482e0c06..c1714aee 100644
--- a/apps/buddy/electron/main/network/DesktopNetwork.ts
+++ b/apps/buddy/electron/main/network/DesktopNetwork.ts
@@ -37,6 +37,7 @@ export class DesktopNetwork {
#startupError: NetworkStartupError | null = null
get startupError(): NetworkStartupError | null { return this.#startupError }
+ get activity() { return this.#proxy.activity }
get snapshot() { return copyEventSnapshot({ revision: this.#revision, status: this.#status, mode: this.#settings?.mode ?? null, pendingSessions: this.#sessionSetup.size, failedSessions: this.#failedSessions.size, failure: this.#startupError?.failure ?? null }) }
get proxyUrl(): string { return this.#startupError ? UNAVAILABLE_PROXY_URL : this.#proxy.url }
get sandboxProxyUrl(): string { return this.#startupError ? UNAVAILABLE_PROXY_URL : this.#proxy.sandboxUrl }
diff --git a/apps/buddy/electron/main/network/OutboundProxy.ts b/apps/buddy/electron/main/network/OutboundProxy.ts
index fab72109..0f9cd25c 100644
--- a/apps/buddy/electron/main/network/OutboundProxy.ts
+++ b/apps/buddy/electron/main/network/OutboundProxy.ts
@@ -16,6 +16,10 @@ export class OutboundProxy {
readonly #sandboxHttpAgent = new HttpAgent({ keepAlive: false })
readonly #resolve: (url: string) => Promise
#generation = 0
+ #accepted = 0
+ #failed = 0
+ #opened = 0
+ #closed = 0
readonly #sandboxGuard = createResolvedAddressGuard({ deniedResolvedAddresses: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '100.64.0.0/10', 'fc00::/7'] })
constructor(resolve: (url: string) => Promise) {
@@ -31,6 +35,7 @@ export class OutboundProxy {
return { requestAuthentication: true }
if (port === this.port && ['localhost', '127.0.0.1', '::1', '[::1]'].includes(hostname))
throw new RequestError('Proxy loop', 502)
+ this.#accepted++
const generation = this.#generation
const authority = `${hostname}:${port}`
const protocol = username.endsWith('-http') ? 'http' : 'https'
@@ -46,8 +51,13 @@ export class OutboundProxy {
}
},
})
+ this.#server.server.on('connection', () => this.#opened++)
+ this.#server.on('connectionClosed', () => this.#closed++)
+ this.#server.on('requestFailed', () => this.#failed++)
+ this.#server.on('tunnelConnectFailed', () => this.#failed++)
}
+ get activity() { return { accepted: this.#accepted, reportedFailures: this.#failed, opened: this.#opened, closed: this.#closed, active: this.#server.connections.size } }
get port(): number { return this.#server.port }
get url(): string { return `http://${this.username}:${this.password}@127.0.0.1:${this.port}` }
get sandboxUrl(): string { return `http://lexora-sandbox:${this.#sandboxPassword}@127.0.0.1:${this.port}` }
diff --git a/apps/buddy/electron/main/runtime/__tests__/buddyServiceProcess.spec.ts b/apps/buddy/electron/main/runtime/__tests__/buddyServiceProcess.spec.ts
index a985485c..00aae736 100644
--- a/apps/buddy/electron/main/runtime/__tests__/buddyServiceProcess.spec.ts
+++ b/apps/buddy/electron/main/runtime/__tests__/buddyServiceProcess.spec.ts
@@ -52,7 +52,7 @@ describe('buddyServiceProcess', () => {
process.exit(7)
expect(await logger.close()).toMatchObject({ written: 1, closeTimedOut: false })
const record = JSON.parse(await readFile(join(directory, 'application.jsonl'), 'utf8'))
- expect(record).toMatchObject({ scope: 'local-service', message: 'Authorization: ' })
+ expect(record).toMatchObject({ scope: 'local-service', output: { bytes: 36, chunks: 2 } })
})
it('finishes the capture and persists the terminal records when Electron removes stderr listeners at exit', async () => {
@@ -75,7 +75,7 @@ describe('buddyServiceProcess', () => {
expect(await logger.close()).toMatchObject({ written: 2, failed: 0, unconfirmed: 0, closeTimedOut: false })
const records = (await readFile(join(directory, 'application.jsonl'), 'utf8')).trimEnd().split('\n').map(line => JSON.parse(line))
expect(records).toMatchObject([
- { scope: 'local-service', event: 'process.stderr', message: 'runtime cleanup completed' },
+ { scope: 'local-service', event: 'process.stderr', output: { bytes: 25, chunks: 2 } },
{ scope: 'desktop', event: 'app.stopped' },
])
})
@@ -92,7 +92,7 @@ describe('buddyServiceProcess', () => {
process.stderr.destroy(new Error('stderr read failed'))
expect(await logger.close()).toMatchObject({ written: 1, closeTimedOut: false })
const record = JSON.parse(await readFile(join(directory, 'application.jsonl'), 'utf8'))
- expect(record).toMatchObject({ event: 'process.stderr_failed', error: { message: 'stderr read failed' } })
+ expect(record).toMatchObject({ event: 'process.stderr_failed', errorType: 'Error', errorCode: 'OPERATION_FAILED' })
process.exit(1)
})
diff --git a/apps/buddy/electron/main/sandbox/registerSandboxHostRpc.ts b/apps/buddy/electron/main/sandbox/registerSandboxHostRpc.ts
index 78db7f9d..c8f027a1 100644
--- a/apps/buddy/electron/main/sandbox/registerSandboxHostRpc.ts
+++ b/apps/buddy/electron/main/sandbox/registerSandboxHostRpc.ts
@@ -8,6 +8,7 @@ import { createSandboxDirectory } from '../../../platform/process/sandboxDirecto
import { createSandboxEnvironment } from '../../../platform/process/sandboxEnvironment'
import { resolveWindowsSandbox } from '../../../platform/process/windowsSandbox'
import sandboxProcessPath from '../../../service/src/sandbox/sandboxProcess?modulePath'
+import { Emitter } from '../../../shared/events/Emitter'
import { sandboxLifecycleNotificationSchema } from '../../../shared/permissions/sandboxLifecycle'
import { SANDBOX_RPC_TIMEOUT_MS, sandboxCancelSchema, sandboxCommandSchema, sandboxNetworkRequestSchema, sandboxOutputSchema } from '../../../shared/permissions/shellSandbox'
import { isLinux, isWindows, OPERATING_SYSTEM, SHELL_SANDBOX_BACKEND } from '../../../shared/platform/identifiers'
@@ -22,7 +23,9 @@ export interface SandboxHostOptions {
windowsShell?: string
}
-export function registerSandboxHostRpc(peer: RuntimeRpcPeerContract, options: SandboxHostOptions): () => void {
+export function registerSandboxHostRpc(peer: RuntimeRpcPeerContract, options: SandboxHostOptions) {
+ const changes = new Emitter>(() => console.error('SANDBOX_HOST_OBSERVER_FAILED'))
+ const publish = (event: Parameters[0]) => changes.fire(Object.freeze(event))
const active = new Map()
let disposed = false
const disposers = [
@@ -43,6 +46,7 @@ export function registerSandboxHostRpc(peer: RuntimeRpcPeerContract, options: Sa
active.set(input.requestId, controller)
let directory: string | undefined
let child: Electron.UtilityProcess | undefined
+ let pid: number | undefined
let childPeer: BuddyServicePeer | undefined
let exited: Promise | undefined
let forceKill: ReturnType | undefined
@@ -54,6 +58,26 @@ export function registerSandboxHostRpc(peer: RuntimeRpcPeerContract, options: Sa
forceKill ??= setTimeout(() => child?.kill(), 15_000)
forceKill.unref()
}
+ const release = async () => {
+ publish({ requestId: input.requestId, kind: 'cleanup_requested', pid })
+ try {
+ child?.kill()
+ await exited
+ childPeer?.close(new Error('Sandbox command finished'))
+ if (directory)
+ await rm(directory, { recursive: true, force: true })
+ publish({ requestId: input.requestId, kind: 'resources_released', pid })
+ }
+ catch (error) {
+ publish({ requestId: input.requestId, kind: 'cleanup_failed', pid })
+ throw error
+ }
+ finally {
+ active.delete(input.requestId)
+ if (disposed && !active.size)
+ changes.dispose()
+ }
+ }
controller.signal.addEventListener('abort', cancel, { once: true })
try {
const windowsSandbox = isWindows(process.platform) ? await resolveWindowsSandbox(options.windowsSandbox) : undefined
@@ -73,11 +97,16 @@ export function registerSandboxHostRpc(peer: RuntimeRpcPeerContract, options: Sa
serviceName: 'Buddy Shell Sandbox',
stdio: 'pipe',
})
+ child.once('spawn', () => {
+ pid = child?.pid
+ publish({ requestId: input.requestId, kind: 'spawned', pid })
+ })
child.stdout?.resume()
child.stderr?.resume()
childPeer = new BuddyServicePeer({ process: child })
const processPeer = childPeer
- exited = new Promise(resolve => child!.once('exit', () => {
+ exited = new Promise(resolve => child!.once('exit', (exitCode) => {
+ publish({ requestId: input.requestId, kind: 'exited', pid, exitCode })
processPeer.close(new Error('Sandbox supervisor exited'))
resolve()
}))
@@ -122,19 +151,17 @@ export function registerSandboxHostRpc(peer: RuntimeRpcPeerContract, options: Sa
controller.signal.removeEventListener('abort', cancel)
if (forceKill)
clearTimeout(forceKill)
- child?.kill()
- await exited
- childPeer?.close(new Error('Sandbox command finished'))
- active.delete(input.requestId)
- if (directory)
- await rm(directory, { recursive: true, force: true })
+ await release()
}
}),
]
- return () => {
+ const dispose = () => {
disposed = true
disposers.forEach(dispose => dispose())
for (const controller of active.values())
controller.abort()
+ if (!active.size)
+ changes.dispose()
}
+ return { dispose, onDidChange: changes.event }
}
diff --git a/apps/buddy/electron/main/sandbox/verifySandboxInstallation.ts b/apps/buddy/electron/main/sandbox/verifySandboxInstallation.ts
index 58b2b393..4eb53891 100644
--- a/apps/buddy/electron/main/sandbox/verifySandboxInstallation.ts
+++ b/apps/buddy/electron/main/sandbox/verifySandboxInstallation.ts
@@ -108,7 +108,7 @@ export async function verifySandboxInstallation(options: SandboxHostOptions): Pr
}
finally {
await client.dispose()
- disposeHost()
+ disposeHost.dispose()
host.close(new Error('Installation verification completed'))
runtime.close(new Error('Installation verification completed'))
channel.port1.close()
diff --git a/apps/buddy/electron/preload/desktop.ts b/apps/buddy/electron/preload/desktop.ts
index f3eaefb4..2c20405e 100644
--- a/apps/buddy/electron/preload/desktop.ts
+++ b/apps/buddy/electron/preload/desktop.ts
@@ -1,6 +1,7 @@
import type { ContextPanelCommand, ContextPanelState } from '../../shared/context-panel/contextPanel'
import type { ApplicationLogExport, ApplicationLogQuery } from '../../shared/diagnostics/applicationLog'
import type { ApplicationStartupState } from '../../shared/diagnostics/applicationStartup'
+import type { CpuProfileRequest } from '../../shared/diagnostics/performanceDiagnostic'
import type { RendererDiagnosticReport } from '../../shared/diagnostics/rendererDiagnostic'
import type { RendererLifecycleReport } from '../../shared/lifecycle/serviceLifecycle'
import type { DesktopAppInfo, DesktopOpenTarget, DesktopWindowState, LexoraConfigPatch, LexoraDesktopApi } from '../shared/desktopApi'
@@ -25,9 +26,13 @@ export function createDesktopApi(): Pick void) => subscribe(DESKTOP_IPC_CHANNELS.contextPanelStateChanged, listener),
}),
app: Object.freeze({
+ performance: Object.freeze({
+ snapshot: () => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.appPerformanceSnapshot),
+ capture: (request: CpuProfileRequest) => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.appPerformanceCapture, { target: request.target, pid: request.pid }),
+ }),
logs: Object.freeze({
report: (input: RendererDiagnosticReport) => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.appLogsReport, input),
- exportDiagnostics: (input: ApplicationLogExport) => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.appLogsExportDiagnostics, { launch: input.launch }),
+ exportDiagnostics: (input: ApplicationLogExport) => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.appLogsExportDiagnostics, { launch: input.launch, anchor: input.anchor ? { ...input.anchor } : undefined }),
query: (input: ApplicationLogQuery) => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.appLogsQuery, {
...input,
anchor: input.anchor ? { launchId: input.anchor.launchId, sequence: input.anchor.sequence } : undefined,
diff --git a/apps/buddy/electron/shared/desktopApi.ts b/apps/buddy/electron/shared/desktopApi.ts
index fc5153e4..29c2addf 100644
--- a/apps/buddy/electron/shared/desktopApi.ts
+++ b/apps/buddy/electron/shared/desktopApi.ts
@@ -1,6 +1,7 @@
import type { DesktopBrowserApi } from '../../shared/browser/browserDesktopApi'
import type { ApplicationLogApi } from '../../shared/diagnostics/applicationLog'
import type { ApplicationStartupState } from '../../shared/diagnostics/applicationStartup'
+import type { PerformanceDiagnosticApi } from '../../shared/diagnostics/performanceDiagnostic'
import type { RendererDiagnosticApi } from '../../shared/diagnostics/rendererDiagnostic'
import type { RendererLifecycleReport } from '../../shared/lifecycle/serviceLifecycle'
import type { SandboxEnvironmentStatus, SandboxSetupResult } from '../../shared/permissions/shellSandbox'
@@ -16,6 +17,8 @@ export const DESKTOP_IPC_CHANNELS = {
contextPanelGetState: 'lexora:context-panel:get-state',
contextPanelExecute: 'lexora:context-panel:execute',
contextPanelStateChanged: 'lexora:context-panel:state-changed',
+ appPerformanceSnapshot: 'lexora:app:performance:snapshot',
+ appPerformanceCapture: 'lexora:app:performance:capture',
appLogsQuery: 'lexora:app:logs:query',
appLogsReport: 'lexora:app:logs:report',
appLogsExportDiagnostics: 'lexora:app:logs:export-diagnostics',
@@ -205,6 +208,7 @@ export interface LexoraDesktopApi {
workbench: import('../../shared/workbench/workbenchState').WorkbenchStateApi
contextPanel: import('../../shared/context-panel/contextPanel').ContextPanelApi
app: {
+ performance: PerformanceDiagnosticApi
logs: ApplicationLogApi & RendererDiagnosticApi
startup: {
getState: () => Promise
diff --git a/apps/buddy/platform/diagnostics/__tests__/cpuProfile.spec.ts b/apps/buddy/platform/diagnostics/__tests__/cpuProfile.spec.ts
new file mode 100644
index 00000000..f4283ee4
--- /dev/null
+++ b/apps/buddy/platform/diagnostics/__tests__/cpuProfile.spec.ts
@@ -0,0 +1,37 @@
+import type { Profiler } from 'node:inspector'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { captureCpuProfile, summarizeCpuProfile } from '../cpuProfile'
+
+afterEach(() => vi.useRealTimers())
+const profile: Profiler.Profile = {
+ startTime: 0,
+ endTime: 10000,
+ nodes: [
+ { id: 1, callFrame: { functionName: 'privateUserFunction', scriptId: '1', url: 'file:///home/private/file.js?secret=value', lineNumber: 99, columnNumber: 5 } },
+ { id: 2, callFrame: { functionName: 'internalFunction', scriptId: '2', url: 'file:///app/index.js', lineNumber: 12, columnNumber: 3 } },
+ ],
+ samples: [1, 2, 2],
+ timeDeltas: [2000, 3000, 5000],
+}
+describe('bounded CPU capture', () => {
+ it('keeps only build code identifiers and sampled self time, dropping untrusted paths and function names', () => {
+ const result = summarizeCpuProfile(profile, 'main', 42, url => url === 'file:///app/index.js' ? 'index.js' : null)
+ expect(result.hotspots).toEqual([{ location: expect.stringMatching(/^main:[a-f0-9]{12}:13:4$/), selfMs: 8 }, { location: 'external', selfMs: 2 }])
+ expect(JSON.stringify(result)).not.toMatch(/private|secret|Function|file:|index\.js/)
+ expect(() => summarizeCpuProfile({ ...profile, samples: Array.from({ length: 20001 }, (_, index) => index) }, 'main', 42, () => null)).toThrow('CPU_PROFILE_LIMIT')
+ })
+
+ it('releases its inspector connection on cancellation and protocol failures', async () => {
+ const controller = new AbortController()
+ const closed: string[] = []
+ const pending = captureCpuProfile({ send: async () => {
+ controller.abort()
+ return {}
+ }, close: () => closed.push('aborted') }, 'main', 1, () => null, controller.signal)
+ await expect(pending).rejects.toThrow()
+ await expect(captureCpuProfile({ send: async () => {
+ throw new Error('protocol')
+ }, close: () => closed.push('failed') }, 'renderer', 2, () => null)).rejects.toThrow('protocol')
+ expect(closed).toEqual(['aborted', 'failed'])
+ })
+})
diff --git a/apps/buddy/platform/diagnostics/cpuProfile.ts b/apps/buddy/platform/diagnostics/cpuProfile.ts
new file mode 100644
index 00000000..a6f16f55
--- /dev/null
+++ b/apps/buddy/platform/diagnostics/cpuProfile.ts
@@ -0,0 +1,64 @@
+import type { Profiler } from 'node:inspector'
+import type { CpuProfileSummary, CpuProfileTarget } from '../../shared/diagnostics/performanceDiagnostic'
+import { createHash } from 'node:crypto'
+import { Session } from 'node:inspector/promises'
+import process from 'node:process'
+import { setTimeout } from 'node:timers/promises'
+import { cpuProfileSummarySchema } from '../../shared/diagnostics/performanceDiagnostic'
+
+export interface CpuProfilerConnection {
+ send: (method: string, params?: Record) => Promise
+ close: () => void
+}
+
+export async function captureCpuProfile(connection: CpuProfilerConnection, target: CpuProfileTarget, pid: number, trustedCode: (url: string) => string | null, signal?: AbortSignal): Promise {
+ try {
+ signal?.throwIfAborted()
+ await connection.send('Profiler.enable')
+ await connection.send('Profiler.setSamplingInterval', { interval: 10_000 })
+ signal?.throwIfAborted()
+ await connection.send('Profiler.start')
+ await setTimeout(5000, undefined, { signal })
+ const { profile } = await connection.send('Profiler.stop') as { profile: Profiler.Profile }
+ return summarizeCpuProfile(profile, target, pid, trustedCode)
+ }
+ finally {
+ connection.close()
+ }
+}
+
+export async function captureNodeCpuProfile(target: 'main' | 'runtime', signal?: AbortSignal): Promise {
+ const session = new Session()
+ session.connect()
+ const root = new URL(import.meta.url.includes('/chunks/') ? '../' : '.', import.meta.url).href
+ return captureCpuProfile({ send: (method, params) => session.post(method, params), close: () => session.disconnect() }, target, process.pid, (url) => {
+ if (!url.startsWith(root))
+ return null
+ const relative = url.slice(root.length)
+ return /^(?:chunks\/)?[\w-]+\.js$/.test(relative) ? relative : null
+ }, signal)
+}
+
+export function summarizeCpuProfile(profile: Profiler.Profile, target: CpuProfileTarget, pid: number, trustedCode: (url: string) => string | null): CpuProfileSummary {
+ if (profile.nodes.length > 50_000 || (profile.samples?.length ?? 0) > 20_000 || !profile.samples || !profile.timeDeltas || profile.samples.length !== profile.timeDeltas.length)
+ throw new Error('CPU_PROFILE_LIMIT')
+ const locations = new Map()
+ for (const node of profile.nodes) {
+ const frame = node.callFrame
+ const code = trustedCode(frame.url)
+ const builtin = new Map([['(idle)', 'idle'], ['(garbage collector)', 'gc'], ['(program)', 'native'], ['(root)', 'root']])
+ const location = code
+ ? `${target}:${createHash('sha256').update(code).digest('hex').slice(0, 12)}:${Math.max(0, frame.lineNumber + 1)}:${Math.max(0, frame.columnNumber + 1)}`
+ : frame.url ? 'external' : builtin.get(frame.functionName) ?? 'native'
+ locations.set(node.id, location)
+ }
+ const totals = new Map()
+ for (let index = 0; index < profile.samples.length; index++) {
+ const location = locations.get(profile.samples[index]!) ?? 'external'
+ const milliseconds = profile.timeDeltas[index]! / 1000
+ if (!Number.isFinite(milliseconds) || milliseconds < 0)
+ throw new Error('CPU_PROFILE_INVALID')
+ totals.set(location, (totals.get(location) ?? 0) + milliseconds)
+ }
+ return cpuProfileSummarySchema.parse({ target, pid, durationMs: (profile.endTime - profile.startTime) / 1000, samples: profile.samples.length, hotspots: [...totals].map(([location, selfMs]) => ({ location, selfMs })).sort((a, b) => b.selfMs - a.selfMs).slice(0, 20) })
+}
diff --git a/apps/buddy/platform/process/windowsSandboxProcess.ts b/apps/buddy/platform/process/windowsSandboxProcess.ts
index 727bbf86..540acce3 100644
--- a/apps/buddy/platform/process/windowsSandboxProcess.ts
+++ b/apps/buddy/platform/process/windowsSandboxProcess.ts
@@ -1,4 +1,5 @@
import type { Buffer } from 'node:buffer'
+import type { SandboxProcessState } from '../../shared/permissions/sandboxLifecycle'
import { spawn } from 'node:child_process'
import process from 'node:process'
import { createInterface } from 'node:readline'
@@ -27,6 +28,7 @@ export async function runWindowsSandboxProcess(executable: string, request: Wind
signal: AbortSignal
onData: (data: Buffer) => void
onStarted: () => void
+ onProcess?: (process: SandboxProcessState) => void
}): Promise {
options.signal.throwIfAborted()
return new Promise((resolve, reject) => {
@@ -35,7 +37,10 @@ export async function runWindowsSandboxProcess(executable: string, request: Wind
let launchError: Error | undefined
let forceKill: ReturnType | undefined
const child = spawn(executable, ['run'], { cwd: request.privateRoot, env: createWindowsHostEnvironment(executable, process.env), stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
+ child.once('spawn', () => options.onProcess?.({ phase: 'spawned', pid: child.pid }))
+ child.once('exit', exitCode => options.onProcess?.({ phase: 'exited', pid: child.pid, exitCode }))
const cancel = () => {
+ options.onProcess?.({ phase: 'cleanup_requested', pid: child.pid })
child.stdin.end()
forceKill ??= setTimeout(() => child.kill(), 10_000)
forceKill.unref()
@@ -49,6 +54,8 @@ export async function runWindowsSandboxProcess(executable: string, request: Wind
if (!options.signal.aborted)
options.onStarted()
}
+ if (diagnostic.type === 'cleanupError')
+ options.onProcess?.({ phase: 'cleanup_failed', pid: child.pid })
if (diagnostic.type === 'error' || diagnostic.type === 'cleanupError')
failed = true
}
@@ -63,6 +70,7 @@ export async function runWindowsSandboxProcess(executable: string, request: Wind
launchError = error
})
child.once('close', (code) => {
+ options.onProcess?.({ phase: 'closed', pid: child.pid, exitCode: code })
options.signal.removeEventListener('abort', cancel)
clearTimeout(forceKill)
lines.close()
diff --git a/apps/buddy/service/src/diagnostics/DiagnosticForwarder.ts b/apps/buddy/service/src/diagnostics/DiagnosticForwarder.ts
new file mode 100644
index 00000000..0349145f
--- /dev/null
+++ b/apps/buddy/service/src/diagnostics/DiagnosticForwarder.ts
@@ -0,0 +1,64 @@
+import type { ApplicationDiagnostic, ApplicationDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic'
+import { DiagnosticAdmission } from '../../../shared/diagnostics/DiagnosticAdmission'
+
+export class DiagnosticForwarder {
+ readonly #admission = new DiagnosticAdmission()
+ readonly #send: ApplicationDiagnosticReporter
+ readonly #producerInstanceId = crypto.randomUUID()
+ #dropped = 0
+ #failed = 0
+ #reported = 0
+ #timer: ReturnType | undefined
+ #disposed = false
+
+ constructor(send: ApplicationDiagnosticReporter) {
+ this.#send = send
+ }
+
+ record(event: ApplicationDiagnostic): void {
+ if (this.#disposed)
+ return
+ if (!this.#admission.take()) {
+ this.#dropped++
+ this.#scheduleFlush()
+ return
+ }
+ this.#deliver({ ...event, producerInstanceId: this.#producerInstanceId })
+ }
+
+ dispose(): void {
+ if (this.#disposed)
+ return
+ this.#disposed = true
+ clearTimeout(this.#timer)
+ this.#timer = undefined
+ this.#flush()
+ }
+
+ #deliver(event: ApplicationDiagnostic): void {
+ try {
+ this.#send(event)
+ }
+ catch {
+ this.#failed++
+ this.#scheduleFlush()
+ }
+ }
+
+ #scheduleFlush(): void {
+ if (this.#timer || this.#disposed)
+ return
+ this.#timer = setTimeout(() => {
+ this.#timer = undefined
+ this.#flush()
+ }, 5000)
+ this.#timer.unref()
+ }
+
+ #flush(): void {
+ if (this.#reported === this.#dropped + this.#failed)
+ return
+ this.#reported = this.#dropped + this.#failed
+ this.#deliver({ event: 'recorder.loss', level: 'warn', producerInstanceId: this.#producerInstanceId, recorderLoss: { dropped: this.#dropped, failed: this.#failed } })
+ }
+}
diff --git a/apps/buddy/service/src/diagnostics/__tests__/DiagnosticForwarder.spec.ts b/apps/buddy/service/src/diagnostics/__tests__/DiagnosticForwarder.spec.ts
new file mode 100644
index 00000000..a1d0a63a
--- /dev/null
+++ b/apps/buddy/service/src/diagnostics/__tests__/DiagnosticForwarder.spec.ts
@@ -0,0 +1,69 @@
+import type { ApplicationDiagnostic } from '../../../../shared/diagnostics/applicationDiagnostic'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { DiagnosticForwarder } from '../DiagnosticForwarder'
+
+beforeEach(() => vi.useFakeTimers({ toFake: ['performance', 'setTimeout', 'clearTimeout'] }))
+afterEach(() => vi.useRealTimers())
+
+describe('diagnostic transport budget', () => {
+ it('bounds a producer storm and reports cumulative loss during idle without replaying suppressed events', () => {
+ const records: ApplicationDiagnostic[] = []
+ const source = new DiagnosticForwarder(event => records.push(event))
+ for (let index = 0; index < 10000; index++)
+ source.record({ event: 'tool.started', level: 'info', count: index })
+ expect(records).toHaveLength(512)
+ expect(vi.getTimerCount()).toBe(1)
+ vi.advanceTimersByTime(4999)
+ expect(records).toHaveLength(512)
+ vi.advanceTimersByTime(1)
+ expect(records.at(-1)).toMatchObject({ event: 'recorder.loss', recorderLoss: { dropped: 9488, failed: 0 } })
+ expect(vi.getTimerCount()).toBe(0)
+ vi.advanceTimersByTime(30000)
+ source.record({ event: 'tool.completed', level: 'info' })
+ expect(records.at(-1)?.event).toBe('tool.completed')
+ expect(new Set(records.map(event => event.producerInstanceId)).size).toBe(1)
+ source.dispose()
+ expect(records).toHaveLength(514)
+ })
+
+ it('isolates a failed transport and exposes its loss after recovery', () => {
+ let failed = true
+ const records: ApplicationDiagnostic[] = []
+ const source = new DiagnosticForwarder((event) => {
+ if (failed)
+ throw new Error('private transport context')
+ records.push(event)
+ })
+ expect(() => source.record({ event: 'tool.started', level: 'info' })).not.toThrow()
+ vi.advanceTimersByTime(5000)
+ expect(records).toEqual([])
+ expect(vi.getTimerCount()).toBe(1)
+ failed = false
+ vi.advanceTimersByTime(5000)
+ expect(records[0]?.recorderLoss).toEqual({ dropped: 0, failed: 2 })
+ expect(vi.getTimerCount()).toBe(0)
+ expect(JSON.stringify(records)).not.toContain('private')
+ source.dispose()
+ })
+
+ it('flushes remaining losses once on shutdown and stops subsequent delivery and retries', () => {
+ const records: ApplicationDiagnostic[] = []
+ const source = new DiagnosticForwarder(event => records.push(event))
+ for (let index = 0; index < 513; index++)
+ source.record({ event: 'tool.started', level: 'info' })
+ source.dispose()
+ expect(records.at(-1)?.recorderLoss).toEqual({ dropped: 1, failed: 0 })
+ expect(vi.getTimerCount()).toBe(0)
+ source.dispose()
+ source.record({ event: 'tool.completed', level: 'info' })
+ vi.advanceTimersByTime(10000)
+ expect(records).toHaveLength(513)
+
+ const broken = new DiagnosticForwarder(() => {
+ throw new Error('transport closed')
+ })
+ broken.record({ event: 'tool.started', level: 'info' })
+ broken.dispose()
+ expect(vi.getTimerCount()).toBe(0)
+ })
+})
diff --git a/apps/buddy/service/src/index.ts b/apps/buddy/service/src/index.ts
index 9eca9c9e..cf226665 100644
--- a/apps/buddy/service/src/index.ts
+++ b/apps/buddy/service/src/index.ts
@@ -14,6 +14,7 @@ import { toPublicRunEvent } from '../../shared/runs/publicRunEvent'
import { runNotifications } from '../../shared/runs/runApi'
import { buddyServiceFailureCodeSchema } from '../../shared/runtime/runtimeProtocol'
import { startBuddyService } from './BuddyService'
+import { DiagnosticForwarder } from './diagnostics/DiagnosticForwarder'
import { createRunEventLog } from './events/createRunEventLog'
import { RunEventLogFatalError } from './events/RunEventFailure'
import { startRuntimeNetwork } from './network/runtimeNetwork'
@@ -50,7 +51,8 @@ async function runBuddyService(): Promise {
let database: ReturnType | null = null
let serviceServer: ReturnType | null = null
const events = new ApplicationEvents()
- events.subscribe(event => serviceServer?.notify(APPLICATION_DIAGNOSTIC_METHOD, event))
+ const diagnostics = new DiagnosticForwarder(event => serviceServer?.notify(APPLICATION_DIAGNOSTIC_METHOD, event))
+ events.subscribe(event => diagnostics.record(event))
const record = events.publish
const lifecycle = new ServiceLifecycleSource(() => record({ event: 'observer.failed', component: 'runtime.lifecycle', level: 'warn' }))
const host = new ServiceHost(lifecycle)
@@ -91,6 +93,7 @@ async function runBuddyService(): Promise {
record({ event: failed ? 'service.stop_failed' : 'service.stopped', level: failed ? 'error' : 'info', component: 'runtime.service' })
lifecycleDelivery.dispose()
stopLifecycleDiagnostics()
+ diagnostics.dispose()
serviceServer?.close(new Error('Buddy Local Service is shutting down'))
process.exit(failed ? 1 : exitCode)
}
@@ -174,6 +177,7 @@ async function runBuddyService(): Promise {
await host.stop().catch(() => {})
lifecycleDelivery.dispose()
stopLifecycleDiagnostics()
+ diagnostics.dispose()
serviceServer.close(new Error('Buddy Local Service startup failed'))
closeDatabase()
throw error
diff --git a/apps/buddy/service/src/rpc/BuddyServiceRpcServer.ts b/apps/buddy/service/src/rpc/BuddyServiceRpcServer.ts
index 6497b8ae..a16cf54c 100644
--- a/apps/buddy/service/src/rpc/BuddyServiceRpcServer.ts
+++ b/apps/buddy/service/src/rpc/BuddyServiceRpcServer.ts
@@ -2,8 +2,10 @@ import type { ApplicationDiagnosticReporter } from '../../../shared/diagnostics/
import type { RuntimeRequestHandler, RuntimeRpcPeerContract } from '../../../shared/runtime/rpcPeer'
import type { BuddyServiceFailureCode } from '../../../shared/runtime/runtimeProtocol'
import process from 'node:process'
+import { captureNodeCpuProfile } from '../../../platform/diagnostics/cpuProfile'
import { RuntimeRpcPeer } from '../../../platform/ipc/runtimeRpcPeer'
import { readDiagnosticErrorCode, safeDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic'
+import { cpuProfileRequestSchema } from '../../../shared/diagnostics/performanceDiagnostic'
import { isRoutineRpc } from '../../../shared/diagnostics/rpcDiagnosticPolicy'
import {
BUDDY_SERVICE_PROTOCOL_VERSION,
@@ -103,6 +105,19 @@ export function createBuddyService(options: CreateBuddyServiceOptions): BuddySer
port: options.port,
onFatalError: options.onFatalError,
})
+ let profiling = false
+ server.onRequest('diagnostics.profile', async (input, signal) => {
+ const request = cpuProfileRequestSchema.parse(input)
+ if (request.target !== 'runtime' || request.pid !== process.pid)
+ throw new Error('CPU_PROFILE_PROCESS_CHANGED')
+ if (profiling)
+ throw new Error('CPU_PROFILE_BUSY')
+ profiling = true
+ try {
+ return await captureNodeCpuProfile('runtime', signal)
+ }
+ finally { profiling = false }
+ })
server.onRequest('runtime.status', () => ({
name: SERVICE_NAME,
protocolVersion: BUDDY_SERVICE_PROTOCOL_VERSION,
diff --git a/apps/buddy/service/src/sandbox/__tests__/ShellSandboxClient.spec.ts b/apps/buddy/service/src/sandbox/__tests__/ShellSandboxClient.spec.ts
index 86dba5f0..d0a6d4e3 100644
--- a/apps/buddy/service/src/sandbox/__tests__/ShellSandboxClient.spec.ts
+++ b/apps/buddy/service/src/sandbox/__tests__/ShellSandboxClient.spec.ts
@@ -3,6 +3,8 @@ import type { RuntimeRpcPeerContract } from '../../../../shared/runtime/rpcPeer'
import type { SandboxClientEvent } from '../ShellSandboxClient'
import { deferred } from '@buddy-tests/deferred'
import { describe, expect, it } from 'vitest'
+import { diagnosticContext } from '../../diagnostics/diagnosticContext'
+import { observeSandboxClient } from '../observeSandboxClient'
import { ShellSandboxClient } from '../ShellSandboxClient'
function fixture() {
@@ -36,6 +38,20 @@ function fixture() {
}
describe('sandbox client facts', () => {
+ it('retains tool correlation across incoming backend notifications outside the original async scope', async () => {
+ const f = fixture()
+ const records: import('../../../../shared/diagnostics/applicationDiagnostic').ApplicationDiagnostic[] = []
+ const subscription = observeSandboxClient(f.client, event => records.push(event))
+ const pending = diagnosticContext.run({ runId: 'run-fixture', toolCallId: 'tool-fixture' }, f.execute)
+ f.backend(1, 'started')
+ f.response.resolve({ ok: true, exitCode: 0 })
+ await pending
+ expect(records).toHaveLength(3)
+ expect(records.every(event => event.runId === 'run-fixture' && event.toolCallId === 'tool-fixture')).toBe(true)
+ subscription.dispose()
+ await f.client.dispose()
+ })
+
it('keeps successful settlement distinct from cleanup and ignores stale backend messages', async () => {
const f = fixture()
const pending = f.execute()
diff --git a/apps/buddy/service/src/sandbox/__tests__/shellSandbox.spec.ts b/apps/buddy/service/src/sandbox/__tests__/shellSandbox.spec.ts
index ea869cd3..4ba8a34f 100644
--- a/apps/buddy/service/src/sandbox/__tests__/shellSandbox.spec.ts
+++ b/apps/buddy/service/src/sandbox/__tests__/shellSandbox.spec.ts
@@ -71,6 +71,18 @@ describe.skipIf(process.platform !== 'linux')('linux shell enforcement', () => {
return { result, output: Buffer.concat(output).toString('utf8') }
}
+ it('records command process exit, pipe close and resource release without command contents', async () => {
+ const events: import('../../../../shared/permissions/sandboxLifecycle').SandboxLifecycleEvent[] = []
+ const { result } = await execute('printf private-fixture-content', { onLifecycle: event => events.push(event) })
+ expect(result).toEqual({ ok: true, exitCode: 0 })
+ const processes = events.filter(event => event.kind === 'process').map(event => event.snapshot.process!)
+ expect(processes.map(process => process.phase)).toEqual(['spawned', 'exited', 'closed', 'cleanup_requested', 'resources_released'])
+ expect(processes[0]?.pid).toBeGreaterThan(0)
+ expect(new Set(processes.map(process => process.pid)).size).toBe(1)
+ expect(processes.find(process => process.phase === 'exited')?.exitCode).toBe(0)
+ expect(JSON.stringify(events)).not.toMatch(/private-fixture|printf/)
+ })
+
it('executes compound commands and workspace edits while masking secrets, symlink escapes and Git metadata', async () => {
await mkdir(join(workspace, '.git'))
await writeFile(join(workspace, '.git/config'), 'git-preserved')
diff --git a/apps/buddy/service/src/sandbox/backends/srt/runSrtSandbox.ts b/apps/buddy/service/src/sandbox/backends/srt/runSrtSandbox.ts
index 07ec08f4..7b5b2c6a 100644
--- a/apps/buddy/service/src/sandbox/backends/srt/runSrtSandbox.ts
+++ b/apps/buddy/service/src/sandbox/backends/srt/runSrtSandbox.ts
@@ -10,6 +10,18 @@ export async function runSrtSandbox(input: SrtSandboxInput, options: SandboxExec
let child: ChildProcess | undefined
const kill = () => killProcessGroup(child?.pid)
options.signal.addEventListener('abort', kill, { once: true })
+ const release = async () => {
+ options.onProcess?.({ phase: 'cleanup_requested', pid: child?.pid })
+ try {
+ kill()
+ await SandboxManager.reset()
+ options.onProcess?.({ phase: 'resources_released', pid: child?.pid })
+ }
+ catch (error) {
+ options.onProcess?.({ phase: 'cleanup_failed', pid: child?.pid })
+ throw error
+ }
+ }
try {
const { config, path } = await createSandboxPolicy(input, options.signal)
await SandboxManager.initialize(config, ({ host, port }) => options.approveNetwork({ host, port: port ?? 443 }), false)
@@ -32,16 +44,22 @@ export async function runSrtSandbox(input: SrtSandboxInput, options: SandboxExec
child.stdout!.on('data', options.onData)
child.stderr!.on('data', options.onData)
child.once('error', reject)
- child.once('close', resolve)
- child.once('spawn', options.onStarted)
+ child.once('exit', exitCode => options.onProcess?.({ phase: 'exited', pid: child?.pid, exitCode }))
+ child.once('close', (exitCode) => {
+ options.onProcess?.({ phase: 'closed', pid: child?.pid, exitCode })
+ resolve(exitCode)
+ })
+ child.once('spawn', () => {
+ options.onProcess?.({ phase: 'spawned', pid: child?.pid })
+ options.onStarted()
+ })
if (options.signal.aborted)
kill()
})
}
finally {
options.signal.removeEventListener('abort', kill)
- kill()
- await SandboxManager.reset()
+ await release()
}
}
diff --git a/apps/buddy/service/src/sandbox/observeSandboxClient.ts b/apps/buddy/service/src/sandbox/observeSandboxClient.ts
index 5e05b150..321c4403 100644
--- a/apps/buddy/service/src/sandbox/observeSandboxClient.ts
+++ b/apps/buddy/service/src/sandbox/observeSandboxClient.ts
@@ -1,20 +1,34 @@
import type { ApplicationDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic'
+import type { EventContext } from '../../../shared/observability/ApplicationEvents'
import type { ShellSandboxClient } from './ShellSandboxClient'
import { safeDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic'
+import { diagnosticContext } from '../diagnostics/diagnosticContext'
export function observeSandboxClient(source: Pick, report: ApplicationDiagnosticReporter) {
const record = safeDiagnosticReporter(report)
- return source.onDidChange((event) => {
+ const contexts = new Map()
+ const subscription = source.onDidChange((event) => {
+ if (event.kind === 'requested' && contexts.size < 64)
+ contexts.set(event.requestId, { ...diagnosticContext.getStore() })
const kind = event.kind === 'backend' ? `backend.${event.event.kind}` : event.kind
+ const cleanupFailed = event.kind === 'backend' && event.event.snapshot.process?.phase === 'cleanup_failed'
const result = event.kind === 'returned' ? event.result : event.kind === 'backend' ? event.event.snapshot.result : undefined
record({
+ ...contexts.get(event.requestId),
event: `sandbox.${kind.replaceAll('-', '_')}`,
- level: event.kind === 'transport-failed' || result?.ok === false ? 'warn' : 'info',
+ level: event.kind === 'transport-failed' || result?.ok === false || cleanupFailed ? 'warn' : 'info',
requestId: event.requestId,
revision: event.revision,
- ...(event.kind === 'backend' ? { count: event.event.snapshot.waiting, sourceSequence: event.event.revision } : {}),
+ ...(event.kind === 'backend' ? { count: event.event.snapshot.waiting, sourceSequence: event.event.revision, sandboxProcess: event.event.snapshot.process } : {}),
+ ...(cleanupFailed ? { errorCode: 'SANDBOX_CLEANUP_FAILED' } : {}),
...(result?.ok === false ? { errorCode: result.code } : {}),
...(event.kind === 'transport-failed' ? { errorCode: 'SANDBOX_TRANSPORT_FAILED' } : {}),
})
+ if (event.kind === 'returned' || event.kind === 'transport-failed')
+ contexts.delete(event.requestId)
})
+ return { dispose: () => {
+ subscription.dispose()
+ contexts.clear()
+ } }
}
diff --git a/apps/buddy/service/src/sandbox/runSandboxCommand.ts b/apps/buddy/service/src/sandbox/runSandboxCommand.ts
index b1af016b..011541b4 100644
--- a/apps/buddy/service/src/sandbox/runSandboxCommand.ts
+++ b/apps/buddy/service/src/sandbox/runSandboxCommand.ts
@@ -7,7 +7,7 @@ import { runSrtSandbox } from './backends/srt/runSrtSandbox'
import { runWindowsSandbox } from './backends/windows/runWindowsSandbox'
import { SandboxExecutionLifecycle } from './sandboxExecutionLifecycle'
-export async function runSandboxCommand(input: SandboxProcessInput, options: Omit & { onLifecycle?: (event: Readonly) => void }): Promise {
+export async function runSandboxCommand(input: SandboxProcessInput, options: Omit & { onLifecycle?: (event: Readonly) => void }): Promise {
const lifecycle = new SandboxExecutionLifecycle(options.signal, input.timeout)
const subscription = options.onLifecycle ? lifecycle.onDidChange(options.onLifecycle) : undefined
lifecycle.begin()
@@ -31,6 +31,7 @@ export async function runSandboxCommand(input: SandboxProcessInput, options: Omi
...options,
signal: lifecycle.signal,
approveNetwork: requestNetwork,
+ onProcess: process => lifecycle.processChanged(process),
onStarted: () => {
if (!lifecycle.signal.aborted)
lifecycle.start()
diff --git a/apps/buddy/service/src/sandbox/sandboxExecutionLifecycle.ts b/apps/buddy/service/src/sandbox/sandboxExecutionLifecycle.ts
index 4f3f1e58..b937b655 100644
--- a/apps/buddy/service/src/sandbox/sandboxExecutionLifecycle.ts
+++ b/apps/buddy/service/src/sandbox/sandboxExecutionLifecycle.ts
@@ -1,6 +1,6 @@
import type { Buffer } from 'node:buffer'
import type { EventSnapshot } from '../../../shared/events/eventTypes'
-import type { SandboxLifecycleEvent, SandboxLifecycleSnapshot } from '../../../shared/permissions/sandboxLifecycle'
+import type { SandboxLifecycleEvent, SandboxLifecycleSnapshot, SandboxProcessState } from '../../../shared/permissions/sandboxLifecycle'
import type { SandboxNetworkTarget, SandboxResult } from '../../../shared/permissions/shellSandbox'
import { Emitter } from '../../../shared/events/Emitter'
import { copyEventSnapshot } from '../../../shared/events/eventSnapshot'
@@ -10,6 +10,7 @@ export interface SandboxExecutionOptions {
approveNetwork: (target: SandboxNetworkTarget) => Promise
onData: (data: Buffer) => void
onStarted: () => void
+ onProcess?: (process: SandboxProcessState) => void
}
export class SandboxExecutionLifecycle {
@@ -36,6 +37,7 @@ export class SandboxExecutionLifecycle {
#phase: SandboxLifecycleSnapshot['phase'] = 'preparing'
#cancellation: SandboxLifecycleSnapshot['cancellation'] = 'none'
#result: SandboxResult | undefined
+ #process: SandboxProcessState | undefined
constructor(signal: AbortSignal, timeoutSeconds = 30 * 60, preparationTimeoutMs = 60_000) {
this.#externalSignal = signal
@@ -53,7 +55,14 @@ export class SandboxExecutionLifecycle {
get started(): boolean { return this.#started }
get timedOut(): boolean { return this.#cancellation === 'timed-out' }
get snapshot(): EventSnapshot {
- return copyEventSnapshot({ phase: this.#phase, started: this.#started, waiting: this.#waiting, cancellation: this.#cancellation, ...this.#result ? { result: this.#result } : {} })
+ return copyEventSnapshot({ phase: this.#phase, started: this.#started, waiting: this.#waiting, cancellation: this.#cancellation, process: this.#process, ...this.#result ? { result: this.#result } : {} })
+ }
+
+ processChanged(process: SandboxProcessState): void {
+ if (this.#phase === 'finished')
+ return
+ this.#process = { ...process }
+ this.#publish('process')
}
begin(): void {
diff --git a/apps/buddy/shared/diagnostics/DiagnosticAdmission.ts b/apps/buddy/shared/diagnostics/DiagnosticAdmission.ts
new file mode 100644
index 00000000..f425d434
--- /dev/null
+++ b/apps/buddy/shared/diagnostics/DiagnosticAdmission.ts
@@ -0,0 +1,14 @@
+export class DiagnosticAdmission {
+ #tokens = 512
+ #updatedAt = performance.now()
+
+ take(): boolean {
+ const now = performance.now()
+ this.#tokens = Math.min(512, this.#tokens + Math.max(0, now - this.#updatedAt) * 0.1)
+ this.#updatedAt = now
+ if (this.#tokens < 1)
+ return false
+ this.#tokens--
+ return true
+ }
+}
diff --git a/apps/buddy/shared/diagnostics/applicationDiagnostic.ts b/apps/buddy/shared/diagnostics/applicationDiagnostic.ts
index 4daee862..c9b53dc2 100644
--- a/apps/buddy/shared/diagnostics/applicationDiagnostic.ts
+++ b/apps/buddy/shared/diagnostics/applicationDiagnostic.ts
@@ -2,8 +2,10 @@ import type { LifecycleFailure } from '../lifecycle/lifecycleFailure'
import { z } from 'zod'
import { extensionIdSchema } from '../extensions/extensionManifest'
import { readLifecycleFailure } from '../lifecycle/lifecycleFailure'
+import { sandboxProcessStateSchema } from '../permissions/sandboxLifecycle'
import { desktopBootstrapFailureSchema, processExitSchema, rendererLoadFailureSchema } from './desktopStartupDiagnostic'
import { networkStartupFailureSchema } from './networkStartupFailure'
+import { cpuProfileSummarySchema, performanceSampleSchema } from './performanceDiagnostic'
import { privateDirectoryFailureSchema } from './privateDirectoryFailure'
import { providerRequestDiagnosticSchema } from './providerRequestDiagnostic'
@@ -44,6 +46,10 @@ export const applicationDiagnosticSchema = z.object({
errorType: z.string().regex(/^[a-z]\w{0,95}$/i).optional(),
failure: z.union([privateDirectoryFailureSchema, desktopBootstrapFailureSchema, networkStartupFailureSchema]).optional(),
providerRequest: providerRequestDiagnosticSchema.optional(),
+ performanceSample: performanceSampleSchema.optional(),
+ cpuProfile: cpuProfileSummarySchema.optional(),
+ output: z.object({ bytes: z.number().int().nonnegative(), chunks: z.number().int().nonnegative() }).strict().optional(),
+ sandboxProcess: sandboxProcessStateSchema.optional(),
recorderLoss: z.object({ dropped: z.number().int().nonnegative(), failed: z.number().int().nonnegative() }).strict().optional(),
processExit: processExitSchema.optional(),
loadFailure: rendererLoadFailureSchema.optional(),
diff --git a/apps/buddy/shared/diagnostics/applicationLog.ts b/apps/buddy/shared/diagnostics/applicationLog.ts
index b352c163..a3d83ca3 100644
--- a/apps/buddy/shared/diagnostics/applicationLog.ts
+++ b/apps/buddy/shared/diagnostics/applicationLog.ts
@@ -70,7 +70,7 @@ export interface ApplicationLogApi {
const domains: ReadonlyArray = [
['models', ['provider', 'providers', 'models']],
['automations', ['automation', 'automations', 'scheduler']],
- ['execution', ['run', 'runs', 'turn', 'tool', 'session', 'sessions', 'session_factory', 'execution', 'chat', 'approvals', 'usage']],
+ ['execution', ['sandbox', 'run', 'runs', 'turn', 'tool', 'session', 'sessions', 'session_factory', 'execution', 'chat', 'approvals', 'usage']],
['storage', ['database', 'event_log', 'event_replay', 'recovery', 'artifacts', 'attachments', 'filesystem', 'spaces']],
['capabilities', ['connector', 'connectors', 'mcp', 'skills', 'web', 'browser', 'browser_adapter', 'pet', 'guard']],
['recorder', ['recorder', 'observer']],
@@ -92,7 +92,7 @@ export function applicationLogCategory(record: Pick
+export type ProcessRole = z.infer
+
+export const cpuProfileTargetSchema = z.enum(['main', 'runtime', 'renderer'])
+export type CpuProfileTarget = z.infer
+export const cpuProfileRequestSchema = z.object({ target: cpuProfileTargetSchema, pid: count }).strict()
+export type CpuProfileRequest = z.infer
+export const cpuProfileSummarySchema = z.object({
+ target: cpuProfileTargetSchema,
+ pid: count,
+ durationMs: measurement,
+ samples: count,
+ hotspots: z.array(z.object({
+ location: z.string().regex(/^(?:main|runtime|renderer|dependency|external|idle|gc|native|root)(?::[a-f0-9]{12}:\d+:\d+)?$/),
+ selfMs: measurement,
+ }).strict()).max(20),
+}).strict()
+export type CpuProfileSummary = z.infer
+
+export interface PerformanceDiagnosticApi {
+ snapshot: () => Promise<{ samples: PerformanceSample[], coverage: 'electron-processes', intervalMs: number, rendererPid: number }>
+ capture: (request: CpuProfileRequest) => Promise
+}
diff --git a/apps/buddy/shared/permissions/sandboxLifecycle.ts b/apps/buddy/shared/permissions/sandboxLifecycle.ts
index 04d39ae6..2458c258 100644
--- a/apps/buddy/shared/permissions/sandboxLifecycle.ts
+++ b/apps/buddy/shared/permissions/sandboxLifecycle.ts
@@ -1,18 +1,26 @@
import { z } from 'zod'
import { sandboxResultSchema } from './shellSandbox'
+export const sandboxProcessStateSchema = z.object({
+ pid: z.number().int().positive().optional(),
+ exitCode: z.number().int().nullable().optional(),
+ phase: z.enum(['spawned', 'exited', 'closed', 'cleanup_requested', 'resources_released', 'cleanup_failed']),
+}).strict()
+export type SandboxProcessState = z.infer
+
export const sandboxLifecycleSnapshotSchema = z.object({
phase: z.enum(['preparing', 'running', 'finished']),
started: z.boolean(),
waiting: z.number().int().nonnegative().max(1024),
cancellation: z.enum(['none', 'requested', 'timed-out']),
result: sandboxResultSchema.optional(),
+ process: sandboxProcessStateSchema.optional(),
}).strict()
export type SandboxLifecycleSnapshot = z.infer
export const sandboxLifecycleEventSchema = z.object({
revision: z.number().int().positive(),
- kind: z.enum(['preparing', 'started', 'approval-wait', 'approval-settled', 'approval-resumed', 'cancel-requested', 'timed-out', 'settled', 'released']),
+ kind: z.enum(['preparing', 'started', 'approval-wait', 'approval-settled', 'approval-resumed', 'cancel-requested', 'timed-out', 'settled', 'released', 'process']),
snapshot: sandboxLifecycleSnapshotSchema,
}).strict()
export type SandboxLifecycleEvent = z.infer
diff --git a/apps/buddy/src/i18n/locales/en-US/applicationLogs.ts b/apps/buddy/src/i18n/locales/en-US/applicationLogs.ts
index c5f97db0..c9815be3 100644
--- a/apps/buddy/src/i18n/locales/en-US/applicationLogs.ts
+++ b/apps/buddy/src/i18n/locales/en-US/applicationLogs.ts
@@ -1,6 +1,37 @@
import type zhCN from '../zh-CN/applicationLogs'
export default {
+ 'applicationLogs.performance.sample': 'Process load snapshot',
+ 'applicationLogs.performance.profile': 'CPU profile summary',
+ 'applicationLogs.performance.sustained': 'Sustained CPU load',
+
+ 'applicationLogs.performance.title': 'Performance diagnostics',
+ 'applicationLogs.performance.unavailable': 'Process metrics are temporarily unavailable.',
+ 'applicationLogs.performance.wait': 'This takes about 5 seconds. Please wait.',
+ 'applicationLogs.performance.completed': 'Analysis complete for {process} (PID {pid})',
+ 'applicationLogs.performance.progress': 'Analyzing {process} (PID {pid})',
+ 'applicationLogs.performance.metricsOnly': 'Metrics only',
+ 'applicationLogs.performance.analysis': 'CPU analysis',
+ 'applicationLogs.performance.memory': 'Memory',
+ 'applicationLogs.performance.currentWindow': 'Interface · Current window',
+ 'applicationLogs.performance.scope': 'Collection details',
+ 'applicationLogs.performance.metrics': 'Updates every 5 seconds · CPU normalized to the whole machine',
+ 'applicationLogs.performance.privacy': 'Analysis adds brief overhead. Results stay on this device; no automatic uploads.',
+ 'applicationLogs.performance.coverage': 'Only performance summaries are recorded, without conversations, file contents or raw stacks. Metrics exclude external children such as FFmpeg and the desktop pet.',
+ 'applicationLogs.performance.process': 'Process',
+ 'applicationLogs.performance.main': 'Main',
+ 'applicationLogs.performance.runtime': 'Task runtime',
+ 'applicationLogs.performance.renderer': 'Interface',
+ 'applicationLogs.performance.gpu': 'Graphics',
+ 'applicationLogs.performance.network': 'Network',
+ 'applicationLogs.performance.sandbox': 'Sandbox host',
+ 'applicationLogs.performance.utility': 'Utility',
+ 'applicationLogs.performance.other': 'Other',
+ 'applicationLogs.performance.capture': 'Analyze 5 seconds',
+ 'applicationLogs.performance.capturing': 'Analyzing…',
+ 'applicationLogs.performance.failed': 'Analysis could not finish. The process may have restarted or be under debugging. Wait for the list to update and try again.',
+ 'applicationLogs.performance.saved': 'Export a diagnostic bundle for further investigation.',
+
'desktop.settings.category.logs': 'Logs',
'applicationLogs.description': 'Explore application activity, state changes and errors.',
'applicationLogs.open': 'View logs',
@@ -30,7 +61,7 @@ export default {
'applicationLogs.refresh': 'Refresh logs',
'applicationLogs.exportDiagnostics': 'Export diagnostics',
'applicationLogs.exportScope': 'Collection scope',
- 'applicationLogs.exportErrors': 'Recent errors and related incidents; recent activity when no errors exist',
+ 'applicationLogs.exportErrors': 'Includes recent errors, performance diagnostics and related operations; falls back to recent activity when none are retained',
'applicationLogs.exportContext': 'Includes related starts, retries and outcomes. Limits and gaps are noted in the bundle.',
'applicationLogs.exportRelated': 'Export related diagnostics',
'applicationLogs.exportSelected': 'Events from the same run or operation as the selected record',
@@ -39,7 +70,7 @@ export default {
'applicationLogs.exportPrivacyDescription': 'Structured activity only. No conversation or file contents, raw requests or responses, credentials or personal paths.',
'applicationLogs.exportLocalOnly': 'Saved locally, never uploaded automatically. Review before sharing.',
'applicationLogs.exportSave': 'Export ZIP',
- 'applicationLogs.exportSucceeded': 'Saved {count} error records and {context} context events',
+ 'applicationLogs.exportSucceeded': 'Diagnostic bundle saved with {context} related activity records',
'applicationLogs.exportEmpty': 'No logs were recorded in the selected launch scope',
'applicationLogs.exportFailed': 'Could not export diagnostics. Please try again.',
'applicationLogs.live': 'Live',
diff --git a/apps/buddy/src/i18n/locales/zh-CN/applicationLogs.ts b/apps/buddy/src/i18n/locales/zh-CN/applicationLogs.ts
index 37f0851e..38a08476 100644
--- a/apps/buddy/src/i18n/locales/zh-CN/applicationLogs.ts
+++ b/apps/buddy/src/i18n/locales/zh-CN/applicationLogs.ts
@@ -1,4 +1,35 @@
export default {
+ 'applicationLogs.performance.sample': '进程负载快照',
+ 'applicationLogs.performance.profile': 'CPU 采样摘要',
+ 'applicationLogs.performance.sustained': '持续高占用记录',
+
+ 'applicationLogs.performance.title': '性能诊断',
+ 'applicationLogs.performance.unavailable': '暂时无法获取进程指标。',
+ 'applicationLogs.performance.wait': '约 5 秒后完成,请稍候。',
+ 'applicationLogs.performance.completed': '已完成{process}(PID {pid})的分析',
+ 'applicationLogs.performance.progress': '正在分析{process}(PID {pid})',
+ 'applicationLogs.performance.metricsOnly': '仅显示指标',
+ 'applicationLogs.performance.analysis': 'CPU 分析',
+ 'applicationLogs.performance.memory': '内存',
+ 'applicationLogs.performance.currentWindow': '界面 · 当前窗口',
+ 'applicationLogs.performance.scope': '采集说明',
+ 'applicationLogs.performance.metrics': '每 5 秒更新 · CPU 以整机 100% 计',
+ 'applicationLogs.performance.privacy': '分析会短暂增加开销;结果仅存本机,不自动上传。',
+ 'applicationLogs.performance.coverage': '仅记录性能摘要,不含对话、文件内容或原始堆栈。指标不含 FFmpeg 等外部子进程和桌宠。',
+ 'applicationLogs.performance.process': '进程',
+ 'applicationLogs.performance.main': '主进程',
+ 'applicationLogs.performance.runtime': '任务运行时',
+ 'applicationLogs.performance.renderer': '界面',
+ 'applicationLogs.performance.gpu': '图形',
+ 'applicationLogs.performance.network': '网络',
+ 'applicationLogs.performance.sandbox': '沙箱宿主',
+ 'applicationLogs.performance.utility': '辅助进程',
+ 'applicationLogs.performance.other': '其他',
+ 'applicationLogs.performance.capture': '分析 5 秒',
+ 'applicationLogs.performance.capturing': '分析中…',
+ 'applicationLogs.performance.failed': '分析未完成,进程可能已重启或正在被调试。请确认列表更新后重试。',
+ 'applicationLogs.performance.saved': '导出诊断包可用于后续排查。',
+
'desktop.settings.category.logs': '日志',
'applicationLogs.description': '查看应用各部分的运行记录、状态变化与异常。',
'applicationLogs.open': '查看日志',
@@ -28,7 +59,7 @@ export default {
'applicationLogs.refresh': '刷新日志',
'applicationLogs.exportDiagnostics': '导出诊断包',
'applicationLogs.exportScope': '采集范围',
- 'applicationLogs.exportErrors': '采集最近错误及相关操作;没有错误时采集最近活动',
+ 'applicationLogs.exportErrors': '采集最近错误、性能诊断及相关操作;没有此类记录时采集最近活动',
'applicationLogs.exportContext': '保留关联运行的开始、重试和结果;截断或缺失会在包内标明',
'applicationLogs.exportRelated': '导出相关诊断',
'applicationLogs.exportSelected': '围绕当前记录,采集同一次运行或操作的相关事件',
@@ -37,7 +68,7 @@ export default {
'applicationLogs.exportPrivacyDescription': '仅包含结构化运行信息,不含对话或文件正文、原始请求与响应、凭据或个人路径。',
'applicationLogs.exportLocalOnly': '文件仅保存在本机,不会自动上传。分享前请检查内容。',
'applicationLogs.exportSave': '导出 ZIP',
- 'applicationLogs.exportSucceeded': '已保存 {count} 条错误和 {context} 条上下文记录',
+ 'applicationLogs.exportSucceeded': '诊断包已保存,包含 {context} 条相关运行记录',
'applicationLogs.exportEmpty': '所选启动范围暂无日志记录',
'applicationLogs.exportFailed': '诊断包导出失败,请稍后重试',
'applicationLogs.live': '实时更新',
diff --git a/apps/buddy/src/modules/settings/model/applicationLogPresentation.ts b/apps/buddy/src/modules/settings/model/applicationLogPresentation.ts
index 892fed1b..2b7b2b84 100644
--- a/apps/buddy/src/modules/settings/model/applicationLogPresentation.ts
+++ b/apps/buddy/src/modules/settings/model/applicationLogPresentation.ts
@@ -2,6 +2,9 @@ import type { ApplicationLogRecord } from '@buddy-shared/diagnostics/application
import type { BuddyI18nKey, BuddyLocale, BuddyTranslate } from '@/i18n/buddyI18n'
const eventTitles: Record = {
+ 'performance.sample': 'applicationLogs.performance.sample',
+ 'performance.cpu_profile': 'applicationLogs.performance.profile',
+ 'performance.sustained_cpu': 'applicationLogs.performance.sustained',
'app.starting': 'applicationLogs.event.appStarting',
'app.ready': 'applicationLogs.event.appReady',
'app.stopping': 'applicationLogs.event.appStopping',
diff --git a/apps/buddy/src/modules/settings/pages/DesktopLogsSettingsView.vue b/apps/buddy/src/modules/settings/pages/DesktopLogsSettingsView.vue
index cd2aeedb..718fbee6 100644
--- a/apps/buddy/src/modules/settings/pages/DesktopLogsSettingsView.vue
+++ b/apps/buddy/src/modules/settings/pages/DesktopLogsSettingsView.vue
@@ -1,16 +1,65 @@
-
+
+
+
+
+
diff --git a/apps/buddy/src/modules/settings/state/__tests__/usePerformanceDiagnostics.spec.ts b/apps/buddy/src/modules/settings/state/__tests__/usePerformanceDiagnostics.spec.ts
new file mode 100644
index 00000000..2bb70eec
--- /dev/null
+++ b/apps/buddy/src/modules/settings/state/__tests__/usePerformanceDiagnostics.spec.ts
@@ -0,0 +1,69 @@
+// @vitest-environment jsdom
+import type { CpuProfileSummary, PerformanceDiagnosticApi } from '@buddy-shared/diagnostics/performanceDiagnostic'
+import { deferred } from '@buddy-tests/deferred'
+import { afterEach, expect, it, vi } from 'vitest'
+import { createApp } from 'vue'
+import { usePerformanceDiagnostics } from '../usePerformanceDiagnostics'
+
+const cleanups: (() => void)[] = []
+afterEach(() => {
+ for (const cleanup of cleanups.splice(0)) cleanup()
+ vi.restoreAllMocks()
+ vi.useRealTimers()
+})
+
+function mount(api: PerformanceDiagnosticApi) {
+ const root = document.createElement('div')
+ document.body.append(root)
+ let state!: ReturnType
+ const app = createApp({ setup() {
+ state = usePerformanceDiagnostics(api)
+ return () => null
+ } })
+ app.mount(root)
+ let disposed = false
+ const dispose = () => {
+ if (disposed)
+ return
+ disposed = true
+ app.unmount()
+ root.remove()
+ }
+ cleanups.push(dispose)
+ return { state, dispose }
+}
+
+it('stops polling and ignores pending snapshots and profiles when the dialog closes', async () => {
+ vi.useFakeTimers()
+ vi.spyOn(document, 'hidden', 'get').mockReturnValue(false)
+ const snapshot = deferred>>()
+ const profile = deferred()
+ const { state, dispose } = mount({ snapshot: () => snapshot.promise, capture: () => profile.promise })
+ const analyzing = state.capture({ target: 'runtime', pid: 42 })
+ expect(state.capturing.value).toEqual({ target: 'runtime', pid: 42 })
+ expect(state.reading.value).toBe(true)
+ dispose()
+ snapshot.resolve({ samples: [], coverage: 'electron-processes', intervalMs: 5000, rendererPid: 17 })
+ profile.resolve({ target: 'runtime', pid: 42, durationMs: 5000, samples: 1, hotspots: [] })
+ await analyzing
+ await vi.advanceTimersByTimeAsync(15000)
+ expect(state.rendererPid.value).toBeNull()
+ expect(state.profile.value).toBeNull()
+ expect(vi.getTimerCount()).toBe(0)
+})
+
+it('keeps snapshot availability separate from manual analysis failures and allows retry', async () => {
+ vi.useFakeTimers()
+ vi.spyOn(document, 'hidden', 'get').mockReturnValue(false)
+ const api: PerformanceDiagnosticApi = {
+ snapshot: async () => ({ samples: [], coverage: 'electron-processes', intervalMs: 5000, rendererPid: 17 }),
+ capture: async () => { throw new Error('CPU_PROFILE_PROCESS_CHANGED') },
+ }
+ const { state } = mount(api)
+ await expect(state.capture({ target: 'runtime', pid: 42 })).rejects.toThrow('CPU_PROFILE_PROCESS_CHANGED')
+ expect(state.capturing.value).toBeNull()
+ expect(state.unavailable.value).toBe(false)
+ api.capture = async request => ({ ...request, durationMs: 5000, samples: 1, hotspots: [] })
+ await state.capture({ target: 'runtime', pid: 43 })
+ expect(state.profile.value).toMatchObject({ target: 'runtime', pid: 43, samples: 1 })
+})
diff --git a/apps/buddy/src/modules/settings/state/usePerformanceDiagnostics.ts b/apps/buddy/src/modules/settings/state/usePerformanceDiagnostics.ts
new file mode 100644
index 00000000..fcd93b3d
--- /dev/null
+++ b/apps/buddy/src/modules/settings/state/usePerformanceDiagnostics.ts
@@ -0,0 +1,65 @@
+import type { CpuProfileRequest, CpuProfileSummary, PerformanceDiagnosticApi, PerformanceSample } from '@buddy-shared/diagnostics/performanceDiagnostic'
+import { onMounted, onScopeDispose, shallowRef } from 'vue'
+
+export function usePerformanceDiagnostics(api: PerformanceDiagnosticApi) {
+ const sample = shallowRef(null)
+ const rendererPid = shallowRef(null)
+ const profile = shallowRef(null)
+ const capturing = shallowRef(null)
+ const unavailable = shallowRef(false)
+ const reading = shallowRef(false)
+ let disposed = false
+ let timer: ReturnType | undefined
+
+ async function refresh(): Promise {
+ if (reading.value || disposed || document.hidden)
+ return
+ reading.value = true
+ try {
+ const snapshot = await api.snapshot()
+ if (!disposed) {
+ sample.value = snapshot.samples.at(-1) ?? null
+ rendererPid.value = snapshot.rendererPid
+ unavailable.value = false
+ }
+ }
+ catch {
+ if (!disposed)
+ unavailable.value = true
+ }
+ finally {
+ if (!disposed)
+ reading.value = false
+ }
+ }
+
+ async function capture(request: CpuProfileRequest): Promise {
+ if (capturing.value || disposed)
+ return
+ capturing.value = request
+ profile.value = null
+ try {
+ const result = await api.capture(request)
+ if (!disposed)
+ profile.value = result
+ }
+ catch (error) {
+ if (!disposed)
+ throw error
+ }
+ finally {
+ if (!disposed)
+ capturing.value = null
+ }
+ }
+
+ onMounted(() => {
+ void refresh()
+ timer = setInterval(() => void refresh(), 5000)
+ })
+ onScopeDispose(() => {
+ disposed = true
+ clearInterval(timer)
+ })
+ return { sample, rendererPid, profile, capturing, unavailable, reading, refresh, capture }
+}
diff --git a/apps/buddy/src/modules/settings/widgets/application-logs/DesktopApplicationLogActions.vue b/apps/buddy/src/modules/settings/widgets/application-logs/DesktopApplicationLogActions.vue
new file mode 100644
index 00000000..578cc965
--- /dev/null
+++ b/apps/buddy/src/modules/settings/widgets/application-logs/DesktopApplicationLogActions.vue
@@ -0,0 +1,42 @@
+
+
+
+
+
+ {{ t('applicationLogs.live') }}
+
+
+
+
+
+
+
+ {{ t('applicationLogs.performance.title') }}
+
+
+
+
+
+ {{ t('applicationLogs.exportDiagnostics') }}
+
+
+
+
+
diff --git a/apps/buddy/src/modules/settings/widgets/application-logs/DesktopApplicationLogFilters.vue b/apps/buddy/src/modules/settings/widgets/application-logs/DesktopApplicationLogFilters.vue
index 59f9ff3d..b781f400 100644
--- a/apps/buddy/src/modules/settings/widgets/application-logs/DesktopApplicationLogFilters.vue
+++ b/apps/buddy/src/modules/settings/widgets/application-logs/DesktopApplicationLogFilters.vue
@@ -2,15 +2,14 @@
import type { ApplicationLogLaunch, ApplicationLogQuery } from '@buddy-shared/diagnostics/applicationLog'
import type { BuddyLocale } from '@/i18n/buddyI18n'
import { APPLICATION_LOG_CATEGORIES } from '@buddy-shared/diagnostics/applicationLog'
-import { ArrowClockwise20Regular, ArrowDownload20Regular, Pause20Regular, Play20Regular, Search20Regular } from '@vicons/fluent'
-import { NButton, NInput, NSelect } from 'naive-ui'
+import { Search20Regular } from '@vicons/fluent'
+import { NInput, NSelect } from 'naive-ui'
import { computed } from 'vue'
import { useBuddyI18n } from '@/i18n/buddyI18n'
import DesktopIcon from '@/shared/ui/icon/DesktopIcon.vue'
import { formatLogTime } from '../../model/applicationLogPresentation'
-const props = defineProps<{ language: BuddyLocale, launches: ApplicationLogLaunch[], currentLaunchId?: string, live: boolean, loading: boolean, exporting: boolean }>()
-const emit = defineEmits<{ refresh: [], toggleLive: [], exportDiagnostics: [] }>()
+const props = defineProps<{ language: BuddyLocale, launches: ApplicationLogLaunch[], currentLaunchId?: string }>()
const launch = defineModel('launch', { required: true })
const category = defineModel>('category', { required: true })
const level = defineModel>('level', { required: true })
@@ -36,23 +35,6 @@ const levelOptions = computed(() => (['all', 'error', 'warn', 'info', 'debug'] a
-
-
-
-
- {{ t(live ? 'applicationLogs.pause' : 'applicationLogs.follow') }}
-
-
-
-
-
- {{ t('applicationLogs.exportDiagnostics') }}
-
-
-
-
-
-
diff --git a/apps/buddy/src/modules/settings/widgets/application-logs/DesktopApplicationLogs.vue b/apps/buddy/src/modules/settings/widgets/application-logs/DesktopApplicationLogs.vue
index e1fd795d..cc8fe2ab 100644
--- a/apps/buddy/src/modules/settings/widgets/application-logs/DesktopApplicationLogs.vue
+++ b/apps/buddy/src/modules/settings/widgets/application-logs/DesktopApplicationLogs.vue
@@ -1,77 +1,54 @@
-
-
-
+
+
+
-
+
-
+
{{ t('applicationLogs.readFailed') }}
+
+ {{ t('desktop.loading.retry') }}
+
diff --git a/apps/buddy/src/modules/settings/widgets/application-logs/DesktopPerformanceDiagnostics.vue b/apps/buddy/src/modules/settings/widgets/application-logs/DesktopPerformanceDiagnostics.vue
new file mode 100644
index 00000000..589e9d5c
--- /dev/null
+++ b/apps/buddy/src/modules/settings/widgets/application-logs/DesktopPerformanceDiagnostics.vue
@@ -0,0 +1,141 @@
+
+
+
+ !value && close()">
+
+
+ {{ t('applicationLogs.performance.metrics') }}
+
+
+ {{ t('applicationLogs.performance.unavailable') }}
+
+ {{ t('desktop.loading.retry') }}
+
+
+
+
+
+
+ {{ t('applicationLogs.performance.privacy') }}
+
+
+
+
+
+
+ {{ t('applicationLogs.performance.scope') }}
+
+
+ {{ t('applicationLogs.performance.coverage') }}
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/buddy/src/platform/runtime/RendererDiagnostics.ts b/apps/buddy/src/platform/runtime/RendererDiagnostics.ts
index 80fd6980..848b9c59 100644
--- a/apps/buddy/src/platform/runtime/RendererDiagnostics.ts
+++ b/apps/buddy/src/platform/runtime/RendererDiagnostics.ts
@@ -1,4 +1,5 @@
import type { RendererDiagnosticApi } from '@buddy-shared/diagnostics/rendererDiagnostic'
+import { DiagnosticAdmission } from '@buddy-shared/diagnostics/DiagnosticAdmission'
import { rendererDiagnosticSchema } from '@buddy-shared/diagnostics/rendererDiagnostic'
import { ApplicationEvents } from '@buddy-shared/observability/ApplicationEvents'
@@ -12,18 +13,19 @@ export function createRendererDiagnostics(api: RendererDiagnosticApi) {
const events = new ApplicationEvents()
const sourceId = crypto.randomUUID()
const pending = new Set>()
+ const admission = new DiagnosticAdmission()
let dropped = 0
let failed = 0
const unsubscribe = events.subscribe((event) => {
+ if (pending.size >= 128 || !admission.take()) {
+ dropped++
+ return
+ }
const parsed = rendererDiagnosticSchema.safeParse(event)
if (!parsed.success) {
failed++
return
}
- if (pending.size >= 128) {
- dropped++
- return
- }
const delivery = Promise.resolve()
.then(() => api.report({ sourceId, diagnostic: parsed.data }))
.then((accepted) => {