From 4d73745ad9ecb071d1245a96ba632c0ed3811790 Mon Sep 17 00:00:00 2001 From: "daniel.ochoa" Date: Tue, 24 Mar 2026 14:32:02 -0500 Subject: [PATCH 1/2] FEAT-133: Add gateway diagnostics logging with UI - Add GatewayLogger class with info/warn/error/debug levels and in-memory ring buffer (500 entries) for UI display - Add structured logging to Socket.IO lifecycle (connect, disconnect, auth errors, hello ack, hello timeout) - Add logging to cloud command executor (enqueue, execute, gateway HTTP errors, timeout, failure) - Add logging to symphony loop harness (request source relay/local, process spawn, exit code, event POST errors, artifact upload errors) - Split logging: lifecycle events and errors always visible, granular per-step details gated behind verbose toggle - Suppress consecutive identical log messages (dedup presence spam) - Add Diagnostics tab with auto-refresh, verbose toggle, and color-coded error/warn row highlighting - Rename Activity Log tab to Requests for clarity - Add verboseLogging to DesktopSettings with persistence Testing: - just desktop-typecheck passes - just desktop-test passes (288/288) - just desktop-lint passes Risks: - Logging adds minor overhead to hot paths (suppressed by dedup) - In-memory buffer capped at 500 entries, no disk persistence --- apps/desktop/package.json | 2 +- apps/desktop/src/main/app.ts | 9 + .../src/main/cloud-command-executor.ts | 12 +- apps/desktop/src/main/cloud-socket.ts | 11 + apps/desktop/src/main/gateway-logger.ts | 93 +++++++ apps/desktop/src/main/preload.ts | 4 +- apps/desktop/src/main/settings-store.ts | 3 + apps/desktop/src/renderer/index.html | 228 +++++++++++++++++- .../src/server/operations/symphony-loop.ts | 23 +- apps/desktop/src/shared/contracts.ts | 4 +- 10 files changed, 382 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/main/gateway-logger.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0e1fa85c..45636ab5 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "desktop", - "version": "0.7.1", + "version": "0.7.2", "description": "ClosedLoop Desktop", "author": "ClosedLoop AI ", "private": true, diff --git a/apps/desktop/src/main/app.ts b/apps/desktop/src/main/app.ts index a006326c..af93911b 100644 --- a/apps/desktop/src/main/app.ts +++ b/apps/desktop/src/main/app.ts @@ -30,6 +30,7 @@ import { import { seedReposConfig } from "./seed-repos-config.js"; import { SUPPORTED_OPERATION_IDS, resolveOperationId } from "./approval-operations.js"; import { shouldAutoApprove, OPERATION_RISK_TIERS } from "./approval-policy.js"; +import { gatewayLog } from "./gateway-logger.js"; import { ActivityLogStore } from "./activity-log-store.js"; import { ApprovalStore } from "./approval-store.js"; import { JobStore, isTerminalJobStatus } from "./job-store.js"; @@ -203,6 +204,7 @@ export class DesktopApplication { this.syncPendingApprovalsToTray(); this.desktopWindow.init(); + gatewayLog.setVerbose(this.settingsStore.getAll().verboseLogging); this.migrateLegacyData(); this.reconcileJobStore(); @@ -728,6 +730,9 @@ export class DesktopApplication { } private registerIpcHandlers(): void { + ipcMain.handle("desktop:get-logs", () => gatewayLog.getEntries()); + ipcMain.handle("desktop:clear-logs", () => { gatewayLog.clear(); }); + ipcMain.handle("desktop:get-settings", () => { const settings = this.settingsStore.getAll(); const activeAlwaysAllowRules = pruneExpiredAlwaysAllowRules(settings.alwaysAllowRules); @@ -749,6 +754,7 @@ export class DesktopApplication { webAppOrigin?: string; defaultApprovalTier?: "auto" | "none" | "low" | "medium" | "high"; autoApprovalRules?: Record; + verboseLogging?: boolean; }) => { const currentSettings = this.settingsStore.getAll(); const nextPartial = { ...partial }; @@ -789,6 +795,9 @@ export class DesktopApplication { } const updated = this.settingsStore.update(nextPartial as Partial); + if (typeof nextPartial.verboseLogging === "boolean") { + gatewayLog.setVerbose(nextPartial.verboseLogging); + } if ( typeof partial.sandboxBaseDirectory === "string" && diff --git a/apps/desktop/src/main/cloud-command-executor.ts b/apps/desktop/src/main/cloud-command-executor.ts index f18089bb..bc555854 100644 --- a/apps/desktop/src/main/cloud-command-executor.ts +++ b/apps/desktop/src/main/cloud-command-executor.ts @@ -1,4 +1,5 @@ import { URL } from "node:url"; +import { gatewayLog } from "./gateway-logger.js"; import type { CommandEventRecord, DesktopCancelEvent, @@ -57,6 +58,7 @@ export class CloudCommandExecutor { const validationError = validateCommand(command); if (validationError) { + gatewayLog.warn("command-executor", `Rejected command ${command.commandId}: ${validationError}`); this.options.sendCommandAck({ commandId: command.commandId, accepted: false, @@ -66,6 +68,7 @@ export class CloudCommandExecutor { return; } + gatewayLog.debug("command-executor", `Enqueued command ${command.commandId}: ${command.method} ${command.path}`); const tracked: TrackedCommand = { command, state: "queued", @@ -181,6 +184,7 @@ export class CloudCommandExecutor { return; } tracked.state = "running"; + gatewayLog.debug("command-executor", `Executing command ${command.commandId}: ${command.method} ${command.path}`); const lockKey = deriveLockKey(command); if (lockKey) { @@ -221,6 +225,7 @@ export class CloudCommandExecutor { cancelled: true, reason: running.cancelReason ?? "cancelled" }); + gatewayLog.debug("command-executor", `Command ${command.commandId} cancelled`); this.markTerminal(command.commandId, "cancelled"); } else if (running.timedOut) { this.emitTrackedEvent(command.commandId, "error", { @@ -229,13 +234,16 @@ export class CloudCommandExecutor { code: "timeout", error: "command timed out" }); + gatewayLog.error("command-executor", `Command ${command.commandId} timed out`); this.markTerminal(command.commandId, "failed"); } else { + const msg = error instanceof Error ? error.message : "unknown command failure"; this.emitTrackedEvent(command.commandId, "error", { type: "error", terminal: true, - error: error instanceof Error ? error.message : "unknown command failure" + error: msg }); + gatewayLog.error("command-executor", `Command ${command.commandId} failed: ${msg}`); this.markTerminal(command.commandId, "failed"); } } finally { @@ -268,6 +276,7 @@ export class CloudCommandExecutor { const method = command.method.toUpperCase(); const body = serializeBody(command.body, headers, method); + gatewayLog.debug("command-executor", `Gateway fetch: ${method} ${requestUrl.pathname}`); const response = await fetch(requestUrl, { method, headers, @@ -280,6 +289,7 @@ export class CloudCommandExecutor { if (!response.ok && !isStream) { const message = await safeReadBodyAsText(response); + gatewayLog.error("command-executor", `Gateway returned ${response.status} for ${method} ${requestUrl.pathname}: ${message}`); throw new Error(`gateway returned ${response.status}${message ? `: ${message}` : ""}`); } diff --git a/apps/desktop/src/main/cloud-socket.ts b/apps/desktop/src/main/cloud-socket.ts index 95292b67..a620d3dd 100644 --- a/apps/desktop/src/main/cloud-socket.ts +++ b/apps/desktop/src/main/cloud-socket.ts @@ -1,4 +1,5 @@ import { createHash, randomUUID } from "node:crypto"; +import { gatewayLog } from "./gateway-logger.js"; import { io, type Socket } from "socket.io-client"; import { PROTOCOL_VERSION, @@ -94,6 +95,7 @@ export class CloudSocketService { state: DesktopPresenceEvent["state"]; } ): void { + gatewayLog.debug("cloud-socket", `Sending presence: state=${event.state}`); this.emit("desktop.presence", event); } @@ -129,6 +131,7 @@ export class CloudSocketService { if (this.stopped) { return; } + gatewayLog.info("cloud-socket", "Connected to relay, sending hello handshake"); this.awaitingHelloAck = true; this.emitHello(); this.scheduleHelloAckTimeout(); @@ -142,11 +145,13 @@ export class CloudSocketService { this.clearHelloAckTimer(); const message = error instanceof Error ? error.message : "connection failed"; if (looksLikeAuthError(error)) { + gatewayLog.error("cloud-socket", "Authentication failed on connect"); this.notifyStatus({ state: "degraded", error: "Authentication failed — verify your API key in Settings" }); } else { + gatewayLog.error("cloud-socket", `Connection error: ${message}`); this.notifyStatus({ state: "degraded", error: `Cloud socket connection failed: ${message}` }); } }); @@ -155,6 +160,7 @@ export class CloudSocketService { if (this.stopped) { return; } + gatewayLog.warn("cloud-socket", `Disconnected: ${reason}`); this.awaitingHelloAck = false; this.clearHelloAckTimer(); this.notifyStatus({ state: "degraded", error: `Cloud socket disconnected: ${reason}` }); @@ -164,12 +170,14 @@ export class CloudSocketService { const event = asObject(payload); const computeTargetId = asNonEmptyString(event.computeTargetId); if (!computeTargetId) { + gatewayLog.warn("cloud-socket", "hello.ack missing computeTargetId, ignoring"); return; } this.targetId = computeTargetId; this.awaitingHelloAck = false; this.clearHelloAckTimer(); + gatewayLog.info("cloud-socket", `Hello ack received, targetId=${computeTargetId}`); const ackEvent: DesktopHelloAckEvent = { ...createEnvelope(), computeTargetId, @@ -190,8 +198,10 @@ export class CloudSocketService { socket.on("desktop.command", (payload: unknown) => { const parsed = parseDesktopCommand(payload); if (!parsed) { + gatewayLog.warn("cloud-socket", "Received unparseable desktop.command, ignoring"); return; } + gatewayLog.debug("cloud-socket", `Command received: ${parsed.operationId} ${parsed.method} ${parsed.path} (commandId=${parsed.commandId})`); this.options.onCommand?.(parsed); }); @@ -267,6 +277,7 @@ export class CloudSocketService { if (this.stopped || !this.awaitingHelloAck) { return; } + gatewayLog.warn("cloud-socket", "Hello ack timeout -- retrying handshake"); this.notifyStatus({ state: "degraded", error: "Connected to cloud socket but did not receive desktop.hello.ack" diff --git a/apps/desktop/src/main/gateway-logger.ts b/apps/desktop/src/main/gateway-logger.ts new file mode 100644 index 00000000..036702a7 --- /dev/null +++ b/apps/desktop/src/main/gateway-logger.ts @@ -0,0 +1,93 @@ +/** + * Structured logger for the desktop gateway. + * All log entries are timestamped, tagged by subsystem, and optionally + * buffered in-memory so the UI can display recent entries. + */ + +export type LogLevel = "info" | "warn" | "error"; + +export interface LogEntry { + timestamp: string; + level: LogLevel; + tag: string; + message: string; +} + +const MAX_BUFFER_SIZE = 500; + +export class GatewayLogger { + private verbose = false; + private readonly buffer: LogEntry[] = []; + private onChange?: (entries: LogEntry[]) => void; + private lastMessage = ""; + + setVerbose(enabled: boolean): void { + if (this.verbose === enabled) return; + this.verbose = enabled; + this.info("logger", enabled ? "Verbose logging enabled" : "Verbose logging disabled"); + } + + isVerbose(): boolean { + return this.verbose; + } + + setOnChange(cb: (entries: LogEntry[]) => void): void { + this.onChange = cb; + } + + info(tag: string, message: string): void { + this.log("info", tag, message); + } + + warn(tag: string, message: string): void { + this.log("warn", tag, message); + } + + error(tag: string, message: string): void { + this.log("error", tag, message); + } + + /** Verbose-only log -- skipped when verbose mode is off. */ + debug(tag: string, message: string): void { + if (!this.verbose) return; + this.log("info", tag, message); + } + + getEntries(): LogEntry[] { + return [...this.buffer]; + } + + clear(): void { + this.buffer.length = 0; + this.onChange?.(this.buffer); + } + + private log(level: LogLevel, tag: string, message: string): void { + const key = `${level}:${tag}:${message}`; + if (key === this.lastMessage) return; + this.lastMessage = key; + + const ts = new Date().toISOString(); + const entry: LogEntry = { timestamp: ts, level, tag, message }; + + this.buffer.push(entry); + if (this.buffer.length > MAX_BUFFER_SIZE) { + this.buffer.splice(0, this.buffer.length - MAX_BUFFER_SIZE); + } + + const short = ts.slice(11, 23); + const prefix = `[${tag}][${short}]`; + if (level === "error") { + console.error(prefix, message); + } else if (level === "warn") { + console.warn(prefix, message); + } else { + console.log(prefix, message); + } + + this.onChange?.([...this.buffer]); + } +} + +/** Singleton instance shared across the app. */ +export const gatewayLog = new GatewayLogger(); diff --git a/apps/desktop/src/main/preload.ts b/apps/desktop/src/main/preload.ts index 2f425b55..b36db329 100644 --- a/apps/desktop/src/main/preload.ts +++ b/apps/desktop/src/main/preload.ts @@ -53,7 +53,9 @@ const desktopApi = { listCompletedJobs: () => ipcRenderer.invoke("desktop:list-completed-jobs") as Promise, getJob: (jobId: string) => ipcRenderer.invoke("desktop:get-job", jobId) as Promise, getJobLogTail: (jobId: string, lines?: number) => - ipcRenderer.invoke("desktop:get-job-log-tail", jobId, lines) as Promise + ipcRenderer.invoke("desktop:get-job-log-tail", jobId, lines) as Promise, + getLogs: () => ipcRenderer.invoke("desktop:get-logs") as Promise, + clearLogs: () => ipcRenderer.invoke("desktop:clear-logs") as Promise }; contextBridge.exposeInMainWorld("desktopApi", desktopApi); diff --git a/apps/desktop/src/main/settings-store.ts b/apps/desktop/src/main/settings-store.ts index b009c068..01bb8031 100644 --- a/apps/desktop/src/main/settings-store.ts +++ b/apps/desktop/src/main/settings-store.ts @@ -181,6 +181,9 @@ export class SettingsStore { if (typeof partial.cloudConnectionEnabled === "boolean") { this.store.set("cloudConnectionEnabled", partial.cloudConnectionEnabled); } + if (typeof partial.verboseLogging === "boolean") { + this.store.set("verboseLogging", partial.verboseLogging); + } if (typeof partial.relayOrigin === "string") { this.store.set("relayOrigin" as keyof DesktopSettings, partial.relayOrigin); } diff --git a/apps/desktop/src/renderer/index.html b/apps/desktop/src/renderer/index.html index 1a9d71b2..64993456 100644 --- a/apps/desktop/src/renderer/index.html +++ b/apps/desktop/src/renderer/index.html @@ -570,6 +570,154 @@ padding: 12px; } + .toggle-switch { + position: relative; + display: inline-block; + width: 36px; + height: 20px; + cursor: pointer; + } + + .toggle-switch input { + opacity: 0; + width: 0; + height: 0; + position: absolute; + } + + .toggle-track { + position: absolute; + inset: 0; + background: var(--border); + border-radius: 10px; + transition: background 0.2s; + } + + .toggle-track::after { + content: ""; + position: absolute; + width: 14px; + height: 14px; + left: 3px; + top: 3px; + background: var(--surface); + border-radius: 50%; + transition: transform 0.2s; + } + + .toggle-switch input:checked + .toggle-track { + background: var(--accent); + } + + .toggle-switch input:checked + .toggle-track::after { + transform: translateX(16px); + } + + .logs-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; + padding: 8px 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + } + + .logs-verbose-label { + display: flex; + align-items: center; + gap: 10px; + font-size: 12px; + font-weight: 500; + color: var(--muted); + cursor: pointer; + user-select: none; + } + + .logs-actions { + display: flex; + gap: 4px; + } + + .logs-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: 6px; + border: 1px solid var(--border); + background: transparent; + color: var(--muted); + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; + } + + .logs-btn:hover { + background: var(--surface-alt); + color: var(--ink); + border-color: var(--ink); + } + + .logs-container { + font-family: "SF Mono", "Menlo", "Consolas", monospace; + font-size: 11px; + line-height: 1; + max-height: calc(100vh - 200px); + overflow-y: auto; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 4px 0; + } + + .log-row { + display: flex; + gap: 8px; + padding: 3px 12px; + border-left: 2px solid transparent; + } + + .log-row:hover { + background: var(--surface-alt); + } + + .log-row.log-error { + border-left-color: #dc2626; + background: rgba(220, 38, 38, 0.06); + } + + .log-row.log-warn { + border-left-color: #d97706; + background: rgba(245, 158, 11, 0.04); + } + + .log-ts { + color: var(--muted); + opacity: 0.7; + flex-shrink: 0; + } + + .log-tag { + color: var(--accent); + flex-shrink: 0; + min-width: 110px; + } + + .log-msg { + color: var(--ink); + word-break: break-word; + } + + .log-row.log-error .log-msg { + color: #f87171; + } + + .log-row.log-warn .log-msg { + color: #fbbf24; + } + .tier-override-row { display: flex; align-items: center; @@ -1306,7 +1454,8 @@

Setup Required

@@ -1384,6 +1533,26 @@

Setup Required

+
+
+ +
+ + +
+
+
+
@@ -1521,6 +1690,13 @@

Always-Allow Rules

tabButton.classList.add("active"); document.getElementById(tabName)?.classList.add("active"); } + if (tabName === "logs") { + void renderLogs(); + startLogsAutoRefresh(); + api.getSettings().then(s => { verboseToggle.checked = !!s.verboseLogging; }); + } else { + stopLogsAutoRefresh(); + } } tabs.forEach((tab) => { @@ -2644,6 +2820,56 @@

Always-Allow Rules

} } + // --- Logs tab --- + const logsList = document.getElementById("logsList"); + const LOG_LEVEL_COLORS = { info: "var(--muted)", warn: "#d97706", error: "#dc2626" }; + + let logsAutoRefreshTimer = null; + + function startLogsAutoRefresh() { + stopLogsAutoRefresh(); + logsAutoRefreshTimer = setInterval(() => void renderLogs(), 3000); + } + + function stopLogsAutoRefresh() { + if (logsAutoRefreshTimer) { + clearInterval(logsAutoRefreshTimer); + logsAutoRefreshTimer = null; + } + } + + async function renderLogs() { + const entries = await api.getLogs(); + if (!entries || !entries.length) { + logsList.innerHTML = '
No log entries yet
Turn on Verbose to capture detailed diagnostics.
'; + return; + } + const html = entries.map(e => { + const isError = e.level === "error"; + const isWarn = e.level === "warn"; + const rowClass = isError ? "log-row log-error" : isWarn ? "log-row log-warn" : "log-row"; + const ts = e.timestamp.slice(11, 23); + return `
${ts}${e.tag}${escapeHtml(e.message)}
`; + }).join(""); + logsList.innerHTML = html; + logsList.scrollTop = logsList.scrollHeight; + } + + function escapeHtml(s) { + return s.replace(/&/g, "&").replace(//g, ">"); + } + + const verboseToggle = document.getElementById("verboseLogging"); + verboseToggle.addEventListener("change", async () => { + await api.updateSettings({ verboseLogging: verboseToggle.checked }); + }); + + document.getElementById("refreshLogsBtn").addEventListener("click", renderLogs); + document.getElementById("clearLogsBtn").addEventListener("click", async () => { + await api.clearLogs(); + await renderLogs(); + }); + document.getElementById("saveRelayGateway").addEventListener("click", saveRelayGatewaySettings); document.getElementById("reloadRelayGateway").addEventListener("click", loadSettings); document.getElementById("saveSecurity").addEventListener("click", saveSecuritySettings); diff --git a/apps/desktop/src/server/operations/symphony-loop.ts b/apps/desktop/src/server/operations/symphony-loop.ts index 8e598f45..8efdd94b 100644 --- a/apps/desktop/src/server/operations/symphony-loop.ts +++ b/apps/desktop/src/server/operations/symphony-loop.ts @@ -1,4 +1,5 @@ import { execSync, spawn } from "node:child_process"; +import { gatewayLog } from "../../main/gateway-logger.js"; import crypto from "node:crypto"; import { closeSync, existsSync, openSync, readFileSync } from "node:fs"; import fs from "node:fs/promises"; @@ -284,11 +285,15 @@ async function postLoopEvent( if (!resp.ok) { const text = await resp.text().catch(() => ""); loopError(loopId, `Event POST failed: ${resp.status} ${resp.statusText}`, text); + gatewayLog.error("loop-event", `POST ${payload.type} to ${url} failed: ${resp.status} ${resp.statusText} ${text}`); } else { loopLog(loopId, `Event POST success: ${resp.status}`); + gatewayLog.debug("loop-event", `POST ${payload.type} to ${url}: ${resp.status}`); } } catch (err) { + const msg = err instanceof Error ? err.message : String(err); loopError(loopId, "Failed to post event:", err); + gatewayLog.error("loop-event", `POST ${payload.type} network error: ${msg}`); } } @@ -312,11 +317,15 @@ async function uploadArtifacts( if (!resp.ok) { const text = await resp.text().catch(() => ""); loopError(loopId, `Upload failed: ${resp.status} ${resp.statusText}`, text); + gatewayLog.error("loop-upload", `Artifact upload to ${url} failed: ${resp.status} ${resp.statusText} ${text}`); } else { loopLog(loopId, `Upload success: ${resp.status}`); + gatewayLog.debug("loop-upload", `Artifact upload to ${url}: ${resp.status}`); } } catch (err) { + const msg = err instanceof Error ? err.message : String(err); loopError(loopId, "Failed to upload artifacts:", err); + gatewayLog.error("loop-upload", `Artifact upload network error: ${msg}`); } } @@ -763,6 +772,7 @@ async function handleProcessCompletion( if (exitCode !== 0) { loopError(loopId, `Process failed with exit code ${exitCode}`); + gatewayLog.error("loop-harness", `${command} failed with exit code ${exitCode}, loopId=${loopId}`); // Error shape matches ECS harness: top-level code/message, not nested error object await postLoopEvent(apiBaseUrl, loopId, closedLoopAuthToken, { type: "error", @@ -792,6 +802,7 @@ async function handleProcessCompletion( } // Read outputs per command + gatewayLog.debug("loop-harness", `${command} succeeded (exit 0), reading artifacts for loopId=${loopId}`); let artifacts: Record = {}; const metadata: Record = {}; @@ -838,7 +849,9 @@ async function handleProcessCompletion( } // Upload artifacts - loopLog(loopId, "Artifact keys:", Object.keys(artifacts)); + const artifactKeys = Object.keys(artifacts); + loopLog(loopId, "Artifact keys:", artifactKeys); + gatewayLog.debug("loop-harness", `Uploading artifacts for ${command} loopId=${loopId}: [${artifactKeys.join(", ")}]`); await uploadArtifacts(apiBaseUrl, loopId, closedLoopAuthToken, { artifacts, metadata, @@ -981,7 +994,9 @@ async function handleLoopRequest( // Claim the loopId immediately to prevent concurrent requests from racing // past the has() check. Replaced with real entry after spawn succeeds. runningLoops.set(body.loopId, { pid: -1, child: null as unknown as ReturnType }); + const requestSource = context.request?.headers?.["x-desktop-source"] === "cloud-socket" ? "relay" : "local"; loopLog(body.loopId, `Received ${body.command} request, repo=${body.repo?.fullName ?? "none"}, stableId=${pickStableId(body)}, parentSessionId=${body.parentSessionId ?? "none"}`); + gatewayLog.info("loop-harness", `${body.command} request via ${requestSource}, loopId=${body.loopId}, repo=${body.repo?.fullName ?? "none"}`); let spawnedSuccessfully = false; try { @@ -1507,7 +1522,10 @@ async function handleLoopRequest( usedTempDir, expandedRepoPath, jobStore - ).catch((err) => loopError(body.loopId, "Completion handler error:", err)); + ).catch((err) => { + loopError(body.loopId, "Completion handler error:", err); + gatewayLog.error("loop-harness", `Completion handler error for loopId=${body.loopId}: ${err instanceof Error ? err.message : err}`); + }); }; // Prevent unhandled 'error' events (e.g. ENOENT if binary vanishes @@ -1538,6 +1556,7 @@ async function handleLoopRequest( runningLoops.set(body.loopId, { pid, child }); spawnedSuccessfully = true; loopLog(body.loopId, `Spawned pid=${pid}, worktree=${worktreeDir}`); + gatewayLog.debug("loop-harness", `Spawned ${body.command} pid=${pid}, loopId=${body.loopId}, worktree=${worktreeDir}`); // Bind runtime details to an existing LocalJob or create a new one for this loop if (jobStore) { diff --git a/apps/desktop/src/shared/contracts.ts b/apps/desktop/src/shared/contracts.ts index dd08004f..bd014085 100644 --- a/apps/desktop/src/shared/contracts.ts +++ b/apps/desktop/src/shared/contracts.ts @@ -58,6 +58,7 @@ export interface DesktopSettings { relayOrigin: string; apiOrigin: string; webAppOrigin: string; + verboseLogging: boolean; } export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { @@ -70,5 +71,6 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { defaultApprovalTier: "high", relayOrigin: DEFAULT_RELAY_ORIGIN, apiOrigin: DEFAULT_AUTH_API_ORIGIN, - webAppOrigin: DEFAULT_WEB_APP_ORIGIN + webAppOrigin: DEFAULT_WEB_APP_ORIGIN, + verboseLogging: false }; From 5e91bba68e986969b17e1e249f0c06adfa6db467 Mon Sep 17 00:00:00 2001 From: "daniel.ochoa" Date: Tue, 24 Mar 2026 15:02:44 -0500 Subject: [PATCH 2/2] FEAT-133: Fix PR review feedback on diagnostics logging - Reset lastMessage on clear() so first post-clear log is not dropped - Pass copy (not mutable ref) from clear() onChange callback - Remove unused LOG_LEVEL_COLORS constant - Escape e.tag in log row innerHTML to prevent XSS - Only auto-scroll to bottom if user was already at bottom --- apps/desktop/src/main/gateway-logger.ts | 3 ++- apps/desktop/src/renderer/index.html | 8 +++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/gateway-logger.ts b/apps/desktop/src/main/gateway-logger.ts index 036702a7..0a349177 100644 --- a/apps/desktop/src/main/gateway-logger.ts +++ b/apps/desktop/src/main/gateway-logger.ts @@ -59,7 +59,8 @@ export class GatewayLogger { clear(): void { this.buffer.length = 0; - this.onChange?.(this.buffer); + this.lastMessage = ""; + this.onChange?.([]); } private log(level: LogLevel, tag: string, message: string): void { diff --git a/apps/desktop/src/renderer/index.html b/apps/desktop/src/renderer/index.html index 64993456..b1e76eb2 100644 --- a/apps/desktop/src/renderer/index.html +++ b/apps/desktop/src/renderer/index.html @@ -2822,7 +2822,6 @@

Always-Allow Rules

// --- Logs tab --- const logsList = document.getElementById("logsList"); - const LOG_LEVEL_COLORS = { info: "var(--muted)", warn: "#d97706", error: "#dc2626" }; let logsAutoRefreshTimer = null; @@ -2839,6 +2838,7 @@

Always-Allow Rules

} async function renderLogs() { + const wasAtBottom = logsList.scrollTop + logsList.clientHeight >= logsList.scrollHeight - 20; const entries = await api.getLogs(); if (!entries || !entries.length) { logsList.innerHTML = '
No log entries yet
Turn on Verbose to capture detailed diagnostics.
'; @@ -2849,10 +2849,12 @@

Always-Allow Rules

const isWarn = e.level === "warn"; const rowClass = isError ? "log-row log-error" : isWarn ? "log-row log-warn" : "log-row"; const ts = e.timestamp.slice(11, 23); - return `
${ts}${e.tag}${escapeHtml(e.message)}
`; + return `
${ts}${escapeHtml(e.tag)}${escapeHtml(e.message)}
`; }).join(""); logsList.innerHTML = html; - logsList.scrollTop = logsList.scrollHeight; + if (wasAtBottom) { + logsList.scrollTop = logsList.scrollHeight; + } } function escapeHtml(s) {