Skip to content

Commit 0505c18

Browse files
VibeWriter Userclaude
andcommitted
feat: add GUI session logging for crash/error diagnostics
Adds nightytidy-gui.log — a file-based audit trail for the GUI so users can send logs when reporting bugs. Server-side: guiLog() buffers in memory before folder selection, flushes to project dir on setGuiLogDir(). Instruments startup, API requests, process lifecycle, watchdog, and shutdown. uncaughtException and unhandledRejection handlers catch crashes. Two new endpoints: POST /api/log-error (frontend errors) and POST /api/log-path. Frontend: window.onerror + onunhandledrejection capture unhandled errors. logToServer() sends key error paths to the backend log. Summary screen shows the log file path for easy user access. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1f07532 commit 0505c18

5 files changed

Lines changed: 255 additions & 4 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ test/
8181
orchestrator.test.js # 36 tests — initRun, runStep, finishRun, dashboard integration with mocked modules, cost tracking, suspiciousFast passthrough
8282
contracts.test.js # 38 tests — module API contract verification against CLAUDE.md
8383
gui-logic.test.js # 112 tests — pure logic functions (buildCommand, parseCliOutput, formatMs, formatCost, formatTokens, formatTime, detectGitError, detectStaleState, preprocessClaudeOutput, etc.)
84-
gui-server.test.js # 36 tests — HTTP server, static files, config, run-command, kill-process, delete-file, heartbeat, security headers, traversal
84+
gui-server.test.js # 44 tests — HTTP server, static files, config, run-command, kill-process, delete-file, heartbeat, log-error, log-path, security headers, traversal
8585
lock.test.js # 9 tests — acquireLock, releaseLock, stale lock removal, persistent mode
8686
orchestrator-extended.test.js # 11 tests — finishRun error paths, timeout propagation, state version checks
8787
dashboard-broadcastoutput.test.js # 5 tests — buffer overflow, throttled writes, clearOutputBuffer with state
@@ -135,7 +135,7 @@ vitest.config.js # Coverage thresholds + strip-shebang Vite plugin (Wi
135135
| `src/setup.js` | `--setup` command: CLAUDE.md integration for target projects | logger, prompts/loader |
136136
| `src/sync.js` | Google Doc prompt sync — fetches published doc, parses HTML, updates prompt files + manifest + STEPS_HASH | crypto, logger |
137137
| `src/prompts/loader.js` | Loads 33 prompts + special prompts (doc-update, changelog, consolidation) from markdown files via manifest.json | fs (data loader) |
138-
| `gui/server.js` | Desktop GUI backend — HTTP server + native folder dialog + Chrome launcher | node:http, node:fs, node:child_process |
138+
| `gui/server.js` | Desktop GUI backend — HTTP server + native folder dialog + Chrome launcher + session logging | node:http, node:fs, node:child_process |
139139
| `gui/resources/logic.js` | GUI pure logic — command building, JSON parsing, formatting | none (browser + Node.js dual) |
140140
| `gui/resources/app.js` | GUI state machine — screen transitions, process spawning, progress polling | logic.js, marked, server.js (via fetch) |
141141

@@ -219,6 +219,7 @@ NightyTidy creates these files/artifacts in the project it runs against:
219219
| `NIGHTYTIDY-ACTIONS.md` | Consolidated prioritized action plan from all step recommendations | Yes (on run branch) |
220220
| `CLAUDE.md` (appended section) | "NightyTidy — Last Run" with undo tag | Yes (on run branch) |
221221
| `nightytidy.lock` | Prevents concurrent runs (PID + timestamp) | No (auto-removed on exit; persistent in orchestrator mode) |
222+
| `nightytidy-gui.log` | GUI session log (startup, API requests, errors, shutdown) | No |
222223
| `nightytidy-run-state.json` | Orchestrator run state (steps, results, branch info) | No (deleted by --finish-run) |
223224
| `nightytidy-before-*` git tag | Safety snapshot before run | Yes (tag) |
224225
| `nightytidy/run-*` git branch | All changes from this run | Yes (branch) |

gui/resources/app.js

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,27 @@
55

66
/* global NtLogic, marked */
77

8+
// ── Frontend Error Logging ──────────────────────────────────────────
9+
10+
function logToServer(level, message) {
11+
fetch('/api/log-error', {
12+
method: 'POST',
13+
headers: { 'Content-Type': 'application/json' },
14+
body: JSON.stringify({ level, message }),
15+
}).catch(() => {});
16+
}
17+
18+
window.onerror = (message, source, lineno, colno, error) => {
19+
const stack = error?.stack || '';
20+
logToServer('error', `${message} at ${source}:${lineno}:${colno}${stack ? '\n' + stack : ''}`);
21+
};
22+
23+
window.onunhandledrejection = (event) => {
24+
const reason = event.reason;
25+
const msg = reason instanceof Error ? reason.stack || reason.message : String(reason);
26+
logToServer('error', `Unhandled promise rejection: ${msg}`);
27+
};
28+
829
// ── API helpers ────────────────────────────────────────────────────
930

1031
async function api(endpoint, body = {}) {
@@ -163,12 +184,13 @@ async function runCli(args) {
163184
state.currentProcessId = null;
164185

165186
if (!result.ok) {
187+
logToServer('warn', `CLI command failed: ${args}`);
166188
return { ok: false, data: null, error: 'NightyTidy command did not complete. Check that the project folder is valid and try again.' };
167189
}
168190

169191
const parsed = NtLogic.parseCliOutput(result.stdout);
170192
if (!parsed.ok) {
171-
const detail = result.stderr ? `\n${result.stderr.trim()}` : '';
193+
logToServer('warn', `CLI output parse failed for: ${args}`);
172194
return { ok: false, data: null, error: 'Could not read NightyTidy output. The command may have failed — check nightytidy-run.log for details.' };
173195
}
174196

@@ -301,6 +323,7 @@ async function selectFolder() {
301323
showFolderPath(result.folder);
302324
await loadSteps();
303325
} catch (err) {
326+
logToServer('error', `Folder selection failed: ${err.message}`);
304327
showError('setup', 'Folder selection did not complete. Please try again or type the path manually.');
305328
}
306329
}
@@ -401,6 +424,7 @@ async function startRun() {
401424
hideInitOverlay();
402425

403426
if (!result.ok) {
427+
logToServer('error', `Init run failed: ${result.error}`);
404428
showError('steps', result.error);
405429
return;
406430
}
@@ -574,6 +598,7 @@ async function runNextStep() {
574598
const liveSnapshot = lastRenderedOutput || '';
575599

576600
if (!result.ok) {
601+
logToServer('warn', `Step ${next} failed: ${result.error}`);
577602
state.failedSteps.push(next);
578603
state.stepResults.push({ step: next, status: 'failed', error: result.error, output: liveSnapshot, costUSD: null, inputTokens: null, outputTokens: null });
579604
updateStepItemStatus(next, 'failed');
@@ -850,6 +875,7 @@ async function finishRun() {
850875
const result = await runCli('--finish-run');
851876

852877
if (!result.ok) {
878+
logToServer('error', `Finish run failed: ${result.error}`);
853879
showError('finishing', result.error);
854880
renderSummary(null);
855881
showScreen(SCREENS.SUMMARY);
@@ -972,6 +998,14 @@ function renderSummary(finishData) {
972998
}
973999
detailsEl.innerHTML = details;
9741000

1001+
// Show log file path so users can find it for bug reports
1002+
api('log-path').then(result => {
1003+
if (result.ok && result.path) {
1004+
const logEl = document.getElementById('summary-log-path');
1005+
if (logEl) logEl.textContent = `GUI log: ${result.path}`;
1006+
}
1007+
}).catch(() => {});
1008+
9751009
const listEl = document.getElementById('summary-step-list');
9761010
listEl.innerHTML = state.stepResults.map(r => {
9771011
const status = r.status || 'pending';

gui/resources/index.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,8 @@ <h2 id="summary-title">Run Complete</h2>
144144
<div class="summary-details" id="summary-details"></div>
145145
</div>
146146

147+
<p id="summary-log-path" style="font-size:0.82rem; color:var(--text-dim); margin:8px 0 0;"></p>
148+
147149
<div class="step-list" id="summary-step-list"></div>
148150

149151
<div class="output-panel" id="summary-output-panel" style="display:none">

gui/server.js

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import { createServer } from 'node:http';
88
import { readFile } from 'node:fs/promises';
9-
import { statSync, writeFileSync, unlinkSync } from 'node:fs';
9+
import { appendFileSync, statSync, writeFileSync, unlinkSync } from 'node:fs';
1010
import { spawn, execSync } from 'node:child_process';
1111
import { join, extname, resolve, sep } from 'node:path';
1212
import { tmpdir } from 'node:os';
@@ -113,6 +113,51 @@ let lastHeartbeat = Date.now();
113113
const HEARTBEAT_CHECK_MS = 5000;
114114
const HEARTBEAT_STALE_MS = 15000;
115115

116+
// ── GUI Logger ──────────────────────────────────────────────────────
117+
// Writes to nightytidy-gui.log in the project directory once selected.
118+
// Before selection, entries buffer in memory and flush on setGuiLogDir().
119+
const GUI_LOG_FILE = 'nightytidy-gui.log';
120+
let guiLogFilePath = null;
121+
const guiLogBuffer = [];
122+
const MAX_BUFFER = 500;
123+
124+
function guiLog(level, message) {
125+
const timestamp = new Date().toISOString();
126+
const tag = level.toUpperCase().padEnd(5);
127+
const line = `[${timestamp}] [${tag}] ${message}\n`;
128+
129+
if (guiLogFilePath) {
130+
try {
131+
appendFileSync(guiLogFilePath, line, 'utf8');
132+
} catch {
133+
if (guiLogBuffer.length < MAX_BUFFER) guiLogBuffer.push(line);
134+
}
135+
} else {
136+
if (guiLogBuffer.length < MAX_BUFFER) guiLogBuffer.push(line);
137+
}
138+
}
139+
140+
function setGuiLogDir(projectDir) {
141+
guiLogFilePath = join(projectDir, GUI_LOG_FILE);
142+
try {
143+
writeFileSync(guiLogFilePath, '', 'utf8');
144+
} catch (err) {
145+
console.error(`Failed to create GUI log file: ${err.message}`);
146+
return;
147+
}
148+
flushGuiLogBuffer();
149+
}
150+
151+
function flushGuiLogBuffer() {
152+
if (!guiLogFilePath || guiLogBuffer.length === 0) return;
153+
try {
154+
appendFileSync(guiLogFilePath, guiLogBuffer.join(''), 'utf8');
155+
guiLogBuffer.length = 0;
156+
} catch {
157+
// Silently fail — don't crash the server over logging
158+
}
159+
}
160+
116161
function killProcess(proc) {
117162
if (process.platform === 'win32') {
118163
execSync(`taskkill /pid ${proc.pid} /T /F`, { windowsHide: true });
@@ -138,6 +183,7 @@ async function serveStatic(res, urlPath) {
138183
// confusion (e.g. "resources-extra" matching "resources")
139184
const boundary = RESOURCES_DIR.endsWith(sep) ? RESOURCES_DIR : RESOURCES_DIR + sep;
140185
if (!filePath.startsWith(boundary) && filePath !== RESOURCES_DIR) {
186+
guiLog('warn', `Blocked path traversal attempt: ${urlPath}`);
141187
res.writeHead(403, { 'Content-Type': 'text/plain', ...SECURITY_HEADERS });
142188
res.end('Forbidden');
143189
return;
@@ -211,10 +257,17 @@ async function handleSelectFolder(res) {
211257
}
212258
}
213259

260+
if (folder) {
261+
guiLog('info', `Folder selected: ${folder}`);
262+
setGuiLogDir(folder);
263+
} else {
264+
guiLog('info', 'Folder dialog closed without selection');
265+
}
214266
sendJson(res, { ok: true, folder });
215267
} catch (err) {
216268
// User cancelled or dialog failed — still refresh heartbeat after blocking execSync
217269
lastHeartbeat = Date.now();
270+
guiLog('info', 'Folder dialog cancelled or failed');
218271
sendJson(res, { ok: true, folder: null });
219272
}
220273
}
@@ -231,6 +284,7 @@ async function handleRunCommand(req, res) {
231284
}
232285

233286
try {
287+
guiLog('info', `Spawning process id=${id || 'none'}`);
234288
const proc = spawn(command, [], {
235289
stdio: ['pipe', 'pipe', 'pipe'],
236290
windowsHide: true,
@@ -248,14 +302,17 @@ async function handleRunCommand(req, res) {
248302

249303
proc.on('close', (exitCode) => {
250304
if (id) activeProcesses.delete(id);
305+
guiLog('info', `Process ${id || 'unknown'} exited code=${exitCode ?? 1}`);
251306
sendJson(res, { ok: true, exitCode: exitCode ?? 1, stdout, stderr });
252307
});
253308

254309
proc.on('error', (err) => {
255310
if (id) activeProcesses.delete(id);
311+
guiLog('error', `Process ${id || 'unknown'} error: ${err.message}`);
256312
sendJson(res, { ok: false, error: err.message });
257313
});
258314
} catch (err) {
315+
guiLog('error', `Spawn failed: ${err.message}`);
259316
sendJson(res, { ok: false, error: err.message });
260317
}
261318
}
@@ -276,8 +333,10 @@ async function handleKillProcess(req, res) {
276333
try {
277334
killProcess(proc);
278335
activeProcesses.delete(id);
336+
guiLog('info', `Killed process ${id}`);
279337
sendJson(res, { ok: true });
280338
} catch (err) {
339+
guiLog('error', `Failed to kill process ${id}: ${err.message}`);
281340
sendJson(res, { ok: false, error: err.message });
282341
}
283342
} else {
@@ -338,9 +397,32 @@ function handleHeartbeat(res) {
338397
sendJson(res, { ok: true });
339398
}
340399

400+
// ── API: Log Error (from frontend) ─────────────────────────────────
401+
402+
async function handleLogError(req, res) {
403+
const body = await readBody(req);
404+
const { level, message } = body;
405+
406+
if (!message) {
407+
sendJson(res, { ok: false, error: 'No message provided' }, 400);
408+
return;
409+
}
410+
411+
const safeLevel = ['error', 'warn', 'info'].includes(level) ? level : 'error';
412+
guiLog(safeLevel, `[frontend] ${message}`);
413+
sendJson(res, { ok: true });
414+
}
415+
416+
// ── API: Log Path ──────────────────────────────────────────────────
417+
418+
function handleLogPath(res) {
419+
sendJson(res, { ok: true, path: guiLogFilePath });
420+
}
421+
341422
// ── API: Shutdown ──────────────────────────────────────────────────
342423

343424
function handleExit(res) {
425+
guiLog('info', 'Exit requested by frontend');
344426
sendJson(res, { ok: true });
345427
killAllProcesses();
346428
setTimeout(() => process.exit(0), 200);
@@ -384,6 +466,11 @@ function sendJson(res, data, status = 200) {
384466
function handleRequest(req, res) {
385467
const url = new URL(req.url, `http://localhost`);
386468

469+
// Log API requests (skip heartbeat — too noisy)
470+
if (url.pathname.startsWith('/api/') && url.pathname !== '/api/heartbeat') {
471+
guiLog('debug', `${req.method} ${url.pathname}`);
472+
}
473+
387474
// API routes
388475
if (url.pathname === '/api/config' && req.method === 'POST') {
389476
return handleConfig(res);
@@ -406,6 +493,12 @@ function handleRequest(req, res) {
406493
if (url.pathname === '/api/heartbeat' && req.method === 'POST') {
407494
return handleHeartbeat(res);
408495
}
496+
if (url.pathname === '/api/log-error' && req.method === 'POST') {
497+
return handleLogError(req, res);
498+
}
499+
if (url.pathname === '/api/log-path' && req.method === 'POST') {
500+
return handleLogPath(res);
501+
}
409502
if (url.pathname === '/api/exit' && req.method === 'POST') {
410503
return handleExit(res);
411504
}
@@ -494,13 +587,15 @@ server.headersTimeout = 15_000; // 15s — max time to receive headers
494587
server.listen(0, '127.0.0.1', () => {
495588
const { port } = server.address();
496589
const url = `http://127.0.0.1:${port}`;
590+
guiLog('info', `GUI server started on ${url}`);
497591
console.log(`NightyTidy GUI server running on ${url}`);
498592
launchChrome(url);
499593

500594
// Watchdog: self-terminate if no heartbeat from the browser for 15s.
501595
// Catches cases where Chrome crashes or is force-killed (unload never fires).
502596
const watchdog = setInterval(() => {
503597
if (Date.now() - lastHeartbeat > HEARTBEAT_STALE_MS) {
598+
guiLog('warn', 'No heartbeat from browser — shutting down');
504599
console.log('No heartbeat from browser — shutting down.');
505600
clearInterval(watchdog);
506601
cleanup();
@@ -517,12 +612,23 @@ server.listen(0, '127.0.0.1', () => {
517612
const SHUTDOWN_FORCE_EXIT_MS = 5000;
518613

519614
function shutdownHandler() {
615+
guiLog('info', 'Shutdown signal received');
520616
cleanup();
521617
const forceTimer = setTimeout(() => process.exit(1), SHUTDOWN_FORCE_EXIT_MS);
522618
forceTimer.unref();
523619
process.exit(0);
524620
}
525621

622+
process.on('uncaughtException', (err) => {
623+
guiLog('error', `Uncaught exception: ${err.stack || err.message}`);
624+
process.exit(1);
625+
});
626+
627+
process.on('unhandledRejection', (reason) => {
628+
const msg = reason instanceof Error ? reason.stack : String(reason);
629+
guiLog('error', `Unhandled rejection: ${msg}`);
630+
});
631+
526632
process.on('SIGINT', shutdownHandler);
527633
process.on('SIGTERM', shutdownHandler);
528634
process.on('SIGHUP', shutdownHandler);

0 commit comments

Comments
 (0)