mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Merge commit '071975e0cc007f88e2424dc86d3310992cf1eb41'
This commit is contained in:
commit
0b87fd0f7e
185
desktop/src/__tests__/diagnosticsSettings.test.tsx
Normal file
185
desktop/src/__tests__/diagnosticsSettings.test.tsx
Normal file
@ -0,0 +1,185 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
import { Settings } from '../pages/Settings'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { useUIStore } from '../stores/uiStore'
|
||||
|
||||
const diagnosticsApiMock = vi.hoisted(() => ({
|
||||
getStatus: vi.fn(),
|
||||
getEvents: vi.fn(),
|
||||
exportBundle: vi.fn(),
|
||||
openLogDir: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../api/diagnostics', () => ({
|
||||
diagnosticsApi: diagnosticsApiMock,
|
||||
}))
|
||||
|
||||
vi.mock('../stores/providerStore', () => ({
|
||||
useProviderStore: () => ({
|
||||
providers: [],
|
||||
activeId: null,
|
||||
hasLoadedProviders: true,
|
||||
presets: [],
|
||||
isLoading: false,
|
||||
isPresetsLoading: false,
|
||||
fetchProviders: vi.fn(),
|
||||
fetchPresets: vi.fn(),
|
||||
deleteProvider: vi.fn(),
|
||||
activateProvider: vi.fn(),
|
||||
activateOfficial: vi.fn(),
|
||||
testProvider: vi.fn(),
|
||||
createProvider: vi.fn(),
|
||||
updateProvider: vi.fn(),
|
||||
testConfig: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../api/providers', () => ({
|
||||
providersApi: {
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
updateSettings: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../components/settings/ClaudeOfficialLogin', () => ({
|
||||
ClaudeOfficialLogin: () => <div />,
|
||||
}))
|
||||
|
||||
vi.mock('../pages/AdapterSettings', () => ({
|
||||
AdapterSettings: () => <div />,
|
||||
}))
|
||||
|
||||
vi.mock('../stores/agentStore', () => ({
|
||||
useAgentStore: () => ({
|
||||
activeAgents: [],
|
||||
allAgents: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
selectedAgent: null,
|
||||
fetchAgents: vi.fn(),
|
||||
selectAgent: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../stores/skillStore', () => ({
|
||||
useSkillStore: () => ({
|
||||
skills: [],
|
||||
selectedSkill: null,
|
||||
isLoading: false,
|
||||
isDetailLoading: false,
|
||||
error: null,
|
||||
fetchSkills: vi.fn(),
|
||||
fetchSkillDetail: vi.fn(),
|
||||
clearSelection: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../components/chat/CodeViewer', () => ({
|
||||
CodeViewer: ({ code }: { code: string }) => <pre>{code}</pre>,
|
||||
}))
|
||||
|
||||
describe('Settings > Diagnostics tab', () => {
|
||||
beforeEach(() => {
|
||||
diagnosticsApiMock.getStatus.mockResolvedValue({
|
||||
logDir: '/tmp/claude/cc-haha/diagnostics',
|
||||
diagnosticsPath: '/tmp/claude/cc-haha/diagnostics/diagnostics.jsonl',
|
||||
runtimeErrorsPath: '/tmp/claude/cc-haha/diagnostics/runtime-errors.log',
|
||||
exportDir: '/tmp/claude/cc-haha/diagnostics/exports',
|
||||
retentionDays: 7,
|
||||
maxBytes: 50 * 1024 * 1024,
|
||||
totalBytes: 4096,
|
||||
eventCount: 2,
|
||||
recentErrorCount: 1,
|
||||
lastEventAt: '2026-05-02T00:00:00.000Z',
|
||||
})
|
||||
diagnosticsApiMock.getEvents.mockResolvedValue({
|
||||
events: [{
|
||||
id: 'event-1',
|
||||
timestamp: '2026-05-02T00:00:00.000Z',
|
||||
type: 'cli_start_failed',
|
||||
severity: 'error',
|
||||
summary: 'CLI exited during startup with code 1',
|
||||
sessionId: 'session-1',
|
||||
}],
|
||||
})
|
||||
diagnosticsApiMock.exportBundle.mockResolvedValue({
|
||||
bundle: {
|
||||
path: '/tmp/claude/cc-haha/diagnostics/exports/cc-haha-diagnostics.tar.gz',
|
||||
fileName: 'cc-haha-diagnostics.tar.gz',
|
||||
bytes: 1024,
|
||||
},
|
||||
})
|
||||
diagnosticsApiMock.openLogDir.mockResolvedValue({ ok: true })
|
||||
diagnosticsApiMock.clear.mockResolvedValue({ ok: true })
|
||||
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
useUIStore.setState({ pendingSettingsTab: null, toasts: [] })
|
||||
})
|
||||
|
||||
it('shows diagnostics status, actions, and recent events', async () => {
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('Diagnostics'))
|
||||
|
||||
expect(await screen.findByText('Log directory')).toBeInTheDocument()
|
||||
expect(screen.getByText('/tmp/claude/cc-haha/diagnostics')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Export Bundle/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Copy Error Summary/i })).toBeInTheDocument()
|
||||
expect(screen.getByText('cli_start_failed')).toBeInTheDocument()
|
||||
expect(screen.getByText('CLI exited during startup with code 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('exports a diagnostics bundle from the settings page', async () => {
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('Diagnostics'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Export Bundle/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(diagnosticsApiMock.exportBundle).toHaveBeenCalled()
|
||||
})
|
||||
expect(await screen.findByText('/tmp/claude/cc-haha/diagnostics/exports/cc-haha-diagnostics.tar.gz')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('copies the recent error summary with the legacy clipboard fallback', async () => {
|
||||
const originalClipboard = navigator.clipboard
|
||||
const originalExecCommand = document.execCommand
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: vi.fn().mockReturnValue(true),
|
||||
})
|
||||
const execCommand = vi.mocked(document.execCommand)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: {
|
||||
writeText: vi.fn().mockRejectedValue(new Error('clipboard blocked')),
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('Diagnostics'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Copy Error Summary/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(execCommand).toHaveBeenCalledWith('copy')
|
||||
})
|
||||
const toasts = useUIStore.getState().toasts
|
||||
expect(toasts[toasts.length - 1]?.message).toBe('Error summary copied.')
|
||||
} finally {
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: originalExecCommand,
|
||||
})
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: originalClipboard,
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
40
desktop/src/api/diagnostics.ts
Normal file
40
desktop/src/api/diagnostics.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { api } from './client'
|
||||
|
||||
export type DiagnosticSeverity = 'debug' | 'info' | 'warn' | 'error'
|
||||
|
||||
export type DiagnosticEvent = {
|
||||
id: string
|
||||
timestamp: string
|
||||
type: string
|
||||
severity: DiagnosticSeverity
|
||||
summary: string
|
||||
sessionId?: string
|
||||
details?: unknown
|
||||
}
|
||||
|
||||
export type DiagnosticsStatus = {
|
||||
logDir: string
|
||||
diagnosticsPath: string
|
||||
runtimeErrorsPath: string
|
||||
exportDir: string
|
||||
retentionDays: number
|
||||
maxBytes: number
|
||||
totalBytes: number
|
||||
eventCount: number
|
||||
recentErrorCount: number
|
||||
lastEventAt: string | null
|
||||
}
|
||||
|
||||
export type DiagnosticsBundle = {
|
||||
path: string
|
||||
fileName: string
|
||||
bytes: number
|
||||
}
|
||||
|
||||
export const diagnosticsApi = {
|
||||
getStatus: () => api.get<DiagnosticsStatus>('/api/diagnostics/status'),
|
||||
getEvents: (limit = 100) => api.get<{ events: DiagnosticEvent[] }>(`/api/diagnostics/events?limit=${limit}`),
|
||||
exportBundle: () => api.post<{ bundle: DiagnosticsBundle }>('/api/diagnostics/export', undefined, { timeout: 60_000 }),
|
||||
openLogDir: () => api.post<{ ok: true }>('/api/diagnostics/open-log-dir'),
|
||||
clear: () => api.delete<{ ok: true }>('/api/diagnostics'),
|
||||
}
|
||||
@ -94,6 +94,7 @@ export const en = {
|
||||
'settings.tab.skills': 'Skills',
|
||||
'settings.tab.mcp': 'MCP',
|
||||
'settings.tab.plugins': 'Plugins',
|
||||
'settings.tab.diagnostics': 'Diagnostics',
|
||||
|
||||
// Settings > Terminal
|
||||
'settings.terminal.title': 'Terminal',
|
||||
@ -111,6 +112,34 @@ export const en = {
|
||||
'settings.terminal.status.unavailable': 'Unavailable',
|
||||
'terminal.newTab': 'New Terminal',
|
||||
|
||||
// Settings > Diagnostics
|
||||
'settings.diagnostics.title': 'Diagnostics',
|
||||
'settings.diagnostics.description': 'Server and CLI runtime logs for debugging startup, provider, and session failures.',
|
||||
'settings.diagnostics.refresh': 'Refresh',
|
||||
'settings.diagnostics.totalSize': 'Log size',
|
||||
'settings.diagnostics.events': 'Events',
|
||||
'settings.diagnostics.recentErrors': '24h warnings',
|
||||
'settings.diagnostics.retention': 'Retention',
|
||||
'settings.diagnostics.retentionValue': '{days} days / {size}',
|
||||
'settings.diagnostics.logDirectory': 'Log directory',
|
||||
'settings.diagnostics.openDirectory': 'Open',
|
||||
'settings.diagnostics.exportBundle': 'Export Bundle',
|
||||
'settings.diagnostics.copySummary': 'Copy Error Summary',
|
||||
'settings.diagnostics.clearLogs': 'Clear Logs',
|
||||
'settings.diagnostics.recentEvents': 'Recent Events',
|
||||
'settings.diagnostics.privacyNote': 'Exported diagnostics are sanitized and do not include chat content, file contents, full environment variables, or API keys.',
|
||||
'settings.diagnostics.noEvents': 'No diagnostic events yet.',
|
||||
'settings.diagnostics.noRecentErrors': 'No recent warnings or errors.',
|
||||
'settings.diagnostics.loadFailed': 'Failed to load diagnostics.',
|
||||
'settings.diagnostics.openFailed': 'Failed to open diagnostics directory.',
|
||||
'settings.diagnostics.exportFailed': 'Failed to export diagnostics bundle.',
|
||||
'settings.diagnostics.exported': 'Exported {file}',
|
||||
'settings.diagnostics.summaryCopied': 'Error summary copied.',
|
||||
'settings.diagnostics.copyFailed': 'Failed to copy error summary.',
|
||||
'settings.diagnostics.confirmClear': 'Clear all local diagnostic logs and exported bundles?',
|
||||
'settings.diagnostics.cleared': 'Diagnostics cleared.',
|
||||
'settings.diagnostics.clearFailed': 'Failed to clear diagnostics.',
|
||||
|
||||
// Settings > Claude Official Login
|
||||
'settings.claudeOfficialLogin.intro': 'Using official Claude models requires signing in to your Claude.ai account. Click the button below to open the official Claude login page in your browser; you\'ll be returned here after authorizing.',
|
||||
'settings.claudeOfficialLogin.loginButton': 'Sign in to Claude',
|
||||
|
||||
@ -96,6 +96,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.tab.skills': '技能',
|
||||
'settings.tab.mcp': 'MCP',
|
||||
'settings.tab.plugins': '插件',
|
||||
'settings.tab.diagnostics': '诊断',
|
||||
|
||||
// Settings > Terminal
|
||||
'settings.terminal.title': '终端',
|
||||
@ -113,6 +114,34 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.terminal.status.unavailable': '不可用',
|
||||
'terminal.newTab': '新建终端',
|
||||
|
||||
// Settings > Diagnostics
|
||||
'settings.diagnostics.title': '诊断',
|
||||
'settings.diagnostics.description': '记录服务端与 CLI 的启动、服务商、会话运行错误,便于复现和定位问题。',
|
||||
'settings.diagnostics.refresh': '刷新',
|
||||
'settings.diagnostics.totalSize': '日志大小',
|
||||
'settings.diagnostics.events': '事件数',
|
||||
'settings.diagnostics.recentErrors': '24h 警告',
|
||||
'settings.diagnostics.retention': '保留策略',
|
||||
'settings.diagnostics.retentionValue': '{days} 天 / {size}',
|
||||
'settings.diagnostics.logDirectory': '日志目录',
|
||||
'settings.diagnostics.openDirectory': '打开',
|
||||
'settings.diagnostics.exportBundle': '导出诊断包',
|
||||
'settings.diagnostics.copySummary': '复制错误摘要',
|
||||
'settings.diagnostics.clearLogs': '清理日志',
|
||||
'settings.diagnostics.recentEvents': '最近事件',
|
||||
'settings.diagnostics.privacyNote': '导出的诊断包会脱敏,不包含聊天内容、文件内容、完整环境变量或 API Key。',
|
||||
'settings.diagnostics.noEvents': '暂无诊断事件。',
|
||||
'settings.diagnostics.noRecentErrors': '最近没有警告或错误。',
|
||||
'settings.diagnostics.loadFailed': '加载诊断信息失败。',
|
||||
'settings.diagnostics.openFailed': '打开诊断目录失败。',
|
||||
'settings.diagnostics.exportFailed': '导出诊断包失败。',
|
||||
'settings.diagnostics.exported': '已导出 {file}',
|
||||
'settings.diagnostics.summaryCopied': '错误摘要已复制。',
|
||||
'settings.diagnostics.copyFailed': '复制错误摘要失败。',
|
||||
'settings.diagnostics.confirmClear': '确定清理所有本地诊断日志和已导出的诊断包?',
|
||||
'settings.diagnostics.cleared': '诊断日志已清理。',
|
||||
'settings.diagnostics.clearFailed': '清理诊断日志失败。',
|
||||
|
||||
// Settings > Claude Official Login
|
||||
'settings.claudeOfficialLogin.intro': '使用官方 Claude 模型需要登录你的 Claude.ai 账号。点击下方按钮,浏览器会打开 Claude 官方登录页面,授权后自动回到这里。',
|
||||
'settings.claudeOfficialLogin.loginButton': '登录 Claude 账号',
|
||||
|
||||
218
desktop/src/pages/DiagnosticsSettings.tsx
Normal file
218
desktop/src/pages/DiagnosticsSettings.tsx
Normal file
@ -0,0 +1,218 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { diagnosticsApi, type DiagnosticEvent, type DiagnosticsStatus } from '../api/diagnostics'
|
||||
import { Button } from '../components/shared/Button'
|
||||
import { copyTextToClipboard } from '../components/chat/clipboard'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { formatBytes } from '../lib/formatBytes'
|
||||
import { useUIStore } from '../stores/uiStore'
|
||||
|
||||
export function DiagnosticsSettings() {
|
||||
const t = useTranslation()
|
||||
const addToast = useUIStore((s) => s.addToast)
|
||||
const [status, setStatus] = useState<DiagnosticsStatus | null>(null)
|
||||
const [events, setEvents] = useState<DiagnosticEvent[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const [isClearing, setIsClearing] = useState(false)
|
||||
const [lastExportPath, setLastExportPath] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const [nextStatus, eventResult] = await Promise.all([
|
||||
diagnosticsApi.getStatus(),
|
||||
diagnosticsApi.getEvents(100),
|
||||
])
|
||||
setStatus(nextStatus)
|
||||
setEvents(eventResult.events)
|
||||
} catch (error) {
|
||||
addToast({
|
||||
type: 'error',
|
||||
message: error instanceof Error ? error.message : t('settings.diagnostics.loadFailed'),
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [addToast, t])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const recentErrorSummary = useMemo(() => {
|
||||
return events
|
||||
.filter((event) => event.severity === 'error' || event.severity === 'warn')
|
||||
.slice(0, 20)
|
||||
.map((event) => `[${event.timestamp}] ${event.severity.toUpperCase()} ${event.type}${event.sessionId ? ` session=${event.sessionId}` : ''}: ${event.summary}`)
|
||||
.join('\n')
|
||||
}, [events])
|
||||
|
||||
const handleOpenDir = async () => {
|
||||
try {
|
||||
await diagnosticsApi.openLogDir()
|
||||
} catch (error) {
|
||||
addToast({
|
||||
type: 'error',
|
||||
message: error instanceof Error ? error.message : t('settings.diagnostics.openFailed'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true)
|
||||
try {
|
||||
const { bundle } = await diagnosticsApi.exportBundle()
|
||||
setLastExportPath(bundle.path)
|
||||
addToast({
|
||||
type: 'success',
|
||||
message: t('settings.diagnostics.exported', { file: bundle.fileName }),
|
||||
})
|
||||
await load()
|
||||
} catch (error) {
|
||||
addToast({
|
||||
type: 'error',
|
||||
message: error instanceof Error ? error.message : t('settings.diagnostics.exportFailed'),
|
||||
})
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopySummary = async () => {
|
||||
const text = recentErrorSummary || t('settings.diagnostics.noRecentErrors')
|
||||
const copied = await copyTextToClipboard(text)
|
||||
if (copied) {
|
||||
addToast({ type: 'success', message: t('settings.diagnostics.summaryCopied') })
|
||||
return
|
||||
}
|
||||
addToast({ type: 'error', message: t('settings.diagnostics.copyFailed') })
|
||||
}
|
||||
|
||||
const handleClear = async () => {
|
||||
if (!window.confirm(t('settings.diagnostics.confirmClear'))) return
|
||||
setIsClearing(true)
|
||||
try {
|
||||
await diagnosticsApi.clear()
|
||||
setEvents([])
|
||||
setStatus(await diagnosticsApi.getStatus())
|
||||
setLastExportPath(null)
|
||||
addToast({ type: 'success', message: t('settings.diagnostics.cleared') })
|
||||
} catch (error) {
|
||||
addToast({
|
||||
type: 'error',
|
||||
message: error instanceof Error ? error.message : t('settings.diagnostics.clearFailed'),
|
||||
})
|
||||
} finally {
|
||||
setIsClearing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl">
|
||||
<div className="flex items-start justify-between gap-4 mb-5">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">{t('settings.diagnostics.title')}</h2>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)] mt-0.5">{t('settings.diagnostics.description')}</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={load} loading={isLoading}>
|
||||
<span className="material-symbols-outlined text-[16px]">refresh</span>
|
||||
{t('settings.diagnostics.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-5">
|
||||
<Metric label={t('settings.diagnostics.totalSize')} value={status ? formatBytes(status.totalBytes) : '-'} />
|
||||
<Metric label={t('settings.diagnostics.events')} value={status ? String(status.eventCount) : '-'} />
|
||||
<Metric label={t('settings.diagnostics.recentErrors')} value={status ? String(status.recentErrorCount) : '-'} />
|
||||
<Metric label={t('settings.diagnostics.retention')} value={status ? t('settings.diagnostics.retentionValue', { days: String(status.retentionDays), size: formatBytes(status.maxBytes) }) : '-'} />
|
||||
</div>
|
||||
|
||||
<div className="border border-[var(--color-border)] rounded-lg mb-5">
|
||||
<div className="px-4 py-3 border-b border-[var(--color-border)] flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-[var(--color-text-primary)]">{t('settings.diagnostics.logDirectory')}</div>
|
||||
<div className="text-xs text-[var(--color-text-tertiary)] font-mono break-all mt-0.5">{status?.logDir ?? '-'}</div>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={handleOpenDir}>
|
||||
<span className="material-symbols-outlined text-[16px]">folder_open</span>
|
||||
{t('settings.diagnostics.openDirectory')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="px-4 py-3 flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" onClick={handleExport} loading={isExporting}>
|
||||
<span className="material-symbols-outlined text-[16px]">archive</span>
|
||||
{t('settings.diagnostics.exportBundle')}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={handleCopySummary}>
|
||||
<span className="material-symbols-outlined text-[16px]">content_copy</span>
|
||||
{t('settings.diagnostics.copySummary')}
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={handleClear} loading={isClearing}>
|
||||
<span className="material-symbols-outlined text-[16px]">delete</span>
|
||||
{t('settings.diagnostics.clearLogs')}
|
||||
</Button>
|
||||
{lastExportPath && (
|
||||
<span className="text-xs text-[var(--color-text-tertiary)] font-mono break-all">
|
||||
{lastExportPath}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">{t('settings.diagnostics.recentEvents')}</h3>
|
||||
<p className="text-xs text-[var(--color-text-tertiary)] mt-0.5">{t('settings.diagnostics.privacyNote')}</p>
|
||||
</div>
|
||||
|
||||
<div className="border border-[var(--color-border)] rounded-lg overflow-hidden">
|
||||
{events.length === 0 ? (
|
||||
<div className="px-4 py-8 text-sm text-[var(--color-text-tertiary)] text-center">
|
||||
{isLoading ? t('common.loading') : t('settings.diagnostics.noEvents')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--color-border)]">
|
||||
{events.map((event) => (
|
||||
<EventRow key={event.id} event={event} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="border border-[var(--color-border)] rounded-lg px-3 py-2">
|
||||
<div className="text-xs text-[var(--color-text-tertiary)]">{label}</div>
|
||||
<div className="text-sm font-semibold text-[var(--color-text-primary)] mt-1">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EventRow({ event }: { event: DiagnosticEvent }) {
|
||||
const severityClass =
|
||||
event.severity === 'error'
|
||||
? 'text-[var(--color-error)]'
|
||||
: event.severity === 'warn'
|
||||
? 'text-[var(--color-warning)]'
|
||||
: 'text-[var(--color-text-tertiary)]'
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3 grid grid-cols-[120px_92px_1fr] gap-3 items-start">
|
||||
<div className="text-xs text-[var(--color-text-tertiary)] font-mono">
|
||||
{new Date(event.timestamp).toLocaleString()}
|
||||
</div>
|
||||
<div className={`text-xs font-semibold uppercase ${severityClass}`}>{event.severity}</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-sm font-medium text-[var(--color-text-primary)] truncate">{event.type}</span>
|
||||
{event.sessionId && (
|
||||
<span className="text-[11px] text-[var(--color-text-tertiary)] font-mono truncate">{event.sessionId}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--color-text-secondary)] mt-1 break-words">{event.summary}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -25,6 +25,7 @@ import { PluginDetail } from '../components/plugins/PluginDetail'
|
||||
import { ComputerUseSettings } from './ComputerUseSettings'
|
||||
import { McpSettings } from './McpSettings'
|
||||
import { TerminalSettings } from './TerminalSettings'
|
||||
import { DiagnosticsSettings } from './DiagnosticsSettings'
|
||||
import { useUIStore, type SettingsTab } from '../stores/uiStore'
|
||||
import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin'
|
||||
import { useUpdateStore } from '../stores/updateStore'
|
||||
@ -58,6 +59,7 @@ export function Settings() {
|
||||
<TabButton icon="auto_awesome" label={t('settings.tab.skills')} active={activeTab === 'skills'} onClick={() => setActiveTab('skills')} />
|
||||
<TabButton icon="extension" label={t('settings.tab.plugins')} active={activeTab === 'plugins'} onClick={() => setActiveTab('plugins')} />
|
||||
<TabButton icon="mouse" label={t('settings.tab.computerUse')} active={activeTab === 'computerUse'} onClick={() => setActiveTab('computerUse')} />
|
||||
<TabButton icon="monitor_heart" label={t('settings.tab.diagnostics')} active={activeTab === 'diagnostics'} onClick={() => setActiveTab('diagnostics')} />
|
||||
</div>
|
||||
<div className="border-t border-[var(--color-border)]/40 pt-1">
|
||||
<TabButton icon="info" label={t('settings.tab.about')} active={activeTab === 'about'} onClick={() => setActiveTab('about')} />
|
||||
@ -76,6 +78,7 @@ export function Settings() {
|
||||
{activeTab === 'skills' && <SkillSettings />}
|
||||
{activeTab === 'plugins' && <PluginSettings />}
|
||||
{activeTab === 'computerUse' && <ComputerUseSettings />}
|
||||
{activeTab === 'diagnostics' && <DiagnosticsSettings />}
|
||||
{activeTab === 'about' && <AboutSettings />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -39,6 +39,7 @@ export type SettingsTab =
|
||||
| 'skills'
|
||||
| 'plugins'
|
||||
| 'computerUse'
|
||||
| 'diagnostics'
|
||||
| 'about'
|
||||
|
||||
type ActiveView = 'code' | 'scheduled' | 'terminal' | 'history' | 'settings'
|
||||
|
||||
@ -185,7 +185,7 @@ describe('ConversationService', () => {
|
||||
expect(() => svc.onOutput('no-such-session', () => {})).not.toThrow()
|
||||
})
|
||||
|
||||
it('should ignore stale process exits after a session restarts', () => {
|
||||
it('should ignore stale process exits after a session restarts', async () => {
|
||||
const svc = new ConversationService()
|
||||
const oldProc = { pid: 1 } as any
|
||||
const newProc = { pid: 2 } as any
|
||||
@ -203,10 +203,10 @@ describe('ConversationService', () => {
|
||||
pendingPermissionRequests: new Map(),
|
||||
})
|
||||
|
||||
;(svc as any).handleProcessExit('session-restart', oldProc, 143)
|
||||
await (svc as any).handleProcessExit('session-restart', oldProc, 143)
|
||||
expect(svc.hasSession('session-restart')).toBe(true)
|
||||
|
||||
;(svc as any).handleProcessExit('session-restart', newProc, 0)
|
||||
await (svc as any).handleProcessExit('session-restart', newProc, 0)
|
||||
expect(svc.hasSession('session-restart')).toBe(false)
|
||||
})
|
||||
|
||||
@ -445,6 +445,34 @@ describe('WebSocket Chat Integration', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function withMockStartupStdoutExit<T>(
|
||||
stdout: string,
|
||||
exitDelayMs: number,
|
||||
callback: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const previousStdout = process.env.MOCK_SDK_STARTUP_STDOUT
|
||||
const previousExitDelay = process.env.MOCK_SDK_EXIT_BEFORE_SDK_MS
|
||||
|
||||
process.env.MOCK_SDK_STARTUP_STDOUT = stdout
|
||||
process.env.MOCK_SDK_EXIT_BEFORE_SDK_MS = String(exitDelayMs)
|
||||
|
||||
try {
|
||||
return await callback()
|
||||
} finally {
|
||||
if (previousStdout === undefined) {
|
||||
delete process.env.MOCK_SDK_STARTUP_STDOUT
|
||||
} else {
|
||||
process.env.MOCK_SDK_STARTUP_STDOUT = previousStdout
|
||||
}
|
||||
|
||||
if (previousExitDelay === undefined) {
|
||||
delete process.env.MOCK_SDK_EXIT_BEFORE_SDK_MS
|
||||
} else {
|
||||
process.env.MOCK_SDK_EXIT_BEFORE_SDK_MS = previousExitDelay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runTurn(sessionId: string, content: string, allowError = false): Promise<any[]> {
|
||||
const messages: any[] = []
|
||||
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
|
||||
@ -872,6 +900,46 @@ describe('WebSocket Chat Integration', () => {
|
||||
expect(secondTurn.some((m) => m.type === 'error')).toBe(false)
|
||||
})
|
||||
|
||||
it('should keep a long desktop session alive in a /tmp project across engineering turns', async () => {
|
||||
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-haha-issue247-project-'))
|
||||
|
||||
try {
|
||||
await fs.writeFile(
|
||||
path.join(projectDir, 'package.json'),
|
||||
JSON.stringify({ name: 'issue-247-repro', type: 'module' }, null, 2),
|
||||
)
|
||||
await fs.mkdir(path.join(projectDir, 'src'), { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(projectDir, 'src', 'index.ts'),
|
||||
'export function greet(name: string) { return `hello ${name}` }\n',
|
||||
)
|
||||
|
||||
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workDir: projectDir }),
|
||||
})
|
||||
expect(createRes.status).toBe(201)
|
||||
const { sessionId } = await createRes.json() as { sessionId: string }
|
||||
|
||||
const prompts = [
|
||||
'Inspect this TypeScript project and summarize what you see.',
|
||||
'Plan a small change to add a farewell helper.',
|
||||
'Implement the helper in src/index.ts.',
|
||||
'Review whether the exported functions are easy to test.',
|
||||
'Suggest the next regression test for this project.',
|
||||
]
|
||||
|
||||
for (const prompt of prompts) {
|
||||
const messages = await runTurn(sessionId, prompt)
|
||||
expect(messages.some((m) => m.type === 'error')).toBe(false)
|
||||
expect(messages.some((m) => m.type === 'message_complete')).toBe(true)
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
}, 20_000)
|
||||
|
||||
it('should clear a desktop session without sending /clear to the CLI turn loop', async () => {
|
||||
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
@ -951,6 +1019,25 @@ describe('WebSocket Chat Integration', () => {
|
||||
expect(error?.message).toContain('configuredProviders:')
|
||||
})
|
||||
|
||||
it('should include CLI stdout diagnostics when startup exits before SDK messages', async () => {
|
||||
const sessionId = `chat-startup-stdout-${crypto.randomUUID()}`
|
||||
|
||||
const messages = await withMockStartupStdoutExit(
|
||||
'provider rejected request: invalid model id',
|
||||
25,
|
||||
() => runTurn(sessionId, 'trigger startup stdout diagnostics', true),
|
||||
)
|
||||
const error = messages.find((msg) => msg.type === 'error')
|
||||
|
||||
expect(error).toMatchObject({
|
||||
code: 'CLI_START_FAILED',
|
||||
})
|
||||
expect(error?.message).toContain(
|
||||
'CLI exited during startup (code 1): provider rejected request: invalid model id',
|
||||
)
|
||||
expect(error?.message).toContain('Desktop service diagnostics:')
|
||||
}, 10_000)
|
||||
|
||||
it('should prewarm the CLI before the first user turn and reuse that process', async () => {
|
||||
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
|
||||
132
src/server/__tests__/diagnostics-service.test.ts
Normal file
132
src/server/__tests__/diagnostics-service.test.ts
Normal file
@ -0,0 +1,132 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { gunzipSync } from 'node:zlib'
|
||||
import { handleDiagnosticsApi } from '../api/diagnostics.js'
|
||||
import { DiagnosticsService, diagnosticsService } from '../services/diagnosticsService.js'
|
||||
|
||||
let tmpDir: string
|
||||
let originalConfigDir: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-haha-diagnostics-test-'))
|
||||
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
diagnosticsService.restoreConsoleCaptureForTests()
|
||||
if (originalConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR
|
||||
else process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function makeRequest(method: string, urlStr: string): { req: Request; url: URL; segments: string[] } {
|
||||
const url = new URL(urlStr, 'http://localhost:3456')
|
||||
const req = new Request(url.toString(), { method })
|
||||
const segments = url.pathname.split('/').filter(Boolean)
|
||||
return { req, url, segments }
|
||||
}
|
||||
|
||||
describe('DiagnosticsService', () => {
|
||||
test('writes sanitized structured events and runtime error summaries', async () => {
|
||||
const service = new DiagnosticsService()
|
||||
await service.recordEvent({
|
||||
type: 'cli_start_failed',
|
||||
severity: 'error',
|
||||
sessionId: 'session-1',
|
||||
summary: 'Authorization: Bearer sk-secret-token /Users/example/path',
|
||||
details: {
|
||||
apiKey: 'sk-secret',
|
||||
url: 'https://api.example.com?api_key=secret-value',
|
||||
nested: { message: `home=${os.homedir()}` },
|
||||
},
|
||||
})
|
||||
|
||||
const raw = await fs.readFile(path.join(tmpDir, 'cc-haha', 'diagnostics', 'diagnostics.jsonl'), 'utf-8')
|
||||
expect(raw).toContain('cli_start_failed')
|
||||
expect(raw).toContain('[REDACTED]')
|
||||
expect(raw).not.toContain('sk-secret')
|
||||
expect(raw).not.toContain(os.homedir())
|
||||
|
||||
const runtime = await fs.readFile(path.join(tmpDir, 'cc-haha', 'diagnostics', 'runtime-errors.log'), 'utf-8')
|
||||
expect(runtime).toContain('cli_start_failed')
|
||||
expect(runtime).toContain('[REDACTED]')
|
||||
expect(runtime).not.toContain('sk-secret-token')
|
||||
})
|
||||
|
||||
test('exports a single diagnostics tarball without provider secrets', async () => {
|
||||
const service = new DiagnosticsService()
|
||||
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(tmpDir, 'cc-haha', 'providers.json'),
|
||||
JSON.stringify({
|
||||
activeId: 'provider-1',
|
||||
providers: [{
|
||||
id: 'provider-1',
|
||||
name: 'Test Provider',
|
||||
presetId: 'custom',
|
||||
apiKey: 'sk-provider-secret',
|
||||
baseUrl: 'https://api.example.com/anthropic',
|
||||
apiFormat: 'anthropic',
|
||||
models: { main: 'main-model', haiku: 'haiku-model', sonnet: 'sonnet-model', opus: 'opus-model' },
|
||||
}],
|
||||
}),
|
||||
'utf-8',
|
||||
)
|
||||
await service.recordEvent({
|
||||
type: 'provider_test_failed',
|
||||
severity: 'warn',
|
||||
sessionId: 'session-abc',
|
||||
summary: 'provider failed with token=provider-secret',
|
||||
details: { accessToken: 'provider-secret' },
|
||||
})
|
||||
|
||||
const bundle = await service.exportBundle()
|
||||
expect(bundle.path).toEndWith('.tar.gz')
|
||||
const archiveText = gunzipSync(await fs.readFile(bundle.path)).toString('utf-8')
|
||||
expect(archiveText).toContain('README.txt')
|
||||
expect(archiveText).toContain('providers-summary.json')
|
||||
expect(archiveText).toContain('sessions-summary.json')
|
||||
expect(archiveText).toContain('Test Provider')
|
||||
expect(archiveText).toContain('api.example.com')
|
||||
expect(archiveText).not.toContain('sk-provider-secret')
|
||||
expect(archiveText).not.toContain('provider-secret')
|
||||
})
|
||||
})
|
||||
|
||||
describe('diagnostics API', () => {
|
||||
test('returns status, events, export path, and supports clearing logs', async () => {
|
||||
const service = diagnosticsService
|
||||
await service.recordEvent({
|
||||
type: 'api_unhandled_error',
|
||||
severity: 'error',
|
||||
summary: 'boom',
|
||||
})
|
||||
|
||||
const statusReq = makeRequest('GET', '/api/diagnostics/status')
|
||||
const statusRes = await handleDiagnosticsApi(statusReq.req, statusReq.url, statusReq.segments)
|
||||
expect(statusRes.status).toBe(200)
|
||||
const status = await statusRes.json() as { logDir: string; recentErrorCount: number }
|
||||
expect(status.logDir).toContain(path.join('cc-haha', 'diagnostics'))
|
||||
expect(status.recentErrorCount).toBe(1)
|
||||
|
||||
const eventsReq = makeRequest('GET', '/api/diagnostics/events?limit=10')
|
||||
const eventsRes = await handleDiagnosticsApi(eventsReq.req, eventsReq.url, eventsReq.segments)
|
||||
expect(eventsRes.status).toBe(200)
|
||||
const events = await eventsRes.json() as { events: Array<{ type: string }> }
|
||||
expect(events.events[0].type).toBe('api_unhandled_error')
|
||||
|
||||
const exportReq = makeRequest('POST', '/api/diagnostics/export')
|
||||
const exportRes = await handleDiagnosticsApi(exportReq.req, exportReq.url, exportReq.segments)
|
||||
expect(exportRes.status).toBe(200)
|
||||
const exported = await exportRes.json() as { bundle: { path: string } }
|
||||
await expect(fs.stat(exported.bundle.path)).resolves.toBeTruthy()
|
||||
|
||||
const clearReq = makeRequest('DELETE', '/api/diagnostics')
|
||||
const clearRes = await handleDiagnosticsApi(clearReq.req, clearReq.url, clearReq.segments)
|
||||
expect(clearRes.status).toBe(200)
|
||||
expect(await service.readRecentEvents()).toEqual([])
|
||||
})
|
||||
})
|
||||
@ -28,6 +28,8 @@ const initMode = process.env.MOCK_SDK_INIT_MODE || 'on_open'
|
||||
const streamDelayMs = Number(process.env.MOCK_SDK_STREAM_DELAY_MS || '0')
|
||||
const exitAfterOpenMs = Number(process.env.MOCK_SDK_EXIT_AFTER_OPEN_MS || '0')
|
||||
const exitAfterFirstUserMs = Number(process.env.MOCK_SDK_EXIT_AFTER_FIRST_USER_MS || '0')
|
||||
const startupStdout = process.env.MOCK_SDK_STARTUP_STDOUT || ''
|
||||
const exitBeforeSdkMs = Number(process.env.MOCK_SDK_EXIT_BEFORE_SDK_MS || '0')
|
||||
let initSent = false
|
||||
let firstUserExitScheduled = false
|
||||
|
||||
@ -36,6 +38,14 @@ if (!sdkUrl) {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (startupStdout) {
|
||||
console.log(startupStdout)
|
||||
}
|
||||
|
||||
if (exitBeforeSdkMs > 0) {
|
||||
setTimeout(() => process.exit(1), exitBeforeSdkMs)
|
||||
}
|
||||
|
||||
const ws = new WebSocket(sdkUrl)
|
||||
|
||||
function sendInit() {
|
||||
|
||||
50
src/server/api/diagnostics.ts
Normal file
50
src/server/api/diagnostics.ts
Normal file
@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Diagnostics REST API
|
||||
*
|
||||
* GET /api/diagnostics/status — log directory, retention and counters
|
||||
* GET /api/diagnostics/events — recent sanitized diagnostic events
|
||||
* POST /api/diagnostics/export — write a sanitized tar.gz bundle
|
||||
* POST /api/diagnostics/open-log-dir — open the diagnostics directory
|
||||
* DELETE /api/diagnostics — clear diagnostics files
|
||||
*/
|
||||
|
||||
import { diagnosticsService } from '../services/diagnosticsService.js'
|
||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||
|
||||
export async function handleDiagnosticsApi(
|
||||
req: Request,
|
||||
url: URL,
|
||||
segments: string[],
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const action = segments[2]
|
||||
|
||||
if (!action && req.method === 'DELETE') {
|
||||
await diagnosticsService.clear()
|
||||
return Response.json({ ok: true })
|
||||
}
|
||||
|
||||
if (action === 'status' && req.method === 'GET') {
|
||||
return Response.json(await diagnosticsService.getStatus())
|
||||
}
|
||||
|
||||
if (action === 'events' && req.method === 'GET') {
|
||||
const limit = Number.parseInt(url.searchParams.get('limit') || '100', 10)
|
||||
const events = await diagnosticsService.readRecentEvents(Number.isFinite(limit) ? limit : 100)
|
||||
return Response.json({ events })
|
||||
}
|
||||
|
||||
if (action === 'export' && req.method === 'POST') {
|
||||
return Response.json({ bundle: await diagnosticsService.exportBundle() })
|
||||
}
|
||||
|
||||
if (action === 'open-log-dir' && req.method === 'POST') {
|
||||
await diagnosticsService.openLogDir()
|
||||
return Response.json({ ok: true })
|
||||
}
|
||||
|
||||
throw new ApiError(404, `Unknown diagnostics endpoint: ${action ?? '(root)'}`, 'NOT_FOUND')
|
||||
} catch (error) {
|
||||
return errorResponse(error)
|
||||
}
|
||||
}
|
||||
@ -24,6 +24,7 @@ import {
|
||||
TestProviderSchema,
|
||||
} from '../types/provider.js'
|
||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||
import { diagnosticsService } from '../services/diagnosticsService.js'
|
||||
|
||||
const providerService = new ProviderService()
|
||||
|
||||
@ -99,6 +100,21 @@ export async function handleProvidersApi(
|
||||
if (body && typeof body === 'object') overrides = body as typeof overrides
|
||||
} catch { /* no body is fine — uses saved values */ }
|
||||
const result = await providerService.testProvider(id, overrides)
|
||||
if (!result.connectivity.success || result.proxy?.success === false) {
|
||||
void diagnosticsService.recordEvent({
|
||||
type: 'provider_test_failed',
|
||||
severity: 'warn',
|
||||
summary: result.connectivity.error || result.proxy?.error || 'Provider test failed',
|
||||
details: {
|
||||
providerId: id,
|
||||
httpStatus: result.connectivity.httpStatus ?? result.proxy?.httpStatus,
|
||||
apiFormat: overrides?.apiFormat,
|
||||
modelId: overrides?.modelId,
|
||||
connectivity: result.connectivity,
|
||||
proxy: result.proxy,
|
||||
},
|
||||
})
|
||||
}
|
||||
return Response.json({ result })
|
||||
}
|
||||
|
||||
@ -150,8 +166,31 @@ async function handleTestUnsaved(req: Request): Promise<Response> {
|
||||
try {
|
||||
const input = TestProviderSchema.parse(body)
|
||||
const result = await providerService.testProviderConfig(input)
|
||||
if (!result.connectivity.success || result.proxy?.success === false) {
|
||||
void diagnosticsService.recordEvent({
|
||||
type: 'provider_test_failed',
|
||||
severity: 'warn',
|
||||
summary: result.connectivity.error || result.proxy?.error || 'Provider test failed',
|
||||
details: {
|
||||
providerId: null,
|
||||
baseUrl: input.baseUrl,
|
||||
apiFormat: input.apiFormat,
|
||||
modelId: input.modelId,
|
||||
connectivity: result.connectivity,
|
||||
proxy: result.proxy,
|
||||
},
|
||||
})
|
||||
}
|
||||
return Response.json({ result })
|
||||
} catch (err) {
|
||||
if (!(err instanceof z.ZodError)) {
|
||||
void diagnosticsService.recordEvent({
|
||||
type: 'provider_test_failed',
|
||||
severity: 'warn',
|
||||
summary: err instanceof Error ? err.message : String(err),
|
||||
details: { providerId: null, error: err },
|
||||
})
|
||||
}
|
||||
if (err instanceof z.ZodError) throw ApiError.badRequest(err.issues.map((i) => i.message).join('; '))
|
||||
throw err
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ import { ProviderService } from './services/providerService.js'
|
||||
import { handleHahaOAuthCallback } from './api/haha-oauth.js'
|
||||
import { ensureDesktopCliLauncherInstalled } from './services/desktopCliLauncherService.js'
|
||||
import { enableConfigs } from '../utils/config.js'
|
||||
import { diagnosticsService } from './services/diagnosticsService.js'
|
||||
|
||||
function readArgValue(flag: string): string | undefined {
|
||||
const args = process.argv.slice(2)
|
||||
@ -48,6 +49,7 @@ const HOST = SERVER_OPTIONS.host
|
||||
|
||||
export function startServer(port = PORT, host = HOST) {
|
||||
enableConfigs()
|
||||
diagnosticsService.installConsoleCapture()
|
||||
ProviderService.setServerPort(port)
|
||||
const localConnectHost =
|
||||
host === '0.0.0.0' || host === '127.0.0.1' || host === 'localhost'
|
||||
@ -163,6 +165,12 @@ export function startServer(port = PORT, host = HOST) {
|
||||
headers,
|
||||
})
|
||||
} catch (error) {
|
||||
void diagnosticsService.recordEvent({
|
||||
type: 'api_request_failed',
|
||||
severity: 'error',
|
||||
summary: error instanceof Error ? error.message : String(error),
|
||||
details: { path: url.pathname, method: req.method, error },
|
||||
})
|
||||
console.error('[Server] API error:', error)
|
||||
return Response.json(
|
||||
{ error: 'Internal server error' },
|
||||
@ -194,6 +202,12 @@ export function startServer(port = PORT, host = HOST) {
|
||||
headers,
|
||||
})
|
||||
} catch (error) {
|
||||
void diagnosticsService.recordEvent({
|
||||
type: 'proxy_request_failed',
|
||||
severity: 'error',
|
||||
summary: error instanceof Error ? error.message : String(error),
|
||||
details: { path: url.pathname, method: req.method, error },
|
||||
})
|
||||
console.error('[Server] Proxy error:', error)
|
||||
return Response.json(
|
||||
{ type: 'error', error: { type: 'api_error', message: 'Internal proxy error' } },
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
* Unified error handling utilities
|
||||
*/
|
||||
|
||||
import { diagnosticsService } from '../services/diagnosticsService.js'
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public statusCode: number,
|
||||
@ -37,6 +39,12 @@ export function errorResponse(error: unknown): Response {
|
||||
)
|
||||
}
|
||||
|
||||
void diagnosticsService.recordEvent({
|
||||
type: 'api_unhandled_error',
|
||||
severity: 'error',
|
||||
summary: error instanceof Error ? error.message : String(error),
|
||||
details: error,
|
||||
})
|
||||
console.error('[Server] Unexpected error:', error)
|
||||
return Response.json(
|
||||
{ error: 'INTERNAL_ERROR', message: 'An unexpected error occurred' },
|
||||
|
||||
@ -19,6 +19,7 @@ import { handleSkillsApi } from './api/skills.js'
|
||||
import { handleComputerUseApi } from './api/computer-use.js'
|
||||
import { handleHahaOAuthApi } from './api/haha-oauth.js'
|
||||
import { handleMcpApi } from './api/mcp.js'
|
||||
import { handleDiagnosticsApi } from './api/diagnostics.js'
|
||||
|
||||
export async function handleApiRequest(req: Request, url: URL): Promise<Response> {
|
||||
const path = url.pathname
|
||||
@ -87,6 +88,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise<Response
|
||||
case 'computer-use':
|
||||
return handleComputerUseApi(req, url, segments)
|
||||
|
||||
case 'diagnostics':
|
||||
return handleDiagnosticsApi(req, url, segments)
|
||||
|
||||
case 'filesystem':
|
||||
return handleFilesystemRoute(url.pathname, url)
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@ import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { ProviderService } from './providerService.js'
|
||||
import { sessionService } from './sessionService.js'
|
||||
import { diagnosticsService } from './diagnosticsService.js'
|
||||
import {
|
||||
buildClaudeCliArgs,
|
||||
resolveClaudeCliLauncher,
|
||||
@ -32,7 +33,11 @@ type SessionProcess = {
|
||||
sdkToken: string
|
||||
sdkSocket: { send(data: string): void } | null
|
||||
pendingOutbound: string[]
|
||||
startupPending: boolean
|
||||
startupExitCode: number | null
|
||||
stdoutLines: string[]
|
||||
stderrLines: string[]
|
||||
outputDrain: Promise<void>
|
||||
sdkMessages: any[]
|
||||
initMessage: any | null
|
||||
pendingPermissionRequests: Map<
|
||||
@ -168,10 +173,23 @@ export class ConversationService {
|
||||
cwd: workDir,
|
||||
env: childEnv,
|
||||
stdin: 'pipe',
|
||||
stdout: 'ignore', // CLI communicates via SDK WebSocket, not stdout
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
} catch (spawnErr) {
|
||||
void diagnosticsService.recordEvent({
|
||||
type: 'cli_spawn_failed',
|
||||
severity: 'error',
|
||||
sessionId,
|
||||
summary: spawnErr instanceof Error ? spawnErr.message : String(spawnErr),
|
||||
details: {
|
||||
workDir,
|
||||
permissionMode: options?.permissionMode || 'default',
|
||||
providerId: options?.providerId ?? null,
|
||||
model: options?.model ?? null,
|
||||
error: spawnErr,
|
||||
},
|
||||
})
|
||||
throw new ConversationStartupError(
|
||||
`Failed to spawn CLI in ${workDir}: ${
|
||||
spawnErr instanceof Error ? spawnErr.message : String(spawnErr)
|
||||
@ -188,17 +206,24 @@ export class ConversationService {
|
||||
sdkToken: this.getSdkTokenFromUrl(sdkUrl),
|
||||
sdkSocket: null,
|
||||
pendingOutbound: [],
|
||||
startupPending: true,
|
||||
startupExitCode: null,
|
||||
stdoutLines: [],
|
||||
stderrLines: [],
|
||||
outputDrain: Promise.resolve(),
|
||||
sdkMessages: [],
|
||||
initMessage: null,
|
||||
pendingPermissionRequests: new Map(),
|
||||
}
|
||||
this.sessions.set(sessionId, session)
|
||||
|
||||
this.readErrorStream(sessionId, proc)
|
||||
session.outputDrain = Promise.all([
|
||||
this.readProcessOutputStream(sessionId, proc.stdout, 'stdout'),
|
||||
this.readProcessOutputStream(sessionId, proc.stderr, 'stderr'),
|
||||
]).then(() => undefined)
|
||||
|
||||
proc.exited.then((code) => {
|
||||
this.handleProcessExit(sessionId, proc, code)
|
||||
void this.handleProcessExit(sessionId, proc, code)
|
||||
})
|
||||
|
||||
const STARTUP_GRACE_MS = 3000
|
||||
@ -209,8 +234,10 @@ export class ConversationService {
|
||||
),
|
||||
])
|
||||
|
||||
if (earlyExitCode !== null) {
|
||||
const startupError = this.buildStartupError(sessionId, earlyExitCode)
|
||||
const startupExitCode = earlyExitCode ?? session.startupExitCode
|
||||
if (startupExitCode !== null) {
|
||||
await this.waitForProcessOutputDrain(session)
|
||||
const startupError = this.buildStartupError(sessionId, startupExitCode)
|
||||
this.sessions.delete(sessionId)
|
||||
|
||||
if (this.clearStaleLock(sessionId)) {
|
||||
@ -221,11 +248,30 @@ export class ConversationService {
|
||||
}
|
||||
|
||||
console.error(
|
||||
`[ConversationService] CLI exited with code ${earlyExitCode} for ${sessionId}: ${startupError.message}`,
|
||||
`[ConversationService] CLI exited with code ${startupExitCode} for ${sessionId}: ${startupError.message}`,
|
||||
)
|
||||
void diagnosticsService.recordEvent({
|
||||
type: 'cli_start_failed',
|
||||
severity: 'error',
|
||||
sessionId,
|
||||
summary: startupError.message,
|
||||
details: {
|
||||
code: startupError.code,
|
||||
exitCode: startupExitCode,
|
||||
retryable: startupError.retryable,
|
||||
workDir,
|
||||
permissionMode: options?.permissionMode || 'default',
|
||||
providerId: options?.providerId ?? null,
|
||||
model: options?.model ?? null,
|
||||
capturedOutput: this.buildCapturedProcessOutputDetail(session),
|
||||
sdkMessages: this.summarizeSdkMessages(session.sdkMessages),
|
||||
},
|
||||
})
|
||||
throw startupError
|
||||
}
|
||||
|
||||
session.startupPending = false
|
||||
|
||||
if (shouldReplacePlaceholder || !launchInfo) {
|
||||
await sessionService.appendSessionMetadata(sessionId, {
|
||||
workDir,
|
||||
@ -503,13 +549,14 @@ export class ConversationService {
|
||||
return Array.from(this.sessions.keys())
|
||||
}
|
||||
|
||||
private async readErrorStream(
|
||||
private async readProcessOutputStream(
|
||||
sessionId: string,
|
||||
proc: ReturnType<typeof Bun.spawn>,
|
||||
stream: ReadableStream | null | undefined,
|
||||
streamName: 'stdout' | 'stderr',
|
||||
): Promise<void> {
|
||||
if (!proc.stderr) return
|
||||
if (!stream) return
|
||||
|
||||
const reader = (proc.stderr as ReadableStream).getReader()
|
||||
const reader = stream.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
try {
|
||||
@ -526,20 +573,38 @@ export class ConversationService {
|
||||
.split('\n')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)) {
|
||||
session.stderrLines.push(line)
|
||||
if (session.stderrLines.length > 20) {
|
||||
session.stderrLines.splice(0, 10)
|
||||
const lines =
|
||||
streamName === 'stderr' ? session.stderrLines : session.stdoutLines
|
||||
lines.push(this.redactProcessOutput(line))
|
||||
if (lines.length > 20) {
|
||||
lines.splice(0, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`[CLI:${sessionId}] ${text.trim()}`)
|
||||
const logLine = this.redactProcessOutput(text.trim())
|
||||
if (streamName === 'stderr') {
|
||||
console.error(`[CLI:${sessionId}:stderr] ${logLine}`)
|
||||
} else {
|
||||
console.log(`[CLI:${sessionId}:stdout] ${logLine}`)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// stderr read failures should not kill the session
|
||||
// Process output read failures should not kill the session.
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForProcessOutputDrain(
|
||||
session: SessionProcess,
|
||||
timeoutMs = 250,
|
||||
): Promise<void> {
|
||||
const outputDrain = session.outputDrain ?? Promise.resolve()
|
||||
await Promise.race([
|
||||
outputDrain.catch(() => undefined),
|
||||
new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)),
|
||||
])
|
||||
}
|
||||
|
||||
private sendSdkMessage(
|
||||
sessionId: string,
|
||||
payload: Record<string, unknown>,
|
||||
@ -556,18 +621,36 @@ export class ConversationService {
|
||||
return true
|
||||
}
|
||||
|
||||
private handleProcessExit(
|
||||
private async handleProcessExit(
|
||||
sessionId: string,
|
||||
proc: SessionProcess['proc'],
|
||||
code: number,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
console.log(
|
||||
`[ConversationService] CLI process for ${sessionId} exited with code ${code}`,
|
||||
)
|
||||
|
||||
const activeSession = this.sessions.get(sessionId)
|
||||
if (activeSession?.proc === proc) {
|
||||
if (activeSession.startupPending) {
|
||||
activeSession.startupExitCode = code
|
||||
return
|
||||
}
|
||||
await this.waitForProcessOutputDrain(activeSession)
|
||||
const exitError = this.buildRuntimeExitMessage(sessionId, code)
|
||||
void diagnosticsService.recordEvent({
|
||||
type: 'cli_runtime_exit',
|
||||
severity: 'error',
|
||||
sessionId,
|
||||
summary: exitError,
|
||||
details: {
|
||||
exitCode: code,
|
||||
workDir: activeSession.workDir,
|
||||
permissionMode: activeSession.permissionMode,
|
||||
capturedOutput: this.buildCapturedProcessOutputDetail(activeSession),
|
||||
sdkMessages: this.summarizeSdkMessages(activeSession.sdkMessages),
|
||||
},
|
||||
})
|
||||
for (const cb of activeSession.outputCallbacks) {
|
||||
cb({
|
||||
type: 'result',
|
||||
@ -854,7 +937,7 @@ export class ConversationService {
|
||||
exitCode: number,
|
||||
): ConversationStartupError {
|
||||
const session = this.sessions.get(sessionId)
|
||||
const stderrText = session?.stderrLines.join('\n') ?? ''
|
||||
const capturedOutput = this.buildCapturedProcessOutputDetail(session)
|
||||
const recentMessages = session?.sdkMessages ?? []
|
||||
const resultMessage = [...recentMessages]
|
||||
.reverse()
|
||||
@ -865,7 +948,7 @@ export class ConversationService {
|
||||
const detail =
|
||||
this.extractStartupDetail(resultMessage) ||
|
||||
this.extractStartupDetail(authStatus) ||
|
||||
stderrText
|
||||
capturedOutput
|
||||
|
||||
if (
|
||||
/(not logged in|run \/login|sign in again|login required|unauthenticated|logged_out)/i.test(
|
||||
@ -890,7 +973,7 @@ export class ConversationService {
|
||||
return new ConversationStartupError(
|
||||
normalizedDetail
|
||||
? `CLI exited during startup (code ${exitCode}): ${normalizedDetail}`
|
||||
: `CLI exited during startup with code ${exitCode}.`,
|
||||
: `CLI exited during startup with code ${exitCode}; no CLI stderr/stdout or SDK error payload was captured before exit.`,
|
||||
'CLI_START_FAILED',
|
||||
true,
|
||||
)
|
||||
@ -898,7 +981,7 @@ export class ConversationService {
|
||||
|
||||
private buildRuntimeExitMessage(sessionId: string, exitCode: number): string {
|
||||
const session = this.sessions.get(sessionId)
|
||||
const stderrText = session?.stderrLines.join('\n').trim() ?? ''
|
||||
const capturedOutput = this.buildCapturedProcessOutputDetail(session)
|
||||
const recentMessages = session?.sdkMessages ?? []
|
||||
const resultMessage = [...recentMessages]
|
||||
.reverse()
|
||||
@ -909,11 +992,33 @@ export class ConversationService {
|
||||
const detail =
|
||||
this.extractStartupDetail(resultMessage) ||
|
||||
this.extractStartupDetail(authStatus) ||
|
||||
stderrText
|
||||
capturedOutput
|
||||
|
||||
return detail
|
||||
? `CLI process exited unexpectedly (code ${exitCode}): ${detail}`
|
||||
: `CLI process exited unexpectedly with code ${exitCode}.`
|
||||
: `CLI process exited unexpectedly with code ${exitCode}; no CLI stderr/stdout or SDK error payload was captured before exit.`
|
||||
}
|
||||
|
||||
private buildCapturedProcessOutputDetail(
|
||||
session: SessionProcess | undefined,
|
||||
): string {
|
||||
if (!session) return ''
|
||||
|
||||
const stderrText = (session.stderrLines ?? []).join('\n').trim()
|
||||
const stdoutText = (session.stdoutLines ?? []).join('\n').trim()
|
||||
|
||||
if (stderrText && stdoutText) {
|
||||
return `stderr:\n${stderrText}\nstdout:\n${stdoutText}`
|
||||
}
|
||||
|
||||
return stderrText || stdoutText
|
||||
}
|
||||
|
||||
private redactProcessOutput(line: string): string {
|
||||
return line
|
||||
.replace(/(ANTHROPIC_(?:API_KEY|AUTH_TOKEN)\s*[:=]\s*)[^\s,;]+/gi, '$1[REDACTED]')
|
||||
.replace(/((?:api[_-]?key|auth[_-]?token|access[_-]?token)\s*[:=]\s*)[^\s,;]+/gi, '$1[REDACTED]')
|
||||
.replace(/(Bearer\s+)[A-Za-z0-9._~+/-]+/gi, '$1[REDACTED]')
|
||||
}
|
||||
|
||||
private extractStartupDetail(message: any): string {
|
||||
@ -932,6 +1037,22 @@ export class ConversationService {
|
||||
return ''
|
||||
}
|
||||
|
||||
private summarizeSdkMessages(messages: any[]): unknown[] {
|
||||
return messages.slice(-10).map((message) => {
|
||||
if (!message || typeof message !== 'object') {
|
||||
return message
|
||||
}
|
||||
return {
|
||||
type: message.type,
|
||||
subtype: message.subtype,
|
||||
is_error: message.is_error,
|
||||
status: typeof message.status === 'string' ? message.status : undefined,
|
||||
result: typeof message.result === 'string' ? message.result : undefined,
|
||||
message: typeof message.message === 'string' ? message.message : undefined,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private buildUserContent(
|
||||
content: string,
|
||||
sessionId: string,
|
||||
|
||||
510
src/server/services/diagnosticsService.ts
Normal file
510
src/server/services/diagnosticsService.ts
Normal file
@ -0,0 +1,510 @@
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { gzipSync } from 'node:zlib'
|
||||
import type { Dirent } from 'node:fs'
|
||||
|
||||
export type DiagnosticSeverity = 'debug' | 'info' | 'warn' | 'error'
|
||||
|
||||
export type DiagnosticEventInput = {
|
||||
type: string
|
||||
severity?: DiagnosticSeverity
|
||||
summary: string
|
||||
sessionId?: string
|
||||
details?: unknown
|
||||
}
|
||||
|
||||
export type DiagnosticEvent = {
|
||||
id: string
|
||||
timestamp: string
|
||||
type: string
|
||||
severity: DiagnosticSeverity
|
||||
summary: string
|
||||
sessionId?: string
|
||||
details?: unknown
|
||||
}
|
||||
|
||||
export type DiagnosticsStatus = {
|
||||
logDir: string
|
||||
diagnosticsPath: string
|
||||
runtimeErrorsPath: string
|
||||
exportDir: string
|
||||
retentionDays: number
|
||||
maxBytes: number
|
||||
totalBytes: number
|
||||
eventCount: number
|
||||
recentErrorCount: number
|
||||
lastEventAt: string | null
|
||||
}
|
||||
|
||||
export type DiagnosticsExportResult = {
|
||||
path: string
|
||||
fileName: string
|
||||
bytes: number
|
||||
}
|
||||
|
||||
const RETENTION_DAYS = 7
|
||||
const MAX_BYTES = 50 * 1024 * 1024
|
||||
const MAX_STRING_LENGTH = 4096
|
||||
const MAX_ARRAY_ITEMS = 40
|
||||
const MAX_OBJECT_KEYS = 80
|
||||
const MAX_EVENTS_IN_EXPORT = 5000
|
||||
const SENSITIVE_KEY_RE = /(api[_-]?key|auth[_-]?token|access[_-]?token|refresh[_-]?token|session[_-]?token|\btoken\b|secret|password|authorization|cookie|oauth)/i
|
||||
|
||||
export class DiagnosticsService {
|
||||
private consoleCaptureInstalled = false
|
||||
private originalConsoleError: typeof console.error | null = null
|
||||
private originalConsoleWarn: typeof console.warn | null = null
|
||||
|
||||
getLogDir(): string {
|
||||
return path.join(this.getConfigDir(), 'cc-haha', 'diagnostics')
|
||||
}
|
||||
|
||||
getDiagnosticsPath(): string {
|
||||
return path.join(this.getLogDir(), 'diagnostics.jsonl')
|
||||
}
|
||||
|
||||
getRuntimeErrorsPath(): string {
|
||||
return path.join(this.getLogDir(), 'runtime-errors.log')
|
||||
}
|
||||
|
||||
getExportDir(): string {
|
||||
return path.join(this.getLogDir(), 'exports')
|
||||
}
|
||||
|
||||
async recordEvent(input: DiagnosticEventInput): Promise<void> {
|
||||
const event: DiagnosticEvent = {
|
||||
id: crypto.randomUUID(),
|
||||
timestamp: new Date().toISOString(),
|
||||
type: input.type,
|
||||
severity: input.severity ?? 'error',
|
||||
summary: this.sanitizeString(input.summary),
|
||||
...(input.sessionId ? { sessionId: this.sanitizeString(input.sessionId, 256) } : {}),
|
||||
...(input.details !== undefined ? { details: this.sanitizeValue(input.details) } : {}),
|
||||
}
|
||||
|
||||
try {
|
||||
await this.ensureLogDir()
|
||||
await fs.appendFile(this.getDiagnosticsPath(), JSON.stringify(event) + '\n', 'utf-8')
|
||||
if (event.severity === 'warn' || event.severity === 'error') {
|
||||
await fs.appendFile(this.getRuntimeErrorsPath(), this.formatRuntimeLogLine(event), 'utf-8')
|
||||
}
|
||||
await this.enforceRetention().catch(() => {})
|
||||
} catch {
|
||||
// Diagnostics must never break the product path.
|
||||
}
|
||||
}
|
||||
|
||||
installConsoleCapture(): void {
|
||||
if (this.consoleCaptureInstalled) return
|
||||
this.consoleCaptureInstalled = true
|
||||
this.originalConsoleError = console.error.bind(console)
|
||||
this.originalConsoleWarn = console.warn.bind(console)
|
||||
|
||||
console.error = (...args: unknown[]) => {
|
||||
this.originalConsoleError?.(...args)
|
||||
void this.recordEvent({
|
||||
type: 'console_error',
|
||||
severity: 'error',
|
||||
summary: this.formatConsoleArgs(args),
|
||||
})
|
||||
}
|
||||
|
||||
console.warn = (...args: unknown[]) => {
|
||||
this.originalConsoleWarn?.(...args)
|
||||
void this.recordEvent({
|
||||
type: 'console_warn',
|
||||
severity: 'warn',
|
||||
summary: this.formatConsoleArgs(args),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
restoreConsoleCaptureForTests(): void {
|
||||
if (this.originalConsoleError) console.error = this.originalConsoleError
|
||||
if (this.originalConsoleWarn) console.warn = this.originalConsoleWarn
|
||||
this.consoleCaptureInstalled = false
|
||||
this.originalConsoleError = null
|
||||
this.originalConsoleWarn = null
|
||||
}
|
||||
|
||||
async getStatus(): Promise<DiagnosticsStatus> {
|
||||
await this.ensureLogDir()
|
||||
const events = await this.readRecentEvents(500)
|
||||
const totalBytes = await this.getDirectorySize(this.getLogDir())
|
||||
const cutoff = Date.now() - 24 * 60 * 60 * 1000
|
||||
return {
|
||||
logDir: this.getLogDir(),
|
||||
diagnosticsPath: this.getDiagnosticsPath(),
|
||||
runtimeErrorsPath: this.getRuntimeErrorsPath(),
|
||||
exportDir: this.getExportDir(),
|
||||
retentionDays: RETENTION_DAYS,
|
||||
maxBytes: MAX_BYTES,
|
||||
totalBytes,
|
||||
eventCount: events.length,
|
||||
recentErrorCount: events.filter((event) =>
|
||||
(event.severity === 'error' || event.severity === 'warn') &&
|
||||
Date.parse(event.timestamp) >= cutoff
|
||||
).length,
|
||||
lastEventAt: events[0]?.timestamp ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
async readRecentEvents(limit = 100): Promise<DiagnosticEvent[]> {
|
||||
const boundedLimit = Math.max(1, Math.min(limit, 1000))
|
||||
let raw = ''
|
||||
try {
|
||||
raw = await fs.readFile(this.getDiagnosticsPath(), 'utf-8')
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []
|
||||
throw err
|
||||
}
|
||||
|
||||
return raw
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.slice(-boundedLimit)
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line) as DiagnosticEvent
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
.filter((event): event is DiagnosticEvent => event !== null)
|
||||
.reverse()
|
||||
}
|
||||
|
||||
async exportBundle(): Promise<DiagnosticsExportResult> {
|
||||
await this.ensureLogDir()
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const fileName = `cc-haha-diagnostics-${timestamp}.tar.gz`
|
||||
const outPath = path.join(this.getExportDir(), fileName)
|
||||
const events = await this.readRecentEvents(MAX_EVENTS_IN_EXPORT)
|
||||
const files = [
|
||||
{
|
||||
name: 'README.txt',
|
||||
content: this.buildReadme(),
|
||||
},
|
||||
{
|
||||
name: 'app-info.json',
|
||||
content: JSON.stringify(this.buildAppInfo(), null, 2) + '\n',
|
||||
},
|
||||
{
|
||||
name: 'diagnostics.jsonl',
|
||||
content: events.map((event) => JSON.stringify(this.sanitizeValue(event))).join('\n') + (events.length ? '\n' : ''),
|
||||
},
|
||||
{
|
||||
name: 'runtime-errors.log',
|
||||
content: await this.readSanitizedTextFile(this.getRuntimeErrorsPath()),
|
||||
},
|
||||
{
|
||||
name: 'providers-summary.json',
|
||||
content: JSON.stringify(await this.buildProvidersSummary(), null, 2) + '\n',
|
||||
},
|
||||
{
|
||||
name: 'sessions-summary.json',
|
||||
content: JSON.stringify(this.buildSessionsSummary(events), null, 2) + '\n',
|
||||
},
|
||||
]
|
||||
|
||||
const archive = this.createTarGz(files)
|
||||
await fs.mkdir(this.getExportDir(), { recursive: true })
|
||||
await fs.writeFile(outPath, archive)
|
||||
return { path: outPath, fileName, bytes: archive.byteLength }
|
||||
}
|
||||
|
||||
async openLogDir(): Promise<void> {
|
||||
await this.ensureLogDir()
|
||||
const dir = this.getLogDir()
|
||||
if (process.platform === 'darwin') {
|
||||
Bun.spawn(['open', dir], { stdout: 'ignore', stderr: 'ignore' })
|
||||
return
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
Bun.spawn(['cmd', '/c', 'start', '', dir], { stdout: 'ignore', stderr: 'ignore' })
|
||||
return
|
||||
}
|
||||
Bun.spawn(['xdg-open', dir], { stdout: 'ignore', stderr: 'ignore' })
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
await fs.rm(this.getLogDir(), { recursive: true, force: true })
|
||||
await this.ensureLogDir()
|
||||
}
|
||||
|
||||
sanitizeValue(value: unknown, depth = 0): unknown {
|
||||
if (depth > 6) return '[TRUNCATED_DEPTH]'
|
||||
if (value === null || value === undefined) return value
|
||||
if (typeof value === 'string') return this.sanitizeString(value)
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return value
|
||||
if (typeof value === 'bigint') return value.toString()
|
||||
if (value instanceof Error) {
|
||||
return {
|
||||
name: value.name,
|
||||
message: this.sanitizeString(value.message),
|
||||
stack: value.stack ? this.sanitizeString(value.stack) : undefined,
|
||||
}
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.slice(0, MAX_ARRAY_ITEMS).map((entry) => this.sanitizeValue(entry, depth + 1))
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const result: Record<string, unknown> = {}
|
||||
let count = 0
|
||||
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (count >= MAX_OBJECT_KEYS) {
|
||||
result.__truncatedKeys = true
|
||||
break
|
||||
}
|
||||
count += 1
|
||||
result[key] = SENSITIVE_KEY_RE.test(key)
|
||||
? '[REDACTED]'
|
||||
: this.sanitizeValue(entry, depth + 1)
|
||||
}
|
||||
return result
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
sanitizeString(value: string, maxLength = MAX_STRING_LENGTH): string {
|
||||
let sanitized = value
|
||||
.replace(/(Bearer\s+)[A-Za-z0-9._~+/-]+/gi, '$1[REDACTED]')
|
||||
.replace(/((?:api[_-]?key|auth[_-]?token|access[_-]?token|refresh[_-]?token|session[_-]?token|token|secret|password)\s*[:=]\s*)[^\s,;"'}]+/gi, '$1[REDACTED]')
|
||||
.replace(/(ANTHROPIC_(?:API_KEY|AUTH_TOKEN)\s*[:=]\s*)[^\s,;"'}]+/gi, '$1[REDACTED]')
|
||||
.replace(/([?&](?:api[_-]?key|token|auth|access_token|refresh_token|key)=)[^&\s]+/gi, '$1[REDACTED]')
|
||||
|
||||
const home = os.homedir()
|
||||
if (home && sanitized.includes(home)) {
|
||||
sanitized = sanitized.split(home).join('~')
|
||||
}
|
||||
|
||||
if (sanitized.length > maxLength) {
|
||||
return `${sanitized.slice(0, maxLength)}...[TRUNCATED ${sanitized.length - maxLength} chars]`
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
||||
private async ensureLogDir(): Promise<void> {
|
||||
await fs.mkdir(this.getExportDir(), { recursive: true })
|
||||
}
|
||||
|
||||
private getConfigDir(): string {
|
||||
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
||||
}
|
||||
|
||||
private formatConsoleArgs(args: unknown[]): string {
|
||||
return this.sanitizeString(args.map((arg) => {
|
||||
if (arg instanceof Error) return `${arg.name}: ${arg.message}\n${arg.stack ?? ''}`
|
||||
if (typeof arg === 'string') return arg
|
||||
try {
|
||||
return JSON.stringify(this.sanitizeValue(arg))
|
||||
} catch {
|
||||
return String(arg)
|
||||
}
|
||||
}).join(' '))
|
||||
}
|
||||
|
||||
private formatRuntimeLogLine(event: DiagnosticEvent): string {
|
||||
return `[${event.timestamp}] ${event.severity.toUpperCase()} ${event.type}${event.sessionId ? ` session=${event.sessionId}` : ''}: ${event.summary}\n`
|
||||
}
|
||||
|
||||
private buildReadme(): string {
|
||||
return [
|
||||
'cc-haha diagnostics bundle',
|
||||
'',
|
||||
'This bundle is generated by the desktop app for debugging server and CLI startup/runtime failures.',
|
||||
'It intentionally excludes chat prompts, assistant replies, file contents, attachments, full environment variables, API keys, bearer tokens, cookies, and OAuth tokens.',
|
||||
'Paths under the current home directory are normalized to "~". Long fields are truncated.',
|
||||
'',
|
||||
'Files:',
|
||||
'- app-info.json: runtime and platform summary.',
|
||||
'- diagnostics.jsonl: sanitized structured diagnostic events.',
|
||||
'- runtime-errors.log: sanitized warning/error summaries.',
|
||||
'- providers-summary.json: provider count, active id, base URL host, model ids, and API format without API keys.',
|
||||
'- sessions-summary.json: session ids observed in diagnostic events, without transcript content.',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
private buildAppInfo(): Record<string, unknown> {
|
||||
return this.sanitizeValue({
|
||||
appVersion: process.env.APP_VERSION || '999.0.0-local',
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
node: process.version,
|
||||
bun: typeof Bun !== 'undefined' ? Bun.version : null,
|
||||
cwd: process.cwd(),
|
||||
uptimeSeconds: Math.round(process.uptime()),
|
||||
generatedAt: new Date().toISOString(),
|
||||
}) as Record<string, unknown>
|
||||
}
|
||||
|
||||
private async buildProvidersSummary(): Promise<Record<string, unknown>> {
|
||||
const providerPath = path.join(this.getConfigDir(), 'cc-haha', 'providers.json')
|
||||
try {
|
||||
const raw = await fs.readFile(providerPath, 'utf-8')
|
||||
const parsed = JSON.parse(raw) as {
|
||||
activeId?: string | null
|
||||
providers?: Array<Record<string, unknown>>
|
||||
}
|
||||
return {
|
||||
activeId: parsed.activeId ?? null,
|
||||
count: Array.isArray(parsed.providers) ? parsed.providers.length : 0,
|
||||
providers: (parsed.providers ?? []).map((provider) => ({
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
presetId: provider.presetId,
|
||||
apiFormat: provider.apiFormat,
|
||||
baseUrl: this.summarizeUrl(typeof provider.baseUrl === 'string' ? provider.baseUrl : ''),
|
||||
models: provider.models,
|
||||
})),
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return { activeId: null, count: 0, providers: [] }
|
||||
}
|
||||
return { error: this.sanitizeString(err instanceof Error ? err.message : String(err)) }
|
||||
}
|
||||
}
|
||||
|
||||
private summarizeUrl(value: string): Record<string, string> | null {
|
||||
if (!value.trim()) return null
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return { protocol: url.protocol, host: url.host, pathname: url.pathname }
|
||||
} catch {
|
||||
return { value: this.sanitizeString(value, 512) }
|
||||
}
|
||||
}
|
||||
|
||||
private buildSessionsSummary(events: DiagnosticEvent[]): Record<string, unknown> {
|
||||
const sessions = new Map<string, { eventCount: number; lastEventAt: string; severities: Set<DiagnosticSeverity> }>()
|
||||
for (const event of events) {
|
||||
if (!event.sessionId) continue
|
||||
const current = sessions.get(event.sessionId) ?? {
|
||||
eventCount: 0,
|
||||
lastEventAt: event.timestamp,
|
||||
severities: new Set<DiagnosticSeverity>(),
|
||||
}
|
||||
current.eventCount += 1
|
||||
current.lastEventAt = current.lastEventAt > event.timestamp ? current.lastEventAt : event.timestamp
|
||||
current.severities.add(event.severity)
|
||||
sessions.set(event.sessionId, current)
|
||||
}
|
||||
return {
|
||||
count: sessions.size,
|
||||
sessions: [...sessions.entries()].map(([sessionId, info]) => ({
|
||||
sessionId,
|
||||
eventCount: info.eventCount,
|
||||
lastEventAt: info.lastEventAt,
|
||||
severities: [...info.severities],
|
||||
})),
|
||||
transcriptContentIncluded: false,
|
||||
}
|
||||
}
|
||||
|
||||
private async readSanitizedTextFile(filePath: string): Promise<string> {
|
||||
try {
|
||||
return this.sanitizeString(await fs.readFile(filePath, 'utf-8'), 2 * MAX_STRING_LENGTH)
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return ''
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
private async enforceRetention(): Promise<void> {
|
||||
const dir = this.getLogDir()
|
||||
const cutoff = Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000
|
||||
const files = await this.listFiles(dir)
|
||||
|
||||
for (const file of files) {
|
||||
if (file.mtimeMs < cutoff) {
|
||||
await fs.rm(file.path, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = (await this.listFiles(dir)).sort((a, b) => a.mtimeMs - b.mtimeMs)
|
||||
let total = remaining.reduce((sum, file) => sum + file.size, 0)
|
||||
for (const file of remaining) {
|
||||
if (total <= MAX_BYTES) break
|
||||
await fs.rm(file.path, { force: true })
|
||||
total -= file.size
|
||||
}
|
||||
}
|
||||
|
||||
private async getDirectorySize(dir: string): Promise<number> {
|
||||
return (await this.listFiles(dir)).reduce((sum, file) => sum + file.size, 0)
|
||||
}
|
||||
|
||||
private async listFiles(dir: string): Promise<Array<{ path: string; size: number; mtimeMs: number }>> {
|
||||
const results: Array<{ path: string; size: number; mtimeMs: number }> = []
|
||||
let entries: Dirent[]
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []
|
||||
throw err
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const filePath = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...await this.listFiles(filePath))
|
||||
continue
|
||||
}
|
||||
if (!entry.isFile()) continue
|
||||
const stat = await fs.stat(filePath)
|
||||
results.push({ path: filePath, size: stat.size, mtimeMs: stat.mtimeMs })
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
private createTarGz(files: Array<{ name: string; content: string }>): Buffer {
|
||||
const chunks: Buffer[] = []
|
||||
const mtime = Math.floor(Date.now() / 1000)
|
||||
for (const file of files) {
|
||||
const body = Buffer.from(file.content, 'utf-8')
|
||||
chunks.push(this.createTarHeader(file.name, body.byteLength, mtime))
|
||||
chunks.push(body)
|
||||
const padding = (512 - (body.byteLength % 512)) % 512
|
||||
if (padding > 0) chunks.push(Buffer.alloc(padding))
|
||||
}
|
||||
chunks.push(Buffer.alloc(1024))
|
||||
return gzipSync(Buffer.concat(chunks))
|
||||
}
|
||||
|
||||
private createTarHeader(name: string, size: number, mtime: number): Buffer {
|
||||
const header = Buffer.alloc(512)
|
||||
this.writeTarString(header, 0, 100, name)
|
||||
this.writeTarString(header, 100, 8, '0000644')
|
||||
this.writeTarString(header, 108, 8, '0000000')
|
||||
this.writeTarString(header, 116, 8, '0000000')
|
||||
this.writeTarOctal(header, 124, 12, size)
|
||||
this.writeTarOctal(header, 136, 12, mtime)
|
||||
header.fill(0x20, 148, 156)
|
||||
header[156] = '0'.charCodeAt(0)
|
||||
this.writeTarString(header, 257, 6, 'ustar')
|
||||
this.writeTarString(header, 263, 2, '00')
|
||||
|
||||
let checksum = 0
|
||||
for (const byte of header) checksum += byte
|
||||
const checksumValue = checksum.toString(8).padStart(6, '0')
|
||||
header.write(checksumValue.slice(-6), 148, 6, 'ascii')
|
||||
header[154] = 0
|
||||
header[155] = 0x20
|
||||
return header
|
||||
}
|
||||
|
||||
private writeTarString(header: Buffer, offset: number, length: number, value: string): void {
|
||||
header.write(value.slice(0, length), offset, length, 'utf-8')
|
||||
}
|
||||
|
||||
private writeTarOctal(header: Buffer, offset: number, length: number, value: number): void {
|
||||
const encoded = value.toString(8).padStart(length - 1, '0')
|
||||
header.write(encoded.slice(-length + 1), offset, length - 1, 'ascii')
|
||||
header[offset + length - 1] = 0
|
||||
}
|
||||
}
|
||||
|
||||
export const diagnosticsService = new DiagnosticsService()
|
||||
@ -17,6 +17,7 @@ import { computerUseApprovalService } from '../services/computerUseApprovalServi
|
||||
import { sessionService } from '../services/sessionService.js'
|
||||
import { SettingsService } from '../services/settingsService.js'
|
||||
import { ProviderService } from '../services/providerService.js'
|
||||
import { diagnosticsService } from '../services/diagnosticsService.js'
|
||||
import { deriveTitle, generateTitle, saveAiTitle } from '../services/titleService.js'
|
||||
import { parseSlashCommand } from '../../utils/slashCommandParsing.js'
|
||||
import {
|
||||
@ -134,6 +135,13 @@ export const handleWebSocket = {
|
||||
switch (message.type) {
|
||||
case 'user_message':
|
||||
handleUserMessage(ws, message).catch((err) => {
|
||||
void diagnosticsService.recordEvent({
|
||||
type: 'ws_user_message_failed',
|
||||
severity: 'error',
|
||||
sessionId: ws.data.sessionId,
|
||||
summary: err instanceof Error ? err.message : String(err),
|
||||
details: err,
|
||||
})
|
||||
console.error(`[WS] Unhandled error in handleUserMessage:`, err)
|
||||
})
|
||||
break
|
||||
@ -245,6 +253,13 @@ async function handleUserMessage(
|
||||
await pendingRuntimeTransition
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err)
|
||||
void diagnosticsService.recordEvent({
|
||||
type: 'runtime_transition_failed',
|
||||
severity: 'error',
|
||||
sessionId,
|
||||
summary: errMsg,
|
||||
details: err,
|
||||
})
|
||||
console.error(`[WS] Runtime transition failed before handling user message for ${sessionId}: ${errMsg}`)
|
||||
sendMessage(ws, {
|
||||
type: 'error',
|
||||
@ -514,6 +529,13 @@ async function restartSessionWithPermissionMode(
|
||||
console.log(`[WS] Restarted CLI for ${sessionId} with permission mode: ${mode}`)
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err)
|
||||
void diagnosticsService.recordEvent({
|
||||
type: 'permission_restart_failed',
|
||||
severity: 'error',
|
||||
sessionId,
|
||||
summary: errMsg,
|
||||
details: { mode, error: err },
|
||||
})
|
||||
console.error(`[WS] Failed to restart CLI for ${sessionId}: ${errMsg}`)
|
||||
sendMessage(ws, {
|
||||
type: 'error',
|
||||
@ -551,6 +573,13 @@ async function restartSessionWithRuntimeConfig(
|
||||
console.log(`[WS] Restarted CLI for ${sessionId} with runtime override`)
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err)
|
||||
void diagnosticsService.recordEvent({
|
||||
type: 'runtime_config_restart_failed',
|
||||
severity: 'error',
|
||||
sessionId,
|
||||
summary: errMsg,
|
||||
details: { runtimeOverride: runtimeOverrides.get(sessionId), error: err },
|
||||
})
|
||||
console.error(`[WS] Failed to restart CLI for ${sessionId} after runtime override: ${errMsg}`)
|
||||
sendMessage(ws, {
|
||||
type: 'error',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user