Skip to content
This repository was archived by the owner on Jun 8, 2026. It is now read-only.
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
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "desktop",
"version": "0.7.1",
"version": "0.7.2",
"description": "ClosedLoop Desktop",
"author": "ClosedLoop AI <support@closedloop.ai>",
"private": true,
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/main/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -203,6 +204,7 @@ export class DesktopApplication {
this.syncPendingApprovalsToTray();
this.desktopWindow.init();

gatewayLog.setVerbose(this.settingsStore.getAll().verboseLogging);
this.migrateLegacyData();
this.reconcileJobStore();

Expand Down Expand Up @@ -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);
Expand All @@ -749,6 +754,7 @@ export class DesktopApplication {
webAppOrigin?: string;
defaultApprovalTier?: "auto" | "none" | "low" | "medium" | "high";
autoApprovalRules?: Record<string, "auto" | "none" | "low" | "medium" | "high">;
verboseLogging?: boolean;
}) => {
const currentSettings = this.settingsStore.getAll();
const nextPartial = { ...partial };
Expand Down Expand Up @@ -789,6 +795,9 @@ export class DesktopApplication {
}

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

if (
typeof partial.sandboxBaseDirectory === "string" &&
Expand Down
12 changes: 11 additions & 1 deletion apps/desktop/src/main/cloud-command-executor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { URL } from "node:url";
import { gatewayLog } from "./gateway-logger.js";
import type {
CommandEventRecord,
DesktopCancelEvent,
Expand Down Expand Up @@ -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,
Expand All @@ -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",
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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", {
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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}` : ""}`);
}

Expand Down
11 changes: 11 additions & 0 deletions apps/desktop/src/main/cloud-socket.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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();
Expand All @@ -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}` });
}
});
Expand All @@ -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}` });
Expand All @@ -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,
Expand All @@ -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);
});

Expand Down Expand Up @@ -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"
Expand Down
94 changes: 94 additions & 0 deletions apps/desktop/src/main/gateway-logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* 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 {
Comment thread
shafty023 marked this conversation as resolved.
this.buffer.length = 0;
this.lastMessage = "";
this.onChange?.([]);
}

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();
4 changes: 3 additions & 1 deletion apps/desktop/src/main/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ const desktopApi = {
listCompletedJobs: () => ipcRenderer.invoke("desktop:list-completed-jobs") as Promise<unknown>,
getJob: (jobId: string) => ipcRenderer.invoke("desktop:get-job", jobId) as Promise<unknown>,
getJobLogTail: (jobId: string, lines?: number) =>
ipcRenderer.invoke("desktop:get-job-log-tail", jobId, lines) as Promise<unknown>
ipcRenderer.invoke("desktop:get-job-log-tail", jobId, lines) as Promise<unknown>,
getLogs: () => ipcRenderer.invoke("desktop:get-logs") as Promise<unknown>,
clearLogs: () => ipcRenderer.invoke("desktop:clear-logs") as Promise<unknown>
};

contextBridge.exposeInMainWorld("desktopApi", desktopApi);
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/main/settings-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Loading
Loading