From a4da7edd73e89e8a92c467fd143e6024c75ca759 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Sat, 3 Oct 2026 04:27:55 +0800 Subject: [PATCH] refactor(stage-tamagotchi): persist local IO traces (#2774) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What users can do - Turn on **Record IO traces automatically** in Settings > System > Developer in Tamagotchi. - Let a coding agent read the saved files to see which step was slow or failed. ## How it works - The main window sends each ended IO span to the main process over Eventa. - The main process appends one JSON line per span to `/io-traces/.jsonl`. A new file starts each time recording starts. - Files older than seven days are removed when recording starts. - The setting is saved and restored at launch. - The IO Tracer window is unchanged. `use-io-tracer.ts` now has a subscriber set next to the existing panel callback, so both receive spans. - A new skill, `analyze-io-traces`, tells agents where the files are and gives `jq` examples. ## Why this shape The goal is to let an agent read local traces. An agent reads files well, so this needs no server, database, token, or CLI. This replaces #2717, which stored traces behind an OTLP server and DuckDB. That design was larger than the goal needs. ## Verification - `pnpm -F @proj-airi/stage-tamagotchi exec vitest run src/main/services/airi/io-trace-recording/index.test.ts`: 2 tests passed, using the real file system. - `pnpm -F @proj-airi/stage-ui exec vitest run --project browser src/composables/use-io-tracer.browser.test.ts`: 1 test passed. - `typecheck` passed for `@proj-airi/stage-tamagotchi` and `@proj-airi/stage-ui`. - ESLint on the changed files: 0 errors. - Not tested: a real Electron run of the toggle, IPC, and file writes. ## Limits - Spans are saved when they end. A call that never ends is not in the file, so live view is not included. - Only the main window forwards spans. - Spans hold timing, counts, and status. They do not hold prompts or replies. - Web (`stage-web`) is not supported, because a browser cannot write local files. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Claude Sonnet 5.5 --- .agents/skills/analyze-io-traces/SKILL.md | 45 ++++++++ AGENTS.md | 1 + .../src/main/configs/io-trace-recording.ts | 12 +++ apps/stage-tamagotchi/src/main/index.ts | 30 +++++- .../airi/io-trace-recording/index.test.ts | 84 +++++++++++++++ .../services/airi/io-trace-recording/index.ts | 100 ++++++++++++++++++ .../airi/io-trace-recording/register.ts | 28 +++++ .../src/main/windows/main/index.ts | 3 + .../main/windows/main/rpc/index.electron.ts | 5 + .../src/main/windows/settings/index.ts | 3 + .../windows/settings/rpc/index.electron.ts | 5 + apps/stage-tamagotchi/src/renderer/App.vue | 5 + .../renderer/bridges/io-trace-recording.ts | 37 +++++++ .../pages/settings/system/developer.vue | 42 +++++++- .../src/shared/eventa/index.ts | 6 ++ .../src/locales/en/tamagotchi/settings.yaml | 3 + .../locales/zh-Hans/tamagotchi/settings.yaml | 4 + packages/stage-shared/src/types/io-trace.ts | 8 ++ .../composables/use-io-tracer.browser.test.ts | 20 ++++ .../stage-ui/src/composables/use-io-tracer.ts | 10 +- 20 files changed, 444 insertions(+), 7 deletions(-) create mode 100644 .agents/skills/analyze-io-traces/SKILL.md create mode 100644 apps/stage-tamagotchi/src/main/configs/io-trace-recording.ts create mode 100644 apps/stage-tamagotchi/src/main/services/airi/io-trace-recording/index.test.ts create mode 100644 apps/stage-tamagotchi/src/main/services/airi/io-trace-recording/index.ts create mode 100644 apps/stage-tamagotchi/src/main/services/airi/io-trace-recording/register.ts create mode 100644 apps/stage-tamagotchi/src/renderer/bridges/io-trace-recording.ts create mode 100644 packages/stage-ui/src/composables/use-io-tracer.browser.test.ts diff --git a/.agents/skills/analyze-io-traces/SKILL.md b/.agents/skills/analyze-io-traces/SKILL.md new file mode 100644 index 0000000000..ca8294a84b --- /dev/null +++ b/.agents/skills/analyze-io-traces/SKILL.md @@ -0,0 +1,45 @@ +--- +name: analyze-io-traces +description: Read and analyze AIRI IO traces that Tamagotchi saved to local files. Use when the user asks why a chat, speech, or audio turn was slow, failed, or behaved wrongly, and recording is enabled in Settings > System > Developer. +--- + +# Analyze IO traces + +Tamagotchi saves each ended IO span as one JSON line when **Record IO traces automatically** is on. The setting is under Settings > System > Developer. + +## Find the files + +Files are named `.jsonl`. A new file starts each time recording starts. Files older than seven days are deleted. + +```sh +ls -t "$HOME/Library/Application Support/ai.moeru.airi/io-traces"/*.jsonl | head -3 +ls -t "$HOME/Library/Application Support/@proj-airi/stage-tamagotchi/io-traces"/*.jsonl | head -3 +``` + +The first path is the packaged app on macOS. The second is `pnpm dev` on macOS. If `APP_USER_DATA_PATH` is set, use `$APP_USER_DATA_PATH/io-traces`. If recording is off or nothing ran yet, the folder does not exist. Ask the user to turn it on and reproduce the problem. + +## Record shape + +Each line is one span with `traceId`, `spanId`, `parentSpanId`, `name`, `startTimeNano`, `endTimeNano`, `status.code` (2 means error), `attributes`, and `events`. Time values are decimal strings in nanoseconds. A trace is one interaction turn. Subsystems are in `attributes["ai.moeru.airi.io.subsystem"]`. + +## Common queries + +Set `F` to the newest file first. + +```sh +# Spans by name, slowest first, in milliseconds +jq -r '[.name, ((.endTimeNano|tonumber)-(.startTimeNano|tonumber))/1e6] | @tsv' "$F" | sort -t$'\t' -k2 -nr | head -20 + +# Spans with an error status +jq -c 'select(.status.code == 2) | {name, traceId, message: .status.message}' "$F" + +# All spans of one trace in start order +jq -c 'select(.traceId == "TRACE_ID")' "$F" | jq -s 'sort_by(.startTimeNano|tonumber)' + +# Time to first token for each LLM span +jq -c 'select(.events | any(.name | endswith("llm.first_token"))) | {traceId, events}' "$F" +``` + +## Limits + +Spans are saved only after they end. A call that never ends is not in the file. Spans record timing and counts, not prompts or replies. Say so when the cause needs content. diff --git a/AGENTS.md b/AGENTS.md index 173680360a..a91e0392ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,6 +150,7 @@ Skills are in `.agents/skills//SKILL.md`. Read the listed file before you | Write or change a string that users see, or a glossary term | [`packages/i18n/AGENTS.md`](packages/i18n/AGENTS.md) | | Work under `server/` | [`server/AGENTS.md`](server/AGENTS.md). For `server/apps/api`, also read [`server/apps/api/AGENTS.md`](server/apps/api/AGENTS.md). | | Write documentation, code comments, commit messages, or PR and issue text | [`simple-english`](.agents/skills/simple-english/SKILL.md) | +| Analyze why a chat, speech, or audio turn was slow or failed, using saved IO traces | [`analyze-io-traces`](.agents/skills/analyze-io-traces/SKILL.md) | | Create or prepare a pull request | [`create-pr`](.agents/skills/create-pr/SKILL.md) | | Upload a local file through a file input or a file chooser in a web or Electron app | [`use-agent-browser-with-input-file`](.agents/skills/use-agent-browser-with-input-file/SKILL.md) and [`agent-browser`](.agents/skills/agent-browser/SKILL.md). For Electron, also read [`agent-browser-electron`](.agents/skills/agent-browser-electron/SKILL.md). | | Test Live2D, VRM, or MMD import and rendering in stage-web, stage-tamagotchi, or stage-pocket | [`use-agent-browser-for-airi`](.agents/skills/use-agent-browser-for-airi/SKILL.md) | diff --git a/apps/stage-tamagotchi/src/main/configs/io-trace-recording.ts b/apps/stage-tamagotchi/src/main/configs/io-trace-recording.ts new file mode 100644 index 0000000000..b817bf48f1 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/configs/io-trace-recording.ts @@ -0,0 +1,12 @@ +import { boolean, object } from 'valibot' + +import { createConfig } from '../libs/electron/persistence' + +export function createIOTraceRecordingConfig() { + const config = createConfig('io-trace-recording', 'options.json', object({ enabled: boolean() }), { + default: { enabled: false }, + autoHeal: true, + }) + config.setup() + return config +} diff --git a/apps/stage-tamagotchi/src/main/index.ts b/apps/stage-tamagotchi/src/main/index.ts index eb55c4ce42..d719092bdc 100644 --- a/apps/stage-tamagotchi/src/main/index.ts +++ b/apps/stage-tamagotchi/src/main/index.ts @@ -4,7 +4,7 @@ import type { FileLoggerHandle } from './app/file-logger' import process, { env, platform } from 'node:process' -import { dirname, resolve } from 'node:path' +import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import messages from '@proj-airi/i18n/locales' @@ -26,6 +26,7 @@ import { resolveIsWayland } from './app/ozone' import { installSingleInstanceGuard } from './app/single-instance' import { createArtistryConfig } from './configs/artistry' import { createGlobalAppConfig } from './configs/global' +import { createIOTraceRecordingConfig } from './configs/io-trace-recording' import { emitAppBeforeQuit, emitAppWindowAllClosed } from './libs/bootkit/lifecycle' import { getElectronMainDirname, setElectronMainDirname } from './libs/electron/location' import { createI18n } from './libs/i18n' @@ -35,6 +36,7 @@ import { setupServerChannel } from './services/airi/channel-server' import { setupComputerUse } from './services/airi/computer-use' import { setupGodotStageManager } from './services/airi/godot-stage' import { setupBuiltInServer } from './services/airi/http-server' +import { IOTraceRecordingService } from './services/airi/io-trace-recording' import { setupMcpStdioManager } from './services/airi/mcp-servers' import { setupExtensionHost } from './services/airi/plugins' import { setupArtistryBridge } from './services/airi/widgets/artistry-bridge' @@ -178,6 +180,7 @@ app.whenReady().then(async () => { const appConfig = injeca.provide('configs:app', () => createGlobalAppConfig()) const artistryConfig = injeca.provide('configs:artistry', () => createArtistryConfig()) + const ioTraceRecordingConfig = injeca.provide('configs:io-trace-recording', () => createIOTraceRecordingConfig()) const electronApp = injeca.provide('host:electron:app', () => app) const autoUpdater = injeca.provide('services:auto-updater', { dependsOn: { appConfig }, @@ -228,6 +231,27 @@ app.whenReady().then(async () => { build: async () => setupMcpStdioManager(), }) + const ioTraceRecording = injeca.provide('services:io-trace-recording', { + dependsOn: { config: ioTraceRecordingConfig, lifecycle }, + build: ({ dependsOn }) => { + const service = new IOTraceRecordingService({ + directory: join(app.getPath('userData'), 'io-traces'), + getStoredEnabled: () => dependsOn.config.get()?.enabled ?? false, + setStoredEnabled: enabled => dependsOn.config.update({ enabled }), + }) + dependsOn.lifecycle.appHooks.onStart(async () => { + try { + await service.restore() + } + catch (error) { + log.withError(error).error('Failed to restore IO trace recording') + } + }) + dependsOn.lifecycle.appHooks.onStop(() => service.dispose()) + return service + }, + }) + const widgetsManager = injeca.provide('windows:widgets', { dependsOn: { serverChannel, i18n }, build: ({ dependsOn }) => setupWidgetsWindowManager(dependsOn), @@ -286,7 +310,7 @@ app.whenReady().then(async () => { }) const settingsWindow = injeca.provide('windows:settings', { - dependsOn: { widgetsManager, beatSync, autoUpdater, devtoolsWindow: devtoolsMarkdownStressWindow, serverChannel, godotStageManager, mcpStdioManager, i18n, globalShortcut, spotlightWindow }, + dependsOn: { widgetsManager, beatSync, autoUpdater, devtoolsWindow: devtoolsMarkdownStressWindow, serverChannel, godotStageManager, mcpStdioManager, i18n, globalShortcut, spotlightWindow, ioTraceRecording }, build: async ({ dependsOn }) => setupSettingsWindowReusableFunc({ ...dependsOn, @@ -304,7 +328,7 @@ app.whenReady().then(async () => { }) const mainWindow = injeca.provide('windows:main', { - dependsOn: { editorWindow, settingsWindow, chatWindow, widgetsManager, noticeWindow, beatSync, autoUpdater, serverChannel, godotStageManager, mcpStdioManager, i18n, onboardingWindowManager, appleSpeechTranscription, appleVision }, + dependsOn: { editorWindow, settingsWindow, chatWindow, widgetsManager, noticeWindow, beatSync, autoUpdater, serverChannel, godotStageManager, mcpStdioManager, i18n, onboardingWindowManager, appleSpeechTranscription, appleVision, ioTraceRecording }, build: async ({ dependsOn }) => setupMainWindow({ ...dependsOn, onWindowCreated: (window) => { diff --git a/apps/stage-tamagotchi/src/main/services/airi/io-trace-recording/index.test.ts b/apps/stage-tamagotchi/src/main/services/airi/io-trace-recording/index.test.ts new file mode 100644 index 0000000000..e0f6bbf768 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/io-trace-recording/index.test.ts @@ -0,0 +1,84 @@ +import type { SerializedIOSpan } from '@proj-airi/stage-shared/types/io-trace' + +import { mkdtemp, readdir, readFile, rm, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, describe, expect, it } from 'vitest' + +import { IOTraceRecordingService } from '.' + +const directories: string[] = [] + +function span(spanId: string): SerializedIOSpan { + return { + attributes: {}, + ended: true, + endTimeNano: '3000000', + events: [], + kind: 0, + name: 'llm.stream', + parentSpanId: '', + spanId, + startTimeNano: '1000000', + status: { code: 1, message: '' }, + traceId: 'trace-1', + } +} + +async function createService(initiallyEnabled = false) { + const root = await mkdtemp(join(tmpdir(), 'airi-io-trace-')) + directories.push(root) + const directory = join(root, 'io-traces') + let stored = initiallyEnabled + const service = new IOTraceRecordingService({ + directory, + getStoredEnabled: () => stored, + setStoredEnabled: (enabled) => { + stored = enabled + }, + }) + return { directory, service, stored: () => stored } +} + +afterEach(async () => { + await Promise.all(directories.splice(0).map(path => rm(path, { force: true, recursive: true }))) +}) + +describe('iOTraceRecordingService', () => { + it('appends ended spans as JSON Lines only while recording and keeps the setting on dispose', async () => { + const { directory, service, stored } = await createService() + + await service.recordSpan(span('before')) + const started = await service.setEnabled(true) + await Promise.all([service.recordSpan(span('first')), service.recordSpan(span('second'))]) + await service.setEnabled(false) + await service.recordSpan(span('after')) + + expect(started.filePath).toBeDefined() + const lines = (await readFile(started.filePath!, 'utf8')).trim().split('\n').map(line => JSON.parse(line)) + expect(lines.map(line => line.spanId)).toEqual(['first', 'second']) + expect(await readdir(directory)).toHaveLength(1) + expect(stored()).toBe(false) + + await service.setEnabled(true) + await service.dispose() + expect(stored()).toBe(true) + expect(service.getState().enabled).toBe(false) + }) + + it('restores the stored setting and removes files older than seven days', async () => { + const { directory, service } = await createService() + await service.setEnabled(true) + await service.dispose() + const stale = join(directory, 'stale.jsonl') + await writeFile(stale, '{}\n') + const old = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000) + await utimes(stale, old, old) + + await service.restore() + + expect(service.getState().enabled).toBe(true) + expect(await readdir(directory)).not.toContain('stale.jsonl') + }) +}) diff --git a/apps/stage-tamagotchi/src/main/services/airi/io-trace-recording/index.ts b/apps/stage-tamagotchi/src/main/services/airi/io-trace-recording/index.ts new file mode 100644 index 0000000000..d30bcebc3b --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/io-trace-recording/index.ts @@ -0,0 +1,100 @@ +import type { IOTraceRecordingState, SerializedIOSpan } from '@proj-airi/stage-shared/types/io-trace' + +import { appendFile, mkdir, readdir, rm, stat } from 'node:fs/promises' +import { join } from 'node:path' + +import { errorMessageFrom } from '@moeru/std' +import { Mutex } from 'async-mutex' + +const RETENTION_MS = 7 * 24 * 60 * 60 * 1000 + +interface IOTraceRecordingServiceOptions { + directory: string + getStoredEnabled: () => boolean + setStoredEnabled: (enabled: boolean) => void +} + +export class IOTraceRecordingService { + private readonly listeners = new Set<(state: IOTraceRecordingState) => void>() + private readonly mutex = new Mutex() + private filePath: string | undefined + private error: string | undefined + + constructor(private readonly options: IOTraceRecordingServiceOptions) {} + + getState(): IOTraceRecordingState { + return { + directory: this.options.directory, + enabled: this.filePath !== undefined, + error: this.error, + filePath: this.filePath, + } + } + + onStateChange(listener: (state: IOTraceRecordingState) => void): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + async restore(): Promise { + if (this.options.getStoredEnabled()) + await this.setEnabled(true, false) + } + + async setEnabled(enabled: boolean, persist = true): Promise { + return await this.mutex.runExclusive(async () => { + if (enabled) + await this.start() + else + this.filePath = undefined + + if (persist) + this.options.setStoredEnabled(enabled) + const state = this.getState() + for (const listener of this.listeners) + listener(state) + return state + }) + } + + async recordSpan(span: SerializedIOSpan): Promise { + await this.mutex.runExclusive(async () => { + if (!this.filePath) + return + await appendFile(this.filePath, `${JSON.stringify(span)}\n`, { mode: 0o600 }) + }) + } + + async dispose(): Promise { + await this.mutex.runExclusive(() => { + this.filePath = undefined + }) + } + + private async start(): Promise { + if (this.filePath) + return + + try { + await mkdir(this.options.directory, { mode: 0o700, recursive: true }) + await this.pruneExpiredFiles() + this.filePath = join(this.options.directory, `${new Date().toISOString().replace(/[:.]/g, '-')}.jsonl`) + this.error = undefined + } + catch (error) { + this.error = errorMessageFrom(error) ?? 'Failed to start IO trace recording.' + throw error + } + } + + private async pruneExpiredFiles(): Promise { + const cutoff = Date.now() - RETENTION_MS + for (const name of await readdir(this.options.directory)) { + if (!name.endsWith('.jsonl')) + continue + const path = join(this.options.directory, name) + if ((await stat(path)).mtimeMs < cutoff) + await rm(path) + } + } +} diff --git a/apps/stage-tamagotchi/src/main/services/airi/io-trace-recording/register.ts b/apps/stage-tamagotchi/src/main/services/airi/io-trace-recording/register.ts new file mode 100644 index 0000000000..1485c8ba9e --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/io-trace-recording/register.ts @@ -0,0 +1,28 @@ +import type { InvocableEventContext } from '@moeru/eventa' + +import type { IOTraceRecordingService } from '.' + +import { defineInvokeHandler } from '@moeru/eventa' + +import { + ioTraceRecordingChanged, + ioTraceRecordingGet, + ioTraceRecordingRecordSpan, + ioTraceRecordingSetEnabled, +} from '../../../../shared/eventa' + +export function registerIOTraceRecording( + context: InvocableEventContext, + service: IOTraceRecordingService, +): () => void { + const disposers = [ + defineInvokeHandler(context, ioTraceRecordingGet, () => service.getState()), + defineInvokeHandler(context, ioTraceRecordingSetEnabled, ({ enabled }) => service.setEnabled(enabled)), + defineInvokeHandler(context, ioTraceRecordingRecordSpan, span => service.recordSpan(span)), + service.onStateChange((state) => { + void context.emit(ioTraceRecordingChanged, state) + }), + ] + + return () => disposers.forEach(dispose => dispose()) +} diff --git a/apps/stage-tamagotchi/src/main/windows/main/index.ts b/apps/stage-tamagotchi/src/main/windows/main/index.ts index 9aa87c518c..8c0018f483 100644 --- a/apps/stage-tamagotchi/src/main/windows/main/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/main/index.ts @@ -4,6 +4,7 @@ import type { InferOutput } from 'valibot' import type { I18n } from '../../libs/i18n' import type { ServerChannel } from '../../services/airi/channel-server' import type { GodotStageManager } from '../../services/airi/godot-stage' +import type { IOTraceRecordingService } from '../../services/airi/io-trace-recording' import type { McpStdioManager } from '../../services/airi/mcp-servers' import type { AutoUpdater } from '../../services/electron/auto-updater' import type { ChatWindowManager } from '../chat' @@ -61,6 +62,7 @@ export async function setupMainWindow(params: { mcpStdioManager: McpStdioManager i18n: I18n onboardingWindowManager: OnboardingWindowManager + ioTraceRecording: IOTraceRecordingService }) { const { setup: setupConfig, @@ -185,6 +187,7 @@ export async function setupMainWindow(params: { mcpStdioManager: params.mcpStdioManager, i18n: params.i18n, onboardingWindowManager: params.onboardingWindowManager, + ioTraceRecording: params.ioTraceRecording, }) await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/', { diff --git a/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts index 52fdb9045e..012afe7512 100644 --- a/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts +++ b/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts @@ -3,6 +3,7 @@ import type { BrowserWindow } from 'electron' import type { I18n } from '../../../libs/i18n' import type { ServerChannel } from '../../../services/airi/channel-server' import type { GodotStageManager } from '../../../services/airi/godot-stage' +import type { IOTraceRecordingService } from '../../../services/airi/io-trace-recording' import type { McpStdioManager } from '../../../services/airi/mcp-servers' import type { AutoUpdater } from '../../../services/electron/auto-updater' import type { ChatWindowManager } from '../../chat' @@ -28,6 +29,7 @@ import { } from '../../../../shared/eventa' import { createAuthService } from '../../../services/airi/auth' import { createGodotStageService } from '../../../services/airi/godot-stage' +import { registerIOTraceRecording } from '../../../services/airi/io-trace-recording/register' import { createMcpServersService } from '../../../services/airi/mcp-servers' import { createOnboardingService } from '../../../services/airi/onboarding' import { createWidgetsService } from '../../../services/airi/widgets' @@ -48,6 +50,7 @@ export async function setupMainWindowElectronInvokes(params: { mcpStdioManager: McpStdioManager i18n: I18n onboardingWindowManager: OnboardingWindowManager + ioTraceRecording: IOTraceRecordingService }) { // TODO: once we refactored eventa to support window-namespaced contexts, // we can remove the setMaxListeners call below since eventa will be able to dispatch and @@ -63,6 +66,8 @@ export async function setupMainWindowElectronInvokes(params: { createGodotStageService({ context, manager: params.godotStageManager, window: params.window }) createOnboardingService({ context, onboardingWindowManager: params.onboardingWindowManager, mainWindow: params.window }) createAuthService({ context, window: params.window }) + const stopIOTraceRecording = registerIOTraceRecording(context, params.ioTraceRecording) + params.window.once('closed', stopIOTraceRecording) defineInvokeHandler(context, electronCenterMainWindow, () => centerWindowOnDisplay(params.window)) defineInvokeHandler(context, electronOpenMainDevtools, () => params.window.webContents.openDevTools({ mode: 'detach' })) diff --git a/apps/stage-tamagotchi/src/main/windows/settings/index.ts b/apps/stage-tamagotchi/src/main/windows/settings/index.ts index fe449364e5..8810184193 100644 --- a/apps/stage-tamagotchi/src/main/windows/settings/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/settings/index.ts @@ -1,6 +1,7 @@ import type { I18n } from '../../libs/i18n' import type { ServerChannel } from '../../services/airi/channel-server' import type { GodotStageManager } from '../../services/airi/godot-stage' +import type { IOTraceRecordingService } from '../../services/airi/io-trace-recording' import type { McpStdioManager } from '../../services/airi/mcp-servers' import type { AutoUpdater } from '../../services/electron/auto-updater' import type { GlobalShortcutService } from '../../services/electron/global-shortcut' @@ -38,6 +39,7 @@ export function setupSettingsWindowReusableFunc(params: { i18n: I18n globalShortcut: GlobalShortcutService spotlightWindow: SpotlightWindowManager + ioTraceRecording: IOTraceRecordingService }): SettingsWindowManager { const rendererBase = baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')) const defaultRoute = '/settings' @@ -76,6 +78,7 @@ export function setupSettingsWindowReusableFunc(params: { i18n: params.i18n, globalShortcut: params.globalShortcut, spotlightWindow: params.spotlightWindow, + ioTraceRecording: params.ioTraceRecording, }) await load(window, withHashRoute(rendererBase, currentRoute, { diff --git a/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts index 3459d11847..8a5d345d64 100644 --- a/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts +++ b/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts @@ -3,6 +3,7 @@ import type { BrowserWindow } from 'electron' import type { I18n } from '../../../libs/i18n' import type { ServerChannel } from '../../../services/airi/channel-server' import type { GodotStageManager } from '../../../services/airi/godot-stage' +import type { IOTraceRecordingService } from '../../../services/airi/io-trace-recording' import type { McpStdioManager } from '../../../services/airi/mcp-servers' import type { AutoUpdater } from '../../../services/electron/auto-updater' import type { GlobalShortcutService } from '../../../services/electron/global-shortcut' @@ -23,6 +24,7 @@ import { } from '../../../../shared/eventa' import { createAuthService } from '../../../services/airi/auth' import { createGodotStageService } from '../../../services/airi/godot-stage' +import { registerIOTraceRecording } from '../../../services/airi/io-trace-recording/register' import { createMcpServersService } from '../../../services/airi/mcp-servers' import { createWidgetsService } from '../../../services/airi/widgets' import { createAutoUpdaterService } from '../../../services/electron' @@ -41,6 +43,7 @@ export async function setupSettingsWindowInvokes(params: { i18n: I18n globalShortcut: GlobalShortcutService spotlightWindow: SpotlightWindowManager + ioTraceRecording: IOTraceRecordingService }) { // TODO: once we refactored eventa to support window-namespaced contexts, // we can remove the setMaxListeners call below since eventa will be able to dispatch and @@ -56,6 +59,8 @@ export async function setupSettingsWindowInvokes(params: { createMcpServersService({ context, manager: params.mcpStdioManager }) createGodotStageService({ context, manager: params.godotStageManager, window: params.settingsWindow }) createAuthService({ context, window: params.settingsWindow }) + const stopIOTraceRecording = registerIOTraceRecording(context, params.ioTraceRecording) + params.settingsWindow.once('closed', stopIOTraceRecording) // Register the global shortcut service for the settings window. params.globalShortcut.registerWindow({ context, window: params.settingsWindow }) diff --git a/apps/stage-tamagotchi/src/renderer/App.vue b/apps/stage-tamagotchi/src/renderer/App.vue index 1415fc859c..4f54b04896 100644 --- a/apps/stage-tamagotchi/src/renderer/App.vue +++ b/apps/stage-tamagotchi/src/renderer/App.vue @@ -64,6 +64,7 @@ import { } from '../shared/eventa/plugin/host' import { electronPluginToolsChanged } from '../shared/eventa/plugin/tools' import { initializeElectronAuthCallbackBridge } from './bridges/electron-auth-callback' +import { initializeIOTraceRecordingBridge } from './bridges/io-trace-recording' import { initializeStageThreeRuntimeTraceBridge } from './bridges/stage-three-runtime-trace' import { useLanguage } from './composables/use-language' import { useServerChannelSettingsStore } from './stores/settings/server-channel' @@ -94,6 +95,9 @@ const isSpotlightWindow = initialRoutePath === '/spotlight' // The floating chat resizes from its own grip, which keeps the corner beside the character in place. const isFloatingChatWindow = initialRoutePath === '/chat-floating' const isSettingsWindow = initialRoutePath === '/settings' || initialRoutePath.startsWith('/settings/') +const stopIOTraceRecordingBridge = initialRoutePath === '/' + ? initializeIOTraceRecordingBridge(context.value) + : undefined async function refreshPluginRuntimeTools() { try { @@ -369,6 +373,7 @@ watch(themeColorsHueDynamic, () => { }, { immediate: true }) onUnmounted(() => { + stopIOTraceRecordingBridge?.() stopLeadershipListener?.() chatStore.dispose() fullStageRuntime?.dispose() diff --git a/apps/stage-tamagotchi/src/renderer/bridges/io-trace-recording.ts b/apps/stage-tamagotchi/src/renderer/bridges/io-trace-recording.ts new file mode 100644 index 0000000000..2ca2d73060 --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/bridges/io-trace-recording.ts @@ -0,0 +1,37 @@ +import type { EventContext } from '@moeru/eventa' + +import { useLogg } from '@guiiai/logg' +import { defineInvoke } from '@moeru/eventa' +import { serializeSpan, subscribeIOSpan } from '@proj-airi/stage-ui/composables/use-io-tracer' + +import { + ioTraceRecordingChanged, + ioTraceRecordingGet, + ioTraceRecordingRecordSpan, +} from '../../shared/eventa' + +const log = useLogg('electron:io-trace-recording').useGlobalConfig() + +export function initializeIOTraceRecordingBridge(context: EventContext): () => void { + const recordSpan = defineInvoke(context, ioTraceRecordingRecordSpan) + let enabled = false + + const stopStateListener = context.on(ioTraceRecordingChanged, (event) => { + enabled = event.body?.enabled ?? false + }) + void defineInvoke(context, ioTraceRecordingGet)() + .then((state) => { + enabled = state?.enabled ?? false + }) + .catch(error => log.withError(error).error('Failed to load IO trace recording state')) + + const stopSpanListener = subscribeIOSpan((span) => { + if (enabled && span.ended) + void recordSpan(serializeSpan(span)).catch(error => log.withError(error).error('Failed to record IO trace span')) + }) + + return () => { + stopStateListener() + stopSpanListener() + } +} diff --git a/apps/stage-tamagotchi/src/renderer/pages/settings/system/developer.vue b/apps/stage-tamagotchi/src/renderer/pages/settings/system/developer.vue index 6c197ed595..3aa6a093a8 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/settings/system/developer.vue +++ b/apps/stage-tamagotchi/src/renderer/pages/settings/system/developer.vue @@ -1,16 +1,41 @@