diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 63d5be87a8..2111a6d081 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -103,6 +103,7 @@ export interface ExtensionMessage { | "rules" | "fileContent" | "rooHistoryImportProgress" + | "diagnosticsRequest" text?: string /** For fileContent: { path, content, error? } */ fileContent?: { path: string; content: string | null; error?: string } @@ -150,6 +151,7 @@ export interface ExtensionMessage { // eslint-disable-next-line @typescript-eslint/no-explicit-any values?: Record requestId?: string + diagnostics?: WebviewDiagnosticsSnapshot promptText?: string results?: | { path: string; type: "file" | "folder"; label?: string }[] @@ -438,6 +440,40 @@ export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "message export type AudioType = "notification" | "celebration" | "progress_loop" +export interface WebviewDiagnosticsSnapshot { + capturedAt: string + didHydrateState?: boolean + documentReadyState?: "loading" | "interactive" | "complete" + documentVisibilityState?: "hidden" | "visible" | "prerender" + activeView?: string + rootMounted?: boolean + rootChildCount?: number + lastReceivedStateSequence?: number + lastAppliedStateSequence?: number + staleStateRejectionCount?: number + unknownMessageUpdateCount?: number + currentTaskId?: string + chatMessageCount?: number + historyItemCount?: number + todoCount?: number + viewport?: { width: number; height: number; devicePixelRatio: number } + theme?: { + kind?: number + identifier?: string + bodyForeground?: string + bodyBackground?: string + rootForeground?: string + rootBackground?: string + variables?: Record + } + error?: { + name?: string + message?: string + fingerprint?: string + stackLocations?: string[] + } +} + export interface UpdateTodoListPayload { // eslint-disable-next-line @typescript-eslint/no-explicit-any todos: any[] @@ -632,6 +668,7 @@ export interface WebviewMessage { | "deleteRule" | "openRuleFile" | "openRulesDirectory" + | "diagnosticsResponse" text?: string taskId?: string editedMessageContent?: string @@ -678,6 +715,7 @@ export interface WebviewMessage { /** Target mode slugs for updateSkillModes */ newSkillModeSlugs?: string[] // For updateSkillModes (new mode restrictions) requestId?: string + diagnostics?: WebviewDiagnosticsSnapshot ids?: string[] terminalOperation?: "continue" | "abort" messageTs?: number diff --git a/packages/types/src/vscode.ts b/packages/types/src/vscode.ts index fd4e31116d..d70bec5845 100644 --- a/packages/types/src/vscode.ts +++ b/packages/types/src/vscode.ts @@ -48,6 +48,7 @@ export const commandIds = [ "toggleAutoApprove", "showRipgrepDiagnostic", + "createDiagnosticsReport", ] as const export type CommandId = (typeof commandIds)[number] diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index 67a2b935ec..b5cbcad73f 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -93,6 +93,10 @@ vi.mock("../../services/ripgrep/diagnostic", () => ({ registerRipgrepDiagnosticCommand: vi.fn().mockReturnValue({ dispose: vi.fn() }), })) +vi.mock("../../services/diagnostics", () => ({ + createDiagnosticsReport: vi.fn().mockResolvedValue(undefined), +})) + describe("getVisibleProviderOrLog", () => { let mockOutputChannel: vscode.OutputChannel @@ -192,6 +196,21 @@ describe("registerCommands handlers", () => { expect(mockContext.subscriptions).toContain(disposable) }) + it("creates diagnostics from all provider instances without requiring a visible provider", async () => { + const { createDiagnosticsReport } = await import("../../services/diagnostics") + const providers = [{}, {}] + ;(ClineProvider.getAllInstances as Mock).mockReturnValue(providers) + ;(ClineProvider.getVisibleInstance as Mock).mockReturnValue(undefined) + + await handlers["zoo-code.createDiagnosticsReport"]() + + expect(createDiagnosticsReport).toHaveBeenCalledWith({ + context: mockContext, + outputChannel: mockOutputChannel, + providers, + }) + }) + it("settingsButtonClicked posts both settingsButtonClicked and didBecomeVisible actions", () => { handlers["zoo-code.settingsButtonClicked"]() diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 692aabfd68..d4be15110e 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -14,6 +14,7 @@ import { CodeIndexManager } from "../services/code-index/manager" import { importSettingsWithFeedback } from "../core/config/importExport" import { MdmService } from "../services/mdm/MdmService" import { registerRipgrepDiagnosticCommand } from "../services/ripgrep/diagnostic" +import { createDiagnosticsReport } from "../services/diagnostics" import { t } from "../i18n" /** @@ -173,6 +174,12 @@ const getCommandsMap = ({ filePath, ) }, + createDiagnosticsReport: () => + createDiagnosticsReport({ + context, + outputChannel, + providers: ClineProvider.getAllInstances(), + }), focusInput: async () => { try { await focusPanel(tabPanel, sidebarPanel) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 573cf92a5b..0aa5366c23 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -36,6 +36,7 @@ import { type ToolUsage, type ExtensionMessage, type ExtensionState, + type WebviewDiagnosticsSnapshot, type MarketplaceInstalledMetadata, RooCodeEventName, requestyDefaultModelId, @@ -85,6 +86,11 @@ import { CodeIndexManager } from "../../services/code-index/manager" import type { IndexProgressUpdate } from "../../services/code-index/interfaces/manager" import { MdmService } from "../../services/mdm/MdmService" import { SkillsManager } from "../../services/skills/SkillsManager" +import { + DiagnosticsRecorder, + DiagnosticsRequestBroker, + type DiagnosticsProviderSourceSnapshot, +} from "../../services/diagnostics" import { fileExistsAtPath } from "../../utils/fs" import { setTtsEnabled, setTtsSpeed } from "../../utils/tts" @@ -189,6 +195,8 @@ export class ClineProvider private taskEventListeners: WeakMap void>> = new WeakMap() private currentWorkspacePath: string | undefined private _disposed = false + private readonly diagnosticsRecorder = new DiagnosticsRecorder(200) + private readonly diagnosticsRequestBroker = new DiagnosticsRequestBroker() private readonly rateLimitClock: RateLimitClock = createRateLimitClock() private recentTasksCache?: string[] @@ -291,6 +299,7 @@ export class ClineProvider ) ClineProvider.activeInstances.add(this) + this.diagnosticsRecorder.record({ boundary: "provider", phase: "success", type: "created" }) this.mdmService = mdmService void this.updateGlobalState("codebaseIndexModels", EMBEDDING_MODEL_PROFILES) @@ -447,6 +456,8 @@ export class ClineProvider * Initialize the TaskHistoryStore and migrate from globalState if needed. */ private async initializeTaskHistoryStore(): Promise { + const startedAt = Date.now() + this.diagnosticsRecorder.record({ boundary: "task-history", phase: "start", type: "initialize" }) try { await this.taskHistoryStore.initialize() @@ -467,7 +478,19 @@ export class ClineProvider } this.taskHistoryStoreInitialized = true + this.diagnosticsRecorder.record({ + boundary: "task-history", + phase: "success", + type: "initialize", + elapsedMs: Date.now() - startedAt, + }) } catch (error) { + this.diagnosticsRecorder.record({ + boundary: "task-history", + phase: "failure", + type: "initialize", + elapsedMs: Date.now() - startedAt, + }) this.log(`[initializeTaskHistoryStore] Error: ${error instanceof Error ? error.message : String(error)}`) } } @@ -746,6 +769,7 @@ export class ClineProvider } this._disposed = true + this.diagnosticsRecorder.record({ boundary: "provider", phase: "start", type: "dispose" }) this.log("Disposing ClineProvider...") // Reject any tasks still waiting for a scheduler permit so they don't @@ -800,6 +824,7 @@ export class ClineProvider this.flushGlobalStateWriteThrough() this.log("Disposed all disposables") ClineProvider.activeInstances.delete(this) + this.diagnosticsRequestBroker.dispose() // Clean up any event listeners attached to this provider this.removeAllListeners() @@ -922,6 +947,11 @@ export class ClineProvider async resolveWebviewView(webviewView: vscode.WebviewView | vscode.WebviewPanel) { this.view = webviewView const inTabMode = "onDidChangeViewState" in webviewView + this.diagnosticsRecorder.record({ + boundary: "provider", + phase: "success", + type: "view-resolved", + }) if (inTabMode) { setPanel(webviewView, "tab") @@ -998,6 +1028,11 @@ export class ClineProvider // WebviewView and WebviewPanel have all the same properties except // for this visibility listener panel. const viewStateDisposable = webviewView.onDidChangeViewState(() => { + this.diagnosticsRecorder.record({ + boundary: "provider", + phase: "success", + type: this.view?.visible ? "view-visible" : "view-hidden", + }) if (this.view?.visible) { void this.postMessageToWebview({ type: "action", action: "didBecomeVisible" }) } else { @@ -1009,6 +1044,11 @@ export class ClineProvider } else if ("onDidChangeVisibility" in webviewView) { // sidebar const visibilityDisposable = webviewView.onDidChangeVisibility(() => { + this.diagnosticsRecorder.record({ + boundary: "provider", + phase: "success", + type: this.view?.visible ? "view-visible" : "view-hidden", + }) if (this.view?.visible) { void this.postMessageToWebview({ type: "action", action: "didBecomeVisible" }) } else { @@ -1354,17 +1394,80 @@ export class ClineProvider } public async postMessageToWebview(message: ExtensionMessage) { + const startedAt = Date.now() + const taskId = this.getCurrentTask()?.taskId + this.diagnosticsRecorder.record({ + boundary: "webview-out", + phase: "start", + type: message.type, + action: message.action, + stateSequence: message.type === "state" ? message.state?.clineMessagesSeq : undefined, + taskId, + }) if (this._disposed) { + this.diagnosticsRecorder.record({ + boundary: "webview-out", + phase: "failure", + type: message.type, + action: message.action, + elapsedMs: Date.now() - startedAt, + taskId, + }) return } try { - await this.view?.webview.postMessage(message) + const delivered = await this.view?.webview.postMessage(message) + this.diagnosticsRecorder.record({ + boundary: "webview-out", + phase: delivered ? "success" : "failure", + type: message.type, + action: message.action, + elapsedMs: Date.now() - startedAt, + stateSequence: message.type === "state" ? message.state?.clineMessagesSeq : undefined, + taskId, + }) } catch { + this.diagnosticsRecorder.record({ + boundary: "webview-out", + phase: "failure", + type: message.type, + action: message.action, + elapsedMs: Date.now() - startedAt, + taskId, + }) // View disposed, drop message silently } } + public getDiagnosticsSnapshot(): DiagnosticsProviderSourceSnapshot { + const currentTask = this.getCurrentTask() + const events = this.diagnosticsRecorder.snapshot(100) + return { + renderContext: this.renderContext, + disposed: this._disposed, + viewPresent: Boolean(this.view), + visible: this.view?.visible === true, + launched: this.isViewLaunched, + taskHistoryInitialized: this.taskHistoryStoreInitialized, + taskCount: this.taskRegistry.length, + currentTaskId: currentTask?.taskId, + currentMessageCount: currentTask?.clineMessages.length ?? 0, + currentTodoCount: currentTask?.todoList?.length ?? 0, + history: this.taskHistoryStore.getAll(), + events: events.events, + eventsTruncated: events.truncated, + } + } + + public requestWebviewDiagnostics(timeoutMs = 1_000): Promise { + if (this._disposed || !this.view) return Promise.resolve(undefined) + return this.diagnosticsRequestBroker.request( + (requestId) => this.postMessageToWebview({ type: "diagnosticsRequest", requestId }), + timeoutMs, + ) + } + private async getHMRHtmlContent(webview: vscode.Webview): Promise { let localPort = "5173" @@ -1553,8 +1656,31 @@ export class ClineProvider * @param webview A reference to the extension webview */ private setWebviewMessageListener(webview: vscode.Webview) { - const onReceiveMessage = async (message: WebviewMessage) => - webviewMessageHandler(this, message, this.marketplaceManager) + const onReceiveMessage = async (message: WebviewMessage) => { + const startedAt = Date.now() + this.diagnosticsRecorder.record({ boundary: "webview-in", phase: "start", type: message.type }) + try { + if (message.type === "diagnosticsResponse" && message.requestId) { + this.diagnosticsRequestBroker.resolve(message.requestId, message.diagnostics) + } else { + await webviewMessageHandler(this, message, this.marketplaceManager) + } + this.diagnosticsRecorder.record({ + boundary: "webview-in", + phase: "success", + type: message.type, + elapsedMs: Date.now() - startedAt, + }) + } catch (error) { + this.diagnosticsRecorder.record({ + boundary: "webview-in", + phase: "failure", + type: message.type, + elapsedMs: Date.now() - startedAt, + }) + throw error + } + } const messageDisposable = webview.onDidReceiveMessage(onReceiveMessage) this.webviewDisposables.push(messageDisposable) @@ -2144,13 +2270,39 @@ export class ClineProvider } async showTaskWithId(id: string) { - if (id !== this.getCurrentTask()?.taskId) { - // Non-current task. - const { historyItem } = await this.getTaskWithId(id) - await this.createTaskWithHistoryItem(historyItem) // Clears existing task. - } + const startedAt = Date.now() + this.diagnosticsRecorder.record({ + boundary: "task-navigation", + phase: "start", + type: "show-task", + taskId: id, + }) + try { + if (id !== this.getCurrentTask()?.taskId) { + // Non-current task. + const { historyItem } = await this.getTaskWithId(id) + await this.createTaskWithHistoryItem(historyItem) // Clears existing task. + } - await this.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) + await this.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) + this.diagnosticsRecorder.record({ + boundary: "task-navigation", + phase: "success", + type: "show-task", + taskId: id, + elapsedMs: Date.now() - startedAt, + messageCount: this.getCurrentTask()?.clineMessages.length ?? 0, + }) + } catch (error) { + this.diagnosticsRecorder.record({ + boundary: "task-navigation", + phase: "failure", + type: "show-task", + taskId: id, + elapsedMs: Date.now() - startedAt, + }) + throw error + } } async exportTaskWithId(id: string) { diff --git a/src/package.json b/src/package.json index f272137dc2..997fc27973 100644 --- a/src/package.json +++ b/src/package.json @@ -165,6 +165,11 @@ "title": "%command.showRipgrepDiagnostic.title%", "category": "%configuration.title%" }, + { + "command": "zoo-code.createDiagnosticsReport", + "title": "%command.createDiagnosticsReport.title%", + "category": "%configuration.title%" + }, { "command": "zoo-code.toggleAutoApprove", "title": "%command.toggleAutoApprove.title%", diff --git a/src/package.nls.ca.json b/src/package.nls.ca.json index 6ddaf181b4..416e638920 100644 --- a/src/package.nls.ca.json +++ b/src/package.nls.ca.json @@ -15,6 +15,7 @@ "command.terminal.explainCommand.title": "Explicar Aquesta Ordre", "command.acceptInput.title": "Acceptar Entrada/Suggeriment", "command.showRipgrepDiagnostic.title": "Mostra el diagnòstic de Ripgrep", + "command.createDiagnosticsReport.title": "Crea un informe de diagnòstic", "command.toggleAutoApprove.title": "Alternar Auto-Aprovació", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", diff --git a/src/package.nls.de.json b/src/package.nls.de.json index 4c8eccb293..63984345ea 100644 --- a/src/package.nls.de.json +++ b/src/package.nls.de.json @@ -15,6 +15,7 @@ "command.terminal.explainCommand.title": "Diesen Befehl Erklären", "command.acceptInput.title": "Eingabe/Vorschlag Akzeptieren", "command.showRipgrepDiagnostic.title": "Ripgrep-Diagnose anzeigen", + "command.createDiagnosticsReport.title": "Diagnosebericht erstellen", "command.toggleAutoApprove.title": "Auto-Genehmigung Umschalten", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", diff --git a/src/package.nls.es.json b/src/package.nls.es.json index 11a705880b..e678db39e9 100644 --- a/src/package.nls.es.json +++ b/src/package.nls.es.json @@ -15,6 +15,7 @@ "command.terminal.explainCommand.title": "Explicar Este Comando", "command.acceptInput.title": "Aceptar Entrada/Sugerencia", "command.showRipgrepDiagnostic.title": "Mostrar diagnóstico de Ripgrep", + "command.createDiagnosticsReport.title": "Crear informe de diagnóstico", "command.toggleAutoApprove.title": "Alternar Auto-Aprobación", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", diff --git a/src/package.nls.fr.json b/src/package.nls.fr.json index 573350bc9a..9e42ed6f09 100644 --- a/src/package.nls.fr.json +++ b/src/package.nls.fr.json @@ -15,6 +15,7 @@ "command.terminal.explainCommand.title": "Expliquer cette Commande", "command.acceptInput.title": "Accepter l'Entrée/Suggestion", "command.showRipgrepDiagnostic.title": "Afficher le diagnostic Ripgrep", + "command.createDiagnosticsReport.title": "Créer un rapport de diagnostic", "command.toggleAutoApprove.title": "Basculer Auto-Approbation", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", diff --git a/src/package.nls.hi.json b/src/package.nls.hi.json index 8135af2ab3..aadd22c94d 100644 --- a/src/package.nls.hi.json +++ b/src/package.nls.hi.json @@ -15,6 +15,7 @@ "command.terminal.explainCommand.title": "यह कमांड समझाएं", "command.acceptInput.title": "इनपुट/सुझाव स्वीकारें", "command.showRipgrepDiagnostic.title": "Ripgrep डायग्नोस्टिक दिखाएं", + "command.createDiagnosticsReport.title": "डायग्नोस्टिक रिपोर्ट बनाएं", "command.toggleAutoApprove.title": "ऑटो-अनुमोदन टॉगल करें", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", diff --git a/src/package.nls.id.json b/src/package.nls.id.json index c5740ad00b..8163935f63 100644 --- a/src/package.nls.id.json +++ b/src/package.nls.id.json @@ -24,6 +24,7 @@ "command.terminal.explainCommand.title": "Jelaskan Perintah Ini", "command.acceptInput.title": "Terima Input/Saran", "command.showRipgrepDiagnostic.title": "Tampilkan Diagnostik Ripgrep", + "command.createDiagnosticsReport.title": "Buat Laporan Diagnostik", "command.toggleAutoApprove.title": "Alihkan Persetujuan Otomatis", "configuration.title": "Zoo Code", "commands.allowedCommands.description": "Perintah yang dapat dijalankan secara otomatis ketika 'Selalu setujui operasi eksekusi' diaktifkan", diff --git a/src/package.nls.it.json b/src/package.nls.it.json index ebf2167a99..bea17811a8 100644 --- a/src/package.nls.it.json +++ b/src/package.nls.it.json @@ -15,6 +15,7 @@ "command.terminal.explainCommand.title": "Spiega Questo Comando", "command.acceptInput.title": "Accetta Input/Suggerimento", "command.showRipgrepDiagnostic.title": "Mostra diagnostica Ripgrep", + "command.createDiagnosticsReport.title": "Crea rapporto di diagnostica", "command.toggleAutoApprove.title": "Attiva/Disattiva Auto-Approvazione", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", diff --git a/src/package.nls.ja.json b/src/package.nls.ja.json index f9daa4bb93..e84ff292c1 100644 --- a/src/package.nls.ja.json +++ b/src/package.nls.ja.json @@ -24,6 +24,7 @@ "command.terminal.explainCommand.title": "このコマンドを説明", "command.acceptInput.title": "入力/提案を承認", "command.showRipgrepDiagnostic.title": "Ripgrep 診断を表示", + "command.createDiagnosticsReport.title": "診断レポートを作成", "command.toggleAutoApprove.title": "自動承認を切替", "configuration.title": "Zoo Code", "commands.allowedCommands.description": "'常に実行操作を承認する'が有効な場合に自動実行できるコマンド", diff --git a/src/package.nls.json b/src/package.nls.json index 4fac644eab..a83dc68354 100644 --- a/src/package.nls.json +++ b/src/package.nls.json @@ -24,6 +24,7 @@ "command.terminal.explainCommand.title": "Explain This Command", "command.acceptInput.title": "Accept Input/Suggestion", "command.showRipgrepDiagnostic.title": "Show Ripgrep Diagnostic", + "command.createDiagnosticsReport.title": "Create Diagnostics Report", "command.toggleAutoApprove.title": "Toggle Auto-Approve", "configuration.title": "Zoo Code", "commands.allowedCommands.description": "Commands that can be auto-executed when 'Always approve execute operations' is enabled", diff --git a/src/package.nls.ko.json b/src/package.nls.ko.json index a743902280..2b8a486b06 100644 --- a/src/package.nls.ko.json +++ b/src/package.nls.ko.json @@ -15,6 +15,7 @@ "command.terminal.explainCommand.title": "이 명령어 설명", "command.acceptInput.title": "입력/제안 수락", "command.showRipgrepDiagnostic.title": "Ripgrep 진단 표시", + "command.createDiagnosticsReport.title": "진단 보고서 만들기", "command.toggleAutoApprove.title": "자동 승인 전환", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", diff --git a/src/package.nls.nl.json b/src/package.nls.nl.json index 72bc15f89a..f7f38ad189 100644 --- a/src/package.nls.nl.json +++ b/src/package.nls.nl.json @@ -24,6 +24,7 @@ "command.terminal.explainCommand.title": "Leg Dit Commando Uit", "command.acceptInput.title": "Invoer/Suggestie Accepteren", "command.showRipgrepDiagnostic.title": "Ripgrep-diagnose weergeven", + "command.createDiagnosticsReport.title": "Diagnoserapport maken", "command.toggleAutoApprove.title": "Auto-Goedkeuring Schakelen", "configuration.title": "Zoo Code", "commands.allowedCommands.description": "Commando's die automatisch kunnen worden uitgevoerd wanneer 'Altijd goedkeuren uitvoerbewerkingen' is ingeschakeld", diff --git a/src/package.nls.pl.json b/src/package.nls.pl.json index 92fb97778b..1c3f17a322 100644 --- a/src/package.nls.pl.json +++ b/src/package.nls.pl.json @@ -15,6 +15,7 @@ "command.terminal.explainCommand.title": "Wyjaśnij tę Komendę", "command.acceptInput.title": "Akceptuj Wprowadzanie/Sugestię", "command.showRipgrepDiagnostic.title": "Pokaż diagnostykę Ripgrep", + "command.createDiagnosticsReport.title": "Utwórz raport diagnostyczny", "command.toggleAutoApprove.title": "Przełącz Auto-Zatwierdzanie", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", diff --git a/src/package.nls.pt-BR.json b/src/package.nls.pt-BR.json index 872af10e80..68a414a75e 100644 --- a/src/package.nls.pt-BR.json +++ b/src/package.nls.pt-BR.json @@ -15,6 +15,7 @@ "command.terminal.explainCommand.title": "Explicar Este Comando", "command.acceptInput.title": "Aceitar Entrada/Sugestão", "command.showRipgrepDiagnostic.title": "Mostrar diagnóstico do Ripgrep", + "command.createDiagnosticsReport.title": "Criar relatório de diagnóstico", "command.toggleAutoApprove.title": "Alternar Auto-Aprovação", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", diff --git a/src/package.nls.ru.json b/src/package.nls.ru.json index cb38655945..fbfa84d8c5 100644 --- a/src/package.nls.ru.json +++ b/src/package.nls.ru.json @@ -24,6 +24,7 @@ "command.terminal.explainCommand.title": "Объяснить эту команду", "command.acceptInput.title": "Принять ввод/предложение", "command.showRipgrepDiagnostic.title": "Показать диагностику Ripgrep", + "command.createDiagnosticsReport.title": "Создать диагностический отчёт", "command.toggleAutoApprove.title": "Переключить Авто-Подтверждение", "configuration.title": "Zoo Code", "commands.allowedCommands.description": "Команды, которые могут быть автоматически выполнены, когда включена опция 'Всегда подтверждать операции выполнения'", diff --git a/src/package.nls.tr.json b/src/package.nls.tr.json index 7d995723ce..f1a02aa58b 100644 --- a/src/package.nls.tr.json +++ b/src/package.nls.tr.json @@ -15,6 +15,7 @@ "command.terminal.explainCommand.title": "Bu Komutu Açıkla", "command.acceptInput.title": "Girişi/Öneriyi Kabul Et", "command.showRipgrepDiagnostic.title": "Ripgrep Tanılamasını Göster", + "command.createDiagnosticsReport.title": "Tanılama Raporu Oluştur", "command.toggleAutoApprove.title": "Otomatik Onayı Değiştir", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", diff --git a/src/package.nls.vi.json b/src/package.nls.vi.json index b50e4db508..c8a30026e9 100644 --- a/src/package.nls.vi.json +++ b/src/package.nls.vi.json @@ -15,6 +15,7 @@ "command.terminal.explainCommand.title": "Giải Thích Lệnh Này", "command.acceptInput.title": "Chấp Nhận Đầu Vào/Gợi Ý", "command.showRipgrepDiagnostic.title": "Hiển thị chẩn đoán Ripgrep", + "command.createDiagnosticsReport.title": "Tạo báo cáo chẩn đoán", "command.toggleAutoApprove.title": "Bật/Tắt Tự Động Phê Duyệt", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", diff --git a/src/package.nls.zh-CN.json b/src/package.nls.zh-CN.json index 0686d03a14..e8ad29c6fd 100644 --- a/src/package.nls.zh-CN.json +++ b/src/package.nls.zh-CN.json @@ -15,6 +15,7 @@ "command.terminal.explainCommand.title": "解释此命令", "command.acceptInput.title": "接受输入/建议", "command.showRipgrepDiagnostic.title": "显示 Ripgrep 诊断", + "command.createDiagnosticsReport.title": "创建诊断报告", "command.toggleAutoApprove.title": "切换自动批准", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", diff --git a/src/package.nls.zh-TW.json b/src/package.nls.zh-TW.json index 8005e0de7f..dafa11fb81 100644 --- a/src/package.nls.zh-TW.json +++ b/src/package.nls.zh-TW.json @@ -15,6 +15,7 @@ "command.terminal.explainCommand.title": "解釋此命令", "command.acceptInput.title": "接受輸入/建議", "command.showRipgrepDiagnostic.title": "顯示 Ripgrep 診斷", + "command.createDiagnosticsReport.title": "建立診斷報告", "command.toggleAutoApprove.title": "切換自動批准", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", diff --git a/src/services/diagnostics/__tests__/command.spec.ts b/src/services/diagnostics/__tests__/command.spec.ts new file mode 100644 index 0000000000..626209a255 --- /dev/null +++ b/src/services/diagnostics/__tests__/command.spec.ts @@ -0,0 +1,96 @@ +const mocks = vi.hoisted(() => ({ + writeFile: vi.fn(), + buildDiagnosticsReport: vi.fn(), + getStorageBasePath: vi.fn(), + openTextDocument: vi.fn(), + showTextDocument: vi.fn(), + writeText: vi.fn(), + showInformationMessage: vi.fn(), + showWarningMessage: vi.fn(), + showErrorMessage: vi.fn(), +})) + +vi.mock("fs/promises", () => ({ writeFile: mocks.writeFile })) +vi.mock("../../../utils/storage", () => ({ getStorageBasePath: mocks.getStorageBasePath })) +vi.mock("../report", () => ({ buildDiagnosticsReport: mocks.buildDiagnosticsReport })) +vi.mock("vscode", () => ({ + version: "1.100.0", + UIKind: { Desktop: 1, Web: 2 }, + ColorThemeKind: { Light: 1, Dark: 2, HighContrast: 3, HighContrastLight: 4 }, + env: { + appName: "Visual Studio Code", + uiKind: 1, + language: "en", + remoteName: undefined, + clipboard: { writeText: mocks.writeText }, + }, + workspace: { + workspaceFolders: [], + getConfiguration: vi.fn(() => ({ get: vi.fn(() => "") })), + openTextDocument: mocks.openTextDocument, + }, + window: { + activeColorTheme: { kind: 2 }, + showTextDocument: mocks.showTextDocument, + showInformationMessage: mocks.showInformationMessage, + showWarningMessage: mocks.showWarningMessage, + showErrorMessage: mocks.showErrorMessage, + }, +})) + +import type * as vscode from "vscode" + +import { createDiagnosticsReport } from "../command" + +describe("createDiagnosticsReport", () => { + const outputChannel = { appendLine: vi.fn() } as Pick as vscode.OutputChannel + const context = { + globalStorageUri: { fsPath: "/private/storage/path" }, + } as Pick as vscode.ExtensionContext + + beforeEach(() => { + vi.clearAllMocks() + mocks.getStorageBasePath.mockResolvedValue("/private/storage/path") + mocks.buildDiagnosticsReport.mockResolvedValue({ schemaVersion: 1, privacy: { uploaded: false } }) + mocks.writeFile.mockResolvedValue(undefined) + mocks.openTextDocument.mockResolvedValue({}) + mocks.showTextDocument.mockResolvedValue(undefined) + mocks.writeText.mockResolvedValue(undefined) + }) + + it("writes valid JSON to temp, opens it, and copies the same JSON without a provider", async () => { + await createDiagnosticsReport({ context, outputChannel, providers: [] }) + + expect(mocks.buildDiagnosticsReport).toHaveBeenCalledWith(expect.objectContaining({ providers: [] })) + expect(mocks.writeFile).toHaveBeenCalledWith( + expect.stringMatching(/zoo-code-diagnostics-\d+-[a-f0-9]{8}\.json$/), + expect.any(String), + "utf8", + ) + const json = mocks.writeFile.mock.calls[0][1] + expect(() => JSON.parse(json)).not.toThrow() + expect(mocks.writeText).toHaveBeenCalledWith(json) + expect(mocks.openTextDocument).toHaveBeenCalledWith(mocks.writeFile.mock.calls[0][0]) + expect(mocks.showInformationMessage).toHaveBeenCalledWith(expect.stringContaining("No data was uploaded")) + }) + + it("reports only a sanitized failure category", async () => { + mocks.writeFile.mockRejectedValue(new Error("secret path /Users/person/report.json")) + + await createDiagnosticsReport({ context, outputChannel, providers: [] }) + + expect(outputChannel.appendLine).toHaveBeenCalledWith("[createDiagnosticsReport] failed: Error") + expect(outputChannel.appendLine).not.toHaveBeenCalledWith(expect.stringContaining("/Users/person")) + expect(mocks.showErrorMessage).toHaveBeenCalledWith("Zoo Code could not create the diagnostics report.") + }) + + it("still copies the report when opening the document fails", async () => { + mocks.openTextDocument.mockRejectedValue(new Error("editor unavailable")) + + await createDiagnosticsReport({ context, outputChannel, providers: [] }) + + expect(mocks.writeText).toHaveBeenCalledTimes(1) + expect(outputChannel.appendLine).toHaveBeenCalledWith("[createDiagnosticsReport] open failed") + expect(mocks.showWarningMessage).toHaveBeenCalledWith(expect.stringContaining("could not open or copy")) + }) +}) diff --git a/src/services/diagnostics/__tests__/persistence.spec.ts b/src/services/diagnostics/__tests__/persistence.spec.ts new file mode 100644 index 0000000000..2ce9575f46 --- /dev/null +++ b/src/services/diagnostics/__tests__/persistence.spec.ts @@ -0,0 +1,139 @@ +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" + +import type { HistoryItem } from "@roo-code/types" + +import { collectPersistenceDiagnostics } from "../persistence" + +describe("collectPersistenceDiagnostics", () => { + let storagePath: string + + beforeEach(async () => { + storagePath = await fs.mkdtemp(path.join(os.tmpdir(), "zoo-diagnostics-test-")) + }) + + afterEach(async () => { + await fs.rm(storagePath, { recursive: true, force: true }) + }) + + it("inspects task files structurally without returning content", async () => { + const history: HistoryItem[] = [ + { + id: "raw-parent-id", + number: 1, + ts: 10, + task: "PRIVATE TASK TITLE", + tokensIn: 1, + tokensOut: 1, + totalCost: 0, + status: "delegated", + childIds: ["raw-child-id"], + awaitingChildId: "raw-child-id", + }, + { + id: "raw-child-id", + parentTaskId: "raw-parent-id", + number: 2, + ts: 20, + task: "SECRET CHILD TITLE", + tokensIn: 2, + tokensOut: 2, + totalCost: 0, + status: "active", + }, + ] + const tasksPath = path.join(storagePath, "tasks") + await fs.mkdir(path.join(tasksPath, "raw-child-id"), { recursive: true }) + await fs.writeFile(path.join(tasksPath, "_index.json"), JSON.stringify({ version: 1, entries: history })) + await fs.writeFile(path.join(tasksPath, "raw-child-id", "history_item.json"), JSON.stringify(history[1])) + await fs.writeFile( + path.join(tasksPath, "raw-child-id", "ui_messages.json"), + JSON.stringify([ + { ts: 100, text: "PRIVATE PROMPT" }, + { ts: 200, text: "PRIVATE RESPONSE" }, + ]), + ) + + const result = await collectPersistenceDiagnostics({ + storagePath, + history, + currentTaskIds: ["raw-child-id"], + pseudonymize: (value) => `hashed-${value === "raw-child-id" ? "child" : "parent"}`, + }) + + expect(result.index).toMatchObject({ parseStatus: "valid", version: 1, entryCount: 2 }) + expect(result.tasks.find((task) => task.id === "hashed-child")?.uiMessages).toMatchObject({ + parseStatus: "valid", + messageCount: 2, + firstTimestamp: 100, + lastTimestamp: 200, + }) + const serialized = JSON.stringify(result) + expect(serialized).not.toContain("PRIVATE") + expect(serialized).not.toContain("raw-child-id") + }) + + it("reports corrupt and missing files without throwing", async () => { + const history: HistoryItem[] = [ + { + id: "task-1", + number: 1, + ts: 10, + task: "not reported", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }, + ] + const taskPath = path.join(storagePath, "tasks", "task-1") + await fs.mkdir(taskPath, { recursive: true }) + await fs.writeFile(path.join(taskPath, "ui_messages.json"), "not json") + + const result = await collectPersistenceDiagnostics({ + storagePath, + history, + currentTaskIds: ["task-1"], + pseudonymize: () => "task-hash", + }) + + expect(result.index.exists).toBe(false) + expect(result.tasks[0].historyItem.exists).toBe(false) + expect(result.tasks[0].uiMessages.parseStatus).toBe("invalid") + }) + + it("reports cycles in parent relationships", async () => { + const history: HistoryItem[] = [ + { + id: "task-1", + parentTaskId: "task-2", + number: 1, + ts: 10, + task: "not reported", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }, + { + id: "task-2", + parentTaskId: "task-1", + number: 2, + ts: 20, + task: "not reported", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }, + ] + + const result = await collectPersistenceDiagnostics({ + storagePath, + history, + currentTaskIds: ["task-1"], + pseudonymize: (value) => `hashed-${value}`, + }) + + expect(result.tasks).toHaveLength(2) + expect(result.tasks.every((task) => task.integrityFindings.includes("parentCycle"))).toBe(true) + }) +}) diff --git a/src/services/diagnostics/__tests__/recorder.spec.ts b/src/services/diagnostics/__tests__/recorder.spec.ts new file mode 100644 index 0000000000..731b744617 --- /dev/null +++ b/src/services/diagnostics/__tests__/recorder.spec.ts @@ -0,0 +1,15 @@ +import { DiagnosticsRecorder } from "../recorder" + +describe("DiagnosticsRecorder", () => { + it("keeps a bounded structural trail without payload fields", () => { + const recorder = new DiagnosticsRecorder(3) + for (let index = 0; index < 5; index++) { + recorder.record({ boundary: "webview-out", phase: "success", type: `message-${index}` }) + } + + const snapshot = recorder.snapshot(2) + expect(snapshot.truncated).toBe(true) + expect(snapshot.events.map((event) => event.type)).toEqual(["message-3", "message-4"]) + expect(JSON.stringify(snapshot)).not.toContain("payload") + }) +}) diff --git a/src/services/diagnostics/__tests__/report.spec.ts b/src/services/diagnostics/__tests__/report.spec.ts new file mode 100644 index 0000000000..085d82b582 --- /dev/null +++ b/src/services/diagnostics/__tests__/report.spec.ts @@ -0,0 +1,163 @@ +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" + +import type { DiagnosticsProviderSource } from "../types" +import { buildDiagnosticsReport } from "../report" + +describe("buildDiagnosticsReport", () => { + it("produces bounded, pseudonymized, privacy-safe valid JSON", async () => { + const storagePath = await fs.mkdtemp(path.join(os.tmpdir(), "zoo-report-test-")) + const provider: DiagnosticsProviderSource = { + getDiagnosticsSnapshot: () => ({ + renderContext: "sidebar", + disposed: false, + viewPresent: true, + visible: false, + launched: true, + taskHistoryInitialized: true, + taskCount: 1, + currentTaskId: "raw-task-id", + currentMessageCount: 4, + currentTodoCount: 2, + history: [ + { + id: "raw-task-id", + number: 1, + ts: 1, + task: "TOP SECRET PROMPT", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }, + { + id: "failed-navigation-target", + number: 2, + ts: 2, + task: "ANOTHER SECRET PROMPT", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }, + ], + events: [ + ...Array.from({ length: 99 }, (_, index) => ({ + timestamp: new Date(index).toISOString(), + boundary: "webview-out" as const, + phase: "success" as const, + type: "state", + taskId: "raw-task-id", + })), + { + timestamp: new Date(100).toISOString(), + boundary: "task-navigation" as const, + phase: "failure" as const, + type: "show-task", + taskId: "failed-navigation-target", + }, + ], + eventsTruncated: true, + }), + requestWebviewDiagnostics: async () => ({ + capturedAt: new Date(0).toISOString(), + didHydrateState: true, + activeView: "chat", + currentTaskId: "raw-task-id", + chatMessageCount: 4, + theme: { + bodyBackground: "#fff", + variables: { + "--vscode-editor-background": "#fff", + "--private-secret": "API_KEY_SECRET", + }, + }, + error: { + message: "user@example.com /Users/person/private.ts", + fingerprint: "error-123", + stackLocations: ["webview-ui/src/App.tsx:1", "/Users/person/private.ts:2"], + }, + }), + } + + try { + const report = await buildDiagnosticsReport({ + providers: [provider], + storagePath, + version: "1.2.3", + releaseChannel: "stable", + environment: { + vscodeVersion: "1.100.0", + appName: "Visual Studio Code", + uiKind: "desktop", + platform: "linux", + architecture: "x64", + locale: "en", + remote: false, + workspaceFolderCount: 1, + customStorageConfigured: false, + colorThemeKind: "dark", + }, + }) + const serialized = JSON.stringify(report) + expect(() => JSON.parse(serialized)).not.toThrow() + expect(report.providers[0].events).toHaveLength(100) + expect(report.providers[0].currentTask).toMatch(/^task-[a-f0-9]{12}$/) + expect(report.providers[0].webviewResponse).toBe("received") + expect(report.persistence.tasks).toHaveLength(2) + expect(report.providers[0].webview).toMatchObject({ + didHydrateState: true, + currentTask: report.providers[0].currentTask, + }) + expect(serialized).not.toContain("raw-task-id") + expect(serialized).not.toContain("failed-navigation-target") + expect(serialized).not.toContain("TOP SECRET") + expect(serialized).not.toContain("API_KEY_SECRET") + expect(serialized).not.toContain("user@example.com") + expect(serialized).not.toContain("/Users/person") + expect(serialized).not.toContain("webview-ui/src/App.tsx") + expect(report.privacy).toMatchObject({ conversationContentIncluded: false, uploaded: false }) + } finally { + await fs.rm(storagePath, { recursive: true, force: true }) + } + }) + + it("marks a nonresponsive webview unavailable", async () => { + const provider: DiagnosticsProviderSource = { + getDiagnosticsSnapshot: () => ({ + renderContext: "editor", + disposed: false, + viewPresent: false, + visible: false, + launched: false, + taskHistoryInitialized: false, + taskCount: 0, + currentMessageCount: 0, + currentTodoCount: 0, + history: [], + events: [], + eventsTruncated: false, + }), + requestWebviewDiagnostics: async () => undefined, + } + const report = await buildDiagnosticsReport({ + providers: [provider], + storagePath: os.tmpdir(), + version: "1", + releaseChannel: "stable", + environment: { + vscodeVersion: "1", + appName: "Code", + uiKind: "desktop", + platform: "linux", + architecture: "x64", + locale: "en", + remote: false, + workspaceFolderCount: 0, + customStorageConfigured: false, + colorThemeKind: "dark", + }, + }) + + expect(report.providers[0].webviewResponse).toBe("unavailable") + }) +}) diff --git a/src/services/diagnostics/__tests__/requestBroker.spec.ts b/src/services/diagnostics/__tests__/requestBroker.spec.ts new file mode 100644 index 0000000000..67fe1018af --- /dev/null +++ b/src/services/diagnostics/__tests__/requestBroker.spec.ts @@ -0,0 +1,27 @@ +import { DiagnosticsRequestBroker } from "../requestBroker" + +describe("DiagnosticsRequestBroker", () => { + it("correlates a live webview response", async () => { + const broker = new DiagnosticsRequestBroker() + let requestId = "" + const response = broker.request(async (id) => { + requestId = id + }, 100) + + expect(requestId).not.toBe("") + expect(broker.resolve(requestId, { capturedAt: "2026-01-01T00:00:00.000Z", activeView: "chat" })).toBe(true) + await expect(response).resolves.toMatchObject({ activeView: "chat" }) + }) + + it("returns unavailable after the response timeout", async () => { + vi.useFakeTimers() + try { + const broker = new DiagnosticsRequestBroker() + const response = broker.request(async () => {}, 1_000) + await vi.advanceTimersByTimeAsync(1_000) + await expect(response).resolves.toBeUndefined() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/services/diagnostics/command.ts b/src/services/diagnostics/command.ts new file mode 100644 index 0000000000..3821869d5b --- /dev/null +++ b/src/services/diagnostics/command.ts @@ -0,0 +1,86 @@ +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" +import { randomBytes } from "crypto" +import * as vscode from "vscode" + +import { Package } from "../../shared/package" +import { getStorageBasePath } from "../../utils/storage" +import { buildDiagnosticsReport } from "./report" +import type { DiagnosticsProviderSource } from "./types" + +export async function createDiagnosticsReport(options: { + context: vscode.ExtensionContext + outputChannel: vscode.OutputChannel + providers: DiagnosticsProviderSource[] +}): Promise { + try { + const colorThemeKind = (() => { + switch (vscode.window.activeColorTheme.kind) { + case vscode.ColorThemeKind.Light: + return "light" as const + case vscode.ColorThemeKind.Dark: + return "dark" as const + case vscode.ColorThemeKind.HighContrast: + return "highContrast" as const + case vscode.ColorThemeKind.HighContrastLight: + return "highContrastLight" as const + default: + return "unknown" as const + } + })() + const storagePath = await getStorageBasePath(options.context.globalStorageUri.fsPath) + const report = await buildDiagnosticsReport({ + providers: options.providers, + storagePath, + version: Package.version, + releaseChannel: Package.releaseChannel, + environment: { + vscodeVersion: vscode.version, + appName: vscode.env.appName, + uiKind: + vscode.env.uiKind === vscode.UIKind.Desktop + ? "desktop" + : vscode.env.uiKind === vscode.UIKind.Web + ? "web" + : "unknown", + platform: process.platform, + architecture: process.arch, + locale: vscode.env.language, + remote: Boolean(vscode.env.remoteName), + workspaceFolderCount: vscode.workspace.workspaceFolders?.length ?? 0, + customStorageConfigured: Boolean( + vscode.workspace.getConfiguration(Package.name).get("customStoragePath", ""), + ), + colorThemeKind, + }, + }) + const json = JSON.stringify(report, null, 2) + const fileName = `zoo-code-diagnostics-${Date.now()}-${randomBytes(4).toString("hex")}.json` + const filePath = path.join(os.tmpdir(), fileName) + await fs.writeFile(filePath, json, "utf8") + const [openResult, copyResult] = await Promise.allSettled([ + vscode.workspace + .openTextDocument(filePath) + .then((document) => vscode.window.showTextDocument(document, { preview: true })), + vscode.env.clipboard.writeText(json), + ]) + if (openResult.status === "fulfilled" && copyResult.status === "fulfilled") { + await vscode.window.showInformationMessage( + "Zoo Code: redacted diagnostics copied and opened for review. No data was uploaded.", + ) + } else { + if (openResult.status === "rejected") + options.outputChannel.appendLine("[createDiagnosticsReport] open failed") + if (copyResult.status === "rejected") + options.outputChannel.appendLine("[createDiagnosticsReport] copy failed") + await vscode.window.showWarningMessage( + "Zoo Code created the diagnostics report, but could not open or copy every result. No data was uploaded.", + ) + } + } catch (error) { + const category = error instanceof Error ? error.name : "UnknownError" + options.outputChannel.appendLine(`[createDiagnosticsReport] failed: ${category}`) + await vscode.window.showErrorMessage("Zoo Code could not create the diagnostics report.") + } +} diff --git a/src/services/diagnostics/index.ts b/src/services/diagnostics/index.ts new file mode 100644 index 0000000000..4be6430873 --- /dev/null +++ b/src/services/diagnostics/index.ts @@ -0,0 +1,9 @@ +export { createDiagnosticsReport } from "./command" +export { DiagnosticsRecorder } from "./recorder" +export { DiagnosticsRequestBroker } from "./requestBroker" +export type { + DiagnosticsProviderSource, + DiagnosticsProviderSourceSnapshot, + DiagnosticsReportV1, + DiagnosticsStructuralEvent, +} from "./types" diff --git a/src/services/diagnostics/persistence.ts b/src/services/diagnostics/persistence.ts new file mode 100644 index 0000000000..33d5d9d6be --- /dev/null +++ b/src/services/diagnostics/persistence.ts @@ -0,0 +1,182 @@ +import * as fs from "fs/promises" +import * as path from "path" + +import type { HistoryItem } from "@roo-code/types" + +import { GlobalFileNames } from "../../shared/globalFileNames" +import type { DiagnosticsFileInspection, DiagnosticsPersistenceReport } from "./types" + +const MAX_TASKS = 10 +const MAX_INSPECTION_BYTES = 25 * 1024 * 1024 +const FILE_TIMEOUT_MS = 500 + +function withTimeout(operation: Promise, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("timeout")), timeoutMs) + void operation.then( + (value) => { + clearTimeout(timeout) + resolve(value) + }, + (error) => { + clearTimeout(timeout) + reject(error) + }, + ) + }) +} + +function getShape(value: unknown): DiagnosticsFileInspection["topLevelShape"] { + if (Array.isArray(value)) return "array" + if (value !== null && typeof value === "object") return "object" + return "other" +} + +async function inspectJson(filePath: string): Promise<{ inspection: DiagnosticsFileInspection; value?: unknown }> { + try { + const stat = await withTimeout(fs.stat(filePath), FILE_TIMEOUT_MS) + if (stat.size > MAX_INSPECTION_BYTES) { + return { inspection: { exists: true, size: stat.size, parseStatus: "tooLargeToInspect" } } + } + const raw = await withTimeout(fs.readFile(filePath, "utf8"), FILE_TIMEOUT_MS) + try { + const value: unknown = JSON.parse(raw) + return { + inspection: { exists: true, size: stat.size, parseStatus: "valid", topLevelShape: getShape(value) }, + value, + } + } catch { + return { inspection: { exists: true, size: stat.size, parseStatus: "invalid" } } + } + } catch (error) { + if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") { + return { inspection: { exists: false } } + } + return { inspection: { exists: false, parseStatus: "unavailable" } } + } +} + +function isHistoryItem(value: unknown): value is HistoryItem { + return value !== null && typeof value === "object" && "id" in value && typeof value.id === "string" +} + +function isSafeTaskId(value: string): boolean { + return /^[A-Za-z0-9_-]{1,128}$/.test(value) +} + +function hasParentCycle(item: HistoryItem, byId: Map): boolean { + const visited = new Set() + let current: HistoryItem | undefined = item + while (current) { + if (visited.has(current.id)) return true + visited.add(current.id) + current = current.parentTaskId ? byId.get(current.parentTaskId) : undefined + } + return false +} + +function selectRelevant(history: HistoryItem[], currentIds: string[]): HistoryItem[] { + const byId = new Map(history.map((item) => [item.id, item])) + const selected = new Set() + const queue = [...currentIds] + if (queue.length === 0 && history[0]) queue.push(history[0].id) + + while (queue.length > 0 && selected.size < MAX_TASKS) { + const id = queue.shift() + if (!id || selected.has(id)) continue + selected.add(id) + const item = byId.get(id) + if (!item) continue + for (const related of [item.rootTaskId, item.parentTaskId, item.awaitingChildId, ...(item.childIds ?? [])]) { + if (related && !selected.has(related)) queue.push(related) + } + } + + return Array.from(selected, (id) => byId.get(id)).filter( + (item): item is HistoryItem => item !== undefined && isSafeTaskId(item.id), + ) +} + +export async function collectPersistenceDiagnostics(options: { + storagePath: string + history: HistoryItem[] + currentTaskIds: string[] + pseudonymize: (value: string) => string +}): Promise { + const { storagePath, history, currentTaskIds, pseudonymize } = options + const tasksPath = path.join(storagePath, "tasks") + const relevant = selectRelevant(history, currentTaskIds) + const relevantIds = new Set(relevant.map((item) => item.id)) + const byId = new Map(history.map((item) => [item.id, item])) + const indexResult = await inspectJson(path.join(tasksPath, GlobalFileNames.historyIndex)) + + let indexEntries: unknown[] | undefined + let indexVersion: number | undefined + if (indexResult.value !== null && typeof indexResult.value === "object" && !Array.isArray(indexResult.value)) { + const value = indexResult.value as Record + indexVersion = typeof value.version === "number" ? value.version : undefined + indexEntries = Array.isArray(value.entries) ? value.entries : undefined + } + const indexIds = new Set(indexEntries?.filter(isHistoryItem).map((item) => item.id) ?? []) + + const tasks = await Promise.all( + relevant.map(async (item) => { + const taskPath = path.join(tasksPath, item.id) + const [historyItemResult, uiMessagesResult] = await Promise.all([ + inspectJson(path.join(taskPath, GlobalFileNames.historyItem)), + inspectJson(path.join(taskPath, GlobalFileNames.uiMessages)), + ]) + const messages = Array.isArray(uiMessagesResult.value) ? uiMessagesResult.value : undefined + const timestamps = (messages ?? []) + .map((message) => + message !== null && typeof message === "object" && "ts" in message && typeof message.ts === "number" + ? message.ts + : undefined, + ) + .filter((timestamp): timestamp is number => timestamp !== undefined) + const findings: string[] = [] + for (const related of [ + item.rootTaskId, + item.parentTaskId, + item.awaitingChildId, + ...(item.childIds ?? []), + ]) { + if (related && !byId.has(related)) findings.push("missingReferencedTask") + } + if (item.awaitingChildId && item.status !== "delegated") findings.push("awaitingChildStatusMismatch") + if (item.parentTaskId && !byId.get(item.parentTaskId)?.childIds?.includes(item.id)) { + findings.push("parentChildMismatch") + } + if (hasParentCycle(item, byId)) findings.push("parentCycle") + + return { + id: pseudonymize(item.id), + rootTask: item.rootTaskId ? pseudonymize(item.rootTaskId) : undefined, + parentTask: item.parentTaskId ? pseudonymize(item.parentTaskId) : undefined, + children: (item.childIds ?? []).slice(0, MAX_TASKS).map(pseudonymize), + status: item.status, + number: item.number, + historyItem: historyItemResult.inspection, + uiMessages: { + ...uiMessagesResult.inspection, + messageCount: messages?.length, + firstTimestamp: timestamps[0], + lastTimestamp: timestamps.at(-1), + }, + integrityFindings: Array.from(new Set(findings)), + } + }), + ) + + return { + cacheEntryCount: history.length, + index: { + ...indexResult.inspection, + version: indexVersion, + entryCount: indexEntries?.length, + relevantEntriesPresent: Array.from(relevantIds).filter((id) => indexIds.has(id)).length, + cacheCountMatches: indexEntries ? indexEntries.length === history.length : undefined, + }, + tasks, + } +} diff --git a/src/services/diagnostics/recorder.ts b/src/services/diagnostics/recorder.ts new file mode 100644 index 0000000000..6193cadf6b --- /dev/null +++ b/src/services/diagnostics/recorder.ts @@ -0,0 +1,24 @@ +import type { DiagnosticsStructuralEvent } from "./types" + +export class DiagnosticsRecorder { + private readonly events: DiagnosticsStructuralEvent[] = [] + private totalEventCount = 0 + + constructor(private readonly capacity = 200) {} + + record(event: Omit & { timestamp?: string }): void { + this.totalEventCount++ + this.events.push({ ...event, timestamp: event.timestamp ?? new Date().toISOString() }) + if (this.events.length > this.capacity) { + this.events.splice(0, this.events.length - this.capacity) + } + } + + snapshot(limit = 100): { events: DiagnosticsStructuralEvent[]; truncated: boolean } { + const boundedLimit = Math.max(0, Math.min(limit, this.capacity)) + return { + events: this.events.slice(-boundedLimit), + truncated: this.totalEventCount > boundedLimit, + } + } +} diff --git a/src/services/diagnostics/report.ts b/src/services/diagnostics/report.ts new file mode 100644 index 0000000000..e69bdfa788 --- /dev/null +++ b/src/services/diagnostics/report.ts @@ -0,0 +1,222 @@ +import { createHash, randomBytes } from "crypto" + +import type { HistoryItem, WebviewDiagnosticsSnapshot } from "@roo-code/types" + +import { collectPersistenceDiagnostics } from "./persistence" +import type { + DiagnosticsProviderReport, + DiagnosticsProviderSource, + DiagnosticsProviderSourceSnapshot, + DiagnosticsReportV1, +} from "./types" + +const WEBVIEW_TIMEOUT_MS = 1_000 +const MAX_STRING_LENGTH = 120 +const ALLOWED_THEME_VARIABLES = new Set([ + "--vscode-foreground", + "--vscode-editor-background", + "--vscode-editor-foreground", + "--vscode-sideBar-background", + "--vscode-sideBar-foreground", +]) + +function withTimeout(operation: Promise, timeoutMs: number): Promise { + return new Promise((resolve) => { + const timeout = setTimeout(() => resolve(undefined), timeoutMs) + void operation.then( + (value) => { + clearTimeout(timeout) + resolve(value) + }, + () => { + clearTimeout(timeout) + resolve(undefined) + }, + ) + }) +} + +function safeToken(value: unknown, maxLength = MAX_STRING_LENGTH): string | undefined { + if (typeof value !== "string") return undefined + const trimmed = value.slice(0, maxLength) + return /^[\w .#(),%:/-]*$/.test(trimmed) && !trimmed.includes("://") ? trimmed : undefined +} + +function safeCount(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined +} + +export function sanitizeWebviewDiagnostics( + snapshot: WebviewDiagnosticsSnapshot, + pseudonymize: (value: string) => string, +): Record { + const variables = Object.fromEntries( + Object.entries(snapshot.theme?.variables ?? {}) + .filter(([key]) => ALLOWED_THEME_VARIABLES.has(key)) + .map(([key, value]) => [key, safeToken(value, 64)]) + .filter((entry): entry is [string, string] => entry[1] !== undefined), + ) + return { + capturedAt: safeToken(snapshot.capturedAt), + didHydrateState: typeof snapshot.didHydrateState === "boolean" ? snapshot.didHydrateState : undefined, + documentReadyState: safeToken(snapshot.documentReadyState), + documentVisibilityState: safeToken(snapshot.documentVisibilityState), + activeView: safeToken(snapshot.activeView, 40), + rootMounted: typeof snapshot.rootMounted === "boolean" ? snapshot.rootMounted : undefined, + rootChildCount: safeCount(snapshot.rootChildCount), + lastReceivedStateSequence: safeCount(snapshot.lastReceivedStateSequence), + lastAppliedStateSequence: safeCount(snapshot.lastAppliedStateSequence), + staleStateRejectionCount: safeCount(snapshot.staleStateRejectionCount), + unknownMessageUpdateCount: safeCount(snapshot.unknownMessageUpdateCount), + currentTask: snapshot.currentTaskId ? pseudonymize(snapshot.currentTaskId) : undefined, + chatMessageCount: safeCount(snapshot.chatMessageCount), + historyItemCount: safeCount(snapshot.historyItemCount), + todoCount: safeCount(snapshot.todoCount), + viewport: snapshot.viewport + ? { + width: safeCount(snapshot.viewport.width), + height: safeCount(snapshot.viewport.height), + devicePixelRatio: safeCount(snapshot.viewport.devicePixelRatio), + } + : undefined, + theme: snapshot.theme + ? { + kind: safeCount(snapshot.theme.kind), + identifier: safeToken(snapshot.theme.identifier, 80), + bodyForeground: safeToken(snapshot.theme.bodyForeground, 64), + bodyBackground: safeToken(snapshot.theme.bodyBackground, 64), + rootForeground: safeToken(snapshot.theme.rootForeground, 64), + rootBackground: safeToken(snapshot.theme.rootBackground, 64), + variables, + } + : undefined, + error: snapshot.error + ? { + name: safeToken(snapshot.error.name, 40), + fingerprint: safeToken(snapshot.error.fingerprint, 128), + } + : undefined, + } +} + +function mergeHistory(snapshots: DiagnosticsProviderSourceSnapshot[]): HistoryItem[] { + const byId = new Map() + for (const snapshot of snapshots) { + for (const item of snapshot.history) byId.set(item.id, item) + } + return Array.from(byId.values()).sort((a, b) => b.ts - a.ts) +} + +export async function buildDiagnosticsReport(options: { + providers: DiagnosticsProviderSource[] + storagePath: string + version: string + releaseChannel: string + environment: DiagnosticsReportV1["environment"] +}): Promise { + const salt = randomBytes(32) + const pseudonymize = (value: string) => + `task-${createHash("sha256").update(salt).update(value).digest("hex").slice(0, 12)}` + const collectionErrors: string[] = [] + const snapshots = options.providers.map((provider, index) => { + try { + return provider.getDiagnosticsSnapshot() + } catch { + collectionErrors.push(`provider-${index + 1}:snapshot-failed`) + return { + renderContext: "sidebar" as const, + disposed: false, + viewPresent: false, + visible: false, + launched: false, + taskHistoryInitialized: false, + taskCount: 0, + currentMessageCount: 0, + currentTodoCount: 0, + history: [], + events: [], + eventsTruncated: false, + } + } + }) + + const providerReports: DiagnosticsProviderReport[] = await Promise.all( + options.providers.map(async (provider, index) => { + const snapshot = snapshots[index] + const webview = await withTimeout( + provider.requestWebviewDiagnostics(WEBVIEW_TIMEOUT_MS), + WEBVIEW_TIMEOUT_MS + 100, + ) + if (!webview && snapshot.viewPresent) collectionErrors.push(`provider-${index + 1}:webview-unavailable`) + return { + instance: index + 1, + renderContext: snapshot.renderContext, + disposed: snapshot.disposed, + viewPresent: snapshot.viewPresent, + visible: snapshot.visible, + launched: snapshot.launched, + taskHistoryInitialized: snapshot.taskHistoryInitialized, + taskCount: snapshot.taskCount, + currentTask: snapshot.currentTaskId ? pseudonymize(snapshot.currentTaskId) : undefined, + currentMessageCount: snapshot.currentMessageCount, + currentTodoCount: snapshot.currentTodoCount, + events: snapshot.events.slice(-100).map((event) => ({ + timestamp: safeToken(event.timestamp, 40) ?? "invalid", + boundary: event.boundary, + phase: event.phase, + type: safeToken(event.type, 80), + action: safeToken(event.action, 80), + elapsedMs: safeCount(event.elapsedMs), + stateSequence: safeCount(event.stateSequence), + messageCount: safeCount(event.messageCount), + task: event.taskId ? pseudonymize(event.taskId) : undefined, + })), + eventsTruncated: snapshot.eventsTruncated || snapshot.events.length > 100, + webviewResponse: webview ? "received" : "unavailable", + webview: webview ? sanitizeWebviewDiagnostics(webview, pseudonymize) : undefined, + } + }), + ) + + let persistence: DiagnosticsReportV1["persistence"] + try { + persistence = await collectPersistenceDiagnostics({ + storagePath: options.storagePath, + history: mergeHistory(snapshots), + currentTaskIds: snapshots.flatMap((snapshot) => [ + ...(snapshot.currentTaskId ? [snapshot.currentTaskId] : []), + ...snapshot.events + .filter((event) => event.boundary === "task-navigation" && event.taskId) + .map((event) => event.taskId!), + ]), + pseudonymize, + }) + } catch { + collectionErrors.push("persistence:collection-failed") + persistence = { cacheEntryCount: 0, index: { exists: false, parseStatus: "unavailable" }, tasks: [] } + } + + return { + schemaVersion: 1, + capturedAt: new Date().toISOString(), + privacy: { + conversationContentIncluded: false, + uploaded: false, + excluded: [ + "conversation content", + "task titles and tool payloads", + "API history and settings values", + "secrets, tokens, and identity", + "machine identifiers and hostnames", + "workspace paths and repository details", + "raw task and workspace identifiers", + "raw logs", + ], + }, + extension: { version: options.version, releaseChannel: options.releaseChannel }, + environment: options.environment, + providers: providerReports, + persistence, + collectionErrors, + } +} diff --git a/src/services/diagnostics/requestBroker.ts b/src/services/diagnostics/requestBroker.ts new file mode 100644 index 0000000000..e03ecf574f --- /dev/null +++ b/src/services/diagnostics/requestBroker.ts @@ -0,0 +1,46 @@ +import { randomUUID } from "crypto" + +import type { WebviewDiagnosticsSnapshot } from "@roo-code/types" + +export class DiagnosticsRequestBroker { + private readonly pending = new Map< + string, + { resolve: (snapshot: WebviewDiagnosticsSnapshot | undefined) => void; timeout: ReturnType } + >() + + request( + send: (requestId: string) => Promise, + timeoutMs: number, + ): Promise { + const requestId = randomUUID() + return new Promise((resolve) => { + const timeout = setTimeout(() => { + this.pending.delete(requestId) + resolve(undefined) + }, timeoutMs) + this.pending.set(requestId, { resolve, timeout }) + void send(requestId).catch(() => { + clearTimeout(timeout) + this.pending.delete(requestId) + resolve(undefined) + }) + }) + } + + resolve(requestId: string, snapshot: WebviewDiagnosticsSnapshot | undefined): boolean { + const pending = this.pending.get(requestId) + if (!pending) return false + clearTimeout(pending.timeout) + this.pending.delete(requestId) + pending.resolve(snapshot) + return true + } + + dispose(): void { + for (const pending of this.pending.values()) { + clearTimeout(pending.timeout) + pending.resolve(undefined) + } + this.pending.clear() + } +} diff --git a/src/services/diagnostics/types.ts b/src/services/diagnostics/types.ts new file mode 100644 index 0000000000..48a91d2d14 --- /dev/null +++ b/src/services/diagnostics/types.ts @@ -0,0 +1,115 @@ +import type { HistoryItem, WebviewDiagnosticsSnapshot } from "@roo-code/types" + +export type DiagnosticsEventPhase = "start" | "success" | "failure" + +export interface DiagnosticsStructuralEvent { + timestamp: string + boundary: "provider" | "webview-in" | "webview-out" | "task-history" | "task-navigation" + phase: DiagnosticsEventPhase + type?: string + action?: string + elapsedMs?: number + stateSequence?: number + taskId?: string + messageCount?: number +} + +export interface DiagnosticsProviderSourceSnapshot { + renderContext: "sidebar" | "editor" + disposed: boolean + viewPresent: boolean + visible: boolean + launched: boolean + taskHistoryInitialized: boolean + taskCount: number + currentTaskId?: string + currentMessageCount: number + currentTodoCount: number + history: HistoryItem[] + events: DiagnosticsStructuralEvent[] + eventsTruncated: boolean +} + +export interface DiagnosticsProviderSource { + getDiagnosticsSnapshot(): DiagnosticsProviderSourceSnapshot + requestWebviewDiagnostics(timeoutMs?: number): Promise +} + +export interface DiagnosticsReportV1 { + schemaVersion: 1 + capturedAt: string + privacy: { + conversationContentIncluded: false + uploaded: false + excluded: string[] + } + extension: { + version: string + releaseChannel: string + } + environment: { + vscodeVersion: string + appName: string + uiKind: "desktop" | "web" | "unknown" + platform: string + architecture: string + locale: string + remote: boolean + workspaceFolderCount: number + customStorageConfigured: boolean + colorThemeKind: "light" | "dark" | "highContrast" | "highContrastLight" | "unknown" + } + providers: DiagnosticsProviderReport[] + persistence: DiagnosticsPersistenceReport + collectionErrors: string[] +} + +export interface DiagnosticsProviderReport { + instance: number + renderContext: "sidebar" | "editor" + disposed: boolean + viewPresent: boolean + visible: boolean + launched: boolean + taskHistoryInitialized: boolean + taskCount: number + currentTask?: string + currentMessageCount: number + currentTodoCount: number + events: Array & { task?: string }> + eventsTruncated: boolean + webviewResponse: "received" | "unavailable" + webview?: Record +} + +export interface DiagnosticsFileInspection { + exists: boolean + size?: number + parseStatus?: "valid" | "invalid" | "tooLargeToInspect" | "unavailable" + topLevelShape?: "array" | "object" | "other" +} + +export interface DiagnosticsPersistenceReport { + cacheEntryCount: number + index: DiagnosticsFileInspection & { + version?: number + entryCount?: number + relevantEntriesPresent?: number + cacheCountMatches?: boolean + } + tasks: Array<{ + id: string + rootTask?: string + parentTask?: string + children: string[] + status?: HistoryItem["status"] + number?: number + historyItem: DiagnosticsFileInspection + uiMessages: DiagnosticsFileInspection & { + messageCount?: number + firstTimestamp?: number + lastTimestamp?: number + } + integrityFindings: string[] + }> +} diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index 0521499dbb..4cc7d9a3d0 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -22,6 +22,7 @@ import ErrorBoundary from "./components/ErrorBoundary" import { useAddNonInteractiveClickListener } from "./components/ui/hooks/useNonInteractiveClick" import { TooltipProvider } from "./components/ui/tooltip" import { STANDARD_TOOLTIP_DELAY } from "./components/ui/standard-tooltip" +import { recordDiagnosticsActiveTab } from "./utils/diagnostics" type Tab = "settings" | "history" | "chat" | "marketplace" @@ -164,6 +165,8 @@ const App = () => { useEvent("message", onMessage) + useEffect(() => recordDiagnosticsActiveTab(tab), [tab]) + useEffect(() => { if (shouldShowAnnouncement && tab === "chat") { setShowAnnouncement(true) diff --git a/webview-ui/src/components/ErrorBoundary.tsx b/webview-ui/src/components/ErrorBoundary.tsx index 51500570d0..3f3ecdc901 100644 --- a/webview-ui/src/components/ErrorBoundary.tsx +++ b/webview-ui/src/components/ErrorBoundary.tsx @@ -3,6 +3,7 @@ import { telemetryClient } from "@src/utils/TelemetryClient" import { withTranslation, WithTranslation } from "react-i18next" import { enhanceErrorWithSourceMaps } from "@src/utils/sourceMapUtils" import { EXTERNAL_LINKS } from "@src/constants/externalLinks" +import { recordDiagnosticsError } from "@src/utils/diagnostics" type ErrorProps = { children: React.ReactNode @@ -21,6 +22,7 @@ class ErrorBoundary extends Component { } static getDerivedStateFromError(error: unknown) { + recordDiagnosticsError("errorBoundary", error) let errorMessage = "" if (error instanceof Error) { diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index d0d4e37afa..62004818b2 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useCallback, useEffect, useState } from "react" +import React, { createContext, useCallback, useEffect, useRef, useState } from "react" import { type ProviderSettings, @@ -32,6 +32,12 @@ import { experimentDefault } from "@roo/experiments" import { vscode } from "@src/utils/vscode" import { convertTextMateToHljs } from "@src/utils/textMateToHljs" +import { + recordDiagnosticsHydration, + recordDiagnosticsExtensionState, + recordDiagnosticsStateSequence, + recordDiagnosticsUnknownMessageUpdate, +} from "@src/utils/diagnostics" export interface ExtensionStateContextType extends ExtensionState { historyPreviewCollapsed?: boolean // Add the new state property @@ -272,6 +278,17 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode includeCurrentCost: true, lockApiConfigAcrossModes: false, }) + const stateRef = useRef(state) + stateRef.current = state + + useEffect(() => { + recordDiagnosticsExtensionState({ + currentTaskId: state.currentTaskId, + chatMessageCount: state.clineMessages.length, + historyItemCount: state.taskHistory.length, + todoCount: state.currentTaskTodos?.length ?? 0, + }) + }, [state.currentTaskId, state.clineMessages.length, state.taskHistory.length, state.currentTaskTodos?.length]) const [didHydrateState, setDidHydrateState] = useState(false) const [showWelcome, setShowWelcome] = useState(false) @@ -316,9 +333,11 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode switch (message.type) { case "state": { const newState = message.state ?? {} + recordDiagnosticsStateSequence(newState.clineMessagesSeq, newState.clineMessages !== undefined) setState((prevState) => mergeExtensionState(prevState, newState)) setShowWelcome(!checkExistKey(newState.apiConfiguration, newState.zooCodeIsAuthenticated)) setDidHydrateState(true) + recordDiagnosticsHydration(true) // Update alwaysAllowFollowupQuestions if present in state message if ((newState as any).alwaysAllowFollowupQuestions !== undefined) { setAlwaysAllowFollowupQuestions((newState as any).alwaysAllowFollowupQuestions) @@ -380,6 +399,9 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode } case "messageUpdated": { const clineMessage = message.clineMessage! + if (!stateRef.current.clineMessages.some((existing) => existing.ts === clineMessage.ts)) { + recordDiagnosticsUnknownMessageUpdate() + } setState((prevState) => { // worth noting it will never be possible for a more up-to-date message to be sent here or in normal messages post since the presentAssistantContent function uses lock const lastIndex = findLastIndex(prevState.clineMessages, (msg) => msg.ts === clineMessage.ts) diff --git a/webview-ui/src/index.tsx b/webview-ui/src/index.tsx index 4793c0272a..fd43c8b332 100644 --- a/webview-ui/src/index.tsx +++ b/webview-ui/src/index.tsx @@ -6,6 +6,9 @@ import App from "./App" import "../node_modules/@vscode/codicons/dist/codicon.css" import { getHighlighter } from "./utils/highlighter" +import { installWebviewDiagnostics } from "./utils/diagnostics" + +installWebviewDiagnostics() // Initialize Shiki early to hide initialization latency (async) getHighlighter().catch((error: Error) => console.error("Failed to initialize Shiki highlighter:", error)) diff --git a/webview-ui/src/utils/__tests__/diagnostics.spec.ts b/webview-ui/src/utils/__tests__/diagnostics.spec.ts new file mode 100644 index 0000000000..4471555973 --- /dev/null +++ b/webview-ui/src/utils/__tests__/diagnostics.spec.ts @@ -0,0 +1,109 @@ +import { + createWebviewDiagnosticsSnapshot, + installWebviewDiagnostics, + recordDiagnosticsActiveTab, + recordDiagnosticsError, + recordDiagnosticsExtensionState, + recordDiagnosticsHydration, + recordDiagnosticsStateSequence, + recordDiagnosticsUnknownMessageUpdate, +} from "../diagnostics" +import { vscode } from "../vscode" + +vi.mock("../vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +describe("webview diagnostics", () => { + beforeAll(() => { + const root = document.createElement("div") + root.id = "root" + root.append(document.createElement("div")) + document.body.append(root) + document.documentElement.style.setProperty("--vscode-foreground", "#eeeeee") + document.documentElement.style.color = "rgb(238, 238, 238)" + document.documentElement.style.backgroundColor = "rgb(30, 30, 30)" + }) + + afterAll(() => { + document.getElementById("root")?.remove() + document.documentElement.removeAttribute("style") + }) + + it("collects a bounded structural snapshot without error paths", () => { + recordDiagnosticsHydration(true) + recordDiagnosticsActiveTab("settings") + recordDiagnosticsExtensionState({ + currentTaskId: "task-123", + chatMessageCount: 4, + historyItemCount: 7, + todoCount: 2, + }) + + recordDiagnosticsStateSequence(25, true) + recordDiagnosticsStateSequence(24, true) + + for (let update = 0; update < 12; update += 1) { + recordDiagnosticsUnknownMessageUpdate() + } + + recordDiagnosticsError( + "windowError", + new Error("Secret content at /Users/example/private/file.ts and C:\\Users\\example\\secret.ts"), + ) + + const snapshot = createWebviewDiagnosticsSnapshot() + + expect(snapshot.didHydrateState).toBe(true) + expect(snapshot.activeView).toBe("settings") + expect(snapshot).toMatchObject({ + currentTaskId: "task-123", + chatMessageCount: 4, + historyItemCount: 7, + todoCount: 2, + }) + expect(snapshot.lastReceivedStateSequence).toBe(24) + expect(snapshot.lastAppliedStateSequence).toBe(25) + expect(snapshot.staleStateRejectionCount).toBe(1) + expect(snapshot.unknownMessageUpdateCount).toBe(12) + expect(snapshot).toMatchObject({ rootMounted: true, rootChildCount: 1 }) + expect(snapshot.theme).toMatchObject({ + rootForeground: "rgb(238, 238, 238)", + rootBackground: "rgb(30, 30, 30)", + }) + expect(snapshot.theme?.variables?.["--vscode-foreground"]).toBe("#eeeeee") + expect(snapshot.error).toMatchObject({ name: "Error", fingerprint: expect.stringMatching(/^fnv1a-/) }) + expect(snapshot.error).not.toHaveProperty("message") + expect(snapshot.error).not.toHaveProperty("stackLocations") + }) + + it("responds only to valid diagnostics requests and contains posting failures", () => { + installWebviewDiagnostics() + + window.dispatchEvent(new MessageEvent("message", { data: { type: "other", requestId: "ignored" } })) + expect(vscode.postMessage).not.toHaveBeenCalled() + + window.dispatchEvent( + new MessageEvent("message", { data: { type: "diagnosticsRequest", requestId: "request-1" } }), + ) + + expect(vscode.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "diagnosticsResponse", + requestId: "request-1", + diagnostics: expect.objectContaining({ activeView: "settings" }), + }), + ) + + vi.mocked(vscode.postMessage).mockImplementationOnce(() => { + throw new Error("disconnected") + }) + expect(() => { + window.dispatchEvent( + new MessageEvent("message", { data: { type: "diagnosticsRequest", requestId: "request-2" } }), + ) + }).not.toThrow() + }) +}) diff --git a/webview-ui/src/utils/diagnostics.ts b/webview-ui/src/utils/diagnostics.ts new file mode 100644 index 0000000000..eb56db9dfc --- /dev/null +++ b/webview-ui/src/utils/diagnostics.ts @@ -0,0 +1,242 @@ +import type { WebviewDiagnosticsSnapshot } from "@roo-code/types" + +import { vscode } from "./vscode" + +const VSCODE_CSS_VARIABLES = [ + "--vscode-foreground", + "--vscode-editor-background", + "--vscode-editor-foreground", + "--vscode-sideBar-background", + "--vscode-sideBar-foreground", +] as const + +type DiagnosticsErrorSource = "errorBoundary" | "windowError" | "unhandledRejection" +type DiagnosticsSnapshot = WebviewDiagnosticsSnapshot & { didHydrateState: boolean } + +const diagnosticsState = { + didHydrateState: false, + lastReceivedStateSequence: undefined as number | undefined, + lastAppliedStateSequence: undefined as number | undefined, + staleStateRejectionCount: 0, + unknownMessageUpdateCount: 0, + activeView: undefined as string | undefined, + currentTaskId: undefined as string | undefined, + chatMessageCount: 0, + historyItemCount: 0, + todoCount: 0, + error: undefined as WebviewDiagnosticsSnapshot["error"], +} + +const fingerprint = (value: string): string => { + let hash = 2166136261 + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index) + hash = Math.imul(hash, 16777619) + } + return `fnv1a-${(hash >>> 0).toString(16).padStart(8, "0")}` +} + +const getErrorName = (error: unknown): string => { + if (!(error instanceof Error)) { + return "UnknownError" + } + + const name = error.name.replace(/[^A-Za-z0-9_.-]/g, "").slice(0, 40) + return name || "Error" +} + +const getErrorFingerprintSource = (source: DiagnosticsErrorSource, error: unknown): string => { + if (error instanceof Error) { + const message = error.message.slice(0, 1_000).replace(/(?:[A-Za-z]:\\|\\\\|\/)\S+/g, "[path]") + return `${source}:${error.name}:${message}` + } + return `${source}:${typeof error}` +} + +export const recordDiagnosticsHydration = (didHydrateState: boolean) => { + diagnosticsState.didHydrateState = didHydrateState +} + +export const recordDiagnosticsExtensionState = (state: { + currentTaskId?: string + chatMessageCount: number + historyItemCount: number + todoCount: number +}) => { + diagnosticsState.currentTaskId = state.currentTaskId + diagnosticsState.chatMessageCount = state.chatMessageCount + diagnosticsState.historyItemCount = state.historyItemCount + diagnosticsState.todoCount = state.todoCount +} + +export const recordDiagnosticsStateSequence = (sequence: unknown, hasMessages: boolean) => { + if (typeof sequence !== "number" || !Number.isSafeInteger(sequence) || sequence < 0) { + return + } + + diagnosticsState.lastReceivedStateSequence = sequence + if (!hasMessages) { + return + } + + if ( + diagnosticsState.lastAppliedStateSequence !== undefined && + sequence <= diagnosticsState.lastAppliedStateSequence + ) { + diagnosticsState.staleStateRejectionCount = Math.min( + diagnosticsState.staleStateRejectionCount + 1, + Number.MAX_SAFE_INTEGER, + ) + return + } + + diagnosticsState.lastAppliedStateSequence = sequence +} + +export const recordDiagnosticsUnknownMessageUpdate = () => { + diagnosticsState.unknownMessageUpdateCount = Math.min( + diagnosticsState.unknownMessageUpdateCount + 1, + Number.MAX_SAFE_INTEGER, + ) +} + +export const recordDiagnosticsActiveTab = (activeView: string) => { + diagnosticsState.activeView = activeView +} + +export const recordDiagnosticsError = (source: DiagnosticsErrorSource, error: unknown) => { + try { + diagnosticsState.error = { + name: getErrorName(error), + fingerprint: fingerprint(getErrorFingerprintSource(source, error)), + } + } catch { + diagnosticsState.error = { + name: "UnknownError", + fingerprint: fingerprint(`${source}:unavailable`), + } + } +} + +const readDocumentSnapshot = (): Pick< + WebviewDiagnosticsSnapshot, + "documentReadyState" | "documentVisibilityState" | "rootMounted" | "rootChildCount" +> => { + if (typeof document === "undefined") { + return {} + } + + try { + const root = document.getElementById("root") + return { + documentReadyState: document.readyState, + documentVisibilityState: document.visibilityState, + rootMounted: root?.hasChildNodes() ?? false, + rootChildCount: root?.childElementCount ?? 0, + } + } catch { + return {} + } +} + +const readViewportSnapshot = (): WebviewDiagnosticsSnapshot["viewport"] => { + if (typeof window === "undefined") { + return undefined + } + + const { innerWidth, innerHeight, devicePixelRatio } = window + if (![innerWidth, innerHeight, devicePixelRatio].every(Number.isFinite)) { + return undefined + } + + return { width: innerWidth, height: innerHeight, devicePixelRatio } +} + +const readThemeSnapshot = (): WebviewDiagnosticsSnapshot["theme"] => { + if (typeof window === "undefined" || typeof document === "undefined") { + return undefined + } + + try { + const bodyStyle = window.getComputedStyle(document.body) + const rootStyle = window.getComputedStyle(document.documentElement) + const variables = Object.fromEntries( + VSCODE_CSS_VARIABLES.map((name) => [name, rootStyle.getPropertyValue(name).trim()]).filter( + (entry) => entry[1] !== "", + ), + ) + + return { + bodyForeground: bodyStyle.color || undefined, + bodyBackground: bodyStyle.backgroundColor || undefined, + rootForeground: rootStyle.color || undefined, + rootBackground: rootStyle.backgroundColor || undefined, + variables, + } + } catch { + return undefined + } +} + +export const createWebviewDiagnosticsSnapshot = (): DiagnosticsSnapshot => ({ + capturedAt: new Date().toISOString(), + didHydrateState: diagnosticsState.didHydrateState, + ...readDocumentSnapshot(), + activeView: diagnosticsState.activeView, + lastReceivedStateSequence: diagnosticsState.lastReceivedStateSequence, + lastAppliedStateSequence: diagnosticsState.lastAppliedStateSequence, + staleStateRejectionCount: diagnosticsState.staleStateRejectionCount, + unknownMessageUpdateCount: diagnosticsState.unknownMessageUpdateCount, + currentTaskId: diagnosticsState.currentTaskId, + chatMessageCount: diagnosticsState.chatMessageCount, + historyItemCount: diagnosticsState.historyItemCount, + todoCount: diagnosticsState.todoCount, + viewport: readViewportSnapshot(), + theme: readThemeSnapshot(), + error: diagnosticsState.error ? { ...diagnosticsState.error } : undefined, +}) + +const isDiagnosticsRequest = (value: unknown): value is { type: "diagnosticsRequest"; requestId: string } => { + if (typeof value !== "object" || value === null) { + return false + } + + const message = value as Record + return ( + message.type === "diagnosticsRequest" && typeof message.requestId === "string" && message.requestId.length > 0 + ) +} + +let installedWindow: Window | undefined + +export const installWebviewDiagnostics = () => { + if (typeof window === "undefined" || installedWindow === window) { + return + } + + installedWindow = window + + window.addEventListener("message", (event) => { + if (!isDiagnosticsRequest(event.data)) { + return + } + + try { + vscode.postMessage({ + type: "diagnosticsResponse", + requestId: event.data.requestId, + diagnostics: createWebviewDiagnosticsSnapshot(), + }) + } catch { + // Diagnostics must never destabilize an already unhealthy webview. + } + }) + + window.addEventListener("error", (event) => { + recordDiagnosticsError("windowError", event.error ?? event.message) + }) + + window.addEventListener("unhandledrejection", (event) => { + recordDiagnosticsError("unhandledRejection", event.reason) + }) +}