Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,58 @@ pnpm --filter @clawwork/desktop run build:dmg
Output at `packages/desktop/dist/ClawWork-<version>-arm64.dmg`.

Unsigned — on first launch, right-click → Open.

## Debug Observability

Structured debug logging across main process, gateway WS, and renderer. All events flow into a ring buffer (1000 entries) and daily ndjson files under `.clawwork-debug/`.

- **Domains**: `app`, `gateway`, `ipc`, `renderer`, `db`, `workspace`, `artifact`, `debug`
- **Correlation**: every event supports `traceId` and `feature` fields to link a user action across IPC → gateway → renderer
- **Renderer bridge**: renderer debug events are sent to main via `debug:renderer-event` IPC channel, so the export bundle contains both sides
- **Export bundle**: `debug:export-bundle` IPC produces a timestamped directory with `recent-events.ndjson`, `timeline.json`, `gateway-status.json`, `config.sanitized.json`, `environment.json`

Trigger export from renderer:

```ts
const result = await window.clawwork.exportDebugBundle({ taskId, limit: 500 });
// result.path → absolute path to the bundle directory
```

### Troubleshooting with debug events

Debug log is at `.clawwork-debug/debug-YYYY-MM-DD.ndjson`, one JSON object per line.

**Event naming convention**: `<domain>.<noun>.<verb>` — e.g. `gateway.req.sent`, `gateway.res.received`, `ipc.ws.send-message.completed`, `renderer.chat.delta.applied`.

**Healthy message send flow** (events in order):

```
ipc.ws.send-message.requested → user hit send
gateway.req.sent → WS frame dispatched (has requestId)
gateway.res.received → server acknowledged (same requestId, ok:true)
gateway.event.received → streaming events arrive (event:"chat")
renderer.chat.delta.applied → UI appended text
renderer.chat.finalized → stream complete
```

**Healthy connection flow**:

```
gateway.connect.start → WS connecting
gateway.ws.open → TCP established
gateway.challenge.received → auth challenge from server
gateway.connect.res.ok → authenticated, ready
gateway.heartbeat.start → keepalive active
```

**How to trace a single user action**: filter by `traceId` (when set) or by `requestId` + `sessionKey` to follow one request across layers.

**Common failure patterns**:

| Symptom | What to look for |
|---------|-----------------|
| Message sent, no response | `gateway.req.sent` present but no matching `gateway.res.received` → check `gateway.req.timeout` |
| Message sent, response OK but nothing in UI | `gateway.res.received` ok:true but no `renderer.chat.delta.applied` → event routing issue, check `renderer.event.dropped.*` |
| Connection drops | `gateway.ws.close` with code/reason, then `gateway.reconnect.scheduled` or `gateway.reconnect.giveup` |
| Auth failure | `gateway.challenge.received` followed by `gateway.challenge.invalid` instead of `gateway.connect.res.ok` |
| IPC call fails silently | `ipc.ws.send-message.requested` present but no `.completed` → check `ipc.ws.send-message.failed` for error |
36 changes: 36 additions & 0 deletions packages/desktop/src/main/debug/export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { sanitizeForLog } from '@clawwork/shared';
import type { DebugEvent } from '@clawwork/shared';
import type { DebugLogger } from './logger.js';

export interface ExportDebugBundleOptions {
outputDir: string;
logger: DebugLogger;
meta?: {
gatewayStatus?: Record<string, unknown>;
config?: Record<string, unknown>;
environment?: Record<string, unknown>;
};
filter?: {
gatewayId?: string;
sessionKey?: string;
taskId?: string;
limit?: number;
};
}

export function exportDebugBundle(options: ExportDebugBundleOptions): { bundlePath: string; events: DebugEvent[] } {
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const bundlePath = join(options.outputDir, `bundle-${stamp}`);
mkdirSync(bundlePath, { recursive: true });

const events = options.logger.getRecentEvents(options.filter);
writeFileSync(join(bundlePath, 'recent-events.ndjson'), events.map((event) => JSON.stringify(event)).join('\n') + (events.length ? '\n' : ''), 'utf8');
writeFileSync(join(bundlePath, 'timeline.json'), JSON.stringify({ events }, null, 2), 'utf8');
writeFileSync(join(bundlePath, 'gateway-status.json'), JSON.stringify(sanitizeForLog(options.meta?.gatewayStatus ?? {}), null, 2), 'utf8');
writeFileSync(join(bundlePath, 'config.sanitized.json'), JSON.stringify(sanitizeForLog(options.meta?.config ?? {}), null, 2), 'utf8');
writeFileSync(join(bundlePath, 'environment.json'), JSON.stringify(sanitizeForLog(options.meta?.environment ?? {}), null, 2), 'utf8');

return { bundlePath, events };
}
33 changes: 33 additions & 0 deletions packages/desktop/src/main/debug/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { BrowserWindow } from 'electron';
import { join } from 'node:path';
import type { DebugEvent } from '@clawwork/shared';
import type { DebugLogger } from './logger.js';
import { createDebugLogger } from './logger.js';

let debugLogger: DebugLogger = createDebugLogger({
debugDir: join(process.cwd(), '.clawwork-debug'),
console: true,
});

export function initDebugLogger(debugDir: string): DebugLogger {
debugLogger = createDebugLogger({
debugDir,
console: true,
onEvent: broadcastDebugEvent,
});
return debugLogger;
}

export function getDebugLogger(): DebugLogger {
return debugLogger;
}

function broadcastDebugEvent(event: DebugEvent): void {
for (const win of BrowserWindow.getAllWindows()) {
try {
win.webContents.send('debug-event', event);
} catch {
// ignore broadcast failures for windows closing during dispatch
}
}
}
119 changes: 119 additions & 0 deletions packages/desktop/src/main/debug/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import type { DebugDomain, DebugEvent, DebugLevel } from '@clawwork/shared';
import { sanitizeForLog } from '@clawwork/shared';

export interface CreateDebugLoggerOptions {
debugDir: string;
maxEvents?: number;
console?: boolean;
onEvent?: (event: DebugEvent) => void;
}

export interface DebugLogFilter {
level?: DebugLevel[];
domain?: DebugDomain[];
gatewayId?: string;
sessionKey?: string;
taskId?: string;
traceId?: string;
feature?: string;
limit?: number;
}

export interface DebugLogger {
debug: (input: LogEventInput) => DebugEvent;
info: (input: LogEventInput) => DebugEvent;
warn: (input: LogEventInput) => DebugEvent;
error: (input: LogEventInput) => DebugEvent;
log: (input: LogEventInput & { level: DebugLevel }) => DebugEvent;
getRecentEvents: (filter?: DebugLogFilter) => DebugEvent[];
currentFilePath: () => string;
}

export interface LogEventInput {
domain: DebugDomain;
event: string;
traceId?: string;
feature?: string;
message?: string;
gatewayId?: string;
sessionKey?: string;
taskId?: string;
runId?: string;
requestId?: string;
wsFrameId?: string;
seq?: number;
attempt?: number;
durationMs?: number;
ok?: boolean;
error?: DebugEvent['error'];
data?: Record<string, unknown>;
}

export function createDebugLogger(options: CreateDebugLoggerOptions): DebugLogger {
const maxEvents = options.maxEvents ?? 1000;
const writeConsole = options.console ?? true;
const recentEvents: DebugEvent[] = [];

ensureDir(options.debugDir);

function log(input: LogEventInput & { level: DebugLevel }): DebugEvent {
const event: DebugEvent = sanitizeForLog({
ts: new Date().toISOString(),
...input,
});

recentEvents.push(event);
if (recentEvents.length > maxEvents) {
recentEvents.splice(0, recentEvents.length - maxEvents);
}

appendFileSync(currentFilePath(), `${JSON.stringify(event)}\n`, 'utf8');

if (writeConsole) {
const line = `[${event.level}] [${event.domain}] ${event.event}`;
if (event.level === 'error') console.error(line, event);
else if (event.level === 'warn') console.warn(line, event);
else console.log(line, event);
}

options.onEvent?.(event);
return event;
}

return {
debug: (input) => log({ ...input, level: 'debug' }),
info: (input) => log({ ...input, level: 'info' }),
warn: (input) => log({ ...input, level: 'warn' }),
error: (input) => log({ ...input, level: 'error' }),
log,
getRecentEvents: (filter) => filterEvents(recentEvents, filter),
currentFilePath,
};

function currentFilePath(): string {
const day = new Date().toISOString().slice(0, 10);
return join(options.debugDir, `debug-${day}.ndjson`);
}
}

function filterEvents(events: DebugEvent[], filter?: DebugLogFilter): DebugEvent[] {
let result = [...events];
if (!filter) return result;
if (filter.level?.length) result = result.filter((event) => filter.level!.includes(event.level));
if (filter.domain?.length) result = result.filter((event) => filter.domain!.includes(event.domain));
if (filter.gatewayId) result = result.filter((event) => event.gatewayId === filter.gatewayId);
if (filter.sessionKey) result = result.filter((event) => event.sessionKey === filter.sessionKey);
if (filter.taskId) result = result.filter((event) => event.taskId === filter.taskId);
if (filter.traceId) result = result.filter((event) => event.traceId === filter.traceId);
if (filter.feature) result = result.filter((event) => event.feature === filter.feature);
if (filter.limit && filter.limit > 0) result = result.slice(-filter.limit);
return result;
}

function ensureDir(dir: string): void {
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
}
28 changes: 26 additions & 2 deletions packages/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import { join } from 'path';
import { writeFileSync } from 'fs';
import { electronApp, optimizer, is } from '@electron-toolkit/utils';
import { initAllGateways, destroyAllGateways, rebindAllWindows } from './ws/index.js';
import { initDebugLogger, getDebugLogger } from './debug/index.js';
import { registerWsHandlers } from './ipc/ws-handlers.js';
import { registerArtifactHandlers } from './ipc/artifact-handlers.js';
import { registerWorkspaceHandlers } from './ipc/workspace-handlers.js';
import { registerSettingsHandlers } from './ipc/settings-handlers.js';
import { registerSearchHandlers } from './ipc/search-handlers.js';
import { registerDataHandlers } from './ipc/data-handlers.js';
import { registerUpdateHandlers } from './ipc/update-handlers.js';
import { registerDebugHandlers } from './ipc/debug-handlers.js';
import { getWorkspacePath, readConfig } from './workspace/config.js';
import { initDatabase, closeDatabase } from './db/index.js';

Expand All @@ -22,7 +24,11 @@ const SCREENSHOT_PATH = '/tmp/clawwork-screenshot.png';
async function captureScreenshot(win: BrowserWindow): Promise<string> {
const image = await win.webContents.capturePage();
writeFileSync(SCREENSHOT_PATH, image.toPNG());
console.log(`[screenshot] saved to ${SCREENSHOT_PATH}`);
getDebugLogger().info({
domain: 'app',
event: 'app.screenshot.saved',
data: { path: SCREENSHOT_PATH },
});
return SCREENSHOT_PATH;
}

Expand All @@ -44,6 +50,7 @@ function setupDevScreenshot(win: BrowserWindow): void {
}

function createWindow(): BrowserWindow {
getDebugLogger().info({ domain: 'app', event: 'app.window.create' });
const mainWindow = new BrowserWindow({
width: 1280,
height: 800,
Expand Down Expand Up @@ -78,6 +85,8 @@ function createWindow(): BrowserWindow {
}

app.whenReady().then(() => {
initDebugLogger(join(app.getPath('userData'), 'debug'));
getDebugLogger().info({ domain: 'app', event: 'app.start', data: { userData: app.getPath('userData') } });
electronApp.setAppUserModelId('com.clawwork.app');

app.on('browser-window-created', (_, window) => {
Expand All @@ -91,10 +100,24 @@ app.whenReady().then(() => {
registerSearchHandlers();
registerDataHandlers();
registerUpdateHandlers();
registerDebugHandlers();

const wsPath = getWorkspacePath();
if (wsPath) {
try { initDatabase(wsPath); } catch (e) { console.error('[startup] DB init failed:', e); }
getDebugLogger().info({ domain: 'workspace', event: 'workspace.detected', data: { workspacePath: wsPath } });
try {
getDebugLogger().info({ domain: 'db', event: 'db.init.start', data: { workspacePath: wsPath } });
initDatabase(wsPath);
getDebugLogger().info({ domain: 'db', event: 'db.init.ok', data: { workspacePath: wsPath } });
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
getDebugLogger().error({
domain: 'db',
event: 'db.init.failed',
data: { workspacePath: wsPath },
error: { name: err.name, message: err.message, stack: err.stack },
});
}
}

const mainWindow = createWindow();
Expand All @@ -116,6 +139,7 @@ app.on('window-all-closed', () => {
});

app.on('before-quit', () => {
getDebugLogger().info({ domain: 'app', event: 'app.before-quit' });
globalShortcut.unregisterAll();
destroyAllGateways();
closeDatabase();
Expand Down
56 changes: 56 additions & 0 deletions packages/desktop/src/main/ipc/debug-handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { app, ipcMain } from 'electron';
import { join } from 'node:path';
import { exportDebugBundle } from '../debug/export.js';
import { getDebugLogger } from '../debug/index.js';
import { getAllGatewayClients } from '../ws/index.js';
import { readConfig } from '../workspace/config.js';

export function registerDebugHandlers(): void {
ipcMain.on('debug:renderer-event', (_event, payload: {
domain: string;
event: string;
traceId?: string;
feature?: string;
data?: Record<string, unknown>;
}) => {
const logger = getDebugLogger();
logger.info({
domain: (payload.domain || 'renderer') as 'renderer',
event: payload.event,
traceId: payload.traceId,
feature: payload.feature,
data: payload.data,
});
});

ipcMain.handle('debug:export-bundle', async (_event, payload?: {
gatewayId?: string;
sessionKey?: string;
taskId?: string;
limit?: number;
}) => {
const clients = getAllGatewayClients();
const gatewayStatus: Record<string, { connected: boolean; name: string }> = {};
for (const [id, client] of clients) {
gatewayStatus[id] = { connected: client.isConnected, name: client.name };
}

const result = exportDebugBundle({
outputDir: join(app.getPath('userData'), 'debug-bundles'),
logger: getDebugLogger(),
meta: {
gatewayStatus,
config: readConfig() as unknown as Record<string, unknown> | undefined,
environment: {
platform: process.platform,
arch: process.arch,
node: process.version,
electron: process.versions.electron,
},
},
filter: payload,
});

return { ok: true, path: result.bundlePath, eventCount: result.events.length };
});
}
Loading
Loading