Skip to content
This repository was archived by the owner on Jun 8, 2026. It is now read-only.

Commit 1daf8df

Browse files
authored
Merge pull request #39 from closedloop-ai/FEAT-145
FEAT-133: Add gateway diagnostics logging with UI
2 parents 178c806 + 5e91bba commit 1daf8df

10 files changed

Lines changed: 385 additions & 7 deletions

File tree

apps/desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "desktop",
3-
"version": "0.7.1",
3+
"version": "0.7.2",
44
"description": "ClosedLoop Desktop",
55
"author": "ClosedLoop AI <support@closedloop.ai>",
66
"private": true,

apps/desktop/src/main/app.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
import { seedReposConfig } from "./seed-repos-config.js";
3131
import { SUPPORTED_OPERATION_IDS, resolveOperationId } from "./approval-operations.js";
3232
import { shouldAutoApprove, OPERATION_RISK_TIERS } from "./approval-policy.js";
33+
import { gatewayLog } from "./gateway-logger.js";
3334
import { ActivityLogStore } from "./activity-log-store.js";
3435
import { ApprovalStore } from "./approval-store.js";
3536
import { JobStore, isTerminalJobStatus } from "./job-store.js";
@@ -203,6 +204,7 @@ export class DesktopApplication {
203204
this.syncPendingApprovalsToTray();
204205
this.desktopWindow.init();
205206

207+
gatewayLog.setVerbose(this.settingsStore.getAll().verboseLogging);
206208
this.migrateLegacyData();
207209
this.reconcileJobStore();
208210

@@ -728,6 +730,9 @@ export class DesktopApplication {
728730
}
729731

730732
private registerIpcHandlers(): void {
733+
ipcMain.handle("desktop:get-logs", () => gatewayLog.getEntries());
734+
ipcMain.handle("desktop:clear-logs", () => { gatewayLog.clear(); });
735+
731736
ipcMain.handle("desktop:get-settings", () => {
732737
const settings = this.settingsStore.getAll();
733738
const activeAlwaysAllowRules = pruneExpiredAlwaysAllowRules(settings.alwaysAllowRules);
@@ -749,6 +754,7 @@ export class DesktopApplication {
749754
webAppOrigin?: string;
750755
defaultApprovalTier?: "auto" | "none" | "low" | "medium" | "high";
751756
autoApprovalRules?: Record<string, "auto" | "none" | "low" | "medium" | "high">;
757+
verboseLogging?: boolean;
752758
}) => {
753759
const currentSettings = this.settingsStore.getAll();
754760
const nextPartial = { ...partial };
@@ -789,6 +795,9 @@ export class DesktopApplication {
789795
}
790796

791797
const updated = this.settingsStore.update(nextPartial as Partial<DesktopSettings>);
798+
if (typeof nextPartial.verboseLogging === "boolean") {
799+
gatewayLog.setVerbose(nextPartial.verboseLogging);
800+
}
792801

793802
if (
794803
typeof partial.sandboxBaseDirectory === "string" &&

apps/desktop/src/main/cloud-command-executor.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { URL } from "node:url";
2+
import { gatewayLog } from "./gateway-logger.js";
23
import type {
34
CommandEventRecord,
45
DesktopCancelEvent,
@@ -57,6 +58,7 @@ export class CloudCommandExecutor {
5758

5859
const validationError = validateCommand(command);
5960
if (validationError) {
61+
gatewayLog.warn("command-executor", `Rejected command ${command.commandId}: ${validationError}`);
6062
this.options.sendCommandAck({
6163
commandId: command.commandId,
6264
accepted: false,
@@ -66,6 +68,7 @@ export class CloudCommandExecutor {
6668
return;
6769
}
6870

71+
gatewayLog.debug("command-executor", `Enqueued command ${command.commandId}: ${command.method} ${command.path}`);
6972
const tracked: TrackedCommand = {
7073
command,
7174
state: "queued",
@@ -181,6 +184,7 @@ export class CloudCommandExecutor {
181184
return;
182185
}
183186
tracked.state = "running";
187+
gatewayLog.debug("command-executor", `Executing command ${command.commandId}: ${command.method} ${command.path}`);
184188

185189
const lockKey = deriveLockKey(command);
186190
if (lockKey) {
@@ -221,6 +225,7 @@ export class CloudCommandExecutor {
221225
cancelled: true,
222226
reason: running.cancelReason ?? "cancelled"
223227
});
228+
gatewayLog.debug("command-executor", `Command ${command.commandId} cancelled`);
224229
this.markTerminal(command.commandId, "cancelled");
225230
} else if (running.timedOut) {
226231
this.emitTrackedEvent(command.commandId, "error", {
@@ -229,13 +234,16 @@ export class CloudCommandExecutor {
229234
code: "timeout",
230235
error: "command timed out"
231236
});
237+
gatewayLog.error("command-executor", `Command ${command.commandId} timed out`);
232238
this.markTerminal(command.commandId, "failed");
233239
} else {
240+
const msg = error instanceof Error ? error.message : "unknown command failure";
234241
this.emitTrackedEvent(command.commandId, "error", {
235242
type: "error",
236243
terminal: true,
237-
error: error instanceof Error ? error.message : "unknown command failure"
244+
error: msg
238245
});
246+
gatewayLog.error("command-executor", `Command ${command.commandId} failed: ${msg}`);
239247
this.markTerminal(command.commandId, "failed");
240248
}
241249
} finally {
@@ -268,6 +276,7 @@ export class CloudCommandExecutor {
268276
const method = command.method.toUpperCase();
269277
const body = serializeBody(command.body, headers, method);
270278

279+
gatewayLog.debug("command-executor", `Gateway fetch: ${method} ${requestUrl.pathname}`);
271280
const response = await fetch(requestUrl, {
272281
method,
273282
headers,
@@ -280,6 +289,7 @@ export class CloudCommandExecutor {
280289

281290
if (!response.ok && !isStream) {
282291
const message = await safeReadBodyAsText(response);
292+
gatewayLog.error("command-executor", `Gateway returned ${response.status} for ${method} ${requestUrl.pathname}: ${message}`);
283293
throw new Error(`gateway returned ${response.status}${message ? `: ${message}` : ""}`);
284294
}
285295

apps/desktop/src/main/cloud-socket.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createHash, randomUUID } from "node:crypto";
2+
import { gatewayLog } from "./gateway-logger.js";
23
import { io, type Socket } from "socket.io-client";
34
import {
45
PROTOCOL_VERSION,
@@ -94,6 +95,7 @@ export class CloudSocketService {
9495
state: DesktopPresenceEvent["state"];
9596
}
9697
): void {
98+
gatewayLog.debug("cloud-socket", `Sending presence: state=${event.state}`);
9799
this.emit("desktop.presence", event);
98100
}
99101

@@ -129,6 +131,7 @@ export class CloudSocketService {
129131
if (this.stopped) {
130132
return;
131133
}
134+
gatewayLog.info("cloud-socket", "Connected to relay, sending hello handshake");
132135
this.awaitingHelloAck = true;
133136
this.emitHello();
134137
this.scheduleHelloAckTimeout();
@@ -142,11 +145,13 @@ export class CloudSocketService {
142145
this.clearHelloAckTimer();
143146
const message = error instanceof Error ? error.message : "connection failed";
144147
if (looksLikeAuthError(error)) {
148+
gatewayLog.error("cloud-socket", "Authentication failed on connect");
145149
this.notifyStatus({
146150
state: "degraded",
147151
error: "Authentication failed — verify your API key in Settings"
148152
});
149153
} else {
154+
gatewayLog.error("cloud-socket", `Connection error: ${message}`);
150155
this.notifyStatus({ state: "degraded", error: `Cloud socket connection failed: ${message}` });
151156
}
152157
});
@@ -155,6 +160,7 @@ export class CloudSocketService {
155160
if (this.stopped) {
156161
return;
157162
}
163+
gatewayLog.warn("cloud-socket", `Disconnected: ${reason}`);
158164
this.awaitingHelloAck = false;
159165
this.clearHelloAckTimer();
160166
this.notifyStatus({ state: "degraded", error: `Cloud socket disconnected: ${reason}` });
@@ -164,12 +170,14 @@ export class CloudSocketService {
164170
const event = asObject(payload);
165171
const computeTargetId = asNonEmptyString(event.computeTargetId);
166172
if (!computeTargetId) {
173+
gatewayLog.warn("cloud-socket", "hello.ack missing computeTargetId, ignoring");
167174
return;
168175
}
169176

170177
this.targetId = computeTargetId;
171178
this.awaitingHelloAck = false;
172179
this.clearHelloAckTimer();
180+
gatewayLog.info("cloud-socket", `Hello ack received, targetId=${computeTargetId}`);
173181
const ackEvent: DesktopHelloAckEvent = {
174182
...createEnvelope(),
175183
computeTargetId,
@@ -190,8 +198,10 @@ export class CloudSocketService {
190198
socket.on("desktop.command", (payload: unknown) => {
191199
const parsed = parseDesktopCommand(payload);
192200
if (!parsed) {
201+
gatewayLog.warn("cloud-socket", "Received unparseable desktop.command, ignoring");
193202
return;
194203
}
204+
gatewayLog.debug("cloud-socket", `Command received: ${parsed.operationId} ${parsed.method} ${parsed.path} (commandId=${parsed.commandId})`);
195205
this.options.onCommand?.(parsed);
196206
});
197207

@@ -267,6 +277,7 @@ export class CloudSocketService {
267277
if (this.stopped || !this.awaitingHelloAck) {
268278
return;
269279
}
280+
gatewayLog.warn("cloud-socket", "Hello ack timeout -- retrying handshake");
270281
this.notifyStatus({
271282
state: "degraded",
272283
error: "Connected to cloud socket but did not receive desktop.hello.ack"
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* Structured logger for the desktop gateway.
3+
* All log entries are timestamped, tagged by subsystem, and optionally
4+
* buffered in-memory so the UI can display recent entries.
5+
*/
6+
7+
export type LogLevel = "info" | "warn" | "error";
8+
9+
export interface LogEntry {
10+
timestamp: string;
11+
level: LogLevel;
12+
tag: string;
13+
message: string;
14+
}
15+
16+
const MAX_BUFFER_SIZE = 500;
17+
18+
export class GatewayLogger {
19+
private verbose = false;
20+
private readonly buffer: LogEntry[] = [];
21+
private onChange?: (entries: LogEntry[]) => void;
22+
private lastMessage = "";
23+
24+
setVerbose(enabled: boolean): void {
25+
if (this.verbose === enabled) return;
26+
this.verbose = enabled;
27+
this.info("logger", enabled ? "Verbose logging enabled" : "Verbose logging disabled");
28+
}
29+
30+
isVerbose(): boolean {
31+
return this.verbose;
32+
}
33+
34+
setOnChange(cb: (entries: LogEntry[]) => void): void {
35+
this.onChange = cb;
36+
}
37+
38+
info(tag: string, message: string): void {
39+
this.log("info", tag, message);
40+
}
41+
42+
warn(tag: string, message: string): void {
43+
this.log("warn", tag, message);
44+
}
45+
46+
error(tag: string, message: string): void {
47+
this.log("error", tag, message);
48+
}
49+
50+
/** Verbose-only log -- skipped when verbose mode is off. */
51+
debug(tag: string, message: string): void {
52+
if (!this.verbose) return;
53+
this.log("info", tag, message);
54+
}
55+
56+
getEntries(): LogEntry[] {
57+
return [...this.buffer];
58+
}
59+
60+
clear(): void {
61+
this.buffer.length = 0;
62+
this.lastMessage = "";
63+
this.onChange?.([]);
64+
}
65+
66+
private log(level: LogLevel, tag: string, message: string): void {
67+
const key = `${level}:${tag}:${message}`;
68+
if (key === this.lastMessage) return;
69+
this.lastMessage = key;
70+
71+
const ts = new Date().toISOString();
72+
const entry: LogEntry = { timestamp: ts, level, tag, message };
73+
74+
this.buffer.push(entry);
75+
if (this.buffer.length > MAX_BUFFER_SIZE) {
76+
this.buffer.splice(0, this.buffer.length - MAX_BUFFER_SIZE);
77+
}
78+
79+
const short = ts.slice(11, 23);
80+
const prefix = `[${tag}][${short}]`;
81+
if (level === "error") {
82+
console.error(prefix, message);
83+
} else if (level === "warn") {
84+
console.warn(prefix, message);
85+
} else {
86+
console.log(prefix, message);
87+
}
88+
89+
this.onChange?.([...this.buffer]);
90+
}
91+
}
92+
93+
/** Singleton instance shared across the app. */
94+
export const gatewayLog = new GatewayLogger();

apps/desktop/src/main/preload.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,9 @@ const desktopApi = {
5353
listCompletedJobs: () => ipcRenderer.invoke("desktop:list-completed-jobs") as Promise<unknown>,
5454
getJob: (jobId: string) => ipcRenderer.invoke("desktop:get-job", jobId) as Promise<unknown>,
5555
getJobLogTail: (jobId: string, lines?: number) =>
56-
ipcRenderer.invoke("desktop:get-job-log-tail", jobId, lines) as Promise<unknown>
56+
ipcRenderer.invoke("desktop:get-job-log-tail", jobId, lines) as Promise<unknown>,
57+
getLogs: () => ipcRenderer.invoke("desktop:get-logs") as Promise<unknown>,
58+
clearLogs: () => ipcRenderer.invoke("desktop:clear-logs") as Promise<unknown>
5759
};
5860

5961
contextBridge.exposeInMainWorld("desktopApi", desktopApi);

apps/desktop/src/main/settings-store.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,9 @@ export class SettingsStore {
181181
if (typeof partial.cloudConnectionEnabled === "boolean") {
182182
this.store.set("cloudConnectionEnabled", partial.cloudConnectionEnabled);
183183
}
184+
if (typeof partial.verboseLogging === "boolean") {
185+
this.store.set("verboseLogging", partial.verboseLogging);
186+
}
184187
if (typeof partial.relayOrigin === "string") {
185188
this.store.set("relayOrigin" as keyof DesktopSettings, partial.relayOrigin);
186189
}

0 commit comments

Comments
 (0)