diff --git a/.agents/skills/process-pr-reviews/SKILL.md b/.agents/skills/process-pr-reviews/SKILL.md index bfd6d2de1..775406a8b 100644 --- a/.agents/skills/process-pr-reviews/SKILL.md +++ b/.agents/skills/process-pr-reviews/SKILL.md @@ -1,10 +1,12 @@ --- name: process-pr-reviews -description: Use when the user asks to process, triage, fetch, view, count, list, or resolve review feedback in a GitHub PR. The built-in workflow currently focuses on CodeRabbit. In this workflow, “real review feedback” is strictly defined as actionable inline comments that are neither review summaries nor nitpicks. +description: Use when the user asks to process, triage, fetch, view, count, list, or resolve review feedback in a GitHub PR. Supports both CodeRabbit and Codex review workflows. In this workflow, “real review feedback” is strictly defined as actionable inline comments; for CodeRabbit, exclude review summaries and nitpicks, and for Codex, exclude review summary cards and use PR main-thread reactions only as status signals. --- # Process PR Reviews +This workflow supports both CodeRabbit and Codex PR review signals. + ## CodeRabbit Reviews “Real review feedback” is strictly defined as: @@ -13,6 +15,8 @@ description: Use when the user asks to process, triage, fetch, view, count, list - **not** a review summary - **not** a nitpick +Nitpick comments from CodeRabbit must always be ignored to avoid unnecessary noise. + There is no need to analyze the comment content itself. ### Data sources @@ -95,6 +99,11 @@ The final goal of the CodeRabbit workflow is always: > **Top-level inline comments left by CodeRabbit in `pulls//comments` that are neither nitpicks nor summaries** +Important: + +- Treat CodeRabbit nitpicks as non-actionable by default. +- Do not include nitpicks in counts, summaries, or resolution queues unless the user explicitly asks for nitpicks. + In practice, do the following: 1. Get CodeRabbit top-level inline comments from `pulls//comments` @@ -114,6 +123,18 @@ If the output of `gh api --paginate ...` is too large and gets truncated: ### Resolving review conversations +### Resolution policy + +When triaging review feedback, apply this rule: + +- If a comment will **not** be fixed, you may resolve the conversation after triage. +- If a comment **will** be fixed, do **not** resolve it first — make the code change first, then resolve the conversation afterward. + +In short: + +- **won't fix / no code change** → triage, then resolve +- **will fix / code change required** → fix first, then resolve + If the user asks to resolve a CodeRabbit review conversation: 1. Identify the target inline comment from the actionable comment list. @@ -131,3 +152,130 @@ Notes: - Resolve the **thread**, not the individual comment. - `pulls//comments` remains the source of truth for identifying actionable inline comments. - `reviewThreads` is only for thread-level operations such as resolving conversations. + +## Codex Reviews + +“Real review feedback” is strictly defined as: + +- **inline review comments** +- **not** the review summary card + +There is no need to analyze the comment content itself. + +### Important behavior differences from CodeRabbit + +- Codex review is **silent while running**. +- Unlike CodeRabbit, Codex does **not** expose an in-progress PR check for review status. +- While Codex is reviewing, the PR main conversation thread gets an `eyes` reaction from `chatgpt-codex-connector[bot]`. +- If Codex finds no issues, it may leave **no actionable inline comments** and instead react to the PR main conversation thread with `+1` (thumbs up). +- If Codex finds issues, it may create inline review comments in `pulls//comments` and a review summary card in `pulls//reviews`. + +### Data sources + +The Codex workflow uses these sources: + +1. **PR review comments** + + ```bash + gh api --paginate repos///pulls//comments + ``` + + This is the authoritative source for actionable inline Codex comments. + +2. **PR reviews** + + ```bash + gh api --paginate repos///pulls//reviews + ``` + + This is used to identify the Codex review summary card such as `### 💡 Codex Review`. It is not the primary source of actionable inline feedback. + +3. **Issue comment reactions on the PR main thread** + + First fetch the PR issue node / comments if needed: + + ```bash + gh api repos///issues//comments + gh api repos///issues//reactions + ``` + + Use reactions on the PR main conversation thread only to detect Codex review state: + + - `eyes` from `chatgpt-codex-connector[bot]` → Codex review appears to be in progress + - `+1` from `chatgpt-codex-connector[bot]` on the PR main thread → Codex reviewed and found no issues + + These reactions are **status signals**, not actionable review feedback. + +Do not treat these as primary sources for actionable comments: + +- `gh pr view ...` +- `gh api repos///issues//comments` +- `gh api repos///issues//reactions` + +Reason: actionable Codex review feedback still lives in PR review comments, not in the PR issue timeline. + +### Workflow + +#### 1. Fetch inline comments + +```bash +gh api --paginate repos///pulls//comments +``` + +Only keep records that satisfy all of the following: + +- `user.login` is `chatgpt-codex-connector[bot]` +- `in_reply_to_id == null` (only top-level inline comments, not replies) + +This is the candidate set of actionable Codex comments. + +#### 2. Fetch reviews to identify the summary card + +```bash +gh api --paginate repos///pulls//reviews +``` + +Identify Codex review summaries. Common characteristics include: + +- `### 💡 Codex Review` +- explanatory text such as `Here are some automated review suggestions for this pull request.` +- “About Codex in GitHub” help text + +These review-level contents are **not the final result**. They are only used to understand whether Codex posted a review summary. + +#### 3. Optionally inspect PR main-thread reactions for status + +If the user asks whether Codex is still reviewing, or whether Codex finished with no findings, inspect reactions on the PR main thread. + +Interpret them as follows: + +- `eyes` by `chatgpt-codex-connector[bot]` → likely still reviewing / review in progress +- `+1` by `chatgpt-codex-connector[bot]` with no Codex inline comments → likely completed with no findings + +Do not count these reactions as review comments. + +### Filtering rule + +The final goal of the Codex workflow is always: + +> **Top-level inline comments left by Codex in `pulls//comments`** + +In practice, do the following: + +1. Get Codex top-level inline comments from `pulls//comments` +2. Use `pulls//reviews` only to recognize the summary card +3. If there are no Codex inline comments, optionally inspect PR main-thread reactions to distinguish: + - still reviewing (`eyes`) + - reviewed with no findings (`+1`) + - no observable Codex activity + +### Large output handling + +If any `gh api --paginate ...` output is too large and gets truncated: + +1. Record the tool output file path +2. Do not manually read through the entire large JSON blob +3. Hand it off to `@explorer` to extract: + - Codex-authored inline comments + - the number of top-level inline comments + - each comment’s `path` / `line` / `body` diff --git a/apps/desktop/main/desktop-diagnostics.ts b/apps/desktop/main/desktop-diagnostics.ts index e65174400..95d8e08a6 100644 --- a/apps/desktop/main/desktop-diagnostics.ts +++ b/apps/desktop/main/desktop-diagnostics.ts @@ -5,6 +5,7 @@ import type { RuntimeEventQueryResult, RuntimeLogEntry, RuntimeState, + StartupProbePayload, } from "../shared/host"; import type { RuntimeOrchestrator } from "./runtime/daemon-supervisor"; import { @@ -54,6 +55,17 @@ type DesktopDiagnosticsSnapshot = { updatedAt: string; isPackaged: boolean; coldStart: DesktopColdStartSnapshot; + startupProbe: { + preloadSeen: boolean; + rendererSeen: boolean; + entries: Array<{ + source: StartupProbePayload["source"]; + stage: string; + status: StartupProbePayload["status"]; + detail: string | null; + at: string; + }>; + }; sleepGuard: SleepGuardSnapshot; renderer: DesktopRendererSnapshot; embeddedContents: DesktopEmbeddedContentSnapshot[]; @@ -68,6 +80,8 @@ function nowIso(): string { return new Date().toISOString(); } +const MAX_STARTUP_PROBE_ENTRIES = 200; + export function getDesktopDiagnosticsFilePath(): string { return resolve(app.getPath("userData"), "logs", "desktop-diagnostics.json"); } @@ -83,6 +97,12 @@ export class DesktopDiagnosticsReporter { error: null, }; + private readonly startupProbe: DesktopDiagnosticsSnapshot["startupProbe"] = { + preloadSeen: false, + rendererSeen: false, + entries: [], + }; + private sleepGuard: SleepGuardSnapshot = createInitialSleepGuardSnapshot(); private readonly renderer: DesktopRendererSnapshot = { @@ -152,6 +172,33 @@ export class DesktopDiagnosticsReporter { this.scheduleFlush(); } + recordStartupProbe(payload: StartupProbePayload): void { + if (payload.source === "preload") { + this.startupProbe.preloadSeen = true; + } + + if (payload.source === "renderer") { + this.startupProbe.rendererSeen = true; + } + + this.startupProbe.entries.push({ + source: payload.source, + stage: payload.stage, + status: payload.status, + detail: payload.detail ?? null, + at: nowIso(), + }); + + if (this.startupProbe.entries.length > MAX_STARTUP_PROBE_ENTRIES) { + this.startupProbe.entries.splice( + 0, + this.startupProbe.entries.length - MAX_STARTUP_PROBE_ENTRIES, + ); + } + + this.scheduleFlush(); + } + recordRendererDidFinishLoad(url: string): void { this.renderer.didFinishLoad = true; this.renderer.lastUrl = url; @@ -277,6 +324,11 @@ export class DesktopDiagnosticsReporter { updatedAt: nowIso(), isPackaged: app.isPackaged, coldStart: { ...this.coldStart }, + startupProbe: { + preloadSeen: this.startupProbe.preloadSeen, + rendererSeen: this.startupProbe.rendererSeen, + entries: this.startupProbe.entries.map((entry) => ({ ...entry })), + }, sleepGuard: { ...this.sleepGuard, counters: { ...this.sleepGuard.counters }, diff --git a/apps/desktop/main/diagnostics-export.ts b/apps/desktop/main/diagnostics-export.ts index e8d61f19b..c95335d38 100644 --- a/apps/desktop/main/diagnostics-export.ts +++ b/apps/desktop/main/diagnostics-export.ts @@ -1,4 +1,4 @@ -import { execFileSync } from "node:child_process"; +import { spawnSync } from "node:child_process"; import type { Dirent } from "node:fs"; import { access, readFile, readdir, stat, writeFile } from "node:fs/promises"; import { homedir, hostname } from "node:os"; @@ -167,6 +167,14 @@ function redactJsonBuffer(raw: Buffer): Buffer { } } +function parseJsonBuffer(raw: Buffer): T | null { + try { + return JSON.parse(raw.toString("utf8")) as T; + } catch { + return null; + } +} + // --------------------------------------------------------------------------- // Artifact collection // --------------------------------------------------------------------------- @@ -229,19 +237,109 @@ async function listFilesRecursive(directoryPath: string): Promise { return output; } +function runCommand( + binaryPath: string, + args: string[], +): { + binaryPath: string; + args: string[]; + ok: boolean; + status: number | null; + signal: NodeJS.Signals | null; + stdout: string | null; + stderr: string | null; + error: string | null; +} { + const result = spawnSync(binaryPath, args, { + encoding: "utf8", + timeout: 5000, + }); + + const stdout = result.stdout?.trim() ?? ""; + const stderr = result.stderr?.trim() ?? ""; + + return { + binaryPath, + args, + ok: result.status === 0 && !result.error, + status: result.status, + signal: result.signal, + stdout: stdout.length > 0 ? stdout : null, + stderr: stderr.length > 0 ? stderr : null, + error: result.error ? String(result.error.message) : null, + }; +} + function readMacOsProductVersion(): string | null { if (process.platform !== "darwin") { return null; } - try { - const output = execFileSync("sw_vers", ["-productVersion"], { - encoding: "utf8", - }).trim(); - return output.length > 0 ? output : null; - } catch { + const result = runCommand("/usr/bin/sw_vers", ["-productVersion"]); + return result.ok ? result.stdout : null; +} + +function buildMachineSummary(runtimeConfig: DesktopRuntimeConfig): object { + const rosettaCheck = + process.platform === "darwin" + ? runCommand("/usr/sbin/sysctl", ["-n", "sysctl.proc_translated"]) + : null; + + const unameMachine = + process.platform === "darwin" ? runCommand("/usr/bin/uname", ["-m"]) : null; + + return { + buildInfo: runtimeConfig.buildInfo, + hostName: hostname(), + platform: process.platform, + arch: process.arch, + osVersion: readMacOsProductVersion(), + processVersions: process.versions, + executablePath: app.getPath("exe"), + processExecPath: process.execPath, + resourcesPath: process.resourcesPath, + isPackaged: app.isPackaged, + rosetta: rosettaCheck + ? { + translated: + rosettaCheck.ok && rosettaCheck.stdout !== null + ? rosettaCheck.stdout === "1" + : null, + command: rosettaCheck, + } + : null, + uname: unameMachine, + appPaths: { + userData: app.getPath("userData"), + logs: app.getPath("logs"), + crashDumps: app.getPath("crashDumps"), + nexuHome: runtimeConfig.paths.nexuHome, + }, + }; +} + +function buildAppSigningSummary(): object | null { + if (process.platform !== "darwin") { return null; } + + const appExecutablePath = app.getPath("exe"); + + return { + executablePath: appExecutablePath, + codesign: runCommand("/usr/bin/codesign", [ + "-dv", + "--verbose=4", + appExecutablePath, + ]), + spctl: runCommand("/usr/sbin/spctl", [ + "--assess", + "--type", + "execute", + "-vv", + appExecutablePath, + ]), + }; } function getTimestampSlug(): string { @@ -265,6 +363,7 @@ async function collectArtifacts( const included: string[] = []; const missing: string[] = []; const warnings: string[] = []; + let desktopDiagnosticsSummary: unknown = null; const additionalArtifacts = { startupHealth: null as CollectedFileMetadata | null, @@ -305,7 +404,7 @@ async function collectArtifacts( } // Desktop diagnostics snapshot - await addFile( + const desktopDiagnosticsMetadata = await addFile( "diagnostics/desktop-diagnostics.json", getDesktopDiagnosticsFilePath(), { @@ -313,6 +412,51 @@ async function collectArtifacts( }, ); + if (desktopDiagnosticsMetadata) { + const desktopDiagnosticsFile = await tryReadFile( + getDesktopDiagnosticsFilePath(), + ); + const parsedDiagnostics = desktopDiagnosticsFile + ? parseJsonBuffer<{ + startupProbe?: { + preloadSeen?: boolean; + rendererSeen?: boolean; + entries?: Array<{ + source?: string; + stage?: string; + status?: string; + detail?: string | null; + at?: string; + }>; + }; + renderer?: { + didFinishLoad?: boolean; + lastError?: string | null; + processGone?: { + seen?: boolean; + reason?: string | null; + exitCode?: number | null; + at?: string | null; + }; + }; + coldStart?: { + status?: string; + step?: string | null; + error?: string | null; + }; + }>(desktopDiagnosticsFile.data) + : null; + + if (parsedDiagnostics) { + desktopDiagnosticsSummary = { + sourceArchivePath: desktopDiagnosticsMetadata.archivePath, + coldStart: parsedDiagnostics.coldStart ?? null, + renderer: parsedDiagnostics.renderer ?? null, + startupProbe: parsedDiagnostics.startupProbe ?? null, + }; + } + } + // Main process logs const logsDir = resolve(app.getPath("userData"), "logs"); await addFile("logs/cold-start.log", resolve(logsDir, "cold-start.log"), { @@ -440,6 +584,8 @@ async function collectArtifacts( // Environment summary (safe metadata only) const envSummary = buildEnvironmentSummary(runtimeConfig); + const machineSummary = buildMachineSummary(runtimeConfig); + const appSigningSummary = buildAppSigningSummary(); const now = new Date(); entries.push({ name: `${archiveRoot}/summary/environment-summary.json`, @@ -448,6 +594,41 @@ async function collectArtifacts( }); included.push("summary/environment-summary.json"); + entries.push({ + name: `${archiveRoot}/summary/machine-info.json`, + data: Buffer.from(`${JSON.stringify(machineSummary, null, 2)}\n`, "utf8"), + modTime: now, + }); + included.push("summary/machine-info.json"); + + if (appSigningSummary) { + entries.push({ + name: `${archiveRoot}/summary/app-signing.json`, + data: Buffer.from( + `${JSON.stringify(appSigningSummary, null, 2)}\n`, + "utf8", + ), + modTime: now, + }); + included.push("summary/app-signing.json"); + } + + if (desktopDiagnosticsSummary) { + const redactedStartupProbeSummary = redactJsonBuffer( + Buffer.from( + `${JSON.stringify(desktopDiagnosticsSummary, null, 2)}\n`, + "utf8", + ), + ); + + entries.push({ + name: `${archiveRoot}/summary/startup-probe-summary.json`, + data: redactedStartupProbeSummary, + modTime: now, + }); + included.push("summary/startup-probe-summary.json"); + } + const extraArtifactsSummary = { startupHealth: additionalArtifacts.startupHealth, openclawLogs: additionalArtifacts.openclawLogs, @@ -465,6 +646,12 @@ async function collectArtifacts( }); included.push("summary/additional-artifacts.json"); + if (missing.length > 0) { + warnings.push(`${missing.length} file(s) were not found and were skipped.`); + } + + included.push("summary/manifest.json"); + // Manifest const manifest = { exportedAt: now.toISOString(), @@ -482,10 +669,6 @@ async function collectArtifacts( modTime: now, }); - if (missing.length > 0) { - warnings.push(`${missing.length} file(s) were not found and were skipped.`); - } - return { entries, warnings }; } diff --git a/apps/desktop/main/index.ts b/apps/desktop/main/index.ts index 492d2af96..b4364b1ea 100644 --- a/apps/desktop/main/index.ts +++ b/apps/desktop/main/index.ts @@ -752,6 +752,12 @@ function createMainWindow(): BrowserWindow { window.webContents.on( "did-fail-load", (_event, errorCode, errorDescription, validatedUrl) => { + diagnosticsReporter?.recordStartupProbe({ + source: "main", + stage: "main:renderer-did-fail-load", + status: "error", + detail: `${errorCode} ${errorDescription} ${validatedUrl}`, + }); diagnosticsReporter?.recordRendererDidFailLoad({ errorCode, errorDescription, @@ -768,6 +774,12 @@ function createMainWindow(): BrowserWindow { ); window.webContents.on("did-finish-load", () => { + diagnosticsReporter?.recordStartupProbe({ + source: "main", + stage: "main:renderer-did-finish-load", + status: "ok", + detail: window.webContents.getURL(), + }); diagnosticsReporter?.recordRendererDidFinishLoad( window.webContents.getURL(), ); @@ -781,6 +793,12 @@ function createMainWindow(): BrowserWindow { }); window.webContents.on("render-process-gone", (_event, details) => { + diagnosticsReporter?.recordStartupProbe({ + source: "main", + stage: "main:renderer-process-gone", + status: "error", + detail: `reason=${details.reason} exitCode=${details.exitCode}`, + }); diagnosticsReporter?.recordRendererProcessGone({ reason: details.reason, exitCode: details.exitCode, @@ -795,6 +813,12 @@ function createMainWindow(): BrowserWindow { }); window.once("ready-to-show", () => { + diagnosticsReporter?.recordStartupProbe({ + source: "main", + stage: "main:window-ready-to-show", + status: "ok", + detail: window.webContents.getURL(), + }); logLaunchTimeline("main window ready-to-show"); if (isMacOS) { window.setBackgroundColor("#00000000"); @@ -811,6 +835,12 @@ function createMainWindow(): BrowserWindow { }); void window.loadFile(resolve(__dirname, "../../dist/index.html")); + diagnosticsReporter?.recordStartupProbe({ + source: "main", + stage: "main:window-load-dispatched", + status: "ok", + detail: resolve(__dirname, "../../dist/index.html"), + }); logLaunchTimeline("main window loadFile dispatched"); mainWindow = window; return window; @@ -919,8 +949,19 @@ logLaunchTimeline("electron main module evaluated"); app.whenReady().then(async () => { logLaunchTimeline("app.whenReady resolved"); installApplicationMenu(); - registerIpcHandlers(orchestrator, runtimeConfig, coldStartReady); diagnosticsReporter = new DesktopDiagnosticsReporter(orchestrator); + diagnosticsReporter.recordStartupProbe({ + source: "main", + stage: "main:app-when-ready", + status: "ok", + detail: app.getVersion(), + }); + registerIpcHandlers( + orchestrator, + runtimeConfig, + diagnosticsReporter, + coldStartReady, + ); const unsubscribeDiagnostics = diagnosticsReporter.start(); sleepGuard = new SleepGuard({ powerMonitor, diff --git a/apps/desktop/main/ipc.ts b/apps/desktop/main/ipc.ts index a4ce11866..8c33576b8 100644 --- a/apps/desktop/main/ipc.ts +++ b/apps/desktop/main/ipc.ts @@ -3,9 +3,11 @@ import { BrowserWindow, app, crashReporter, ipcMain, shell } from "electron"; import { type HostInvokePayloadMap, type HostInvokeResultMap, + type StartupProbePayload, hostInvokeChannels, } from "../shared/host"; import type { DesktopRuntimeConfig } from "../shared/runtime-config"; +import type { DesktopDiagnosticsReporter } from "./desktop-diagnostics"; import { exportDiagnostics } from "./diagnostics-export"; import type { RuntimeOrchestrator } from "./runtime/daemon-supervisor"; import type { ComponentUpdater } from "./updater/component-updater"; @@ -113,6 +115,7 @@ function assertValidChannel( export function registerIpcHandlers( orchestrator: RuntimeOrchestrator, runtimeConfig: DesktopRuntimeConfig, + diagnosticsReporter: DesktopDiagnosticsReporter | null, coldStartReady?: Promise, ): void { orchestrator.subscribe((runtimeEvent) => { @@ -487,4 +490,8 @@ export function registerIpcHandlers( } }, ); + + ipcMain.on("host:startup-probe", (_event, payload: StartupProbePayload) => { + diagnosticsReporter?.recordStartupProbe(payload); + }); } diff --git a/apps/desktop/preload/index.ts b/apps/desktop/preload/index.ts index 544b61d46..0b0d7cdcf 100644 --- a/apps/desktop/preload/index.ts +++ b/apps/desktop/preload/index.ts @@ -6,6 +6,7 @@ import { type HostInvokePayloadMap, type HostInvokeResultMap, type RuntimeEvent, + type StartupProbePayload, type UpdaterBridge, type UpdaterEvent, type UpdaterEventMap, @@ -21,6 +22,42 @@ const runtimeConfig = getDesktopRuntimeConfig(process.env, { useBuildConfig: !process.defaultApp, }); +function reportStartupProbe(payload: StartupProbePayload): void { + try { + ipcRenderer.send("host:startup-probe", payload); + } catch (error) { + console.error("[desktop] failed to report startup probe", error); + } +} + +reportStartupProbe({ + source: "preload", + stage: "preload:module-start", + status: "ok", +}); + +process.on("uncaughtException", (error) => { + reportStartupProbe({ + source: "preload", + stage: "preload:uncaught-exception", + status: "error", + detail: + error instanceof Error ? (error.stack ?? error.message) : String(error), + }); +}); + +process.on("unhandledRejection", (reason) => { + reportStartupProbe({ + source: "preload", + stage: "preload:unhandled-rejection", + status: "error", + detail: + reason instanceof Error + ? (reason.stack ?? reason.message) + : String(reason), + }); +}); + const hostBridge: HostBridge = { bootstrap: { buildInfo: runtimeConfig.buildInfo, @@ -41,6 +78,10 @@ const hostBridge: HostBridge = { >; }, + reportStartupProbe(payload) { + reportStartupProbe(payload); + }, + onDesktopCommand(listener) { const wrapped = ( _event: Electron.IpcRendererEvent, @@ -74,6 +115,12 @@ const hostBridge: HostBridge = { contextBridge.exposeInMainWorld("nexuHost", hostBridge); +reportStartupProbe({ + source: "preload", + stage: "preload:bridge-exposed", + status: "ok", +}); + const validUpdaterEvents = new Set(updaterEvents); const updaterBridge: UpdaterBridge = { @@ -101,3 +148,9 @@ const updaterBridge: UpdaterBridge = { }; contextBridge.exposeInMainWorld("nexuUpdater", updaterBridge); + +reportStartupProbe({ + source: "preload", + stage: "preload:updater-bridge-exposed", + status: "ok", +}); diff --git a/apps/desktop/preload/webview-preload.ts b/apps/desktop/preload/webview-preload.ts index 9f196d2af..af5b04a43 100644 --- a/apps/desktop/preload/webview-preload.ts +++ b/apps/desktop/preload/webview-preload.ts @@ -6,6 +6,7 @@ import { type HostInvokePayloadMap, type HostInvokeResultMap, type RuntimeEvent, + type StartupProbePayload, hostInvokeChannels, } from "../shared/host"; import { getDesktopRuntimeConfig } from "../shared/runtime-config"; @@ -17,6 +18,14 @@ const runtimeConfig = getDesktopRuntimeConfig(process.env, { useBuildConfig: !process.defaultApp, }); +function reportStartupProbe(payload: StartupProbePayload): void { + try { + ipcRenderer.send("host:startup-probe", payload); + } catch (error) { + console.error("[desktop] failed to report startup probe", error); + } +} + const hostBridge: HostBridge = { bootstrap: { buildInfo: runtimeConfig.buildInfo, @@ -37,6 +46,10 @@ const hostBridge: HostBridge = { >; }, + reportStartupProbe(payload) { + reportStartupProbe(payload); + }, + onDesktopCommand(listener) { const wrapped = ( _event: Electron.IpcRendererEvent, diff --git a/apps/desktop/shared/host.ts b/apps/desktop/shared/host.ts index 95fd56c1c..2e3c8b214 100644 --- a/apps/desktop/shared/host.ts +++ b/apps/desktop/shared/host.ts @@ -60,6 +60,15 @@ export type DiagnosticsExportResult = { errorMessage?: string; }; +export type StartupProbeStatus = "ok" | "error"; + +export type StartupProbePayload = { + source: "main" | "preload" | "renderer"; + stage: string; + status: StartupProbeStatus; + detail?: string | null; +}; + export type HostInvokePayloadMap = { "app:get-info": undefined; "diagnostics:get-info": undefined; @@ -533,6 +542,7 @@ export type HostBridge = { channel: TChannel, payload: HostInvokePayloadMap[TChannel], ): Promise; + reportStartupProbe(payload: StartupProbePayload): void; onDesktopCommand(listener: (command: HostDesktopCommand) => void): () => void; onRuntimeEvent(listener: (event: RuntimeEvent) => void): () => void; }; diff --git a/apps/desktop/src/lib/host-api.ts b/apps/desktop/src/lib/host-api.ts index bc15f42a6..a6a7e3dcd 100644 --- a/apps/desktop/src/lib/host-api.ts +++ b/apps/desktop/src/lib/host-api.ts @@ -9,6 +9,7 @@ import type { RuntimeEventQueryResult, RuntimeState, RuntimeUnitId, + StartupProbePayload, UpdateChannelName, UpdateSource, } from "@shared/host"; @@ -34,6 +35,10 @@ export async function exportDiagnostics( return getHostBridge().invoke("diagnostics:export", { source }); } +export function reportStartupProbe(payload: StartupProbePayload): void { + getHostBridge().reportStartupProbe(payload); +} + export async function triggerMainProcessCrash(): Promise { await getHostBridge().invoke("diagnostics:crash-main", undefined); } diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index 0ab651cc8..8dfe2d518 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -32,6 +32,7 @@ import { installComponent, onDesktopCommand, onRuntimeEvent, + reportStartupProbe, showRuntimeLogFile, startUnit, stopUnit, @@ -46,6 +47,42 @@ const rendererSentryDsn = typeof window === "undefined" ? null : window.nexuHost.bootstrap.sentryDsn; let rendererSentryInitialized = false; +let amplitudeTelemetryInitialized = false; +let rendererCommitReported = false; + +function sendRendererStartupProbe( + stage: string, + status: "ok" | "error", + detail?: string | null, +): void { + try { + reportStartupProbe({ + source: "renderer", + stage, + status, + detail: detail ?? null, + }); + } catch (error) { + console.error("[desktop] failed to report startup probe", error); + } +} + +sendRendererStartupProbe("renderer:module-start", "ok"); + +window.addEventListener("error", (event) => { + const detail = + event.error instanceof Error + ? (event.error.stack ?? event.error.message) + : event.message; + sendRendererStartupProbe("renderer:window-error", "error", detail); +}); + +window.addEventListener("unhandledrejection", (event) => { + const reason = event.reason; + const detail = + reason instanceof Error ? (reason.stack ?? reason.message) : String(reason); + sendRendererStartupProbe("renderer:unhandled-rejection", "error", detail); +}); function initializeRendererSentry(dsn: string): void { if (rendererSentryInitialized) { @@ -68,8 +105,19 @@ function initializeRendererSentry(dsn: string): void { rendererSentryInitialized = true; } -if (rendererSentryDsn) { - initializeRendererSentry(rendererSentryDsn); +function initializeAmplitudeTelemetry(): void { + if (amplitudeTelemetryInitialized || !amplitudeApiKey) { + return; + } + + amplitude.initAll(amplitudeApiKey, { + analytics: { autocapture: true }, + sessionReplay: { sampleRate: 1 }, + }); + const env = new Identify(); + env.set("environment", import.meta.env.MODE); + amplitude.identify(env); + amplitudeTelemetryInitialized = true; } function maskSentryDsn(dsn: string | null | undefined): string { @@ -128,16 +176,6 @@ function formatBuildCommit(value: string | null | undefined): string { return value.slice(0, 7); } -if (amplitudeApiKey) { - amplitude.initAll(amplitudeApiKey, { - analytics: { autocapture: true }, - sessionReplay: { sampleRate: 1 }, - }); - const env = new Identify(); - env.set("environment", import.meta.env.MODE); - amplitude.identify(env); -} - const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -1171,16 +1209,76 @@ function RootApp() { return ; } +function RendererTelemetryBootstrap() { + useEffect(() => { + if (rendererSentryDsn && !rendererSentryInitialized) { + sendRendererStartupProbe("renderer:sentry-init:start", "ok"); + try { + initializeRendererSentry(rendererSentryDsn); + sendRendererStartupProbe("renderer:sentry-init:success", "ok"); + } catch (error) { + sendRendererStartupProbe( + "renderer:sentry-init:error", + "error", + error instanceof Error + ? (error.stack ?? error.message) + : String(error), + ); + console.error("[desktop] renderer Sentry init failed", error); + } + } + + if (!amplitudeApiKey || amplitudeTelemetryInitialized) { + return; + } + + sendRendererStartupProbe("renderer:amplitude-init:start", "ok"); + try { + initializeAmplitudeTelemetry(); + sendRendererStartupProbe("renderer:amplitude-init:success", "ok"); + } catch (error) { + sendRendererStartupProbe( + "renderer:amplitude-init:error", + "error", + error instanceof Error ? (error.stack ?? error.message) : String(error), + ); + console.error("[desktop] renderer Amplitude init failed", error); + } + }, []); + + return null; +} + +function RendererStartupSentinel() { + useEffect(() => { + if (rendererCommitReported) { + return; + } + + rendererCommitReported = true; + sendRendererStartupProbe("renderer:react-render:committed", "ok"); + }, []); + + return null; +} + const rootElement = document.getElementById("root"); if (!rootElement) { + sendRendererStartupProbe("renderer:root-element-missing", "error"); throw new Error("Root element not found"); } +sendRendererStartupProbe("renderer:react-render:start", "ok"); + ReactDOM.createRoot(rootElement).render( + + , ); + +sendRendererStartupProbe("renderer:react-render:scheduled", "ok"); diff --git a/specs/current/diagnostics/export-diagnostics.md b/specs/current/diagnostics/export-diagnostics.md new file mode 100644 index 000000000..47c6d1b5d --- /dev/null +++ b/specs/current/diagnostics/export-diagnostics.md @@ -0,0 +1,168 @@ +# Desktop `Export Diagnostics` + +## Purpose + +`Help -> Export Diagnostics…` exports a shareable diagnostics bundle from the desktop app so startup and runtime issues can be investigated more efficiently. + +It is primarily intended for cases such as: + +- cold-start failures +- Intel Mac white-screen / crash / no-window startup failures +- renderer / preload / embedded webview startup issues +- OpenClaw / controller / web runtime problems +- local crash, local Sentry cache, and macOS crash report analysis + +The goal is to **capture a startup scene as completely as possible** so users do not need to manually gather logs, screenshots, and file paths. + +## How it works + +### 1. Entry points + +The main entry point is the desktop app menu: + +- `Help -> Export Diagnostics…` + +The renderer-side diagnostics page can trigger the same export flow as well. + +### 2. Core flow + +The export logic runs in the Electron main process. + +At a high level, it does the following: + +1. The user triggers export. +2. The main process opens a save dialog. +3. The app collects diagnostics files that are available from the current desktop runtime. +4. JSON files and logs are redacted with the shared scrubbing rules. +5. A ZIP archive is created. +6. The archive is written to the user-selected location. + +The main implementation lives in: + +- `apps/desktop/main/diagnostics-export.ts` +- `apps/desktop/main/desktop-diagnostics.ts` +- `apps/desktop/main/ipc.ts` +- `apps/desktop/preload/index.ts` +- `apps/desktop/src/main.tsx` + +### 3. Startup diagnostics model + +To improve Intel Mac startup investigations, the export bundle now includes structured startup probes in addition to the existing logs. + +These probes record whether: + +- `preload` started running +- `contextBridge` was successfully exposed +- the renderer main module started +- React render actually committed +- renderer Sentry initialization succeeded or failed +- Amplitude initialization succeeded or failed +- the main process observed `did-finish-load`, `did-fail-load`, or `render-process-gone` + +The probes are continuously written into `desktop-diagnostics.json`. During export, the app also produces a startup-focused summary so we can quickly tell whether failure happened: + +- before or inside preload +- right after renderer JavaScript starts +- during telemetry initialization +- after the page was already mounted + +### 4. Intel / macOS environment details + +The export also includes machine and signing information that is useful for Intel, Rosetta, and packaged-app validation issues, including: + +- `process.arch` +- `uname -m` +- `sysctl.proc_translated` +- `process.versions` +- app executable path +- `codesign` output +- `spctl` output + +These commands run only on macOS and use absolute binary paths so the packaged app does not depend on the user's PATH. + +## Exported file structure + +After extraction, the bundle looks roughly like this: + +```text +nexu-diagnostics-/ +├── config/ +│ └── openclaw.json +├── diagnostics/ +│ ├── crashes/ +│ │ └── *.json +│ ├── desktop-diagnostics.json +│ ├── sentry/ +│ │ └── **/*.json +│ └── startup-health.json +├── logs/ +│ ├── cold-start.log +│ ├── desktop-main.log +│ ├── openclaw/ +│ │ └── openclaw-*.log +│ └── runtime-units/ +│ ├── controller.log +│ ├── openclaw.log +│ └── web.log +└── summary/ + ├── additional-artifacts.json + ├── app-signing.json + ├── environment-summary.json + ├── machine-info.json + ├── manifest.json + └── startup-probe-summary.json +``` + +> Exact contents can vary by runtime mode and failure stage. Missing files are recorded in `summary/manifest.json`. + +## Key files + +### `diagnostics/desktop-diagnostics.json` + +This is the structured desktop diagnostics snapshot. It includes: + +- cold-start state +- sleep-guard state +- renderer load / process-gone information +- embedded content state +- runtime state and recent events +- startup probe timeline + +### `diagnostics/crashes/*.json` + +These are recent macOS crash reports collected from `~/Library/Logs/DiagnosticReports/` for files whose names contain `exu`. They are wrapped into JSON before being added to the export bundle. + +### `summary/startup-probe-summary.json` + +This is a startup-focused summary derived from `desktop-diagnostics.json`. It is meant to quickly answer questions such as: + +- Was preload ever seen? +- Was the renderer ever seen? +- Which telemetry step failed? +- Did the renderer finish loading or go away? + +### `summary/machine-info.json` + +This file summarizes machine architecture and runtime environment details, especially for Intel / Rosetta analysis. + +### `summary/app-signing.json` + +This file captures packaged-app signing and system assessment results to help diagnose signing-related launch failures. + +## Redaction + +The export applies basic redaction before writing files into the ZIP: + +- JSON fields matching token / password / secret / key / dsn-like names are replaced +- URL-embedded token fragments inside logs and text payloads are scrubbed + +The goal is to preserve debugging value while reducing the chance of exporting sensitive information in plain text. + +## Recommended use cases + +This feature is especially useful for: + +- Intel Macs where the desktop app does not open +- Electron renderer crashes during early startup +- user reports like “it disappears immediately after launch” without a reliable repro +- investigations that need to correlate system crash reports, startup probes, and runtime logs diff --git a/specs/current/diagnostics/diagnostics.md b/specs/current/diagnostics/trigger-export-diagnostics.md similarity index 92% rename from specs/current/diagnostics/diagnostics.md rename to specs/current/diagnostics/trigger-export-diagnostics.md index d8e988db8..a70cb4106 100644 --- a/specs/current/diagnostics/diagnostics.md +++ b/specs/current/diagnostics/trigger-export-diagnostics.md @@ -123,9 +123,12 @@ nexu-diagnostics-/ ├── config/ │ └── openclaw.json └── summary/ - ├── environment-summary.json ├── additional-artifacts.json - └── manifest.json + ├── app-signing.json + ├── environment-summary.json + ├── machine-info.json + ├── manifest.json + └── startup-probe-summary.json ``` Recommended: use the following command to inspect the ZIP internal paths directly (more reliable than Finder): @@ -146,6 +149,12 @@ unzip -l /.tmp/diagnostics/nexu-diagnostics-.zip - Native OpenClaw logs from `/tmp/openclaw`, supplementing troubleshooting info beyond runtime-units. - `summary/additional-artifacts.json` - Index of newly collected files (source path, archive path, size, modification time) for quickly determining "did we collect everything?" +- `summary/startup-probe-summary.json` + - Extracted preload / renderer / main startup probe timeline for locating early boot failures. +- `summary/machine-info.json` + - Machine architecture, Rosetta check, executable path, and runtime version summary for Intel mac investigations. +- `summary/app-signing.json` + - `codesign` and `spctl` output for packaged app signature / system assessment troubleshooting. ## Environment Differences (local dev vs packaged build)