From 187160adb269381ccba91f460d0e0ec64f695b7f Mon Sep 17 00:00:00 2001 From: Kris Wong Date: Thu, 26 Mar 2026 14:01:13 -0500 Subject: [PATCH] Add support for sending structured telemetry back to the cloud relay via websocket https://app.closedloop.ai/implementation-plans/PLAN-88 --- apps/desktop/package.json | 2 +- apps/desktop/src/main/app.ts | 628 ++++++++----- .../src/main/cloud-command-executor.ts | 277 ++++-- apps/desktop/src/main/cloud-socket.ts | 14 +- apps/desktop/src/main/job-store.ts | 1 + apps/desktop/src/main/telemetry-protocol.ts | 64 ++ apps/desktop/src/main/telemetry-service.ts | 120 +++ .../src/server/operations/symphony-loop.ts | 837 +++++++++++++----- apps/desktop/src/server/router.ts | 5 +- apps/desktop/src/server/server.ts | 7 +- .../test/cloud-command-executor.test.ts | 422 ++++++++- apps/desktop/test/helpers/mock-api-server.ts | 89 ++ .../test/symphony-loop-execute.test.ts | 87 +- .../test/symphony-loop-generate-prd.test.ts | 118 +-- .../test/telemetry-loop-integration.test.ts | 638 +++++++++++++ apps/desktop/test/telemetry-service.test.ts | 339 +++++++ 16 files changed, 2935 insertions(+), 713 deletions(-) create mode 100644 apps/desktop/src/main/telemetry-protocol.ts create mode 100644 apps/desktop/src/main/telemetry-service.ts create mode 100644 apps/desktop/test/helpers/mock-api-server.ts create mode 100644 apps/desktop/test/telemetry-loop-integration.test.ts create mode 100644 apps/desktop/test/telemetry-service.test.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d71f2b83..1871789c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "desktop", - "version": "0.8.1", + "version": "0.9.0", "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 af93911b..72ef668d 100644 --- a/apps/desktop/src/main/app.ts +++ b/apps/desktop/src/main/app.ts @@ -1,6 +1,12 @@ import os from "node:os"; import path from "node:path"; -import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs"; +import { + copyFileSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, +} from "node:fs"; import { readFile } from "node:fs/promises"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; @@ -12,9 +18,12 @@ import { DESKTOP_GATEWAY_VERSION, EMPTY_CAPABILITIES, type DesktopSettings, - type RiskTier + type RiskTier, } from "../shared/contracts.js"; -import { buildAllowedDirectories, normalizeScopePath } from "../shared/sandbox-policy.js"; +import { + buildAllowedDirectories, + normalizeScopePath, +} from "../shared/sandbox-policy.js"; import { ApiKeyStore } from "./api-key-store.js"; import { CloudCommandExecutor } from "./cloud-command-executor.js"; import type { CloudSocketStatus } from "./cloud-protocol.js"; @@ -25,17 +34,27 @@ import { DesktopWindow } from "./window.js"; import { DesktopGatewayServer } from "../server/server.js"; import { computeSymphonyDir, - SymphonyDirNotConfiguredError + SymphonyDirNotConfiguredError, } from "../server/operations/symphony-utils.js"; import { seedReposConfig } from "./seed-repos-config.js"; -import { SUPPORTED_OPERATION_IDS, resolveOperationId } from "./approval-operations.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"; -import type { GatewayApprovalRequest, GatewayApprovalResult } from "../server/router.js"; -import { normalizeAndValidateOrigin, normalizeWebAppOrigin } from "./origin-policy.js"; +import { TelemetryService } from "./telemetry-service.js"; +import type { + GatewayApprovalRequest, + GatewayApprovalResult, +} from "../server/router.js"; +import { + normalizeAndValidateOrigin, + normalizeWebAppOrigin, +} from "./origin-policy.js"; import { LocalSessionStore } from "./local-session-store.js"; import { enrichJobSnapshot } from "../server/operations/symphony-job-snapshot.js"; import pkg from "electron-updater"; @@ -59,6 +78,7 @@ export class DesktopApplication { private readonly jobStore: JobStore; private readonly gatewayAuthToken: string; private readonly sessionStore: LocalSessionStore; + private readonly telemetry: TelemetryService; private shuttingDown = false; private dangerousAutoApprove = false; private cloudStatus: CloudSocketStatus = { state: "idle" }; @@ -69,9 +89,13 @@ export class DesktopApplication { constructor() { this.gatewayAuthToken = randomBytes(24).toString("hex"); this.sessionStore = new LocalSessionStore(); + this.telemetry = new TelemetryService({ + sendTelemetry: (event) => this.cloudSocket?.sendTelemetry(event), + }); this.settingsStore = new SettingsStore(); this.cloudCommandsPaused = this.settingsStore.getCloudCommandsPaused(); - this.cloudConnectionEnabled = this.settingsStore.getCloudConnectionEnabled(); + this.cloudConnectionEnabled = + this.settingsStore.getCloudConnectionEnabled(); this.apiKeyStore = new ApiKeyStore(); this.tray = new DesktopTray(); this.desktopWindow = new DesktopWindow(); @@ -86,14 +110,16 @@ export class DesktopApplication { }); notification.on("click", () => { this.desktopWindow.show(); - this.desktopWindow.getWindow()?.webContents.send("desktop:navigate-tab", "approvals"); + this.desktopWindow + .getWindow() + ?.webContents.send("desktop:navigate-tab", "approvals"); }); notification.show(); - } + }, }); this.server = DesktopGatewayServer.createDefault( this.settingsStore.getWebAppOrigin(), - () => this.isNoAuthMode() ? undefined : this.gatewayAuthToken, + () => (this.isNoAuthMode() ? undefined : this.gatewayAuthToken), () => this.getAllowedDirectoriesFromSandbox(), os.hostname(), DESKTOP_GATEWAY_VERSION, @@ -108,7 +134,8 @@ export class DesktopApplication { () => this.settingsStore.getApiOrigin(), () => this.settingsStore.getWebAppOrigin(), this.isProdOriginsOnly(), - this.jobStore + this.jobStore, + this.telemetry, ); this.commandExecutor = new CloudCommandExecutor({ getGatewayPort: () => this.server.getActivePort(), @@ -116,16 +143,21 @@ export class DesktopApplication { maxInFlightCommands: MAX_IN_FLIGHT_COMMANDS, sendCommandAck: (event) => this.cloudSocket.sendCommandAck(event), sendCommandEvent: (event) => this.cloudSocket.sendCommandEvent(event), + emitTelemetry: (event) => this.telemetry.emit(event), onQueueStatsChange: (stats) => { const presenceState = - this.cloudStatus.state === "online" && !this.cloudCommandsPaused ? "online" : "degraded"; + this.cloudStatus.state === "online" && !this.cloudCommandsPaused + ? "online" + : "degraded"; this.cloudSocket.sendPresence({ state: presenceState, - ...(this.cloudCommandsPaused ? { error: "cloud commands paused by user" } : {}), + ...(this.cloudCommandsPaused + ? { error: "cloud commands paused by user" } + : {}), activeCommands: stats.activeCommands, - queueDepth: stats.queueDepth + queueDepth: stats.queueDepth, }); - } + }, }); this.cloudSocket = new CloudSocketService({ getRelayOrigin: () => this.settingsStore.getRelayOrigin(), @@ -137,6 +169,10 @@ export class DesktopApplication { supportedOperations: [...SUPPORTED_OPERATION_IDS], onStatusChange: (status) => this.onCloudSocketStatus(status), onHelloAck: (event) => { + this.telemetry.setTargetId(event.computeTargetId); + if (event.sessionId) { + this.telemetry.setGatewaySessionId(event.sessionId); + } if (event.resumeFromSequence) { this.commandExecutor.replayFrom(event.resumeFromSequence); } @@ -147,7 +183,7 @@ export class DesktopApplication { commandId: command.commandId, accepted: false, state: "failed", - reason: "onboarding not completed" + reason: "onboarding not completed", }); return; } @@ -160,7 +196,7 @@ export class DesktopApplication { commandId: command.commandId, accepted: false, state: "failed", - reason: "operationId/path mismatch" + reason: "operationId/path mismatch", }); return; } @@ -169,7 +205,7 @@ export class DesktopApplication { commandId: command.commandId, accepted: false, state: "failed", - reason: "cloud commands paused by user" + reason: "cloud commands paused by user", }); return; } @@ -180,7 +216,7 @@ export class DesktopApplication { }, onCommandEventAck: (event) => { this.commandExecutor.acknowledge(event); - } + }, }); this.registerIpcHandlers(); } @@ -191,14 +227,14 @@ export class DesktopApplication { ? process.resourcesPath : path.join(__dirname, "..", "..", "resources"); const dockIcon = nativeImage.createFromPath( - path.join(resourcesDir, "icon-1024.png") + path.join(resourcesDir, "icon-1024.png"), ); app.dock.setIcon(dockIcon); } this.tray.init({ onOpen: () => this.desktopWindow.show(), - onTogglePaused: (paused) => this.setCloudCommandsPaused(paused) + onTogglePaused: (paused) => this.setCloudCommandsPaused(paused), }); this.tray.setPaused(this.cloudCommandsPaused); this.syncPendingApprovalsToTray(); @@ -218,26 +254,31 @@ export class DesktopApplication { const configuredOrigins = { relayOrigin: this.settingsStore.getRelayOrigin(), apiOrigin: this.settingsStore.getApiOrigin(), - webAppOrigin: this.settingsStore.getWebAppOrigin() + webAppOrigin: this.settingsStore.getWebAppOrigin(), }; this.refreshTrayState( - `Serving on localhost:${this.server.getActivePort()} | relay=${configuredOrigins.relayOrigin} api=${configuredOrigins.apiOrigin} web=${configuredOrigins.webAppOrigin}` + `Serving on localhost:${this.server.getActivePort()} | relay=${configuredOrigins.relayOrigin} api=${configuredOrigins.apiOrigin} web=${configuredOrigins.webAppOrigin}`, ); if (this.cloudConnectionEnabled) { void this.cloudSocket.start(); } else { - this.cloudStatus = { state: "degraded", error: "Cloud connection disabled by user" }; + this.cloudStatus = { + state: "degraded", + error: "Cloud connection disabled by user", + }; } if (app.isPackaged) { autoUpdater.autoDownload = true; autoUpdater.autoInstallOnAppQuit = true; autoUpdater.on("update-available", (info) => { - this.desktopWindow.getWindow()?.webContents.send("desktop:update-available", { - updateAvailable: true, - version: info.version - }); + this.desktopWindow + .getWindow() + ?.webContents.send("desktop:update-available", { + updateAvailable: true, + version: info.version, + }); }); void autoUpdater.checkForUpdates().catch(() => {}); if (this.updateCheckTimer) clearInterval(this.updateCheckTimer); @@ -245,22 +286,31 @@ export class DesktopApplication { void autoUpdater.checkForUpdates().catch(() => {}); }, UPDATE_CHECK_INTERVAL_MS); } else { - void this.checkForUpdate().then((result) => { - if (result.updateAvailable) { - this.desktopWindow.getWindow()?.webContents.send("desktop:update-available", result); - } - }).catch(() => {}); - if (this.updateCheckTimer) clearInterval(this.updateCheckTimer); - this.updateCheckTimer = setInterval(() => { - void this.checkForUpdate().then((result) => { + void this.checkForUpdate() + .then((result) => { if (result.updateAvailable) { - this.desktopWindow.getWindow()?.webContents.send("desktop:update-available", result); + this.desktopWindow + .getWindow() + ?.webContents.send("desktop:update-available", result); } - }).catch(() => {}); + }) + .catch(() => {}); + if (this.updateCheckTimer) clearInterval(this.updateCheckTimer); + this.updateCheckTimer = setInterval(() => { + void this.checkForUpdate() + .then((result) => { + if (result.updateAvailable) { + this.desktopWindow + .getWindow() + ?.webContents.send("desktop:update-available", result); + } + }) + .catch(() => {}); }, UPDATE_CHECK_INTERVAL_MS); } } catch (error) { - const message = error instanceof Error ? error.message : "unknown startup error"; + const message = + error instanceof Error ? error.message : "unknown startup error"; this.tray.setState("error", `Desktop startup failed: ${message}`); throw error; } @@ -290,7 +340,10 @@ export class DesktopApplication { private onCloudSocketStatus(status: CloudSocketStatus): void { if (!this.cloudConnectionEnabled) { - this.cloudStatus = { state: "degraded", error: "Cloud connection disabled by user" }; + this.cloudStatus = { + state: "degraded", + error: "Cloud connection disabled by user", + }; this.refreshTrayState(); return; } @@ -303,11 +356,15 @@ export class DesktopApplication { if (status.state === "online") { this.cloudSocket.sendPresence({ state: this.cloudCommandsPaused ? "degraded" : "online", - ...(this.cloudCommandsPaused ? { error: "cloud commands paused by user" } : {}), + ...(this.cloudCommandsPaused + ? { error: "cloud commands paused by user" } + : {}), activeCommands: stats.activeCommands, - queueDepth: stats.queueDepth + queueDepth: stats.queueDepth, }); - this.refreshTrayState(`Serving on localhost:${this.server.getActivePort()} | cloud: online (${status.targetId})`); + this.refreshTrayState( + `Serving on localhost:${this.server.getActivePort()} | cloud: online (${status.targetId})`, + ); return; } @@ -316,9 +373,11 @@ export class DesktopApplication { state: "degraded", error: status.error, activeCommands: stats.activeCommands, - queueDepth: stats.queueDepth + queueDepth: stats.queueDepth, }); - this.refreshTrayState(`Serving on localhost:${this.server.getActivePort()} | cloud degraded: ${status.error}`); + this.refreshTrayState( + `Serving on localhost:${this.server.getActivePort()} | cloud degraded: ${status.error}`, + ); return; } @@ -333,10 +392,11 @@ export class DesktopApplication { const stats = this.commandExecutor.getStats(); this.cloudSocket.sendPresence({ - state: this.cloudStatus.state === "online" && !paused ? "online" : "degraded", + state: + this.cloudStatus.state === "online" && !paused ? "online" : "degraded", ...(paused ? { error: "cloud commands paused by user" } : {}), activeCommands: stats.activeCommands, - queueDepth: stats.queueDepth + queueDepth: stats.queueDepth, }); } @@ -345,7 +405,10 @@ export class DesktopApplication { this.settingsStore.setCloudConnectionEnabled(enabled); if (!enabled) { this.cloudSocket.stop(); - this.cloudStatus = { state: "degraded", error: "Cloud connection disabled by user" }; + this.cloudStatus = { + state: "degraded", + error: "Cloud connection disabled by user", + }; this.refreshTrayState(); return; } @@ -370,7 +433,9 @@ export class DesktopApplication { } private getSymphonyDir(): string { - const sandboxBase = normalizeScopePath(this.settingsStore.getSandboxBaseDirectory()); + const sandboxBase = normalizeScopePath( + this.settingsStore.getSandboxBaseDirectory(), + ); if (!sandboxBase?.trim()) { throw new SymphonyDirNotConfiguredError(); } @@ -378,7 +443,9 @@ export class DesktopApplication { } private migrateLegacyData(): void { - const sandboxBase = normalizeScopePath(this.settingsStore.getSandboxBaseDirectory()); + const sandboxBase = normalizeScopePath( + this.settingsStore.getSandboxBaseDirectory(), + ); if (!sandboxBase?.trim()) { return; } @@ -399,12 +466,12 @@ export class DesktopApplication { if (entry.isDirectory()) { copyDirIfMissing( path.join(srcDir, entry.name), - path.join(destDir, entry.name) + path.join(destDir, entry.name), ); } else { copyIfMissing( path.join(srcDir, entry.name), - path.join(destDir, entry.name) + path.join(destDir, entry.name), ); } } @@ -417,29 +484,29 @@ export class DesktopApplication { // sessions.json — try newer path first, then legacy copyIfMissing( path.join(os.homedir(), ".closedloop-ai", "sessions.json"), - path.join(newDir, "sessions.json") + path.join(newDir, "sessions.json"), ); copyIfMissing( path.join(os.homedir(), ".symphony", "sessions.json"), - path.join(newDir, "sessions.json") + path.join(newDir, "sessions.json"), ); // repos.json copyIfMissing( path.join(os.homedir(), ".claude", "closedloop", "repos.json"), - path.join(newDir, "config", "repos.json") + path.join(newDir, "config", "repos.json"), ); // chats from ~/.closedloop-ai/chats/ copyDirIfMissing( path.join(os.homedir(), ".closedloop-ai", "chats"), - path.join(newDir, "chats") + path.join(newDir, "chats"), ); // chats from ~/.claude/.symphony/chats/ copyDirIfMissing( path.join(os.homedir(), ".claude", ".symphony", "chats"), - path.join(newDir, "chats") + path.join(newDir, "chats"), ); } catch { // Migration is best-effort — don't block startup @@ -459,7 +526,9 @@ export class DesktopApplication { } private getAllowedDirectoriesFromSandbox(): string[] { - return buildAllowedDirectories(this.settingsStore.getSandboxBaseDirectory()); + return buildAllowedDirectories( + this.settingsStore.getSandboxBaseDirectory(), + ); } private getOnboardingState(): { @@ -473,9 +542,10 @@ export class DesktopApplication { settings: { ...settings, sandboxBaseDirectory: - normalizeScopePath(settings.sandboxBaseDirectory) ?? settings.sandboxBaseDirectory + normalizeScopePath(settings.sandboxBaseDirectory) ?? + settings.sandboxBaseDirectory, }, - hasStoredApiKey: this.apiKeyStore.getStatus().hasApiKey + hasStoredApiKey: this.apiKeyStore.getStatus().hasApiKey, }; } @@ -483,7 +553,8 @@ export class DesktopApplication { if (this.cloudCommandsPaused) { this.tray.setState( "degraded", - explicitDetails ?? `Serving on localhost:${this.server.getActivePort()} | cloud commands paused` + explicitDetails ?? + `Serving on localhost:${this.server.getActivePort()} | cloud commands paused`, ); return; } @@ -492,7 +563,7 @@ export class DesktopApplication { this.tray.setState( "ready", explicitDetails ?? - `Serving on localhost:${this.server.getActivePort()} | cloud: online (${this.cloudStatus.targetId})` + `Serving on localhost:${this.server.getActivePort()} | cloud: online (${this.cloudStatus.targetId})`, ); return; } @@ -501,25 +572,28 @@ export class DesktopApplication { this.tray.setState( "degraded", explicitDetails ?? - `Serving on localhost:${this.server.getActivePort()} | cloud degraded: ${this.cloudStatus.error}` + `Serving on localhost:${this.server.getActivePort()} | cloud degraded: ${this.cloudStatus.error}`, ); return; } this.tray.setState( "ready", - explicitDetails ?? `Serving on localhost:${this.server.getActivePort()} | cloud: connecting` + explicitDetails ?? + `Serving on localhost:${this.server.getActivePort()} | cloud: connecting`, ); } - private async evaluateApproval(request: GatewayApprovalRequest): Promise { + private async evaluateApproval( + request: GatewayApprovalRequest, + ): Promise { if (!this.settingsStore.getOnboardingCompleted()) { return { allow: false, statusCode: 403, payload: { - error: "onboarding not completed" - } + error: "onboarding not completed", + }, }; } @@ -532,13 +606,15 @@ export class DesktopApplication { return { allow: false, statusCode: 403, - payload: { error: `Unmapped operation: ${request.path}` } + payload: { error: `Unmapped operation: ${request.path}` }, }; } const settings = this.settingsStore.getAll(); const requestScopePath = resolveApprovalScopePath(request.body); - const activeAlwaysAllowRules = pruneExpiredAlwaysAllowRules(settings.alwaysAllowRules); + const activeAlwaysAllowRules = pruneExpiredAlwaysAllowRules( + settings.alwaysAllowRules, + ); if (activeAlwaysAllowRules.length !== settings.alwaysAllowRules.length) { this.settingsStore.setAlwaysAllowRules(activeAlwaysAllowRules); } @@ -547,19 +623,28 @@ export class DesktopApplication { operationId, method: request.method, path: request.path, - scopePath: requestScopePath + scopePath: requestScopePath, }) ) { return { allow: true }; } - const configuredTier = (settings.autoApprovalRules[operationId] ?? - settings.defaultApprovalTier) as RiskTier; - if (shouldAutoApprove(operationId, configuredTier, request.forceApproval ?? false)) { + const configuredTier = + settings.autoApprovalRules[operationId] ?? settings.defaultApprovalTier; + if ( + shouldAutoApprove( + operationId, + configuredTier, + request.forceApproval ?? false, + ) + ) { return { allow: true }; } - const operationRisk = (OPERATION_RISK_TIERS as Record>)[operationId] ?? "high"; + const operationRisk = + (OPERATION_RISK_TIERS as Record>)[ + operationId + ] ?? "high"; const reason = request.approvalReason?.trim() || `${operationId} is ${operationRisk}-risk, but your auto-approve threshold is ${configuredTier}`; @@ -571,9 +656,12 @@ export class DesktopApplication { body: request.body, scopePath: requestScopePath ?? undefined, location: describeRequestLocation(request), - reason + reason, }); - const decision = await this.approvalStore.waitForDecision(pending.id, APPROVAL_TIMEOUT_MS); + const decision = await this.approvalStore.waitForDecision( + pending.id, + APPROVAL_TIMEOUT_MS, + ); if (decision === "always_allow") { this.saveAlwaysAllowRuleForPending(pending); @@ -590,8 +678,8 @@ export class DesktopApplication { payload: { error: "approval timed out", operationId, - approvalId: pending.id - } + approvalId: pending.id, + }, }; } @@ -601,8 +689,8 @@ export class DesktopApplication { payload: { error: "request denied", operationId, - approvalId: pending.id - } + approvalId: pending.id, + }, }; } @@ -614,7 +702,10 @@ export class DesktopApplication { }): void { const settings = this.settingsStore.getAll(); const now = Date.now(); - const activeRules = pruneExpiredAlwaysAllowRules(settings.alwaysAllowRules, now); + const activeRules = pruneExpiredAlwaysAllowRules( + settings.alwaysAllowRules, + now, + ); const expiresAt = new Date(now + ALWAYS_ALLOW_RULE_TTL_MS).toISOString(); const existingIndex = activeRules.findIndex( @@ -622,13 +713,14 @@ export class DesktopApplication { rule.operationId === pending.operationId && rule.method.toUpperCase() === pending.method.toUpperCase() && rule.path === pending.path && - normalizeScopePath(rule.scopePath) === normalizeScopePath(pending.scopePath) + normalizeScopePath(rule.scopePath) === + normalizeScopePath(pending.scopePath), ); if (existingIndex >= 0) { activeRules[existingIndex] = { ...activeRules[existingIndex], - expiresAt + expiresAt, }; } else { activeRules.push({ @@ -638,29 +730,41 @@ export class DesktopApplication { path: pending.path, scopePath: normalizeScopePath(pending.scopePath) ?? undefined, createdAt: new Date(now).toISOString(), - expiresAt + expiresAt, }); } this.settingsStore.setAlwaysAllowRules(activeRules); } - private async checkForUpdate(): Promise<{ updateAvailable: boolean; currentHash: string; remoteHash: string }> { + private async checkForUpdate(): Promise<{ + updateAvailable: boolean; + currentHash: string; + remoteHash: string; + }> { const repoRoot = path.resolve(__dirname, "../../../.."); await execFileAsync("git", ["fetch", "origin", "main"], { cwd: repoRoot }); - const { stdout } = await execFileAsync("git", ["rev-parse", "origin/main"], { cwd: repoRoot }); + const { stdout } = await execFileAsync( + "git", + ["rev-parse", "origin/main"], + { cwd: repoRoot }, + ); const remoteHash = stdout.trim(); return { updateAvailable: remoteHash !== BUILD_COMMIT_HASH, currentHash: BUILD_COMMIT_HASH, - remoteHash + remoteHash, }; } private async applyUpdate(): Promise { const repoRoot = path.resolve(__dirname, "../../../.."); - await execFileAsync("git", ["pull", "--rebase", "origin", "main"], { cwd: repoRoot }); - await execFileAsync("pnpm", ["-C", "apps/desktop", "build"], { cwd: repoRoot }); + await execFileAsync("git", ["pull", "--rebase", "origin", "main"], { + cwd: repoRoot, + }); + await execFileAsync("pnpm", ["-C", "apps/desktop", "build"], { + cwd: repoRoot, + }); app.relaunch(); app.exit(0); } @@ -693,21 +797,44 @@ export class DesktopApplication { try { const stateRaw = readFileSync(job.statePath, "utf-8"); const state = JSON.parse(stateRaw) as Record; - const rawStatus = typeof state.status === "string" ? state.status.toUpperCase() : null; + const rawStatus = + typeof state.status === "string" + ? state.status.toUpperCase() + : null; if (rawStatus === "COMPLETED") { - return { ...job, status: "COMPLETED", updatedAt: now, completedAt: now }; + return { + ...job, + status: "COMPLETED", + updatedAt: now, + completedAt: now, + }; } if (rawStatus === "FAILED") { - return { ...job, status: "FAILED", updatedAt: now, completedAt: now }; + return { + ...job, + status: "FAILED", + updatedAt: now, + completedAt: now, + }; } if (rawStatus === "CANCELLED") { - return { ...job, status: "CANCELLED", updatedAt: now, completedAt: now }; + return { + ...job, + status: "CANCELLED", + updatedAt: now, + completedAt: now, + }; } if (rawStatus === "AWAITING_USER") { return { ...job, status: "AWAITING_USER", updatedAt: now }; } if (rawStatus === "STOPPED") { - return { ...job, status: "STOPPED", updatedAt: now, completedAt: now }; + return { + ...job, + status: "STOPPED", + updatedAt: now, + completedAt: now, + }; } } catch { // state.json unreadable -- fall through @@ -715,7 +842,12 @@ export class DesktopApplication { } // CANCEL_PENDING + process dead = confirmed cancelled if (job.status === "CANCEL_PENDING") { - return { ...job, status: "CANCELLED", updatedAt: now, completedAt: now }; + return { + ...job, + status: "CANCELLED", + updatedAt: now, + completedAt: now, + }; } return { ...job, status: "UNKNOWN", updatedAt: now, completedAt: now }; } @@ -731,31 +863,41 @@ export class DesktopApplication { private registerIpcHandlers(): void { ipcMain.handle("desktop:get-logs", () => gatewayLog.getEntries()); - ipcMain.handle("desktop:clear-logs", () => { gatewayLog.clear(); }); + ipcMain.handle("desktop:clear-logs", () => { + gatewayLog.clear(); + }); ipcMain.handle("desktop:get-settings", () => { const settings = this.settingsStore.getAll(); - const activeAlwaysAllowRules = pruneExpiredAlwaysAllowRules(settings.alwaysAllowRules); + const activeAlwaysAllowRules = pruneExpiredAlwaysAllowRules( + settings.alwaysAllowRules, + ); if (activeAlwaysAllowRules.length !== settings.alwaysAllowRules.length) { this.settingsStore.setAlwaysAllowRules(activeAlwaysAllowRules); } return { ...settings, - alwaysAllowRules: activeAlwaysAllowRules + alwaysAllowRules: activeAlwaysAllowRules, }; }); ipcMain.handle( "desktop:update-settings", - async (_event, partial: { - sandboxBaseDirectory?: string; - onboardingCompleted?: boolean; - relayOrigin?: string; - apiOrigin?: string; - webAppOrigin?: string; - defaultApprovalTier?: "auto" | "none" | "low" | "medium" | "high"; - autoApprovalRules?: Record; - verboseLogging?: boolean; - }) => { + async ( + _event, + partial: { + sandboxBaseDirectory?: string; + onboardingCompleted?: boolean; + relayOrigin?: string; + apiOrigin?: string; + 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 }; // Normalize legacy "auto" tier to "high" (they behave identically) @@ -763,18 +905,24 @@ export class DesktopApplication { nextPartial.defaultApprovalTier = "high"; } if (nextPartial.autoApprovalRules) { - for (const [key, val] of Object.entries(nextPartial.autoApprovalRules)) { + for (const [key, val] of Object.entries( + nextPartial.autoApprovalRules, + )) { if (val === "auto") nextPartial.autoApprovalRules[key] = "high"; } } if (typeof partial.relayOrigin === "string") { - nextPartial.relayOrigin = normalizeAndValidateOrigin(partial.relayOrigin); + nextPartial.relayOrigin = normalizeAndValidateOrigin( + partial.relayOrigin, + ); } if (typeof partial.apiOrigin === "string") { nextPartial.apiOrigin = normalizeAndValidateOrigin(partial.apiOrigin); } if (typeof partial.webAppOrigin === "string") { - nextPartial.webAppOrigin = normalizeWebAppOrigin(partial.webAppOrigin); + nextPartial.webAppOrigin = normalizeWebAppOrigin( + partial.webAppOrigin, + ); } const selectedSandbox = typeof partial.sandboxBaseDirectory === "string" @@ -791,10 +939,14 @@ export class DesktopApplication { partial.onboardingCompleted && !selectedSandbox ) { - throw new Error("Complete onboarding requires a sandbox base directory"); + throw new Error( + "Complete onboarding requires a sandbox base directory", + ); } - const updated = this.settingsStore.update(nextPartial as Partial); + const updated = this.settingsStore.update( + nextPartial as Partial, + ); if (typeof nextPartial.verboseLogging === "boolean") { gatewayLog.setVerbose(nextPartial.verboseLogging); } @@ -802,14 +954,15 @@ export class DesktopApplication { if ( typeof partial.sandboxBaseDirectory === "string" && selectedSandbox && - selectedSandbox !== normalizeScopePath(currentSettings.sandboxBaseDirectory) + selectedSandbox !== + normalizeScopePath(currentSettings.sandboxBaseDirectory) ) { await seedReposConfig(selectedSandbox); } this.restartCloudSocket(); return updated; - } + }, ); ipcMain.handle("desktop:get-runtime-status", () => ({ port: this.server.getActivePort(), @@ -818,17 +971,24 @@ export class DesktopApplication { apiOrigin: this.settingsStore.getApiOrigin(), sandboxBaseDirectory: this.settingsStore.getSandboxBaseDirectory(), commandsPaused: this.cloudCommandsPaused, - connectionEnabled: this.cloudConnectionEnabled + connectionEnabled: this.cloudConnectionEnabled, })); ipcMain.handle("desktop:list-running-jobs", async () => { const jobs = this.jobStore.listRunning(); - const snapshots = await Promise.all(jobs.map((j) => enrichJobSnapshot(j))); + const snapshots = await Promise.all( + jobs.map((j) => enrichJobSnapshot(j)), + ); // Reconcile: if enrichment detected a terminal status (process dead), // persist it so the job moves from active to terminal in the store. const stillRunning = []; for (const snapshot of snapshots) { - if (isTerminalJobStatus(snapshot.status) && !isTerminalJobStatus(this.jobStore.getById(snapshot.id)?.status ?? "UNKNOWN")) { + if ( + isTerminalJobStatus(snapshot.status) && + !isTerminalJobStatus( + this.jobStore.getById(snapshot.id)?.status ?? "UNKNOWN", + ) + ) { this.jobStore.upsert({ ...this.jobStore.getById(snapshot.id)!, status: snapshot.status, @@ -842,37 +1002,48 @@ export class DesktopApplication { return stillRunning; }); - ipcMain.handle("desktop:list-completed-jobs", () => this.jobStore.listCompleted()); + ipcMain.handle("desktop:list-completed-jobs", () => + this.jobStore.listCompleted(), + ); ipcMain.handle("desktop:get-job", (_event, jobId: string) => { if (typeof jobId !== "string" || !jobId.trim()) { throw new Error("jobId is required"); } return this.jobStore.getById(jobId.trim()) ?? null; }); - ipcMain.handle("desktop:get-job-log-tail", async (_event, jobId: string, lines?: number) => { - if (typeof jobId !== "string" || !jobId.trim()) { - throw new Error("jobId is required"); - } - const job = this.jobStore.getById(jobId.trim()); - if (!job?.logPath) { - return null; - } - try { - const content = await readFile(job.logPath!, "utf-8"); - const allLines = content.split("\n"); - const maxLines = typeof lines === "number" && lines > 0 ? lines : 200; - return allLines.slice(-maxLines).join("\n"); - } catch { - return null; - } - }); - ipcMain.handle("desktop:get-activity-events", () => this.activityLog.list()); + ipcMain.handle( + "desktop:get-job-log-tail", + async (_event, jobId: string, lines?: number) => { + if (typeof jobId !== "string" || !jobId.trim()) { + throw new Error("jobId is required"); + } + const job = this.jobStore.getById(jobId.trim()); + if (!job?.logPath) { + return null; + } + try { + const content = await readFile(job.logPath, "utf-8"); + const allLines = content.split("\n"); + const maxLines = typeof lines === "number" && lines > 0 ? lines : 200; + return allLines.slice(-maxLines).join("\n"); + } catch { + return null; + } + }, + ); + ipcMain.handle("desktop:get-activity-events", () => + this.activityLog.list(), + ); ipcMain.handle("desktop:clear-activity-events", () => { this.activityLog.clear(); return this.activityLog.list(); }); - ipcMain.handle("desktop:get-pending-approvals", () => this.approvalStore.listPending()); - ipcMain.handle("desktop:get-resolved-approvals", () => this.approvalStore.listResolved()); + ipcMain.handle("desktop:get-pending-approvals", () => + this.approvalStore.listPending(), + ); + ipcMain.handle("desktop:get-resolved-approvals", () => + this.approvalStore.listResolved(), + ); ipcMain.handle("desktop:clear-resolved-approvals", () => { this.approvalStore.clearResolved(); return []; @@ -891,38 +1062,48 @@ export class DesktopApplication { this.approvalStore.deny(approvalId.trim()); return this.approvalStore.listPending(); }); - ipcMain.handle("desktop:always-allow-approval", (_event, approvalId: string) => { - if (typeof approvalId !== "string" || !approvalId.trim()) { - throw new Error("approvalId is required"); - } - const pending = this.approvalStore.getPendingById(approvalId.trim()); - if (!pending) { + ipcMain.handle( + "desktop:always-allow-approval", + (_event, approvalId: string) => { + if (typeof approvalId !== "string" || !approvalId.trim()) { + throw new Error("approvalId is required"); + } + const pending = this.approvalStore.getPendingById(approvalId.trim()); + if (!pending) { + return { + pendingApprovals: this.approvalStore.listPending(), + settings: this.settingsStore.getAll(), + }; + } + this.saveAlwaysAllowRuleForPending(pending); + this.approvalStore.alwaysAllow(approvalId.trim()); return { pendingApprovals: this.approvalStore.listPending(), - settings: this.settingsStore.getAll() + settings: this.settingsStore.getAll(), }; - } - this.saveAlwaysAllowRuleForPending(pending); - this.approvalStore.alwaysAllow(approvalId.trim()); - return { - pendingApprovals: this.approvalStore.listPending(), - settings: this.settingsStore.getAll() - }; - }); - ipcMain.handle("desktop:remove-always-allow-rule", (_event, ruleId: string) => { - if (typeof ruleId !== "string" || !ruleId.trim()) { - throw new Error("ruleId is required"); - } - const settings = this.settingsStore.getAll(); - const updated = (settings.alwaysAllowRules ?? []).filter((r) => r.id !== ruleId.trim()); - this.settingsStore.setAlwaysAllowRules(updated); - return { alwaysAllowRules: updated }; - }); + }, + ); + ipcMain.handle( + "desktop:remove-always-allow-rule", + (_event, ruleId: string) => { + if (typeof ruleId !== "string" || !ruleId.trim()) { + throw new Error("ruleId is required"); + } + const settings = this.settingsStore.getAll(); + const updated = (settings.alwaysAllowRules ?? []).filter( + (r) => r.id !== ruleId.trim(), + ); + this.settingsStore.setAlwaysAllowRules(updated); + return { alwaysAllowRules: updated }; + }, + ); ipcMain.handle("desktop:clear-pending-approvals", () => { this.approvalStore.clear(); return this.approvalStore.listPending(); }); - ipcMain.handle("desktop:get-api-key-status", () => this.apiKeyStore.getStatus()); + ipcMain.handle("desktop:get-api-key-status", () => + this.apiKeyStore.getStatus(), + ); ipcMain.handle("desktop:set-api-key", (_event, apiKey: string) => { const trimmed = typeof apiKey === "string" ? apiKey.trim() : ""; if (!trimmed.startsWith("sk_live_")) { @@ -937,17 +1118,31 @@ export class DesktopApplication { this.restartCloudSocket(); return this.apiKeyStore.getStatus(); }); - ipcMain.handle("desktop:get-cloud-commands-paused", () => this.cloudCommandsPaused); - ipcMain.handle("desktop:set-cloud-commands-paused", (_event, paused: boolean) => { - this.setCloudCommandsPaused(Boolean(paused)); - return { paused: this.cloudCommandsPaused }; - }); - ipcMain.handle("desktop:get-cloud-connection-enabled", () => this.cloudConnectionEnabled); - ipcMain.handle("desktop:set-cloud-connection-enabled", (_event, enabled: boolean) => { - this.setCloudConnectionEnabled(Boolean(enabled)); - return { enabled: this.cloudConnectionEnabled }; - }); - ipcMain.handle("desktop:get-onboarding-state", () => this.getOnboardingState()); + ipcMain.handle( + "desktop:get-cloud-commands-paused", + () => this.cloudCommandsPaused, + ); + ipcMain.handle( + "desktop:set-cloud-commands-paused", + (_event, paused: boolean) => { + this.setCloudCommandsPaused(Boolean(paused)); + return { paused: this.cloudCommandsPaused }; + }, + ); + ipcMain.handle( + "desktop:get-cloud-connection-enabled", + () => this.cloudConnectionEnabled, + ); + ipcMain.handle( + "desktop:set-cloud-connection-enabled", + (_event, enabled: boolean) => { + this.setCloudConnectionEnabled(Boolean(enabled)); + return { enabled: this.cloudConnectionEnabled }; + }, + ); + ipcMain.handle("desktop:get-onboarding-state", () => + this.getOnboardingState(), + ); ipcMain.handle( "desktop:complete-onboarding", async ( @@ -958,21 +1153,26 @@ export class DesktopApplication { webAppOrigin: string; sandboxBaseDirectory: string; apiKey?: string; - } + }, ) => { - const relayOrigin = typeof payload.relayOrigin === "string" && payload.relayOrigin.trim() - ? normalizeAndValidateOrigin(payload.relayOrigin) - : undefined; - const apiOrigin = typeof payload.apiOrigin === "string" && payload.apiOrigin.trim() - ? normalizeAndValidateOrigin(payload.apiOrigin) - : undefined; + const relayOrigin = + typeof payload.relayOrigin === "string" && payload.relayOrigin.trim() + ? normalizeAndValidateOrigin(payload.relayOrigin) + : undefined; + const apiOrigin = + typeof payload.apiOrigin === "string" && payload.apiOrigin.trim() + ? normalizeAndValidateOrigin(payload.apiOrigin) + : undefined; const webAppOrigin = normalizeWebAppOrigin(payload.webAppOrigin); - const sandboxBaseDirectory = normalizeScopePath(payload.sandboxBaseDirectory); + const sandboxBaseDirectory = normalizeScopePath( + payload.sandboxBaseDirectory, + ); if (!sandboxBaseDirectory) { throw new Error("Sandbox base directory is required"); } - const trimmedApiKey = typeof payload.apiKey === "string" ? payload.apiKey.trim() : ""; + const trimmedApiKey = + typeof payload.apiKey === "string" ? payload.apiKey.trim() : ""; if (trimmedApiKey) { if (!trimmedApiKey.startsWith("sk_live_")) { throw new Error("API key must start with sk_live_"); @@ -985,35 +1185,44 @@ export class DesktopApplication { ...(apiOrigin !== undefined ? { apiOrigin } : {}), webAppOrigin, sandboxBaseDirectory, - onboardingCompleted: true + onboardingCompleted: true, }); await seedReposConfig(sandboxBaseDirectory); this.restartCloudSocket(); return this.getOnboardingState(); - } + }, ); ipcMain.handle("desktop:pick-sandbox-directory", async () => { const result = await dialog.showOpenDialog({ - properties: ["openDirectory", "createDirectory"] + properties: ["openDirectory", "createDirectory"], }); if (result.canceled || result.filePaths.length === 0) { return null; } return result.filePaths[0]; }); - ipcMain.handle("desktop:get-dangerous-auto-approve", () => this.dangerousAutoApprove); - ipcMain.handle("desktop:set-dangerous-auto-approve", (_event, enabled: boolean) => { - this.dangerousAutoApprove = Boolean(enabled); - return this.dangerousAutoApprove; - }); - ipcMain.handle("desktop:is-debug-auth-enabled", () => this.isDebugAuthEnabled()); + ipcMain.handle( + "desktop:get-dangerous-auto-approve", + () => this.dangerousAutoApprove, + ); + ipcMain.handle( + "desktop:set-dangerous-auto-approve", + (_event, enabled: boolean) => { + this.dangerousAutoApprove = Boolean(enabled); + return this.dangerousAutoApprove; + }, + ); + ipcMain.handle("desktop:is-debug-auth-enabled", () => + this.isDebugAuthEnabled(), + ); ipcMain.handle("desktop:mint-debug-token", (_event, origin?: string) => { if (!this.isDebugAuthEnabled()) { throw new Error("Debug auth is not enabled"); } - const boundOrigin = typeof origin === "string" && origin.trim() - ? origin.trim() - : "http://localhost"; + const boundOrigin = + typeof origin === "string" && origin.trim() + ? origin.trim() + : "http://localhost"; const session = this.sessionStore.create(boundOrigin); return { ...session, origin: boundOrigin }; }); @@ -1023,13 +1232,17 @@ export class DesktopApplication { const result = await autoUpdater.checkForUpdates(); const remoteVersion = result?.updateInfo?.version; return { - updateAvailable: remoteVersion != null && remoteVersion !== app.getVersion(), - version: remoteVersion + updateAvailable: + remoteVersion != null && remoteVersion !== app.getVersion(), + version: remoteVersion, }; } return await this.checkForUpdate(); } catch (error) { - return { updateAvailable: false, error: error instanceof Error ? error.message : "unknown error" }; + return { + updateAvailable: false, + error: error instanceof Error ? error.message : "unknown error", + }; } }); ipcMain.handle("desktop:apply-update", async () => { @@ -1042,14 +1255,13 @@ export class DesktopApplication { } } - const APPROVAL_TIMEOUT_MS = 120_000; const MAX_IN_FLIGHT_COMMANDS = 2; const ALWAYS_ALLOW_RULE_TTL_MS = 7 * 24 * 60 * 60 * 1000; function pruneExpiredAlwaysAllowRules( rules: AlwaysAllowRule[] | undefined, - now = Date.now() + now = Date.now(), ): AlwaysAllowRule[] { if (!Array.isArray(rules) || rules.length === 0) { return []; @@ -1066,7 +1278,12 @@ function pruneExpiredAlwaysAllowRules( function matchesAlwaysAllowRule( rules: AlwaysAllowRule[], - request: { operationId: string; method: string; path: string; scopePath?: string | null } + request: { + operationId: string; + method: string; + path: string; + scopePath?: string | null; + }, ): boolean { const normalizedScope = normalizeScopePath(request.scopePath); return rules.some((rule) => { @@ -1084,7 +1301,7 @@ function matchesAlwaysAllowRule( } function resolveApprovalScopePath(rawBody: string): string | null { - if (!rawBody || !rawBody.trim()) { + if (!rawBody?.trim()) { return null; } @@ -1106,8 +1323,7 @@ function maybeString(value: unknown): string | null { if (typeof value !== "string") { return null; } - const trimmed = value.trim(); - return trimmed ? trimmed : null; + return value.trim(); } function describeRequestLocation(request: GatewayApprovalRequest): string { diff --git a/apps/desktop/src/main/cloud-command-executor.ts b/apps/desktop/src/main/cloud-command-executor.ts index bc555854..926a693e 100644 --- a/apps/desktop/src/main/cloud-command-executor.ts +++ b/apps/desktop/src/main/cloud-command-executor.ts @@ -6,8 +6,10 @@ import type { DesktopCommandAckEvent, DesktopCommandEvent, DesktopCommandStreamAckEvent, - DesktopCommandStreamEvent + DesktopCommandStreamEvent, + ProtocolEnvelope, } from "./cloud-protocol.js"; +import type { TelemetryEventPayload } from "./telemetry-protocol.js"; const COMMAND_RETENTION_MS = 10 * 60_000; const MAX_RETAINED_TERMINAL_COMMANDS = 200; @@ -16,9 +18,17 @@ export interface CloudCommandExecutorOptions { getGatewayPort: () => number; getGatewayAuthToken: () => string; maxInFlightCommands: number; - sendCommandAck: (event: Omit) => void; - sendCommandEvent: (event: Omit) => void; - onQueueStatsChange?: (stats: { activeCommands: number; queueDepth: number }) => void; + sendCommandAck: ( + event: Omit, + ) => void; + sendCommandEvent: ( + event: Omit, + ) => void; + onQueueStatsChange?: (stats: { + activeCommands: number; + queueDepth: number; + }) => void; + emitTelemetry?: (event: TelemetryEventPayload) => void; } export class CloudCommandExecutor { @@ -28,6 +38,7 @@ export class CloudCommandExecutor { private readonly lockOwners = new Map(); private readonly trackedByCommandId = new Map(); private connected = false; + private disposed = false; constructor(options: CloudCommandExecutorOptions) { this.options = options; @@ -48,7 +59,7 @@ export class CloudCommandExecutor { this.options.sendCommandAck({ commandId: command.commandId, accepted: true, - state: "accepted" + state: "accepted", }); if (existing.state === "terminal") { this.replayBuffered(command.commandId, 0); @@ -58,44 +69,52 @@ export class CloudCommandExecutor { const validationError = validateCommand(command); if (validationError) { - gatewayLog.warn("command-executor", `Rejected command ${command.commandId}: ${validationError}`); + gatewayLog.warn( + "command-executor", + `Rejected command ${command.commandId}: ${validationError}`, + ); this.options.sendCommandAck({ commandId: command.commandId, accepted: false, state: "failed", - reason: validationError + reason: validationError, }); return; } - gatewayLog.debug("command-executor", `Enqueued command ${command.commandId}: ${command.method} ${command.path}`); + gatewayLog.debug( + "command-executor", + `Enqueued command ${command.commandId}: ${command.method} ${command.path}`, + ); const tracked: TrackedCommand = { command, state: "queued", lastEmittedSequence: 0, buffered: { lastAckedSequence: 0, - events: [] - } + events: [], + }, }; this.trackedByCommandId.set(command.commandId, tracked); this.queue.push(command); this.options.sendCommandAck({ commandId: command.commandId, accepted: true, - state: "accepted" + state: "accepted", }); this.schedule(); } cancel(cancelEvent: DesktopCancelEvent): void { - const queuedIndex = this.queue.findIndex((item) => item.commandId === cancelEvent.commandId); + const queuedIndex = this.queue.findIndex( + (item) => item.commandId === cancelEvent.commandId, + ); if (queuedIndex >= 0) { this.queue.splice(queuedIndex, 1); this.emitTrackedEvent(cancelEvent.commandId, "done", { type: "done", cancelled: true, - reason: cancelEvent.reason ?? "cancelled" + reason: cancelEvent.reason ?? "cancelled", }); this.markTerminal(cancelEvent.commandId, "cancelled"); this.notifyQueueStats(); @@ -119,23 +138,29 @@ export class CloudCommandExecutor { } tracked.buffered.lastAckedSequence = Math.max( tracked.buffered.lastAckedSequence, - ackEvent.sequence + ackEvent.sequence, ); if (tracked.state === "terminal") { return; } tracked.buffered.events = tracked.buffered.events.filter( - (event) => event.sequence > tracked.buffered.lastAckedSequence + (event) => event.sequence > tracked.buffered.lastAckedSequence, ); } replayFrom(resumeFromSequence: Record): void { - for (const [commandId, fromSequence] of Object.entries(resumeFromSequence)) { - this.replayBuffered(commandId, Number.isFinite(fromSequence) ? Math.trunc(fromSequence) : 0); + for (const [commandId, fromSequence] of Object.entries( + resumeFromSequence, + )) { + this.replayBuffered( + commandId, + Number.isFinite(fromSequence) ? Math.trunc(fromSequence) : 0, + ); } } dispose(): void { + this.disposed = true; for (const running of this.inFlightByCommandId.values()) { if (running.timeout) { clearTimeout(running.timeout); @@ -152,7 +177,7 @@ export class CloudCommandExecutor { getStats(): { activeCommands: number; queueDepth: number } { return { activeCommands: this.inFlightByCommandId.size, - queueDepth: this.queue.length + queueDepth: this.queue.length, }; } @@ -160,7 +185,10 @@ export class CloudCommandExecutor { if (!this.connected) { return; } - while (this.inFlightByCommandId.size < Math.max(1, this.options.maxInFlightCommands)) { + while ( + this.inFlightByCommandId.size < + Math.max(1, this.options.maxInFlightCommands) + ) { const nextIndex = this.queue.findIndex((candidate) => { const lockKey = deriveLockKey(candidate); if (!lockKey) { @@ -184,7 +212,10 @@ export class CloudCommandExecutor { return; } tracked.state = "running"; - gatewayLog.debug("command-executor", `Executing command ${command.commandId}: ${command.method} ${command.path}`); + gatewayLog.debug( + "command-executor", + `Executing command ${command.commandId}: ${command.method} ${command.path}`, + ); const lockKey = deriveLockKey(command); if (lockKey) { @@ -195,7 +226,7 @@ export class CloudCommandExecutor { const running: RunningCommand = { lockKey, abortController, - cancelRequested: false + cancelRequested: false, }; if (typeof command.timeoutMs === "number" && command.timeoutMs > 0) { running.timeout = setTimeout(() => { @@ -209,7 +240,7 @@ export class CloudCommandExecutor { this.emitTrackedEvent(command.commandId, "status", { type: "status", status: "running", - operationId: command.operationId + operationId: command.operationId, }); try { @@ -219,31 +250,64 @@ export class CloudCommandExecutor { this.markTerminal(command.commandId, "done"); } } catch (error) { + if (this.disposed) { + this.markTerminal(command.commandId, "failed"); + return; + } + + const trace = { + commandId: command.commandId, + operationId: command.operationId, + }; + if (running.cancelRequested) { this.emitTrackedEvent(command.commandId, "done", { type: "done", cancelled: true, - reason: running.cancelReason ?? "cancelled" + reason: running.cancelReason ?? "cancelled", }); - gatewayLog.debug("command-executor", `Command ${command.commandId} cancelled`); + gatewayLog.debug( + "command-executor", + `Command ${command.commandId} cancelled`, + ); + this.emitCommandTelemetry( + "warn", + "command.cancelled", + "Command cancelled", + trace, + ); this.markTerminal(command.commandId, "cancelled"); } else if (running.timedOut) { this.emitTrackedEvent(command.commandId, "error", { type: "error", terminal: true, code: "timeout", - error: "command timed out" + error: "command timed out", }); - gatewayLog.error("command-executor", `Command ${command.commandId} timed out`); + gatewayLog.error( + "command-executor", + `Command ${command.commandId} timed out`, + ); + this.emitCommandTelemetry( + "error", + "command.timeout", + "Command timed out", + trace, + ); this.markTerminal(command.commandId, "failed"); } else { - const msg = error instanceof Error ? error.message : "unknown command failure"; + const msg = + error instanceof Error ? error.message : "unknown command failure"; this.emitTrackedEvent(command.commandId, "error", { type: "error", terminal: true, - error: msg + error: msg, }); - gatewayLog.error("command-executor", `Command ${command.commandId} failed: ${msg}`); + gatewayLog.error( + "command-executor", + `Command ${command.commandId} failed: ${msg}`, + ); + this.emitCommandTelemetry("error", "command.gateway_error", msg, trace); this.markTerminal(command.commandId, "failed"); } } finally { @@ -258,7 +322,10 @@ export class CloudCommandExecutor { } } - private async executeViaGateway(command: DesktopCommandEvent, signal: AbortSignal): Promise { + private async executeViaGateway( + command: DesktopCommandEvent, + signal: AbortSignal, + ): Promise { const port = this.options.getGatewayPort(); const requestUrl = new URL(command.path, `http://127.0.0.1:${port}`); applyQuery(requestUrl, command.query); @@ -266,6 +333,12 @@ export class CloudCommandExecutor { const headers = new Headers(command.headers); headers.set("x-desktop-gateway-token", this.options.getGatewayAuthToken()); headers.set("x-desktop-source", "cloud-socket"); + if (isValidUuid(command.commandId)) { + headers.set("x-desktop-command-id", command.commandId); + } + if (isSafeHeaderValue(command.operationId)) { + headers.set("x-desktop-operation-id", command.operationId); + } if (command.requiresApproval) { headers.set("x-desktop-force-approval", "1"); if (command.approvalReason) { @@ -276,21 +349,32 @@ export class CloudCommandExecutor { const method = command.method.toUpperCase(); const body = serializeBody(command.body, headers, method); - gatewayLog.debug("command-executor", `Gateway fetch: ${method} ${requestUrl.pathname}`); + gatewayLog.debug( + "command-executor", + `Gateway fetch: ${method} ${requestUrl.pathname}`, + ); const response = await fetch(requestUrl, { method, headers, body, - signal + signal, }); - const contentType = (response.headers.get("content-type") ?? "").toLowerCase(); + const contentType = ( + response.headers.get("content-type") ?? "" + ).toLowerCase(); const isStream = - contentType.includes("text/event-stream") || contentType.includes("application/x-ndjson"); + contentType.includes("text/event-stream") || + contentType.includes("application/x-ndjson"); 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}` : ""}`); + gatewayLog.error( + "command-executor", + `Gateway returned ${response.status} for ${method} ${requestUrl.pathname}: ${message}`, + ); + throw new Error( + `gateway returned ${response.status}${message ? `: ${message}` : ""}`, + ); } if (isStream) { @@ -303,13 +387,16 @@ export class CloudCommandExecutor { type: "result", statusCode: response.status, success: response.ok, - data: payload + data: payload, }); this.emitTrackedEvent(command.commandId, "done", { type: "done" }); this.markTerminal(command.commandId, "done"); } - private async consumeStreamResponse(commandId: string, response: Response): Promise { + private async consumeStreamResponse( + commandId: string, + response: Response, + ): Promise { if (!response.body) { return; } @@ -334,24 +421,15 @@ export class CloudCommandExecutor { if (!trimmed) { continue; } - - const mapped = mapGatewayLineToCommandEvent(trimmed); - this.emitTrackedEvent(commandId, mapped.eventType, mapped.data); - if (isTerminalEvent(mapped.eventType, mapped.data)) { + if (this.processStreamLine(commandId, trimmed)) { emittedTerminal = true; - this.markTerminal(commandId, resolveTerminalState(mapped.eventType, mapped.data)); } } } const trailing = buffer.trim(); - if (trailing) { - const mapped = mapGatewayLineToCommandEvent(trailing); - this.emitTrackedEvent(commandId, mapped.eventType, mapped.data); - if (isTerminalEvent(mapped.eventType, mapped.data)) { - emittedTerminal = true; - this.markTerminal(commandId, resolveTerminalState(mapped.eventType, mapped.data)); - } + if (trailing && this.processStreamLine(commandId, trailing)) { + emittedTerminal = true; } if (!emittedTerminal) { @@ -360,10 +438,24 @@ export class CloudCommandExecutor { } } + /** Emit a mapped stream line event. Returns true if the event was terminal. */ + private processStreamLine(commandId: string, line: string): boolean { + const mapped = mapGatewayLineToCommandEvent(line); + this.emitTrackedEvent(commandId, mapped.eventType, mapped.data); + if (isTerminalEvent(mapped.eventType, mapped.data)) { + this.markTerminal( + commandId, + resolveTerminalState(mapped.eventType, mapped.data), + ); + return true; + } + return false; + } + private emitTrackedEvent( commandId: string, eventType: DesktopCommandStreamEvent["eventType"], - data: unknown + data: unknown, ): void { const tracked = this.trackedByCommandId.get(commandId); if (!tracked) { @@ -378,14 +470,14 @@ export class CloudCommandExecutor { const record: CommandEventRecord = { sequence, eventType, - data + data, }; tracked.buffered.events.push(record); this.options.sendCommandEvent({ commandId, sequence, eventType, - data + data, }); } @@ -412,7 +504,7 @@ export class CloudCommandExecutor { commandId, sequence: event.sequence, eventType: event.eventType, - data: event.data + data: event.data, }); } } @@ -421,10 +513,24 @@ export class CloudCommandExecutor { this.options.onQueueStatsChange?.(this.getStats()); } + private emitCommandTelemetry( + severity: TelemetryEventPayload["severity"], + category: "command.timeout" | "command.cancelled" | "command.gateway_error", + message: string, + trace: { commandId: string; operationId?: string }, + ): void { + this.options.emitTelemetry?.({ + severity, + category, + message, + trace, + }); + } + private pruneTerminalCommands(): void { const now = Date.now(); const terminalEntries = [...this.trackedByCommandId.entries()].filter( - ([, tracked]) => tracked.state === "terminal" + ([, tracked]) => tracked.state === "terminal", ); for (const [commandId, tracked] of terminalEntries) { @@ -441,7 +547,7 @@ export class CloudCommandExecutor { .sort( (a, b) => (a[1].completedAt ?? Number.MAX_SAFE_INTEGER) - - (b[1].completedAt ?? Number.MAX_SAFE_INTEGER) + (b[1].completedAt ?? Number.MAX_SAFE_INTEGER), ); while (remainingTerminal.length > MAX_RETAINED_TERMINAL_COMMANDS) { const [commandId] = remainingTerminal.shift() as [string, TrackedCommand]; @@ -450,11 +556,7 @@ export class CloudCommandExecutor { } } -type EnvelopeFields = { - protocolVersion: string; - messageId: string; - timestamp: string; -}; +type EnvelopeFields = ProtocolEnvelope; interface BufferedCommandEvents { lastAckedSequence: number; @@ -494,7 +596,13 @@ function validateCommand(command: DesktopCommandEvent): string | null { return null; } -const SUPPORTED_HTTP_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]); +const SUPPORTED_HTTP_METHODS = new Set([ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", +]); function deriveLockKey(command: DesktopCommandEvent): string | null { if (command.lockKey?.trim()) { @@ -525,8 +633,7 @@ function asNonEmptyString(value: unknown): string | null { if (typeof value !== "string") { return null; } - const trimmed = value.trim(); - return trimmed ? trimmed : null; + return value.trim(); } function applyQuery(url: URL, query: DesktopCommandEvent["query"]): void { @@ -544,11 +651,15 @@ function applyQuery(url: URL, query: DesktopCommandEvent["query"]): void { } } -function serializeBody(body: unknown, headers: Headers, method: string): string | undefined { +function serializeBody( + body: unknown, + headers: Headers, + method: string, +): string | undefined { if (method === "GET") { return undefined; } - if (typeof body === "undefined") { + if (body === undefined) { return undefined; } if (typeof body === "string") { @@ -569,7 +680,9 @@ async function safeReadBodyAsText(response: Response): Promise { } async function parseNonStreamingBody(response: Response): Promise { - const contentType = (response.headers.get("content-type") ?? "").toLowerCase(); + const contentType = ( + response.headers.get("content-type") ?? "" + ).toLowerCase(); if (contentType.includes("application/json")) { try { return await response.json(); @@ -599,22 +712,22 @@ function mapGatewayLineToCommandEvent(line: string): { if (type === "done") { return { eventType: "done", data: parsed }; } - if (type === "text" || typeof parsed.content === "string") { - return { eventType: "chunk", data: parsed }; - } return { eventType: "chunk", data: parsed }; } catch { return { eventType: "chunk", data: { type: "text", - content: line - } + content: line, + }, }; } } -function isTerminalEvent(eventType: DesktopCommandStreamEvent["eventType"], data: unknown): boolean { +function isTerminalEvent( + eventType: DesktopCommandStreamEvent["eventType"], + data: unknown, +): boolean { if (eventType === "done") { return true; } @@ -627,7 +740,7 @@ function isTerminalEvent(eventType: DesktopCommandStreamEvent["eventType"], data function resolveTerminalState( eventType: DesktopCommandStreamEvent["eventType"], - data: unknown + data: unknown, ): TerminalCommandState { if (eventType === "done") { const record = asRecord(data); @@ -640,5 +753,19 @@ function resolveTerminalState( } function isTerminalState(state: TerminalCommandState | undefined): boolean { - return state === "done" || state === "failed" || state === "cancelled"; + return state !== undefined; +} + +const UUID_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function isValidUuid(value: string): boolean { + return UUID_REGEX.test(value); +} + +/** Allow any non-empty printable ASCII string without CRLF (header injection guard). */ +const SAFE_HEADER_VALUE_REGEX = /^[\x20-\x7e]+$/; + +function isSafeHeaderValue(value: string): boolean { + return SAFE_HEADER_VALUE_REGEX.test(value); } diff --git a/apps/desktop/src/main/cloud-socket.ts b/apps/desktop/src/main/cloud-socket.ts index a620d3dd..668398ef 100644 --- a/apps/desktop/src/main/cloud-socket.ts +++ b/apps/desktop/src/main/cloud-socket.ts @@ -12,9 +12,11 @@ import { type DesktopCommandStreamEvent, type DesktopHelloAckEvent, type DesktopHelloEvent, - type DesktopPresenceEvent + type DesktopPresenceEvent, + type ProtocolEnvelope } from "./cloud-protocol.js"; import { normalizeAndValidateOrigin } from "./origin-policy.js"; +import type { DesktopTelemetryEvent } from "./telemetry-protocol.js"; export interface CloudSocketOptions { getRelayOrigin: () => string; @@ -82,6 +84,10 @@ export class CloudSocketService { void this.start(); } + sendTelemetry(event: Omit): void { + this.emit("desktop.telemetry", event); + } + sendCommandAck(event: Omit): void { this.emit("desktop.command.ack", event); } @@ -302,11 +308,7 @@ export class CloudSocketService { } } -type EnvelopeOnlyFields = { - protocolVersion: string; - messageId: string; - timestamp: string; -}; +type EnvelopeOnlyFields = ProtocolEnvelope; const HELLO_ACK_TIMEOUT_MS = 10_000; diff --git a/apps/desktop/src/main/job-store.ts b/apps/desktop/src/main/job-store.ts index 1bbaeeb5..a8ec2b15 100644 --- a/apps/desktop/src/main/job-store.ts +++ b/apps/desktop/src/main/job-store.ts @@ -27,6 +27,7 @@ export type LocalJob = { kind: LocalJobKind; loopId: string; commandId?: string; + operationId?: string; command: LocalJobCommand; ticketId?: string; artifactId?: string; diff --git a/apps/desktop/src/main/telemetry-protocol.ts b/apps/desktop/src/main/telemetry-protocol.ts new file mode 100644 index 00000000..992ad0ba --- /dev/null +++ b/apps/desktop/src/main/telemetry-protocol.ts @@ -0,0 +1,64 @@ +import type { ProtocolEnvelope } from "./cloud-protocol.js"; + +// Constants for log tail collection +export const TELEMETRY_LOG_TAIL_LINES = 50; +export const TELEMETRY_LOG_TAIL_MAX_BYTES = 32_768; // 32 KiB +export const TELEMETRY_MAX_FIELD_BYTES = 4_096; // 4 KiB + +export type TelemetrySeverity = "info" | "warn" | "error"; + +export type TelemetryCategory = + | "command.timeout" + | "command.cancelled" + | "command.gateway_error" + | "job.started" + | "job.completed" + | "job.failed" + | "preflight.binary_not_found" + | "preflight.script_not_found" + | "preflight.spawn_failed"; + +export interface TelemetryTraceContext { + computeTargetId?: string; + commandId?: string; + operationId?: string; + loopId?: string; + jobId?: string; + gatewaySessionId?: string; + loopSessionId?: string; +} + +export interface TelemetryDiagnostics { + exitCode?: number; + logTail?: string; + tokenUsage?: { inputTokens: number; outputTokens: number }; + diagnosticsVersion?: number; + errorStack?: string; + extra?: Record; +} + +/** Telemetry event payload without protocol envelope fields (added by transport layer). */ +export interface TelemetryEventPayload { + severity: TelemetrySeverity; + category: TelemetryCategory; + message: string; + schemaVersion?: number; + timestamp?: string; + trace?: TelemetryTraceContext; + diagnostics?: TelemetryDiagnostics; +} + +export interface TelemetryEmitter { + emit(event: TelemetryEventPayload): void; +} + +/** Full wire-format event including protocol envelope (used by transport layer). */ +export interface DesktopTelemetryEvent extends ProtocolEnvelope { + severity: TelemetrySeverity; + category: TelemetryCategory; + message: string; + schemaVersion: number; + timestamp: string; + trace?: TelemetryTraceContext; + diagnostics?: TelemetryDiagnostics; +} diff --git a/apps/desktop/src/main/telemetry-service.ts b/apps/desktop/src/main/telemetry-service.ts new file mode 100644 index 00000000..9deeee4f --- /dev/null +++ b/apps/desktop/src/main/telemetry-service.ts @@ -0,0 +1,120 @@ +import { Buffer } from "node:buffer"; +import { + TELEMETRY_MAX_FIELD_BYTES, + type TelemetryEventPayload, +} from "./telemetry-protocol.js"; + +/** Enriched event with schemaVersion and timestamp always populated by TelemetryService. */ +export type EnrichedTelemetryEvent = TelemetryEventPayload & { + schemaVersion: number; + timestamp: string; +}; + +export interface TelemetryServiceOptions { + sendTelemetry: (event: EnrichedTelemetryEvent) => void; +} + +/** + * Fire-and-forget telemetry emitter. + * + * TelemetryService wraps the raw sendTelemetry transport callback with: + * - computeTargetId auto-injection into every event's trace context + * - logTail truncation to TELEMETRY_MAX_FIELD_BYTES (4 KiB) + * - try/catch so emit() never throws regardless of callback behavior + * + * The TelemetryEmitter interface lives in telemetry-protocol.ts so that + * callers in the server layer can import only from a types-only module. + */ +export class TelemetryService { + private readonly options: TelemetryServiceOptions; + private computeTargetId: string | undefined; + private gatewaySessionId: string | undefined; + + constructor(options: TelemetryServiceOptions) { + this.options = options; + } + + /** + * Store the computeTargetId received from the relay hello-ack. + * Subsequent emit() calls will inject this into trace.computeTargetId. + */ + setTargetId(id: string): void { + this.computeTargetId = id; + } + + /** + * Store the gateway session ID from the cloud socket connection. + * Subsequent emit() calls will inject this into trace.gatewaySessionId. + */ + setGatewaySessionId(id: string): void { + this.gatewaySessionId = id; + } + + /** + * Emit a telemetry event. Never throws. + * + * - Injects computeTargetId into trace if one has been set via setTargetId() + * - Truncates diagnostics.logTail to TELEMETRY_MAX_FIELD_BYTES if present + * - All errors from the sendTelemetry callback are swallowed silently + */ + emit(event: TelemetryEventPayload): void { + try { + const enrichedEvent = this.enrichEvent(event); + this.options.sendTelemetry(enrichedEvent); + } catch { + // Swallow all errors per AC-006: TelemetryService never throws + } + } + + private enrichEvent(event: TelemetryEventPayload): EnrichedTelemetryEvent { + let trace = event.trace; + + if (this.computeTargetId || this.gatewaySessionId) { + trace = { + ...event.trace, + ...(this.computeTargetId + ? { computeTargetId: this.computeTargetId } + : {}), + ...(this.gatewaySessionId + ? { gatewaySessionId: this.gatewaySessionId } + : {}), + }; + } + + const diagnostics = event.diagnostics?.logTail + ? { + ...event.diagnostics, + logTail: truncateToBytes( + event.diagnostics.logTail, + TELEMETRY_MAX_FIELD_BYTES, + ), + } + : event.diagnostics; + + return { + ...event, + schemaVersion: 1, + timestamp: new Date().toISOString(), + trace, + diagnostics, + }; + } +} + +/** + * Truncate a UTF-8 string to at most maxBytes bytes. + * Splits on newline boundaries to avoid cutting mid-line where possible, + * but falls back to raw byte truncation if the string is single-line. + */ +function truncateToBytes(value: string, maxBytes: number): string { + const encoded = Buffer.from(value, "utf8"); + if (encoded.length <= maxBytes) { + return value; + } + // Take the last maxBytes bytes from the tail (most recent log lines) + const tail = encoded.subarray(encoded.length - maxBytes); + // The tail may start mid-codepoint or mid-line; drop up to the first newline + const newlineIndex = tail.indexOf(0x0a); // 0x0a = '\n' + const trimmed = newlineIndex >= 0 ? tail.subarray(newlineIndex + 1) : tail; + return trimmed.toString("utf8"); +} diff --git a/apps/desktop/src/server/operations/symphony-loop.ts b/apps/desktop/src/server/operations/symphony-loop.ts index 15af888e..d16de147 100644 --- a/apps/desktop/src/server/operations/symphony-loop.ts +++ b/apps/desktop/src/server/operations/symphony-loop.ts @@ -1,18 +1,37 @@ import { execSync, spawn } from "node:child_process"; import { gatewayLog } from "../../main/gateway-logger.js"; import crypto from "node:crypto"; -import { closeSync, existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { + closeSync, + existsSync, + mkdirSync, + openSync, + readFileSync, + readSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { JobStore, LocalJobCommand } from "../../main/job-store.js"; +import { + TELEMETRY_LOG_TAIL_LINES, + TELEMETRY_LOG_TAIL_MAX_BYTES, +} from "../../main/telemetry-protocol.js"; +import type { TelemetryEmitter } from "../../main/telemetry-protocol.js"; import type { OperationDispatcher, OperationRequestContext, } from "../operation-dispatcher.js"; import { readJsonFileSync } from "../read-json-file-sync.js"; import { assertPathAllowed, DirectoryNotAllowedError } from "../security.js"; -import { findPluginScript, findPluginVersions, getPluginCacheRoot } from "./plugin-cache.js"; +import { + findPluginScript, + findPluginVersions, + getPluginCacheRoot, +} from "./plugin-cache.js"; import { readEvaluatePrdOutputs, writePrdArtifact, @@ -140,7 +159,7 @@ function loopError(loopId: string, ...args: unknown[]): void { function json( context: OperationRequestContext, status: number, - payload: unknown + payload: unknown, ): void { context.response.statusCode = status; context.response.setHeader("content-type", "application/json"); @@ -148,7 +167,7 @@ function json( } function parseJsonBody( - context: OperationRequestContext + context: OperationRequestContext, ): Record | null { if (!context.body.trim()) { return null; @@ -179,7 +198,9 @@ function findStreamFormatter(): string | null { const versions = findPluginVersions(pluginDir); for (const v of versions) { const p = path.join(pluginDir, v, "tools", "python", "stream_formatter.py"); - if (existsSync(p)) { return p; } + if (existsSync(p)) { + return p; + } } return null; } @@ -192,7 +213,7 @@ function findStreamFormatter(): string | null { function buildClaudePipeline( claudeArgs: string[], claudeWorkDir: string, - stdinFile?: string + stdinFile?: string, ): { cmd: string; args: string[] } { const formatter = findStreamFormatter(); const stderrFile = path.join(claudeWorkDir, "claude-stderr.log"); @@ -224,10 +245,7 @@ function buildClaudePipeline( } /** Find the local repo path for a given fullName (e.g. "org/repo"). */ -function findLocalRepo( - fullName: string, - allowedDirs: string[] -): string | null { +function findLocalRepo(fullName: string, allowedDirs: string[]): string | null { const repoName = fullName.split("/").pop(); if (!repoName) { return null; @@ -254,12 +272,12 @@ function findLocalRepo( */ function resolveLoopWorktreeDir( expandedRepoPath: string, - stableId: string + stableId: string, ): string { const repoName = path.basename(expandedRepoPath); return path.join( resolveWorktreeParentDir(expandedRepoPath), - `${repoName}-loop-${stableId}` + `${repoName}-loop-${stableId}`, ); } @@ -270,7 +288,7 @@ function resolveLoopWorktreeDir( function slugifyLoopId(loopId: string): string { return loopId .toLowerCase() - .replace(/[^a-z0-9-]/g, "-") + .replaceAll(/[^a-z0-9-]/g, "-") .slice(0, 50); } @@ -290,7 +308,7 @@ async function postLoopEvent( apiBaseUrl: string, loopId: string, token: string, - eventBody: Record + eventBody: Record, ): Promise { const url = `${apiBaseUrl}/loops/${loopId}/events`; // Auto-inject timestamp on every event (matches ECS harness reportEvent()) @@ -303,24 +321,37 @@ async function postLoopEvent( const resp = await fetch(url, { method: "POST", headers: { - "Authorization": `Bearer ${token}`, + Authorization: `Bearer ${token}`, "Content-Type": "application/json", "x-loop-event-nonce": crypto.randomUUID(), }, body: JSON.stringify(payload), }); - 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 { + if (resp.ok) { loopLog(loopId, `Event POST success: ${resp.status}`); - gatewayLog.debug("loop-event", `POST ${payload.type} to ${url}: ${resp.status}`); + gatewayLog.debug( + "loop-event", + `POST ${payload.type} to ${url}: ${resp.status}`, + ); + } else { + 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}`, + ); } } 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}`); + gatewayLog.error( + "loop-event", + `POST ${payload.type} network error: ${msg}`, + ); } } @@ -328,7 +359,7 @@ async function uploadArtifacts( apiBaseUrl: string, loopId: string, token: string, - body: Record + body: Record, ): Promise { const url = `${apiBaseUrl}/loops/${loopId}/upload-artifacts`; loopLog(loopId, "Uploading artifacts...", url); @@ -336,18 +367,28 @@ async function uploadArtifacts( const resp = await fetch(url, { method: "POST", headers: { - "Authorization": `Bearer ${token}`, + Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }); - 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 { + if (resp.ok) { loopLog(loopId, `Upload success: ${resp.status}`); - gatewayLog.debug("loop-upload", `Artifact upload to ${url}: ${resp.status}`); + gatewayLog.debug( + "loop-upload", + `Artifact upload to ${url}: ${resp.status}`, + ); + } else { + 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}`, + ); } } catch (err) { const msg = err instanceof Error ? err.message : String(err); @@ -364,7 +405,7 @@ async function ensureWorktree( expandedRepoPath: string, worktreeDir: string, branchName: string, - baseBranch: string + baseBranch: string, ): Promise { if (existsSync(worktreeDir)) { return; @@ -400,14 +441,14 @@ async function ensureWorktree( cwd: expandedRepoPath, stdio: "pipe", timeout: 30_000, - } + }, ); } /** Find existing worktree for a branch name. */ function findWorktreeForBranch( expandedRepoPath: string, - branchName: string + branchName: string, ): string | null { try { const output = execSync("git worktree list --porcelain", { @@ -445,7 +486,7 @@ function findWorktreeForBranch( async function cleanupGeneratePrdWorktree( worktreeDir: string, expandedRepoPath: string, - loopId?: string + loopId?: string, ): Promise { try { execSync(`git worktree remove --force ${shellEscape(worktreeDir)}`, { @@ -455,11 +496,18 @@ async function cleanupGeneratePrdWorktree( }); } catch { if (loopId) { - loopLog(loopId, `git worktree remove failed for GENERATE_PRD, falling back to fs.rm`); + loopLog( + loopId, + `git worktree remove failed for GENERATE_PRD, falling back to fs.rm`, + ); } await fs.rm(worktreeDir, { recursive: true, force: true }); try { - execSync("git worktree prune", { cwd: expandedRepoPath, stdio: "pipe", timeout: 10_000 }); + execSync("git worktree prune", { + cwd: expandedRepoPath, + stdio: "pipe", + timeout: 10_000, + }); } catch { // Best-effort } @@ -477,13 +525,14 @@ async function cleanupGeneratePrdWorktree( async function writeArtifactsForPlan( claudeWorkDir: string, artifacts: LoopArtifact[], - prompt?: string + prdContent: string | null = null, ): Promise { // Priority: explicit prompt > PRD artifact > FEATURE artifact (matches harness) - let prdContent = prompt ?? null; if (!prdContent) { - const prdArtifact = artifacts.find((a) => a.type === "PRD" || a.type === "prd"); + const prdArtifact = artifacts.find( + (a) => a.type === "PRD" || a.type === "prd", + ); const featureArtifact = prdArtifact ? null : artifacts.find((a) => a.type === "FEATURE" || a.type === "artifact"); @@ -501,7 +550,7 @@ async function writeArtifactsForPlan( async function writeArtifactsForExecuteOrAmend( claudeWorkDir: string, artifacts: LoopArtifact[], - prompt?: string + prompt?: string, ): Promise { for (const artifact of artifacts) { if (artifact.type === "IMPLEMENTATION_PLAN" || artifact.type === "plan") { @@ -512,7 +561,9 @@ async function writeArtifactsForExecuteOrAmend( const planJsonPath = path.join(claudeWorkDir, "plan.json"); if (existsSync(planJsonPath)) { try { - const existing = JSON.parse(readFileSync(planJsonPath, "utf-8")) as Record; + const existing = JSON.parse( + readFileSync(planJsonPath, "utf-8"), + ) as Record; existing.content = artifact.content; await fs.writeFile(planJsonPath, JSON.stringify(existing, null, 2)); } catch { @@ -528,11 +579,16 @@ async function writeArtifactsForExecuteOrAmend( } catch { await fs.writeFile( planJsonPath, - JSON.stringify({ content: artifact.content }, null, 2) + JSON.stringify({ content: artifact.content }, null, 2), ); } } - } else if (artifact.type === "prd" || artifact.type === "artifact" || artifact.type === "PRD" || artifact.type === "FEATURE") { + } else if ( + artifact.type === "prd" || + artifact.type === "artifact" || + artifact.type === "PRD" || + artifact.type === "FEATURE" + ) { await fs.writeFile(path.join(claudeWorkDir, "prd.md"), artifact.content); } } @@ -550,7 +606,7 @@ async function writeArtifactsForGeneratePrd( worktreeDir: string, artifacts: LoopArtifact[], prompt: string, - repo?: unknown + repo?: unknown, ): Promise { const contextDir = path.join(worktreeDir, ".claude", "context"); const artifactsDir = path.join(contextDir, "artifacts"); @@ -563,18 +619,23 @@ async function writeArtifactsForGeneratePrd( if (repo) { await fs.writeFile( path.join(contextDir, "repo-info.json"), - JSON.stringify(repo, null, 2) + JSON.stringify(repo, null, 2), ); } // Write each artifact for (const artifact of artifacts) { - const safeName = artifact.type.toLowerCase().replace(/[^a-z0-9_-]/g, "_"); - const safeId = (artifact.id ?? "unknown").replace(/[^a-zA-Z0-9_-]/g, "_"); + const safeName = artifact.type + .toLowerCase() + .replaceAll(/[^a-z0-9_-]/g, "_"); + const safeId = (artifact.id ?? "unknown").replaceAll( + /[^a-zA-Z0-9_-]/g, + "_", + ); const header = `# ${artifact.title ?? "Untitled"}\n\n`; await fs.writeFile( path.join(artifactsDir, `${safeName}-${safeId}.md`), - header + artifact.content + header + artifact.content, ); } } @@ -596,7 +657,7 @@ function readTextFile(filePath: string): string | null { function readPlanOutputs(claudeWorkDir: string): Record { const plan = readJsonFileSync(path.join(claudeWorkDir, "plan.json")); const openQuestions = readTextFile( - path.join(claudeWorkDir, "open-questions.md") + path.join(claudeWorkDir, "open-questions.md"), ); const judges = readJsonFileSync(path.join(claudeWorkDir, "judges.json")); @@ -609,10 +670,10 @@ function readPlanOutputs(claudeWorkDir: string): Record { function readExecuteOutputs(claudeWorkDir: string): Record { const executionResult = readJsonFileSync( - path.join(claudeWorkDir, "execution-result.json") + path.join(claudeWorkDir, "execution-result.json"), ); const codeJudges = readJsonFileSync( - path.join(claudeWorkDir, "code-judges.json") + path.join(claudeWorkDir, "code-judges.json"), ); return { @@ -632,8 +693,11 @@ function readGeneratePrdOutputs(worktreeDir: string): Record { } /** Parse token usage from claude-output.jsonl (JSONL stream output). */ -function parseTokenUsage(claudeWorkDir: string): { input: number; output: number } { - const totals = { input: 0, output: 0 }; +function parseTokenUsage(claudeWorkDir: string): { + inputTokens: number; + outputTokens: number; +} { + const totals = { inputTokens: 0, outputTokens: 0 }; const outputFile = path.join(claudeWorkDir, "claude-output.jsonl"); if (!existsSync(outputFile)) { return totals; @@ -650,11 +714,11 @@ function parseTokenUsage(claudeWorkDir: string): { input: number; output: number const message = entry.message as Record | undefined; const usage = message?.usage as Record | undefined; if (usage) { - totals.input += + totals.inputTokens += (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0); - totals.output += usage.output_tokens ?? 0; + totals.outputTokens += usage.output_tokens ?? 0; } } } catch { @@ -667,6 +731,108 @@ function parseTokenUsage(claudeWorkDir: string): { input: number; output: number return totals; } +// --------------------------------------------------------------------------- +// Failure diagnostics helpers +// --------------------------------------------------------------------------- + +/** Maximum bytes to read from the tail of a log file for diagnostics. */ +const LOG_TAIL_MAX_BYTES = TELEMETRY_LOG_TAIL_MAX_BYTES; + +/** + * Read up to LOG_TAIL_MAX_BYTES from the tail of a log file synchronously. + * Exported so that edge-case tests can import it directly. + */ +export function readLogTail(logPath: string): string | null { + if (!existsSync(logPath)) { + return null; + } + try { + const stat = statSync(logPath); + const fileSize = stat.size; + if (fileSize === 0) { + return null; + } + const readBytes = Math.min(fileSize, LOG_TAIL_MAX_BYTES); + const offset = fileSize - readBytes; + const buf = Buffer.alloc(readBytes); + const fd = openSync(logPath, "r"); + try { + readSync(fd, buf, 0, readBytes, offset); + } finally { + closeSync(fd); + } + const raw = buf.toString("utf-8"); + // If we started mid-file, drop any partial first line to avoid garbled output + let tail: string; + if (offset > 0) { + const newlineIdx = raw.indexOf("\n"); + tail = newlineIdx === -1 ? raw : raw.slice(newlineIdx + 1); + } else { + tail = raw; + } + // Cap to the last TELEMETRY_LOG_TAIL_LINES lines (AC-002: "last 50 lines / 32KB") + const lines = tail.split("\n"); + if (lines.length > TELEMETRY_LOG_TAIL_LINES) { + return lines.slice(-TELEMETRY_LOG_TAIL_LINES).join("\n"); + } + return tail; + } catch { + return null; + } +} + +/** + * Patterns matching common credential / secret formats. + * Applied to log tail before including in telemetry events. + * Each entry is a [pattern, replacement] tuple with a string replacement. + */ +const CREDENTIAL_PATTERNS: Array<[RegExp, string]> = [ + // AWS keys: AKIA... style (20 uppercase alphanum after AKIA/ASIA/AROA prefix) + [/\b(AKIA|ASIA|AROA)[A-Z0-9]{16}\b/g, "[REDACTED_AWS_KEY]"], + // Generic bearer / API tokens: "Bearer " + [/\bBearer\s+[A-Za-z0-9\-._~+/]+=*/g, "Bearer [REDACTED]"], + // sk- prefixed API keys (OpenAI, Anthropic, etc.) + [/\bsk-[A-Za-z0-9\-_]{10,}/g, "[REDACTED_SK_KEY]"], + // GitHub personal access tokens: ghp_, gho_, ghs_, ghr_ + [/\b(ghp|gho|ghs|ghr)_[A-Za-z0-9]{36,}/g, "[REDACTED_GH_TOKEN]"], + // Generic "password=..." or "secret=..." in query strings / env + [ + /\b(password|secret|passwd|api_key|apikey|auth_token)=[^\s&"']+/gi, + "$1=[REDACTED]", + ], +]; + +/** + * Apply credential-pattern filters to redact common secret formats from a string. + */ +function redactCredentials(text: string): string { + let result = text; + for (const [pattern, replacement] of CREDENTIAL_PATTERNS) { + result = result.replace(pattern, replacement); + } + return result; +} + +/** + * Collect failure diagnostics for a failed loop process. + * Returns an object suitable for inclusion in the error telemetry event. + */ +function collectFailureDiagnostics(claudeWorkDir: string): { + logTail: string | undefined; + tokenUsage: { inputTokens: number; outputTokens: number }; + diagnosticsVersion: number; +} { + const logPath = path.join(claudeWorkDir, "symphony-loop.log"); + const rawTail = readLogTail(logPath); + const logTail = rawTail ? redactCredentials(rawTail) : undefined; + const tokenUsage = parseTokenUsage(claudeWorkDir); + return { + logTail, + tokenUsage, + diagnosticsVersion: 1, + }; +} + // --------------------------------------------------------------------------- // LLM-assisted commit (EXECUTE only) // --------------------------------------------------------------------------- @@ -678,15 +844,18 @@ async function attemptLlmCommit( command: string, artifactSlug: string | undefined, webAppOrigin: string, - committer: LoopCommitter | undefined + committer: LoopCommitter | undefined, ): Promise { // Build metadata footer for PR body // Strip newlines from user-controlled fields to prevent prompt injection - const safeBranch = baseBranch.replace(/[\r\n]/g, ''); - const safeLoopId = sanitizeCommitMessage(loopId).replace(/[\r\n]/g, ''); + const safeBranch = baseBranch.replaceAll(/[\r\n]/g, ""); + const safeLoopId = sanitizeCommitMessage(loopId).replaceAll(/[\r\n]/g, ""); let footer: string; if (artifactSlug) { - const safeSlug = sanitizeCommitMessage(artifactSlug).replace(/[\r\n]/g, ''); + const safeSlug = sanitizeCommitMessage(artifactSlug).replaceAll( + /[\r\n]/g, + "", + ); const artifactLink = `${webAppOrigin}/artifact/by-slug/${safeSlug}`; footer = `---\nLoop ID: ${safeLoopId}\nArtifact: ${artifactLink}`; } else { @@ -695,9 +864,9 @@ async function attemptLlmCommit( // Build slug instruction for the prompt const slugInstruction = artifactSlug - ? `The artifact slug is ${sanitizeCommitMessage(artifactSlug).replace(/[\r\n]/g, '')}. ` + - `You MUST prefix the PR title with "${sanitizeCommitMessage(artifactSlug).replace(/[\r\n]/g, '')}: " ` + - `(e.g., "${sanitizeCommitMessage(artifactSlug).replace(/[\r\n]/g, '')}: Add feature X"). ` + + ? `The artifact slug is ${sanitizeCommitMessage(artifactSlug).replaceAll(/[\r\n]/g, "")}. ` + + `You MUST prefix the PR title with "${sanitizeCommitMessage(artifactSlug).replaceAll(/[\r\n]/g, "")}: " ` + + `(e.g., "${sanitizeCommitMessage(artifactSlug).replaceAll(/[\r\n]/g, "")}: Add feature X"). ` + `Also prefix the commit message the same way.` : "No artifact slug is available — use a descriptive title without a prefix."; @@ -751,7 +920,10 @@ async function attemptLlmCommit( loopLog(loopId, "Attempting LLM-assisted commit..."); - const spawnEnv: Record = { ...process.env } as Record; + const spawnEnv: Record = { ...process.env } as Record< + string, + string + >; if (committer) { spawnEnv.GIT_AUTHOR_NAME = committer.name; spawnEnv.GIT_AUTHOR_EMAIL = committer.email; @@ -764,7 +936,7 @@ async function attemptLlmCommit( child = spawn( "claude", ["-p", prompt, "--allowedTools", "Bash,Read,Write,Glob,Grep"], - { cwd: worktreeDir, detached: true, stdio: "pipe", env: spawnEnv } + { cwd: worktreeDir, detached: true, stdio: "pipe", env: spawnEnv }, ); } catch (err) { loopError(loopId, "LLM commit spawn failed:", err); @@ -840,17 +1012,35 @@ async function attemptLlmCommit( const raw = readFileSync(resultFilePath, "utf-8"); const parsed: unknown = JSON.parse(raw); if (isExecutionResult(parsed)) { - loopLog(loopId, `LLM commit wrote execution-result.json, pr=${parsed.prUrl}`); + loopLog( + loopId, + `LLM commit wrote execution-result.json, pr=${parsed.prUrl}`, + ); result = parsed; } else { - loopError(loopId, "LLM execution-result.json failed type guard, returning null"); + loopError( + loopId, + "LLM execution-result.json failed type guard, returning null", + ); } } catch (err) { - loopError(loopId, "LLM commit: failed to read execution-result.json:", err); + loopError( + loopId, + "LLM commit: failed to read execution-result.json:", + err, + ); } // Always remove LLM scratch files from the worktree - try { unlinkSync(resultFilePath); } catch { /* may not exist */ } - try { unlinkSync(prBodyFilePath); } catch { /* may not exist */ } + try { + unlinkSync(resultFilePath); + } catch { + /* may not exist */ + } + try { + unlinkSync(prBodyFilePath); + } catch { + /* may not exist */ + } resolve(result); }); @@ -877,10 +1067,18 @@ function executeGitOperations( loopId: string, command: string, artifactSlug?: string, - webAppOrigin?: string -): { prUrl: string; prNumber: number; branchName: string; commitSha: string } | null { + webAppOrigin?: string, +): { + prUrl: string; + prNumber: number; + branchName: string; + commitSha: string; +} | null { const shortId = loopId.slice(0, 8); - const env: Record = { ...process.env } as Record; + const env: Record = { ...process.env } as Record< + string, + string + >; if (committer) { env.GIT_AUTHOR_NAME = committer.name; env.GIT_AUTHOR_EMAIL = committer.email; @@ -945,9 +1143,10 @@ function executeGitOperations( // Build PR body with metadata footer, written to a temp file to avoid // shell escaping issues with special characters (--body-file approach). - const artifactLine = artifactSlug && webAppOrigin - ? `\nArtifact: ${webAppOrigin}/artifact/by-slug/${artifactSlug}` - : ""; + const artifactLine = + artifactSlug && webAppOrigin + ? `\nArtifact: ${webAppOrigin}/artifact/by-slug/${artifactSlug}` + : ""; const prBody = `Loop ID: ${loopId}\nCommand: ${command}${artifactLine}`; const bodyFile = path.join(worktreeDir, ".claude", "work", "pr-body.md"); mkdirSync(path.dirname(bodyFile), { recursive: true }); @@ -965,7 +1164,7 @@ function executeGitOperations( stdio: "pipe", env, timeout: 15_000, - } + }, ).trim(); const parsed = JSON.parse(existingPr) as { url: string; number: number }; prUrl = parsed.url; @@ -983,7 +1182,7 @@ function executeGitOperations( stdio: "pipe", env, timeout: 30_000, - } + }, ).trim(); prUrl = prOutput; const prNumberMatch = /\/pull\/(\d+)/.exec(prUrl); @@ -1009,7 +1208,13 @@ function executeGitOperations( try { const currentBody = execSync( `gh pr view ${prNumber} --json body --jq .body`, - { cwd: worktreeDir, encoding: "utf-8", stdio: "pipe", env, timeout: 15_000 } + { + cwd: worktreeDir, + encoding: "utf-8", + stdio: "pipe", + env, + timeout: 15_000, + }, ).trim(); // Only update if the footer isn't already present if (!currentBody.includes(`Loop ID: ${loopId}`)) { @@ -1019,7 +1224,7 @@ function executeGitOperations( writeFileSync(bodyFile, updatedBody); execSync( `gh pr edit ${prNumber} --body-file ${shellEscape(bodyFile)}`, - { cwd: worktreeDir, stdio: "pipe", env, timeout: 15_000 } + { cwd: worktreeDir, stdio: "pipe", env, timeout: 15_000 }, ); } } catch { @@ -1046,7 +1251,10 @@ async function handleProcessCompletion( usedTempDir: boolean, expandedRepoPath: string | null, jobStore?: JobStore, - webAppOrigin?: string + webAppOrigin?: string, + telemetry?: TelemetryEmitter, + commandId?: string, + operationId?: string, ): Promise { const { loopId, command, closedLoopAuthToken, committer } = body; @@ -1055,13 +1263,41 @@ 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}`); + gatewayLog.error( + "loop-harness", + `${command} failed with exit code ${exitCode}, loopId=${loopId}`, + ); + // Collect diagnostics (log tail + token usage) for the failure event + const diagnostics = collectFailureDiagnostics(claudeWorkDir); + const sessionFileForTelemetry = path.join(claudeWorkDir, "session-id.txt"); + const rawSessionId = readTextFile(sessionFileForTelemetry); + const failureSessionId = rawSessionId ? rawSessionId.trim() : undefined; + const failedJob = jobStore?.getByLoopId(loopId); + telemetry?.emit({ + severity: "error", + category: "job.failed", + message: `Process exited with code ${exitCode}`, + trace: { + commandId: commandId ?? failedJob?.commandId, + operationId: operationId ?? failedJob?.operationId, + loopId, + jobId: loopId, + loopSessionId: failureSessionId, + }, + diagnostics: { + ...diagnostics, + exitCode, + }, + }); // Error shape matches ECS harness: top-level code/message, not nested error object await postLoopEvent(apiBaseUrl, loopId, closedLoopAuthToken, { type: "error", code: "PROCESS_FAILED", message: `Process exited with code ${exitCode}`, loopId, + tokenUsage: diagnostics.tokenUsage, + logTail: diagnostics.logTail, + diagnosticsVersion: diagnostics.diagnosticsVersion, }); if (jobStore) { const existingJob = jobStore.getByLoopId(loopId); @@ -1085,7 +1321,10 @@ async function handleProcessCompletion( } // Read outputs per command - gatewayLog.debug("loop-harness", `${command} succeeded (exit 0), reading artifacts for loopId=${loopId}`); + gatewayLog.debug( + "loop-harness", + `${command} succeeded (exit 0), reading artifacts for loopId=${loopId}`, + ); let artifacts: Record = {}; const metadata: Record = {}; @@ -1107,7 +1346,7 @@ async function handleProcessCompletion( command, body.artifactSlug, webAppOrigin ?? "", - committer + committer, ); // Clean up any remaining LLM scratch files before fallback to prevent @@ -1115,12 +1354,34 @@ async function handleProcessCompletion( // already cleans up on success, but these guards cover edge cases where // the process was killed before the cleanup ran. if (!llmResult) { - try { unlinkSync(path.join(worktreeDir, 'execution-result.json')); } catch { /* may not exist */ } - try { unlinkSync(path.join(worktreeDir, 'pr-body.md')); } catch { /* may not exist */ } + try { + unlinkSync(path.join(worktreeDir, "execution-result.json")); + } catch { + /* may not exist */ + } + try { + unlinkSync(path.join(worktreeDir, "pr-body.md")); + } catch { + /* may not exist */ + } } - const gitResult: { prUrl: string; prNumber: number; branchName: string; commitSha: string } | null = - llmResult ?? executeGitOperations(worktreeDir, committer, baseBranch, loopId, command, body.artifactSlug, webAppOrigin ?? ""); + const gitResult: { + prUrl: string; + prNumber: number; + branchName: string; + commitSha: string; + } | null = + llmResult ?? + executeGitOperations( + worktreeDir, + committer, + baseBranch, + loopId, + command, + body.artifactSlug, + webAppOrigin ?? "", + ); if (gitResult) { // Merge git info into execution result @@ -1154,7 +1415,10 @@ async function handleProcessCompletion( // Upload artifacts const artifactKeys = Object.keys(artifacts); loopLog(loopId, "Artifact keys:", artifactKeys); - gatewayLog.debug("loop-harness", `Uploading artifacts for ${command} loopId=${loopId}: [${artifactKeys.join(", ")}]`); + gatewayLog.debug( + "loop-harness", + `Uploading artifacts for ${command} loopId=${loopId}: [${artifactKeys.join(", ")}]`, + ); await uploadArtifacts(apiBaseUrl, loopId, closedLoopAuthToken, { artifacts, metadata, @@ -1162,7 +1426,10 @@ async function handleProcessCompletion( // Parse token usage from claude output const tokensUsed = parseTokenUsage(claudeWorkDir); - loopLog(loopId, `Tokens used: input=${tokensUsed.input}, output=${tokensUsed.output}`); + loopLog( + loopId, + `Tokens used: input=${tokensUsed.inputTokens}, output=${tokensUsed.outputTokens}`, + ); // Post completed event — shape matches ECS harness reportFinalStatus() const result: Record = { @@ -1226,6 +1493,21 @@ async function handleProcessCompletion( } } + const completedJob = jobStore?.getByLoopId(loopId); + telemetry?.emit({ + severity: "info", + category: "job.completed", + message: `Job completed successfully`, + trace: { + commandId: commandId ?? completedJob?.commandId, + operationId: operationId ?? completedJob?.operationId, + loopId, + jobId: loopId, + loopSessionId: + typeof metadata.sessionId === "string" ? metadata.sessionId : undefined, + }, + }); + // Clean up temp claude workdir after all reads and uploads are complete if (usedTempDir) { fs.rm(claudeWorkDir, { recursive: true, force: true }).catch(() => {}); @@ -1243,7 +1525,8 @@ async function handleLoopRequest( getAllowedDirectories: () => string[], getApiOrigin?: () => string, jobStore?: JobStore, - getWebAppOrigin?: () => string + getWebAppOrigin?: () => string, + telemetry?: TelemetryEmitter, ): Promise { // Derive the callback URL from the gateway's trusted configuration. // body.apiBaseUrl is ignored -- the caller does not control where @@ -1253,7 +1536,7 @@ async function handleLoopRequest( json(context, 503, { error: "API origin not configured" }); return; } - const webAppOrigin = getWebAppOrigin?.() ?? ''; + const webAppOrigin = getWebAppOrigin?.() ?? ""; const rawBody = parseJsonBody(context); if (!rawBody) { @@ -1262,7 +1545,20 @@ async function handleLoopRequest( } const body = rawBody as unknown as LoopRequestBody; - const repoRequirement = REPO_REQUIREMENT_BY_COMMAND[body.command] ?? "NOT_REQUIRED"; + + // Extract tracing headers forwarded by the cloud command executor. + // Use typeof guards because IncomingMessage headers values are string | string[] | undefined. + const commandId = + typeof context.request?.headers?.["x-desktop-command-id"] === "string" + ? context.request.headers["x-desktop-command-id"] + : undefined; + const operationId = + typeof context.request?.headers?.["x-desktop-operation-id"] === "string" + ? context.request.headers["x-desktop-operation-id"] + : undefined; + + const repoRequirement = + REPO_REQUIREMENT_BY_COMMAND[body.command] ?? "NOT_REQUIRED"; if (!body.loopId || !body.command || !body.closedLoopAuthToken) { json(context, 400, { @@ -1276,7 +1572,11 @@ async function handleLoopRequest( return; } - if (!/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i.test(body.loopId)) { + if ( + !/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i.test( + body.loopId, + ) + ) { json(context, 400, { error: "loopId must be a valid UUID" }); return; } @@ -1286,7 +1586,10 @@ async function handleLoopRequest( return; } - if (body.command === "GENERATE_PRD" && (typeof body.prompt !== "string" || !body.prompt.trim())) { + if ( + body.command === "GENERATE_PRD" && + (typeof body.prompt !== "string" || !body.prompt.trim()) + ) { json(context, 400, { error: "No prompt found for GENERATE_PRD" }); return; } @@ -1298,10 +1601,22 @@ 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"}`); + 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 { @@ -1311,7 +1626,10 @@ async function handleLoopRequest( if (repoRequirement !== "NOT_REQUIRED" && body.localRepoPath) { // localRepoPath takes precedence over repo.fullName lookup when present try { - const repoResult = tryAssertRepoAllowed(body.localRepoPath, allowedDirs); + const repoResult = tryAssertRepoAllowed( + body.localRepoPath, + allowedDirs, + ); if ("error" in repoResult) { if (repoRequirement === "REQUIRED") { json(context, repoResult.status, { error: repoResult.error }); @@ -1319,7 +1637,7 @@ async function handleLoopRequest( } loopLog( body.loopId, - `Ignoring localRepoPath for ${body.command}: ${repoResult.error}` + `Ignoring localRepoPath for ${body.command}: ${repoResult.error}`, ); } else { expandedRepoPath = repoResult.path; @@ -1331,23 +1649,12 @@ async function handleLoopRequest( } loopLog( body.loopId, - `Ignoring localRepoPath for ${body.command} after resolution error: ${repoPathError instanceof Error ? repoPathError.message : String(repoPathError)}` + `Ignoring localRepoPath for ${body.command} after resolution error: ${repoPathError instanceof Error ? repoPathError.message : String(repoPathError)}`, ); } } else if (repoRequirement !== "NOT_REQUIRED" && body.repo?.fullName) { expandedRepoPath = findLocalRepo(body.repo.fullName, allowedDirs); - if (!expandedRepoPath) { - if (repoRequirement === "REQUIRED") { - json(context, 404, { - error: `Repository not found locally: ${body.repo.fullName}`, - }); - return; - } - loopLog( - body.loopId, - `Ignoring repo.fullName for ${body.command}: not found locally (${body.repo.fullName})` - ); - } else { + if (expandedRepoPath) { try { assertPathAllowed(expandedRepoPath, allowedDirs); } catch (err) { @@ -1358,13 +1665,24 @@ async function handleLoopRequest( } loopLog( body.loopId, - `Ignoring repo.fullName for ${body.command}: repository path not allowed (${expandedRepoPath})` + `Ignoring repo.fullName for ${body.command}: repository path not allowed (${expandedRepoPath})`, ); expandedRepoPath = null; } else { throw err; } } + } else { + if (repoRequirement === "REQUIRED") { + json(context, 404, { + error: `Repository not found locally: ${body.repo.fullName}`, + }); + return; + } + loopLog( + body.loopId, + `Ignoring repo.fullName for ${body.command}: not found locally (${body.repo.fullName})`, + ); } } @@ -1379,7 +1697,7 @@ async function handleLoopRequest( const label = body.command === "DECOMPOSE" ? "decompose" : "evaluate-prd"; const tmpDir = path.join( os.tmpdir(), - `symphony-${label}-${body.loopId.slice(0, 8)}` + `symphony-${label}-${body.loopId.slice(0, 8)}`, ); await fs.rm(tmpDir, { recursive: true, force: true }); await fs.mkdir(tmpDir, { recursive: true }); @@ -1387,10 +1705,15 @@ async function handleLoopRequest( await writePrdArtifact(claudeWorkDir, body.artifacts, body.prompt); } else if (repoRequirement === "REQUIRED" && !expandedRepoPath) { json(context, 400, { - error: "Repository required for PLAN, EXECUTE, REQUEST_CHANGES, and GENERATE_PRD commands", + error: + "Repository required for PLAN, EXECUTE, REQUEST_CHANGES, and GENERATE_PRD commands", }); return; - } else if (body.command === "PLAN" || body.command === "EXECUTE" || body.command === "REQUEST_CHANGES") { + } else if ( + body.command === "PLAN" || + body.command === "EXECUTE" || + body.command === "REQUEST_CHANGES" + ) { // expandedRepoPath is guaranteed non-null here: the repoRequirement === "REQUIRED" // guard above already returned 400 when it was missing. const repoPath = expandedRepoPath!; @@ -1413,20 +1736,33 @@ async function handleLoopRequest( // PLAN has requiresParent: false, so it must not inherit prior state. const staleWorktree = findWorktreeForBranch(repoPath, branchName); if (staleWorktree) { - loopLog(body.loopId, `Removing stale worktree for fresh PLAN: ${staleWorktree}`); + loopLog( + body.loopId, + `Removing stale worktree for fresh PLAN: ${staleWorktree}`, + ); try { - execSync(`git worktree remove --force ${shellEscape(staleWorktree)}`, { - cwd: repoPath, - stdio: "pipe", - timeout: 15_000, - }); + execSync( + `git worktree remove --force ${shellEscape(staleWorktree)}`, + { + cwd: repoPath, + stdio: "pipe", + timeout: 15_000, + }, + ); } catch (wtErr) { - loopLog(body.loopId, `git worktree remove failed, falling back to fs.rm: ${wtErr instanceof Error ? wtErr.message : wtErr}`); + loopLog( + body.loopId, + `git worktree remove failed, falling back to fs.rm: ${wtErr instanceof Error ? wtErr.message : wtErr}`, + ); // Force-remove the directory so ensureWorktree can recreate it await fs.rm(staleWorktree, { recursive: true, force: true }); // Prune stale worktree entries from git's tracking try { - execSync("git worktree prune", { cwd: repoPath, stdio: "pipe", timeout: 10_000 }); + execSync("git worktree prune", { + cwd: repoPath, + stdio: "pipe", + timeout: 10_000, + }); } catch { // Best-effort } @@ -1436,23 +1772,32 @@ async function handleLoopRequest( repoPath, worktreeDir, branchName, - body.repo?.branch ?? "main" + body.repo?.branch ?? "main", + ); + loopLog( + body.loopId, + `Created fresh worktree for PLAN: ${worktreeDir} (branch: ${branchName})`, ); - loopLog(body.loopId, `Created fresh worktree for PLAN: ${worktreeDir} (branch: ${branchName})`); } else { // EXECUTE/REQUEST_CHANGES: reuse existing worktree. // Try artifact slug first, then parentLoopId fallback, then create new. const existingWorktree = findWorktreeForBranch(repoPath, branchName); if (existingWorktree) { worktreeDir = existingWorktree; - loopLog(body.loopId, `Reusing worktree via artifact slug: ${worktreeDir} (branch: ${branchName})`); + loopLog( + body.loopId, + `Reusing worktree via artifact slug: ${worktreeDir} (branch: ${branchName})`, + ); } else if (body.parentLoopId) { // Fallback: try parent's loopId-based branch (pre-slug deployments or missing slug) const parentBranch = `symphony/loop-${slugifyLoopId(body.parentLoopId)}`; const parentWorktree = findWorktreeForBranch(repoPath, parentBranch); if (parentWorktree) { worktreeDir = parentWorktree; - loopLog(body.loopId, `Reusing worktree via parentLoopId fallback: ${worktreeDir} (branch: ${parentBranch})`); + loopLog( + body.loopId, + `Reusing worktree via parentLoopId fallback: ${worktreeDir} (branch: ${parentBranch})`, + ); } } if (!worktreeDir || !existsSync(worktreeDir)) { @@ -1462,9 +1807,12 @@ async function handleLoopRequest( repoPath, worktreeDir, branchName, - body.repo?.branch ?? "main" + body.repo?.branch ?? "main", + ); + loopLog( + body.loopId, + `Created new worktree: ${worktreeDir} (branch: ${branchName})`, ); - loopLog(body.loopId, `Created new worktree: ${worktreeDir} (branch: ${branchName})`); } } @@ -1472,7 +1820,9 @@ async function handleLoopRequest( assertPathAllowed(worktreeDir, allowedDirs); } catch (e) { if (e instanceof DirectoryNotAllowedError) { - json(context, 403, { error: `Worktree path not allowed: ${worktreeDir}` }); + json(context, 403, { + error: `Worktree path not allowed: ${worktreeDir}`, + }); return; } throw e; @@ -1489,7 +1839,7 @@ async function handleLoopRequest( await writeArtifactsForExecuteOrAmend( claudeWorkDir, body.artifacts, - body.prompt + body.prompt, ); } } else if (body.command === "GENERATE_PRD") { @@ -1507,12 +1857,18 @@ async function handleLoopRequest( ? `symphony/generate-prd-${sanitizedSlug}` : `symphony/generate-prd-${pickStableId(body)}`; - worktreeDir = resolveLoopWorktreeDir(repoPath, `generate-prd-${worktreeKey}`); + worktreeDir = resolveLoopWorktreeDir( + repoPath, + `generate-prd-${worktreeKey}`, + ); // Always start fresh: remove any stale worktree for this branch before creation. const staleWorktree = findWorktreeForBranch(repoPath, branchName); if (staleWorktree) { - loopLog(body.loopId, `Removing stale worktree for fresh GENERATE_PRD: ${staleWorktree}`); + loopLog( + body.loopId, + `Removing stale worktree for fresh GENERATE_PRD: ${staleWorktree}`, + ); await cleanupGeneratePrdWorktree(staleWorktree, repoPath, body.loopId); } @@ -1520,16 +1876,21 @@ async function handleLoopRequest( repoPath, worktreeDir, branchName, - body.repo?.branch ?? "main" + body.repo?.branch ?? "main", + ); + loopLog( + body.loopId, + `Created worktree for GENERATE_PRD: ${worktreeDir} (branch: ${branchName})`, ); - loopLog(body.loopId, `Created worktree for GENERATE_PRD: ${worktreeDir} (branch: ${branchName})`); try { assertPathAllowed(worktreeDir, allowedDirs); } catch (e) { if (e instanceof DirectoryNotAllowedError) { await cleanupGeneratePrdWorktree(worktreeDir, repoPath, body.loopId); - json(context, 403, { error: `Worktree path not allowed: ${worktreeDir}` }); + json(context, 403, { + error: `Worktree path not allowed: ${worktreeDir}`, + }); return; } throw e; @@ -1540,7 +1901,12 @@ async function handleLoopRequest( // Logs, PID, and prompt file go to claudeWorkDir, not the repo root. claudeWorkDir = path.join(worktreeDir, ".claude", "work"); await fs.mkdir(claudeWorkDir, { recursive: true }); - await writeArtifactsForGeneratePrd(worktreeDir, body.artifacts, body.prompt!, body.repo); + await writeArtifactsForGeneratePrd( + worktreeDir, + body.artifacts, + body.prompt!, + body.repo, + ); } else { json(context, 400, { error: `Unknown command: ${body.command}` }); return; @@ -1549,10 +1915,16 @@ async function handleLoopRequest( /** Clean up temporary resources on early-return error paths. */ const cleanupOnError = async (): Promise => { if (usedTempDir) { - await fs.rm(claudeWorkDir, { recursive: true, force: true }).catch(() => {}); + await fs + .rm(claudeWorkDir, { recursive: true, force: true }) + .catch(() => {}); } if (body.command === "GENERATE_PRD" && worktreeDir && expandedRepoPath) { - await cleanupGeneratePrdWorktree(worktreeDir, expandedRepoPath, body.loopId); + await cleanupGeneratePrdWorktree( + worktreeDir, + expandedRepoPath, + body.loopId, + ); } }; @@ -1570,16 +1942,21 @@ async function handleLoopRequest( try { execSync("which claude", { stdio: "pipe", timeout: 5000 }); } catch { - await postLoopEvent( - apiBaseUrl, - body.loopId, - body.closedLoopAuthToken, - { - type: "error", - code: "BINARY_NOT_FOUND", - message: "claude CLI not found in PATH", - } - ); + await postLoopEvent(apiBaseUrl, body.loopId, body.closedLoopAuthToken, { + type: "error", + code: "BINARY_NOT_FOUND", + message: "claude CLI not found in PATH", + }); + telemetry?.emit({ + severity: "error", + category: "preflight.binary_not_found", + message: "claude CLI not found in PATH", + trace: { + commandId, + operationId, + loopId: body.loopId, + }, + }); await cleanupOnError(); json(context, 500, { error: "claude CLI not found in PATH" }); return; @@ -1587,16 +1964,21 @@ async function handleLoopRequest( } else if (usesRunLoop) { scriptPath = findPluginScript("code", "run-loop.sh"); if (!scriptPath) { - await postLoopEvent( - apiBaseUrl, - body.loopId, - body.closedLoopAuthToken, - { - type: "error", - code: "SCRIPT_NOT_FOUND", - message: "run-loop.sh not found in plugin cache", - } - ); + await postLoopEvent(apiBaseUrl, body.loopId, body.closedLoopAuthToken, { + type: "error", + code: "SCRIPT_NOT_FOUND", + message: "run-loop.sh not found in plugin cache", + }); + telemetry?.emit({ + severity: "error", + category: "preflight.script_not_found", + message: "run-loop.sh not found in plugin cache", + trace: { + commandId, + operationId, + loopId: body.loopId, + }, + }); json(context, 500, { error: "run-loop.sh not found in plugin cache" }); return; } @@ -1604,12 +1986,9 @@ async function handleLoopRequest( // Post "started" event — only after confirming we can proceed loopLog(body.loopId, "Posting started event..."); - await postLoopEvent( - apiBaseUrl, - body.loopId, - body.closedLoopAuthToken, - { type: "started" } - ); + await postLoopEvent(apiBaseUrl, body.loopId, body.closedLoopAuthToken, { + type: "started", + }); // Spawn process const logFile = path.join(claudeWorkDir, "symphony-loop.log"); @@ -1623,6 +2002,16 @@ async function handleLoopRequest( code: "SPAWN_FAILED", message: `Cannot open log file: ${msg}`, }); + telemetry?.emit({ + severity: "error", + category: "preflight.spawn_failed", + message: `Cannot open log file: ${msg}`, + trace: { + commandId, + operationId, + loopId: body.loopId, + }, + }); await cleanupOnError(); json(context, 500, { error: `Cannot open log file: ${msg}` }); return; @@ -1640,22 +2029,31 @@ async function handleLoopRequest( // REQUEST_CHANGES omits "-" (stdin) because it passes the prompt as a CLI argument. const baseClaudeArgs: string[] = [ "-p", - "--output-format", "stream-json", + "--output-format", + "stream-json", "--verbose", "--allowedTools", "Bash,Glob,Grep,Read,Write,Edit,Task,Skill,SlashCommand,TodoWrite", - "--max-turns", "200", + "--max-turns", + "200", ]; const stdinClaudeArgs = ["-p", "-", ...baseClaudeArgs.slice(1)]; if (body.command === "DECOMPOSE") { // DECOMPOSE: write prompt to file and pass via stdin to avoid E2BIG - const prdContent = readTextFile(path.join(claudeWorkDir, "prd.md")) ?? ""; - const decomposePrompt = body.prompt ?? `Decompose the following PRD into features:\n\n${prdContent}`; + const prdContent = + readTextFile(path.join(claudeWorkDir, "prd.md")) ?? ""; + const decomposePrompt = + body.prompt ?? + `Decompose the following PRD into features:\n\n${prdContent}`; const promptFile = path.join(claudeWorkDir, "decompose-prompt.txt"); await fs.writeFile(promptFile, decomposePrompt); - const pipeline = buildClaudePipeline(stdinClaudeArgs, claudeWorkDir, promptFile); + const pipeline = buildClaudePipeline( + stdinClaudeArgs, + claudeWorkDir, + promptFile, + ); child = spawn(pipeline.cmd, pipeline.args, { cwd: claudeWorkDir, detached: true, @@ -1665,15 +2063,18 @@ async function handleLoopRequest( child.unref(); } else if (body.command === "EVALUATE_PRD") { // REPO_PATH only when a target repo is linked (expandedRepoPath). - let evaluatePrdPrompt = - `Activate judges:run-judges skill --artifact-type prd --workdir ${claudeWorkDir}.\n`; + let evaluatePrdPrompt = `Activate judges:run-judges skill --artifact-type prd --workdir ${claudeWorkDir}.\n`; if (expandedRepoPath) { evaluatePrdPrompt += `REPO_PATH=${expandedRepoPath} (search here for relevant code).\n`; } const promptFile = path.join(claudeWorkDir, "evaluate-prd-prompt.txt"); await fs.writeFile(promptFile, evaluatePrdPrompt); - const pipeline = buildClaudePipeline(stdinClaudeArgs, claudeWorkDir, promptFile); + const pipeline = buildClaudePipeline( + stdinClaudeArgs, + claudeWorkDir, + promptFile, + ); child = spawn(pipeline.cmd, pipeline.args, { cwd: claudeWorkDir, detached: true, @@ -1694,17 +2095,18 @@ async function handleLoopRequest( // Build /code:amend-plan invocation matching harness const promptFile = path.join(claudeWorkDir, "prompt.md"); - let amendPrompt = "Please amend the plan based on the requested changes."; + let amendPrompt = + "Please amend the plan based on the requested changes."; if (existsSync(promptFile)) { amendPrompt = readFileSync(promptFile, "utf-8"); } // Sanitize prompt matching harness's prepare-message step const sanitized = amendPrompt - .replace(/[\n\r]+/g, " ") - .replace(/\s{2,}/g, " ") - .replace(/"/g, '\\"'); + .replaceAll(/[\n\r]+/g, " ") + .replaceAll(/\s{2,}/g, " ") + .replaceAll(/"/g, '\\"'); claudeArgs.push( - `/code:amend-plan --workdir ${claudeWorkDir} --message "${sanitized}"` + `/code:amend-plan --workdir ${claudeWorkDir} --message "${sanitized}"`, ); const pipeline = buildClaudePipeline(claudeArgs, claudeWorkDir); @@ -1719,7 +2121,11 @@ async function handleLoopRequest( const promptFile = path.join(claudeWorkDir, "generate-prd-prompt.txt"); await fs.writeFile(promptFile, body.prompt!); - const pipeline = buildClaudePipeline(stdinClaudeArgs, claudeWorkDir, promptFile); + const pipeline = buildClaudePipeline( + stdinClaudeArgs, + claudeWorkDir, + promptFile, + ); child = spawn(pipeline.cmd, pipeline.args, { cwd: worktreeDir!, detached: true, @@ -1753,12 +2159,23 @@ async function handleLoopRequest( } } catch (spawnErr) { closeSync(logFd); - const msg = spawnErr instanceof Error ? spawnErr.message : String(spawnErr); + const msg = + spawnErr instanceof Error ? spawnErr.message : String(spawnErr); await postLoopEvent(apiBaseUrl, body.loopId, body.closedLoopAuthToken, { type: "error", code: "SPAWN_FAILED", message: msg, }); + telemetry?.emit({ + severity: "error", + category: "preflight.spawn_failed", + message: msg, + trace: { + commandId, + operationId, + loopId: body.loopId, + }, + }); await cleanupOnError(); json(context, 500, { error: `Failed to spawn process: ${msg}` }); return; @@ -1782,10 +2199,16 @@ async function handleLoopRequest( usedTempDir, expandedRepoPath, jobStore, - webAppOrigin + webAppOrigin, + telemetry, + commandId, + operationId, ).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}`); + gatewayLog.error( + "loop-harness", + `Completion handler error for loopId=${body.loopId}: ${err instanceof Error ? err.message : err}`, + ); }); }; @@ -1817,7 +2240,10 @@ 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}`); + 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) { @@ -1832,7 +2258,9 @@ async function handleLoopRequest( kind: "SYMPHONY_LOOP", loopId: body.loopId, command, - ...(existing ?? {}), + ...existing, + ...(commandId ? { commandId } : {}), + ...(operationId ? { operationId } : {}), worktreeDir: worktreeDir ?? undefined, claudeWorkDir, logPath, @@ -1845,11 +2273,20 @@ async function handleLoopRequest( }); } + telemetry?.emit({ + severity: "info", + category: "job.started", + message: `Job started with pid=${pid}`, + trace: { + commandId, + operationId, + loopId: body.loopId, + jobId: body.loopId, + }, + }); + // Write PID file (safe to await now — close handler is already registered) - await fs.writeFile( - path.join(claudeWorkDir, "process.pid"), - String(pid) - ); + await fs.writeFile(path.join(claudeWorkDir, "process.pid"), String(pid)); json(context, 200, { success: true, @@ -1869,9 +2306,7 @@ async function handleLoopRequest( // Kill handler // --------------------------------------------------------------------------- -async function handleLoopKill( - context: OperationRequestContext -): Promise { +async function handleLoopKill(context: OperationRequestContext): Promise { const rawBody = parseJsonBody(context); if (!rawBody) { json(context, 400, { error: "Invalid JSON body" }); @@ -1922,14 +2357,22 @@ export function registerSymphonyLoopRoutes( getAllowedDirectories: () => string[], getApiOrigin?: () => string, jobStore?: JobStore, - getWebAppOrigin?: () => string + getWebAppOrigin?: () => string, + telemetry?: TelemetryEmitter, ): void { dispatcher.register( "POST", "/api/engineer/symphony/loop", async (context) => { - await handleLoopRequest(context, getAllowedDirectories, getApiOrigin, jobStore, getWebAppOrigin); - } + await handleLoopRequest( + context, + getAllowedDirectories, + getApiOrigin, + jobStore, + getWebAppOrigin, + telemetry, + ); + }, ); dispatcher.register( @@ -1937,6 +2380,6 @@ export function registerSymphonyLoopRoutes( "/api/engineer/symphony/loop/kill", async (context) => { await handleLoopKill(context); - } + }, ); } diff --git a/apps/desktop/src/server/router.ts b/apps/desktop/src/server/router.ts index 93faae73..1751c750 100644 --- a/apps/desktop/src/server/router.ts +++ b/apps/desktop/src/server/router.ts @@ -3,6 +3,7 @@ import { timingSafeEqual } from "node:crypto"; import type { ComputeTargetCapabilities, HealthResponse } from "../shared/contracts.js"; import { isLoopbackIPv4 } from "../shared/network-utils.js"; import type { JobStore } from "../main/job-store.js"; +import type { TelemetryEmitter } from "../main/telemetry-protocol.js"; import type { LocalSessionStore } from "../main/local-session-store.js"; import { verifyChallenge } from "../main/local-auth-verifier.js"; import { OperationDispatcher } from "./operation-dispatcher.js"; @@ -58,6 +59,7 @@ export interface GatewayRouterOptions { getApiOrigin?: () => string; prodOriginsOnly?: boolean; jobStore?: JobStore; + telemetry?: TelemetryEmitter; } export interface GatewayActivityEvent { @@ -156,7 +158,8 @@ export class GatewayRouter { this.options.getAllowedDirectories, this.options.getApiOrigin, this.options.jobStore, - this.options.getWebAppOrigin ?? (() => this.options.webAppOrigin) + this.options.getWebAppOrigin ?? (() => this.options.webAppOrigin), + this.options.telemetry ); registerSymphonyLogsRoutes(this.operationDispatcher, this.options.getAllowedDirectories); registerSymphonyPlanRoutes(this.operationDispatcher, this.options.getAllowedDirectories); diff --git a/apps/desktop/src/server/server.ts b/apps/desktop/src/server/server.ts index 1b61ed9f..df3bcc80 100644 --- a/apps/desktop/src/server/server.ts +++ b/apps/desktop/src/server/server.ts @@ -16,6 +16,7 @@ import { type GatewayApprovalRequest, type GatewayApprovalResult } from "./router.js"; +import type { TelemetryEmitter } from "../main/telemetry-protocol.js"; export interface DesktopGatewayServerOptions { host: string; @@ -40,6 +41,7 @@ export interface DesktopGatewayServerOptions { getApiOrigin?: () => string; prodOriginsOnly?: boolean; jobStore?: JobStore; + telemetry?: TelemetryEmitter; } export class DesktopGatewayServer { @@ -73,6 +75,7 @@ export class DesktopGatewayServer { getApiOrigin: this.options.getApiOrigin, prodOriginsOnly: this.options.prodOriginsOnly, jobStore: this.options.jobStore, + telemetry: this.options.telemetry, }); } @@ -93,7 +96,8 @@ export class DesktopGatewayServer { getApiOrigin?: () => string, getWebAppOrigin?: () => string, prodOriginsOnly?: boolean, - jobStore?: JobStore + jobStore?: JobStore, + telemetry?: TelemetryEmitter ): DesktopGatewayServer { return new DesktopGatewayServer({ host: "127.0.0.1", @@ -115,6 +119,7 @@ export class DesktopGatewayServer { getApiOrigin, prodOriginsOnly, jobStore, + telemetry, }); } diff --git a/apps/desktop/test/cloud-command-executor.test.ts b/apps/desktop/test/cloud-command-executor.test.ts index 0bedaf76..94f3534f 100644 --- a/apps/desktop/test/cloud-command-executor.test.ts +++ b/apps/desktop/test/cloud-command-executor.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import crypto from "node:crypto"; import http from "node:http"; import { afterEach, test } from "node:test"; import { CloudCommandExecutor } from "../src/main/cloud-command-executor.js"; @@ -6,8 +7,9 @@ import type { DesktopCancelEvent, DesktopCommandAckEvent, DesktopCommandEvent, - DesktopCommandStreamEvent + DesktopCommandStreamEvent, } from "../src/main/cloud-protocol.js"; +import type { TelemetryEventPayload } from "../src/main/telemetry-protocol.js"; let gatewayServer: http.Server | null = null; let gatewayPort = 0; @@ -52,23 +54,42 @@ test("serializes conflicting lock keys while allowing parallel non-conflicting c response.end(JSON.stringify({ ok: true, command, body })); }); - const events: Array> = []; + const events: Array< + Omit< + DesktopCommandStreamEvent, + "protocolVersion" | "messageId" | "timestamp" + > + > = []; executor = createExecutor({ maxInFlightCommands: 2, - onEvent: (event) => events.push(event) + onEvent: (event) => events.push(event), }); executor.setConnected(true); - executor.enqueue(buildCommand("c1", { command: "c1" }, { repoPath: "/repo/a" })); - executor.enqueue(buildCommand("c2", { command: "c2" }, { repoPath: "/repo/a" })); - executor.enqueue(buildCommand("c3", { command: "c3" }, { repoPath: "/repo/b" })); + executor.enqueue( + buildCommand("c1", { command: "c1" }, { repoPath: "/repo/a" }), + ); + executor.enqueue( + buildCommand("c2", { command: "c2" }, { repoPath: "/repo/a" }), + ); + executor.enqueue( + buildCommand("c3", { command: "c3" }, { repoPath: "/repo/b" }), + ); - await waitFor(() => countDone(events, "c1") === 1 && countDone(events, "c2") === 1 && countDone(events, "c3") === 1); + await waitFor( + () => + countDone(events, "c1") === 1 && + countDone(events, "c2") === 1 && + countDone(events, "c3") === 1, + ); assert.equal(maxActive, 2); const c2Index = startOrder.indexOf("c2"); const c3Index = startOrder.indexOf("c3"); - assert.ok(c2Index > c3Index, `expected c3 to start before c2, got order: ${startOrder.join(",")}`); + assert.ok( + c2Index > c3Index, + `expected c3 to start before c2, got order: ${startOrder.join(",")}`, + ); }); test("cancels queued command with terminal done(cancelled=true)", async () => { @@ -84,24 +105,38 @@ test("cancels queued command with terminal done(cancelled=true)", async () => { response.end(JSON.stringify({ ok: true, command })); }); - const events: Array> = []; + const events: Array< + Omit< + DesktopCommandStreamEvent, + "protocolVersion" | "messageId" | "timestamp" + > + > = []; executor = createExecutor({ maxInFlightCommands: 1, - onEvent: (event) => events.push(event) + onEvent: (event) => events.push(event), }); executor.setConnected(true); - executor.enqueue(buildCommand("c1", { command: "c1" }, { repoPath: "/repo/a" })); - executor.enqueue(buildCommand("c2", { command: "c2" }, { repoPath: "/repo/b" })); + executor.enqueue( + buildCommand("c1", { command: "c1" }, { repoPath: "/repo/a" }), + ); + executor.enqueue( + buildCommand("c2", { command: "c2" }, { repoPath: "/repo/b" }), + ); executor.cancel(buildCancel("c2", "user requested cancel")); - await waitFor(() => countDone(events, "c1") === 1 && countDone(events, "c2") === 1); + await waitFor( + () => countDone(events, "c1") === 1 && countDone(events, "c2") === 1, + ); const cancelledDone = events.find( - (event) => event.commandId === "c2" && event.eventType === "done" + (event) => event.commandId === "c2" && event.eventType === "done", ); assert.ok(cancelledDone); - assert.equal((cancelledDone?.data as Record).cancelled, true); + assert.equal( + (cancelledDone?.data as Record).cancelled, + true, + ); assert.deepEqual(started, ["c1"]); }); @@ -110,15 +145,24 @@ test("emits terminal timeout error when command exceeds timeoutMs", async () => await sleep(250); }); - const events: Array> = []; + const events: Array< + Omit< + DesktopCommandStreamEvent, + "protocolVersion" | "messageId" | "timestamp" + > + > = []; executor = createExecutor({ maxInFlightCommands: 1, - onEvent: (event) => events.push(event) + onEvent: (event) => events.push(event), }); executor.setConnected(true); executor.enqueue( - buildCommand("timeout-command", { command: "timeout-command" }, { repoPath: "/repo/a", timeoutMs: 30 }) + buildCommand( + "timeout-command", + { command: "timeout-command" }, + { repoPath: "/repo/a", timeoutMs: 30 }, + ), ); await waitFor( @@ -128,9 +172,9 @@ test("emits terminal timeout error when command exceeds timeoutMs", async () => event.commandId === "timeout-command" && event.eventType === "error" && asRecord(event.data).terminal === true && - asRecord(event.data).code === "timeout" + asRecord(event.data).code === "timeout", ), - 2000 + 2000, ); }); @@ -143,21 +187,34 @@ test("replays buffered events from resume sequence", async () => { response.end(JSON.stringify({ ok: true, command })); }); - const events: Array> = []; + const events: Array< + Omit< + DesktopCommandStreamEvent, + "protocolVersion" | "messageId" | "timestamp" + > + > = []; executor = createExecutor({ maxInFlightCommands: 1, - onEvent: (event) => events.push(event) + onEvent: (event) => events.push(event), }); executor.setConnected(true); - executor.enqueue(buildCommand("replay-command", { command: "replay-command" }, { repoPath: "/repo/a" })); + executor.enqueue( + buildCommand( + "replay-command", + { command: "replay-command" }, + { repoPath: "/repo/a" }, + ), + ); await waitFor(() => countDone(events, "replay-command") === 1); const eventCountBeforeReplay = events.length; executor.replayFrom({ "replay-command": 1 }); await waitFor(() => events.length > eventCountBeforeReplay); - const replayed = events.slice(eventCountBeforeReplay).filter((event) => event.commandId === "replay-command"); + const replayed = events + .slice(eventCountBeforeReplay) + .filter((event) => event.commandId === "replay-command"); assert.ok(replayed.every((event) => event.sequence > 1)); }); @@ -173,7 +230,12 @@ test("forwards request body for DELETE commands", async () => { response.end(JSON.stringify({ ok: true })); }); - const events: Array> = []; + const events: Array< + Omit< + DesktopCommandStreamEvent, + "protocolVersion" | "messageId" | "timestamp" + > + > = []; executor = createExecutor({ maxInFlightCommands: 1, onEvent: (event) => events.push(event), @@ -213,7 +275,12 @@ test("does not forward body for GET commands", async () => { response.end(JSON.stringify({ ok: true })); }); - const events: Array> = []; + const events: Array< + Omit< + DesktopCommandStreamEvent, + "protocolVersion" | "messageId" | "timestamp" + > + > = []; executor = createExecutor({ maxInFlightCommands: 1, onEvent: (event) => events.push(event), @@ -237,19 +304,273 @@ test("does not forward body for GET commands", async () => { assert.equal(receivedBody, ""); }); +test("sends x-desktop-command-id and x-desktop-operation-id headers in gateway requests", async () => { + let receivedCommandId: string | undefined; + let receivedOperationId: string | undefined; + + const commandId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + const operationId = "f1e2d3c4-b5a6-7890-abcd-ef1234567890"; + + await startGateway(async (request, response) => { + receivedCommandId = request.headers["x-desktop-command-id"] as + | string + | undefined; + receivedOperationId = request.headers["x-desktop-operation-id"] as + | string + | undefined; + response.statusCode = 200; + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ ok: true })); + }); + + const events: Array< + Omit< + DesktopCommandStreamEvent, + "protocolVersion" | "messageId" | "timestamp" + > + > = []; + executor = createExecutor({ + maxInFlightCommands: 1, + onEvent: (event) => events.push(event), + }); + executor.setConnected(true); + + executor.enqueue({ + protocolVersion: "1", + messageId: "header-test-msg", + timestamp: new Date().toISOString(), + commandId, + operationId, + method: "GET", + path: "/api/engineer/git", + query: {}, + }); + + await waitFor(() => countDone(events, commandId) === 1); + + assert.equal(receivedCommandId, commandId); + assert.equal(receivedOperationId, operationId); +}); + +test("omits x-desktop-command-id header when commandId is not a valid UUID", async () => { + let receivedHeaders: http.IncomingHttpHeaders | undefined; + + await startGateway(async (request, response) => { + receivedHeaders = request.headers; + response.statusCode = 200; + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ ok: true })); + }); + + const events: Array< + Omit< + DesktopCommandStreamEvent, + "protocolVersion" | "messageId" | "timestamp" + > + > = []; + executor = createExecutor({ + maxInFlightCommands: 1, + onEvent: (event) => events.push(event), + }); + executor.setConnected(true); + + // commandId with CRLF injection attempt — not a valid UUID + const injectedCommandId = "not-a-uuid\r\nX-Injected: evil"; + executor.enqueue({ + protocolVersion: "1", + messageId: "injection-test-msg", + timestamp: new Date().toISOString(), + commandId: injectedCommandId, + operationId: "git_action", + method: "GET", + path: "/api/engineer/git", + query: {}, + }); + + await waitFor(() => countDone(events, injectedCommandId) === 1); + + assert.ok(receivedHeaders !== undefined); + assert.equal(receivedHeaders["x-desktop-command-id"], undefined); + assert.equal(receivedHeaders["x-injected"], undefined); + // operationId "git_action" is a safe non-UUID value — header should be set + assert.equal(receivedHeaders["x-desktop-operation-id"], "git_action"); +}); + +test("omits x-desktop-operation-id header when operationId contains CRLF", async () => { + let receivedHeaders: http.IncomingHttpHeaders | undefined; + + await startGateway(async (request, response) => { + receivedHeaders = request.headers; + response.statusCode = 200; + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ ok: true })); + }); + + const events: Array< + Omit< + DesktopCommandStreamEvent, + "protocolVersion" | "messageId" | "timestamp" + > + > = []; + executor = createExecutor({ + maxInFlightCommands: 1, + onEvent: (event) => events.push(event), + }); + executor.setConnected(true); + + const commandId = crypto.randomUUID(); + executor.enqueue({ + protocolVersion: "1", + messageId: "op-injection-test-msg", + timestamp: new Date().toISOString(), + commandId, + operationId: "evil\r\nX-Injected: pwned", + method: "GET", + path: "/api/engineer/git", + query: {}, + }); + + await waitFor(() => countDone(events, commandId) === 1); + + assert.ok(receivedHeaders !== undefined); + assert.equal(receivedHeaders["x-desktop-command-id"], commandId); + assert.equal(receivedHeaders["x-desktop-operation-id"], undefined); + assert.equal(receivedHeaders["x-injected"], undefined); +}); + +test("emitTelemetry is called with severity=error and category=command.timeout on timeout", async () => { + await startGateway(async () => { + await sleep(250); + }); + + const telemetryEvents: TelemetryEventPayload[] = []; + const commandEvents: Array< + Omit< + DesktopCommandStreamEvent, + "protocolVersion" | "messageId" | "timestamp" + > + > = []; + + executor = createExecutor({ + maxInFlightCommands: 1, + onEvent: (event) => commandEvents.push(event), + emitTelemetry: (event) => telemetryEvents.push(event), + }); + executor.setConnected(true); + + const commandId = "a1b2c3d4-e5f6-7890-abcd-111111111111"; + executor.enqueue( + buildCommand( + commandId, + { command: commandId }, + { repoPath: "/repo/a", timeoutMs: 30 }, + ), + ); + + await waitFor(() => telemetryEvents.length > 0, 2000); + + const telemetry = telemetryEvents[0]; + assert.ok(telemetry !== undefined); + assert.equal(telemetry.severity, "error"); + assert.equal(telemetry.category, "command.timeout"); + assert.equal(telemetry.trace?.commandId, commandId); +}); + +test("emitTelemetry is called with severity=warn and category=command.cancelled on cancel", async () => { + await startGateway(async () => { + await sleep(300); + }); + + const telemetryEvents: TelemetryEventPayload[] = []; + const commandEvents: Array< + Omit< + DesktopCommandStreamEvent, + "protocolVersion" | "messageId" | "timestamp" + > + > = []; + + executor = createExecutor({ + maxInFlightCommands: 1, + onEvent: (event) => commandEvents.push(event), + emitTelemetry: (event) => telemetryEvents.push(event), + }); + executor.setConnected(true); + + const commandId = "a1b2c3d4-e5f6-7890-abcd-222222222222"; + executor.enqueue( + buildCommand(commandId, { command: commandId }, { repoPath: "/repo/a" }), + ); + + // Wait for the command to be in-flight before cancelling + await waitFor(() => + commandEvents.some( + (e) => e.commandId === commandId && e.eventType === "status", + ), + ); + executor.cancel(buildCancel(commandId, "user requested cancel")); + + await waitFor(() => telemetryEvents.length > 0, 2000); + + const telemetry = telemetryEvents[0]; + assert.ok(telemetry !== undefined); + assert.equal(telemetry.severity, "warn"); + assert.equal(telemetry.category, "command.cancelled"); + assert.equal(telemetry.trace?.commandId, commandId); +}); + +test("emitTelemetry is called with severity=error and category=command.gateway_error on gateway error", async () => { + await startGateway(async (_request, response) => { + response.statusCode = 500; + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ error: "internal server error" })); + }); + + const telemetryEvents: TelemetryEventPayload[] = []; + const commandEvents: Array< + Omit< + DesktopCommandStreamEvent, + "protocolVersion" | "messageId" | "timestamp" + > + > = []; + + executor = createExecutor({ + maxInFlightCommands: 1, + onEvent: (event) => commandEvents.push(event), + emitTelemetry: (event) => telemetryEvents.push(event), + }); + executor.setConnected(true); + + const commandId = "a1b2c3d4-e5f6-7890-abcd-333333333333"; + executor.enqueue( + buildCommand(commandId, { command: commandId }, { repoPath: "/repo/a" }), + ); + + await waitFor(() => telemetryEvents.length > 0, 2000); + + const telemetry = telemetryEvents[0]; + assert.ok(telemetry !== undefined); + assert.equal(telemetry.severity, "error"); + assert.equal(telemetry.category, "command.gateway_error"); + assert.equal(telemetry.trace?.commandId, commandId); +}); + function createExecutor(options: { maxInFlightCommands: number; - onEvent: (event: Omit) => void; + onEvent: ( + event: Omit< + DesktopCommandStreamEvent, + "protocolVersion" | "messageId" | "timestamp" + >, + ) => void; + emitTelemetry?: (event: TelemetryEventPayload) => void; }): CloudCommandExecutor { - const acks: Array> = []; return new CloudCommandExecutor({ getGatewayPort: () => gatewayPort, getGatewayAuthToken: () => "test-gateway-token", maxInFlightCommands: options.maxInFlightCommands, - sendCommandAck: (event) => { - acks.push(event); - }, - sendCommandEvent: options.onEvent + sendCommandAck: () => {}, + sendCommandEvent: options.onEvent, + emitTelemetry: options.emitTelemetry, }); } @@ -259,7 +580,7 @@ function buildCommand( options?: { repoPath?: string; timeoutMs?: number; - } + }, ): DesktopCommandEvent { return { protocolVersion: "1", @@ -272,9 +593,9 @@ function buildCommand( query, body: { action: "status", - repoPath: options?.repoPath ?? "/repo/default" + repoPath: options?.repoPath ?? "/repo/default", }, - timeoutMs: options?.timeoutMs + timeoutMs: options?.timeoutMs, }; } @@ -284,23 +605,30 @@ function buildCancel(commandId: string, reason: string): DesktopCancelEvent { messageId: `${commandId}-cancel`, timestamp: new Date().toISOString(), commandId, - reason + reason, }; } function countDone( - events: Array>, - commandId: string + events: Array< + Omit< + DesktopCommandStreamEvent, + "protocolVersion" | "messageId" | "timestamp" + > + >, + commandId: string, ): number { - return events.filter((event) => event.commandId === commandId && event.eventType === "done").length; + return events.filter( + (event) => event.commandId === commandId && event.eventType === "done", + ).length; } async function startGateway( handler: ( request: http.IncomingMessage, response: http.ServerResponse, - body: string - ) => Promise + body: string, + ) => Promise, ): Promise { gatewayServer = http.createServer((request, response) => { void (async () => { @@ -311,8 +639,11 @@ async function startGateway( response.setHeader("content-type", "application/json"); response.end( JSON.stringify({ - error: error instanceof Error ? error.message : "unknown test server failure" - }) + error: + error instanceof Error + ? error.message + : "unknown test server failure", + }), ); }); }); @@ -337,7 +668,10 @@ async function readBody(request: http.IncomingMessage): Promise { return Buffer.concat(chunks).toString("utf-8"); } -async function waitFor(predicate: () => boolean, timeoutMs = 3000): Promise { +async function waitFor( + predicate: () => boolean, + timeoutMs = 3000, +): Promise { const started = Date.now(); while (!predicate()) { if (Date.now() - started > timeoutMs) { diff --git a/apps/desktop/test/helpers/mock-api-server.ts b/apps/desktop/test/helpers/mock-api-server.ts new file mode 100644 index 00000000..ac1c5231 --- /dev/null +++ b/apps/desktop/test/helpers/mock-api-server.ts @@ -0,0 +1,89 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import http from "node:http"; +import path from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export type RecordedRequest = { method: string; url: string; body: string }; + +export async function initGitRepo(repoPath: string): Promise { + await execFileAsync("git", ["init", "-b", "main", repoPath]); + await execFileAsync("git", ["-C", repoPath, "config", "user.email", "test@test.com"]); + await execFileAsync("git", ["-C", repoPath, "config", "user.name", "Test"]); + await fs.writeFile(path.join(repoPath, "README.md"), "# initial\n"); + await execFileAsync("git", ["-C", repoPath, "add", "."]); + await execFileAsync("git", ["-C", repoPath, "commit", "-m", "initial"]); +} + +export async function startMockApiServer(defaultTimeoutMs = 20_000): Promise<{ + server: http.Server; + port: number; + requests: RecordedRequest[]; + waitForRequest: (urlSubstring: string, timeoutMs?: number) => Promise; +}> { + const requests: RecordedRequest[] = []; + const waiters: Array<{ urlSubstring: string; resolve: (r: RecordedRequest) => void }> = []; + + const server = http.createServer((req, res) => { + void (async () => { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); + } + const recorded: RecordedRequest = { + method: req.method ?? "", + url: req.url ?? "", + body: Buffer.concat(chunks).toString("utf-8"), + }; + requests.push(recorded); + + for (let i = waiters.length - 1; i >= 0; i--) { + if (recorded.url.includes(waiters[i].urlSubstring)) { + waiters[i].resolve(recorded); + waiters.splice(i, 1); + } + } + + res.statusCode = 200; + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ success: true })); + })(); + }); + + await new Promise((resolve, reject) => { + server.listen(0, "127.0.0.1", () => resolve()); + server.once("error", reject); + }); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("failed to bind mock API server"); + } + + function waitForRequest(urlSubstring: string, timeoutMs = defaultTimeoutMs): Promise { + const existing = requests.find((r) => r.url.includes(urlSubstring)); + if (existing) { + return Promise.resolve(existing); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject( + new Error( + `Timed out waiting for request matching "${urlSubstring}" after ${timeoutMs}ms` + ) + ); + }, timeoutMs); + waiters.push({ + urlSubstring, + resolve: (r) => { + clearTimeout(timer); + resolve(r); + }, + }); + }); + } + + return { server, port: address.port, requests, waitForRequest }; +} diff --git a/apps/desktop/test/symphony-loop-execute.test.ts b/apps/desktop/test/symphony-loop-execute.test.ts index 5133febd..3ef0ce0e 100644 --- a/apps/desktop/test/symphony-loop-execute.test.ts +++ b/apps/desktop/test/symphony-loop-execute.test.ts @@ -17,18 +17,14 @@ */ import assert from "node:assert/strict"; -import { execFile, execSync } from "node:child_process"; import fs from "node:fs/promises"; import http from "node:http"; import os from "node:os"; import path from "node:path"; import { afterEach, test } from "node:test"; -import { promisify } from "node:util"; import { DesktopGatewayServer } from "../src/server/server.js"; import { EMPTY_CAPABILITIES, PORT_PROBE_ORDER } from "../src/shared/contracts.js"; -const execFileAsync = promisify(execFile); - // --------------------------------------------------------------------------- // Shared state and cleanup // --------------------------------------------------------------------------- @@ -86,87 +82,8 @@ afterEach(async () => { // Helpers // --------------------------------------------------------------------------- -async function initGitRepo(repoPath: string): Promise { - await execFileAsync("git", ["init", "-b", "main", repoPath]); - await execFileAsync("git", ["-C", repoPath, "config", "user.email", "test@test.com"]); - await execFileAsync("git", ["-C", repoPath, "config", "user.name", "Test"]); - await fs.writeFile(path.join(repoPath, "README.md"), "# initial\n"); - await execFileAsync("git", ["-C", repoPath, "add", "."]); - await execFileAsync("git", ["-C", repoPath, "commit", "-m", "initial"]); -} - -type RecordedRequest = { method: string; url: string; body: string }; - -async function startMockApiServer(): Promise<{ - server: http.Server; - port: number; - requests: RecordedRequest[]; - waitForRequest: (urlSubstring: string, timeoutMs?: number) => Promise; -}> { - const requests: RecordedRequest[] = []; - const waiters: Array<{ urlSubstring: string; resolve: (r: RecordedRequest) => void }> = []; - - const server = http.createServer((req, res) => { - void (async () => { - const chunks: Buffer[] = []; - for await (const chunk of req) { - chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); - } - const recorded: RecordedRequest = { - method: req.method ?? "", - url: req.url ?? "", - body: Buffer.concat(chunks).toString("utf-8"), - }; - requests.push(recorded); - - for (let i = waiters.length - 1; i >= 0; i--) { - if (recorded.url.includes(waiters[i].urlSubstring)) { - waiters[i].resolve(recorded); - waiters.splice(i, 1); - } - } - - res.statusCode = 200; - res.setHeader("content-type", "application/json"); - res.end(JSON.stringify({ success: true })); - })(); - }); - - await new Promise((resolve, reject) => { - server.listen(0, "127.0.0.1", () => resolve()); - server.once("error", reject); - }); - - const address = server.address(); - if (!address || typeof address === "string") { - throw new Error("failed to bind mock API server"); - } - - function waitForRequest(urlSubstring: string, timeoutMs = 20_000): Promise { - const existing = requests.find((r) => r.url.includes(urlSubstring)); - if (existing) { - return Promise.resolve(existing); - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject( - new Error( - `Timed out waiting for request matching "${urlSubstring}" after ${timeoutMs}ms` - ) - ); - }, timeoutMs); - waiters.push({ - urlSubstring, - resolve: (r) => { - clearTimeout(timer); - resolve(r); - }, - }); - }); - } - - return { server, port: address.port, requests, waitForRequest }; -} +// Shared test helpers — see test/helpers/mock-api-server.ts +import { initGitRepo, startMockApiServer } from "./helpers/mock-api-server.js"; /** * Create the fake plugin cache structure so findPluginScript("code", "run-loop.sh") diff --git a/apps/desktop/test/symphony-loop-generate-prd.test.ts b/apps/desktop/test/symphony-loop-generate-prd.test.ts index a1aa8b2a..5a74d2dd 100644 --- a/apps/desktop/test/symphony-loop-generate-prd.test.ts +++ b/apps/desktop/test/symphony-loop-generate-prd.test.ts @@ -5,17 +5,18 @@ * and real git repos with worktrees. */ import assert from "node:assert/strict"; -import { execFile, execSync } from "node:child_process"; +import { execSync } from "node:child_process"; import fs from "node:fs/promises"; import http from "node:http"; import os from "node:os"; import path from "node:path"; import { afterEach, test } from "node:test"; -import { promisify } from "node:util"; import { DesktopGatewayServer } from "../src/server/server.js"; -import { EMPTY_CAPABILITIES, PORT_PROBE_ORDER } from "../src/shared/contracts.js"; +import { EMPTY_CAPABILITIES } from "../src/shared/contracts.js"; -const execFileAsync = promisify(execFile); +// Use a file-local port range so this suite does not collide with other +// integration test files that still use the default gateway probe order. +const GENERATE_PRD_TEST_PORTS = [39432, 39433, 39434, 39435] as const; // --------------------------------------------------------------------------- // Shared state and cleanup @@ -66,85 +67,8 @@ afterEach(async () => { // Helpers // --------------------------------------------------------------------------- -async function initGitRepo(repoPath: string): Promise { - await execFileAsync("git", ["init", "-b", "main", repoPath]); - await execFileAsync("git", ["-C", repoPath, "config", "user.email", "test@test.com"]); - await execFileAsync("git", ["-C", repoPath, "config", "user.name", "Test"]); - await fs.writeFile(path.join(repoPath, "README.md"), "# initial\n"); - await execFileAsync("git", ["-C", repoPath, "add", "."]); - await execFileAsync("git", ["-C", repoPath, "commit", "-m", "initial"]); -} - -type RecordedRequest = { method: string; url: string; body: string }; - -async function startMockApiServer(): Promise<{ - server: http.Server; - port: number; - requests: RecordedRequest[]; - waitForRequest: (urlSubstring: string, timeoutMs?: number) => Promise; -}> { - const requests: RecordedRequest[] = []; - const waiters: Array<{ urlSubstring: string; resolve: (r: RecordedRequest) => void }> = []; - - const server = http.createServer((req, res) => { - void (async () => { - const chunks: Buffer[] = []; - for await (const chunk of req) { - chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); - } - const recorded: RecordedRequest = { - method: req.method ?? "", - url: req.url ?? "", - body: Buffer.concat(chunks).toString("utf-8"), - }; - requests.push(recorded); - - // Resolve any waiters matching this URL - for (let i = waiters.length - 1; i >= 0; i--) { - if (recorded.url.includes(waiters[i].urlSubstring)) { - waiters[i].resolve(recorded); - waiters.splice(i, 1); - } - } - - res.statusCode = 200; - res.setHeader("content-type", "application/json"); - res.end(JSON.stringify({ success: true })); - })(); - }); - - await new Promise((resolve, reject) => { - server.listen(0, "127.0.0.1", () => resolve()); - server.once("error", reject); - }); - - const address = server.address(); - if (!address || typeof address === "string") { - throw new Error("failed to bind mock API server"); - } - - function waitForRequest(urlSubstring: string, timeoutMs = 15_000): Promise { - // Check if already received - const existing = requests.find((r) => r.url.includes(urlSubstring)); - if (existing) { - return Promise.resolve(existing); - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error(`Timed out waiting for request matching "${urlSubstring}" after ${timeoutMs}ms`)); - }, timeoutMs); - waiters.push({ - urlSubstring, - resolve: (r) => { - clearTimeout(timer); - resolve(r); - }, - }); - }); - } - - return { server, port: address.port, requests, waitForRequest }; -} +// Shared test helpers — see test/helpers/mock-api-server.ts +import { initGitRepo, startMockApiServer } from "./helpers/mock-api-server.js"; const LOOP_UUID = "00000000-0000-0000-0000-000000000099"; @@ -161,8 +85,8 @@ test("GENERATE_PRD: rejects with 400 when no repo configured", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: GENERATE_PRD_TEST_PORTS[0], + fallbackPorts: GENERATE_PRD_TEST_PORTS.slice(1), webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "genprd-norepo-machine", @@ -226,8 +150,8 @@ test("GENERATE_PRD: accepts valid command and responds 200", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: GENERATE_PRD_TEST_PORTS[0], + fallbackPorts: GENERATE_PRD_TEST_PORTS.slice(1), webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "genprd-accept-machine", @@ -274,8 +198,8 @@ test("GENERATE_PRD: rejects with 400 when prompt is missing", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: GENERATE_PRD_TEST_PORTS[0], + fallbackPorts: GENERATE_PRD_TEST_PORTS.slice(1), webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "genprd-noprompt-machine", @@ -382,8 +306,8 @@ test("GENERATE_PRD: spawns with worktree cwd, writes context pack, no --add-dir" const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: GENERATE_PRD_TEST_PORTS[0], + fallbackPorts: GENERATE_PRD_TEST_PORTS.slice(1), webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "genprd-layout-machine", @@ -495,8 +419,8 @@ test("GENERATE_PRD: uploads { prd: { content } } when prd.md is written", async const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: GENERATE_PRD_TEST_PORTS[0], + fallbackPorts: GENERATE_PRD_TEST_PORTS.slice(1), webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "genprd-upload-machine", @@ -567,8 +491,8 @@ test("GENERATE_PRD: uploads empty artifacts when prd.md is not written", async ( const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: GENERATE_PRD_TEST_PORTS[0], + fallbackPorts: GENERATE_PRD_TEST_PORTS.slice(1), webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "genprd-noout-machine", @@ -638,8 +562,8 @@ test("GENERATE_PRD: cleans up worktree on failure (exit code 1)", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: GENERATE_PRD_TEST_PORTS[0], + fallbackPorts: GENERATE_PRD_TEST_PORTS.slice(1), webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "genprd-cleanup-machine", diff --git a/apps/desktop/test/telemetry-loop-integration.test.ts b/apps/desktop/test/telemetry-loop-integration.test.ts new file mode 100644 index 00000000..af2e8cf5 --- /dev/null +++ b/apps/desktop/test/telemetry-loop-integration.test.ts @@ -0,0 +1,638 @@ +/** + * Integration tests for telemetry emission from the symphony loop handler. + * + * Covers all 5 emission function scopes: + * 1. job.failed telemetry — correct category/trace/diagnostics + * 2. job.completed telemetry — correct category/trace + * 3. preflight.binary_not_found telemetry — correct category/trace (claude not in PATH) + * 4. preflight.spawn_failed telemetry — correct category (log file cannot be opened) + * 5. x-desktop-command-id / x-desktop-operation-id header trace propagation + * + * Also verifies end-to-end logTail truncation via TelemetryService: + * - diagnostics.logTail is capped at TELEMETRY_MAX_FIELD_BYTES in the captured event. + * + * Teardown: stopGateway() / fs.rm (recursive+force) / kill spawned child processes. + */ + +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, test } from "node:test"; +import { DesktopGatewayServer } from "../src/server/server.js"; +import { EMPTY_CAPABILITIES } from "../src/shared/contracts.js"; + +// Use unique high ports to avoid EADDRINUSE with other test files that use PORT_PROBE_ORDER (19432-19435) +const TELEM_TEST_PORTS = [29432, 29433, 29434, 29435] as const; +import { TELEMETRY_MAX_FIELD_BYTES } from "../src/main/telemetry-protocol.js"; +import { TelemetryService, type EnrichedTelemetryEvent } from "../src/main/telemetry-service.js"; + +// --------------------------------------------------------------------------- +// Shared teardown state +// --------------------------------------------------------------------------- + +const serversToClose: DesktopGatewayServer[] = []; +const mockServersToClose: http.Server[] = []; +const tempPathsToClean: string[] = []; + +const originalPath = process.env.PATH; +const originalHome = process.env.HOME; +const originalRawPipeline = process.env.CLOSEDLOOP_SYMPHONY_TEST_RAW_CLAUDE_PIPELINE; +const originalWorktreeParentDir = process.env.SYMPHONY_WORKTREE_PARENT_DIR; + +afterEach(async () => { + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + + if (originalRawPipeline === undefined) { + delete process.env.CLOSEDLOOP_SYMPHONY_TEST_RAW_CLAUDE_PIPELINE; + } else { + process.env.CLOSEDLOOP_SYMPHONY_TEST_RAW_CLAUDE_PIPELINE = originalRawPipeline; + } + + if (originalWorktreeParentDir === undefined) { + delete process.env.SYMPHONY_WORKTREE_PARENT_DIR; + } else { + process.env.SYMPHONY_WORKTREE_PARENT_DIR = originalWorktreeParentDir; + } + + for (const server of serversToClose.splice(0)) { + await server.stop(); + } + + for (const ms of mockServersToClose.splice(0)) { + await new Promise((resolve, reject) => { + ms.close((err) => (err ? reject(err) : resolve())); + }); + } + + for (const tempPath of tempPathsToClean.splice(0)) { + await fs.rm(tempPath, { recursive: true, force: true }); + } +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// Shared test helpers — see test/helpers/mock-api-server.ts +import { initGitRepo, startMockApiServer } from "./helpers/mock-api-server.js"; + +/** + * Build a TelemetryService that collects all emitted events. + * Returns the service and the shared captured events array. + */ +function buildCapturingTelemetry(): { + service: TelemetryService; + captured: EnrichedTelemetryEvent[]; + waitForCategory: (category: string, timeoutMs?: number) => Promise; +} { + const captured: EnrichedTelemetryEvent[] = []; + const waiters: Array<{ category: string; resolve: (e: EnrichedTelemetryEvent) => void }> = []; + + const service = new TelemetryService({ + sendTelemetry: (event) => { + captured.push(event); + for (let i = waiters.length - 1; i >= 0; i--) { + if (event.category === waiters[i].category) { + waiters[i].resolve(event); + waiters.splice(i, 1); + } + } + }, + }); + + function waitForCategory(category: string, timeoutMs = 20_000): Promise { + const existing = captured.find((e) => e.category === category); + if (existing) { + return Promise.resolve(existing); + } + return new Promise((resolve, reject) => { + const onTimeout = (): void => { + reject( + new Error( + `Timed out waiting for telemetry category "${category}" after ${timeoutMs}ms. Captured: ${JSON.stringify(captured.map((e) => e.category))}` + ) + ); + }; + const timer = setTimeout(onTimeout, timeoutMs); + const onResolve = (e: EnrichedTelemetryEvent): void => { + clearTimeout(timer); + resolve(e); + }; + waiters.push({ category, resolve: onResolve }); + }); + } + + return { service, captured, waitForCategory }; +} + +/** + * Create a fake claude binary that exits with the given code. + * DECOMPOSE / EVALUATE_PRD use `which claude` (preflight) then `claude` directly. + */ +async function createFakeClaudeBin(fakeBin: string, exitCode: number): Promise { + await fs.mkdir(fakeBin, { recursive: true }); + await fs.writeFile( + path.join(fakeBin, "claude"), + `#!/bin/sh\nexit ${exitCode}\n`, + { mode: 0o755 } + ); +} + +// --------------------------------------------------------------------------- +// Test 1: job.failed — telemetry emitted when spawned process exits non-zero +// --------------------------------------------------------------------------- + +test("telemetry: job.failed emitted with correct category/trace/diagnostics on process exit non-zero", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "telem-failed-")); + tempPathsToClean.push(tmpDir); + + process.env.HOME = tmpDir; + process.env.CLOSEDLOOP_SYMPHONY_TEST_RAW_CLAUDE_PIPELINE = "1"; + + // fake claude exits 1 → triggers job.failed telemetry + const fakeBin = path.join(tmpDir, "fake-bin"); + await createFakeClaudeBin(fakeBin, 1); + process.env.PATH = `${fakeBin}:/usr/bin:/bin`; + + const mock = await startMockApiServer(); + mockServersToClose.push(mock.server); + + const { service, waitForCategory } = buildCapturingTelemetry(); + + const server = new DesktopGatewayServer({ + host: "127.0.0.1", + preferredPort: TELEM_TEST_PORTS[0], + fallbackPorts: TELEM_TEST_PORTS.slice(1), + webAppOrigin: "https://app.symphony.com", + getAllowedDirectories: () => [tmpDir], + machineName: "telem-failed-machine", + version: "0.1.0-test", + capabilities: EMPTY_CAPABILITIES, + discoveryFilePath: path.join(tmpDir, "electron-port"), + getApiOrigin: () => `http://127.0.0.1:${mock.port}`, + telemetry: service, + }); + serversToClose.push(server); + await server.start(); + + const loopId = "00000000-0000-0000-0000-000000000a01"; + const response = await fetch( + `http://127.0.0.1:${server.getActivePort()}/api/engineer/symphony/loop`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + loopId, + command: "DECOMPOSE", + closedLoopAuthToken: "tok", + artifacts: [], + prompt: "Decompose this feature into tasks", + }), + } + ); + + assert.equal( + response.status, + 200, + `Expected 200 but got ${response.status}: ${await response.text().catch(() => "")}` + ); + + const event = await waitForCategory("job.failed"); + + assert.equal(event.category, "job.failed", "category must be job.failed"); + assert.equal(event.severity, "error", "severity must be error"); + assert.ok( + typeof event.message === "string" && event.message.length > 0, + "message must be non-empty" + ); + assert.ok(event.trace, "trace must be present"); + assert.equal(event.trace?.loopId, loopId, "trace.loopId must match"); + assert.equal(event.trace?.jobId, loopId, "trace.jobId must match loopId"); + // AC-002: diagnostics must include exitCode, tokenUsage, diagnosticsVersion + const diag = event.diagnostics; + assert.ok(diag, "diagnostics must be present on job.failed"); + assert.equal(typeof diag!.exitCode, "number", "diagnostics.exitCode must be a number"); + assert.ok(diag!.exitCode !== 0, "diagnostics.exitCode must be non-zero for failures"); + assert.ok(diag!.tokenUsage, "diagnostics.tokenUsage must be present"); + assert.equal(typeof diag!.tokenUsage!.inputTokens, "number", "tokenUsage.inputTokens must be a number"); + assert.equal(typeof diag!.tokenUsage!.outputTokens, "number", "tokenUsage.outputTokens must be a number"); + assert.equal(typeof diag!.diagnosticsVersion, "number", "diagnostics.diagnosticsVersion must be a number"); + assert.ok(diag!.diagnosticsVersion! >= 1, "diagnosticsVersion must be >= 1"); +}); + +// --------------------------------------------------------------------------- +// Test 2: job.completed — telemetry emitted when spawned process exits 0 +// --------------------------------------------------------------------------- + +test("telemetry: job.completed emitted with correct category/trace on process exit 0", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "telem-completed-")); + tempPathsToClean.push(tmpDir); + + process.env.HOME = tmpDir; + process.env.CLOSEDLOOP_SYMPHONY_TEST_RAW_CLAUDE_PIPELINE = "1"; + + // fake claude exits 0 → triggers job.completed telemetry + const fakeBin = path.join(tmpDir, "fake-bin"); + await createFakeClaudeBin(fakeBin, 0); + process.env.PATH = `${fakeBin}:/usr/bin:/bin`; + + const mock = await startMockApiServer(); + mockServersToClose.push(mock.server); + + const { service, waitForCategory } = buildCapturingTelemetry(); + + const server = new DesktopGatewayServer({ + host: "127.0.0.1", + preferredPort: TELEM_TEST_PORTS[0], + fallbackPorts: TELEM_TEST_PORTS.slice(1), + webAppOrigin: "https://app.symphony.com", + getAllowedDirectories: () => [tmpDir], + machineName: "telem-completed-machine", + version: "0.1.0-test", + capabilities: EMPTY_CAPABILITIES, + discoveryFilePath: path.join(tmpDir, "electron-port"), + getApiOrigin: () => `http://127.0.0.1:${mock.port}`, + telemetry: service, + }); + serversToClose.push(server); + await server.start(); + + const loopId = "00000000-0000-0000-0000-000000000a02"; + const response = await fetch( + `http://127.0.0.1:${server.getActivePort()}/api/engineer/symphony/loop`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + loopId, + command: "DECOMPOSE", + closedLoopAuthToken: "tok", + artifacts: [], + prompt: "Decompose this feature into tasks", + }), + } + ); + + assert.equal( + response.status, + 200, + `Expected 200 but got ${response.status}: ${await response.text().catch(() => "")}` + ); + + const event = await waitForCategory("job.completed"); + + assert.equal(event.category, "job.completed", "category must be job.completed"); + assert.equal(event.severity, "info", "severity must be info"); + assert.ok( + typeof event.message === "string" && event.message.length > 0, + "message must be non-empty" + ); + assert.ok(event.trace, "trace must be present"); + assert.equal(event.trace?.loopId, loopId, "trace.loopId must match"); + assert.equal(event.trace?.jobId, loopId, "trace.jobId must match loopId"); + // job.completed has no diagnostics + assert.equal( + event.diagnostics, + undefined, + "job.completed must not emit diagnostics" + ); +}); + +// --------------------------------------------------------------------------- +// Test 3: preflight.binary_not_found — claude not found in PATH +// --------------------------------------------------------------------------- + +test("telemetry: preflight.binary_not_found emitted when claude is absent from PATH", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "telem-binnotfound-")); + tempPathsToClean.push(tmpDir); + + process.env.HOME = tmpDir; + process.env.CLOSEDLOOP_SYMPHONY_TEST_RAW_CLAUDE_PIPELINE = "1"; + + // No claude binary — PATH has no executable named "claude" + const emptyBin = path.join(tmpDir, "empty-bin"); + await fs.mkdir(emptyBin, { recursive: true }); + process.env.PATH = emptyBin; + + const mock = await startMockApiServer(); + mockServersToClose.push(mock.server); + + const { service, waitForCategory } = buildCapturingTelemetry(); + + const server = new DesktopGatewayServer({ + host: "127.0.0.1", + preferredPort: TELEM_TEST_PORTS[0], + fallbackPorts: TELEM_TEST_PORTS.slice(1), + webAppOrigin: "https://app.symphony.com", + getAllowedDirectories: () => [tmpDir], + machineName: "telem-binnotfound-machine", + version: "0.1.0-test", + capabilities: EMPTY_CAPABILITIES, + discoveryFilePath: path.join(tmpDir, "electron-port"), + getApiOrigin: () => `http://127.0.0.1:${mock.port}`, + telemetry: service, + }); + serversToClose.push(server); + await server.start(); + + const loopId = "00000000-0000-0000-0000-000000000a03"; + const response = await fetch( + `http://127.0.0.1:${server.getActivePort()}/api/engineer/symphony/loop`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + loopId, + command: "DECOMPOSE", + closedLoopAuthToken: "tok", + artifacts: [], + prompt: "Decompose this feature into tasks", + }), + } + ); + + // Handler returns 500 when binary not found + assert.equal( + response.status, + 500, + `Expected 500 but got ${response.status}: ${await response.text().catch(() => "")}` + ); + + const event = await waitForCategory("preflight.binary_not_found"); + + assert.equal(event.category, "preflight.binary_not_found", "category must be preflight.binary_not_found"); + assert.equal(event.severity, "error", "severity must be error"); + assert.ok( + typeof event.message === "string" && event.message.includes("claude"), + `message must mention "claude", got: ${event.message}` + ); + assert.ok(event.trace, "trace must be present"); + assert.equal(event.trace?.loopId, loopId, "trace.loopId must match"); +}); + +// --------------------------------------------------------------------------- +// Test 4: preflight.spawn_failed — log file cannot be opened (EISDIR) +// --------------------------------------------------------------------------- +// +// Strategy: Use the PLAN command with a predictable worktree path. +// Pre-create symphony-loop.log as a DIRECTORY at the expected claudeWorkDir +// location. When handleLoopRequest calls openSync(logFile, "a"), it fails +// with EISDIR, triggering the preflight.spawn_failed telemetry path. +// +// The worktree directory is created by git worktree add during ensureWorktree, +// but the .claude/work sub-directory (claudeWorkDir) is created afterwards by +// fs.mkdir. We pre-populate the log file as a directory inside it so that +// the mkdir(..., { recursive: true }) succeeds (it already exists as a dir) +// while openSync on a subdirectory entry that is itself a directory fails. + +test("telemetry: preflight.spawn_failed emitted when log file open fails (EISDIR)", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "telem-spawnfail-")); + tempPathsToClean.push(tmpDir); + + process.env.HOME = tmpDir; + process.env.CLOSEDLOOP_SYMPHONY_TEST_RAW_CLAUDE_PIPELINE = "1"; + + // Provide a fake claude (not needed for PLAN but avoids PATH issues) + const fakeBin = path.join(tmpDir, "fake-bin"); + await createFakeClaudeBin(fakeBin, 0); + + // Create a fake run-loop.sh in the plugin cache so findPluginScript returns non-null + const scriptDir = path.join( + tmpDir, + ".claude", + "plugins", + "cache", + "closedloop-ai", + "code", + "1.0.0", + "scripts" + ); + await fs.mkdir(scriptDir, { recursive: true }); + const runLoopScript = path.join(scriptDir, "run-loop.sh"); + await fs.writeFile(runLoopScript, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + + process.env.PATH = `${fakeBin}:/usr/bin:/bin`; + + const mock = await startMockApiServer(); + mockServersToClose.push(mock.server); + + const { service, waitForCategory } = buildCapturingTelemetry(); + + // loopId determines the worktree path — we use a known value to predict the path + const loopId = "00000000-0000-0000-0000-000000000a04"; + + const repoPath = path.join(tmpDir, "spawn-fail-repo"); + await initGitRepo(repoPath); + + const worktreeParent = path.join(tmpDir, "worktrees"); + await fs.mkdir(worktreeParent, { recursive: true }); + process.env.SYMPHONY_WORKTREE_PARENT_DIR = worktreeParent; + + // Compute the worktreeDir that ensureWorktree would create: + // resolveLoopWorktreeDir(repoPath, slugifyLoopId(loopId)) + // = path.join(worktreeParent, `spawn-fail-repo-loop-${slugifiedLoopId}`) + // slugifyLoopId keeps the UUID as-is (all chars are [a-z0-9-]) + const slugifiedId = loopId; // UUID has only lowercase hex and dashes + const predictedWorktreeDir = path.join( + worktreeParent, + `spawn-fail-repo-loop-${slugifiedId}` + ); + const predictedClaudeWorkDir = path.join(predictedWorktreeDir, ".claude", "work"); + const predictedLogFile = path.join(predictedClaudeWorkDir, "symphony-loop.log"); + + // Pre-create worktreeDir as a plain directory (not a real git worktree). + // ensureWorktree calls existsSync(worktreeDir) first and returns early if it exists, + // so git worktree add is never called and we control the directory contents. + // Create claudeWorkDir and symphony-loop.log as a DIRECTORY inside it. + // When fs.mkdir(claudeWorkDir, { recursive: true }) runs, it succeeds (already exists). + // When openSync(logFile, "a") runs on the log path that is a directory, it fails with EISDIR. + await fs.mkdir(predictedClaudeWorkDir, { recursive: true }); + await fs.mkdir(predictedLogFile, { recursive: true }); // log "file" is actually a dir + + const server = new DesktopGatewayServer({ + host: "127.0.0.1", + preferredPort: TELEM_TEST_PORTS[0], + fallbackPorts: TELEM_TEST_PORTS.slice(1), + webAppOrigin: "https://app.symphony.com", + getAllowedDirectories: () => [tmpDir], + machineName: "telem-spawnfail-machine", + version: "0.1.0-test", + capabilities: EMPTY_CAPABILITIES, + discoveryFilePath: path.join(tmpDir, "electron-port"), + getApiOrigin: () => `http://127.0.0.1:${mock.port}`, + telemetry: service, + }); + serversToClose.push(server); + await server.start(); + + const response = await fetch( + `http://127.0.0.1:${server.getActivePort()}/api/engineer/symphony/loop`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + loopId, + command: "PLAN", + closedLoopAuthToken: "tok", + artifacts: [], + repo: { fullName: `spawn-fail/${path.basename(repoPath)}`, branch: "main" }, + }), + } + ); + + // Handler returns 500 when log file cannot be opened + assert.equal( + response.status, + 500, + `Expected 500 but got ${response.status}: ${await response.text().catch(() => "")}` + ); + + const event = await waitForCategory("preflight.spawn_failed"); + + assert.equal(event.category, "preflight.spawn_failed", "category must be preflight.spawn_failed"); + assert.equal(event.severity, "error", "severity must be error"); + assert.ok( + typeof event.message === "string" && event.message.length > 0, + "message must be non-empty" + ); +}); + +// --------------------------------------------------------------------------- +// Test 5: x-desktop-command-id / x-desktop-operation-id header trace propagation +// --------------------------------------------------------------------------- + +test("telemetry: commandId and operationId from request headers appear in trace context", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "telem-headers-")); + tempPathsToClean.push(tmpDir); + + process.env.HOME = tmpDir; + process.env.CLOSEDLOOP_SYMPHONY_TEST_RAW_CLAUDE_PIPELINE = "1"; + + // fake claude exits 1 → triggers job.failed with trace we can inspect + const fakeBin = path.join(tmpDir, "fake-bin"); + await createFakeClaudeBin(fakeBin, 1); + process.env.PATH = `${fakeBin}:/usr/bin:/bin`; + + const mock = await startMockApiServer(); + mockServersToClose.push(mock.server); + + const { service, waitForCategory } = buildCapturingTelemetry(); + + const server = new DesktopGatewayServer({ + host: "127.0.0.1", + preferredPort: TELEM_TEST_PORTS[0], + fallbackPorts: TELEM_TEST_PORTS.slice(1), + webAppOrigin: "https://app.symphony.com", + getAllowedDirectories: () => [tmpDir], + machineName: "telem-headers-machine", + version: "0.1.0-test", + capabilities: EMPTY_CAPABILITIES, + discoveryFilePath: path.join(tmpDir, "electron-port"), + getApiOrigin: () => `http://127.0.0.1:${mock.port}`, + telemetry: service, + }); + serversToClose.push(server); + await server.start(); + + const loopId = "00000000-0000-0000-0000-000000000a05"; + const testCommandId = "cmd-test-abc123"; + const testOperationId = "op-test-xyz789"; + + const response = await fetch( + `http://127.0.0.1:${server.getActivePort()}/api/engineer/symphony/loop`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-desktop-command-id": testCommandId, + "x-desktop-operation-id": testOperationId, + }, + body: JSON.stringify({ + loopId, + command: "DECOMPOSE", + closedLoopAuthToken: "tok", + artifacts: [], + prompt: "Decompose this feature into tasks", + }), + } + ); + + assert.equal( + response.status, + 200, + `Expected 200 but got ${response.status}: ${await response.text().catch(() => "")}` + ); + + // Wait for job.failed which is emitted after process exits non-zero + const event = await waitForCategory("job.failed"); + + assert.ok(event.trace, "trace must be present"); + assert.equal( + event.trace?.commandId, + testCommandId, + `trace.commandId must match header value, got: ${event.trace?.commandId}` + ); + assert.equal( + event.trace?.operationId, + testOperationId, + `trace.operationId must match header value, got: ${event.trace?.operationId}` + ); + assert.equal(event.trace?.loopId, loopId, "trace.loopId must match"); +}); + +// --------------------------------------------------------------------------- +// Test 6: logTail truncation — diagnostics.logTail capped at TELEMETRY_MAX_FIELD_BYTES +// --------------------------------------------------------------------------- + +test("telemetry: TelemetryService truncates logTail to TELEMETRY_MAX_FIELD_BYTES", () => { + const { service, captured } = buildCapturingTelemetry(); + + // Build a string longer than TELEMETRY_MAX_FIELD_BYTES (4096 bytes) + // Use ASCII lines so byte count == char count + const lineCount = 200; + const lineContent = "A".repeat(100); + const largeTail = Array.from({ length: lineCount }, (_, i) => `line-${i}: ${lineContent}`).join("\n"); + + // Verify our test data is actually over the limit + const encoder = new TextEncoder(); + assert.ok( + encoder.encode(largeTail).length > TELEMETRY_MAX_FIELD_BYTES, + "Test data must exceed TELEMETRY_MAX_FIELD_BYTES for this test to be meaningful" + ); + + service.emit({ + severity: "error", + category: "job.failed", + message: "test truncation", + trace: { loopId: "00000000-0000-0000-0000-000000000b01" }, + diagnostics: { logTail: largeTail }, + }); + + assert.equal(captured.length, 1, "Expected exactly one captured event"); + const event = captured[0]; + + const logTail = event.diagnostics?.logTail; + assert.ok(logTail !== undefined, "diagnostics.logTail must be present"); + const truncatedBytes = encoder.encode(logTail).length; + assert.ok( + truncatedBytes <= TELEMETRY_MAX_FIELD_BYTES, + `logTail must be <= ${TELEMETRY_MAX_FIELD_BYTES} bytes after truncation, got ${truncatedBytes}` + ); + // Verify that the original large string was actually truncated + assert.ok( + logTail.length < largeTail.length, + "truncated logTail must be shorter than the original" + ); +}); diff --git a/apps/desktop/test/telemetry-service.test.ts b/apps/desktop/test/telemetry-service.test.ts new file mode 100644 index 00000000..50650fd3 --- /dev/null +++ b/apps/desktop/test/telemetry-service.test.ts @@ -0,0 +1,339 @@ +/** + * Unit tests for TelemetryService (T-6.1) + * + * Covers: + * - emit() calls sendTelemetry with correct structure + * - emit() never throws even if callback throws + * - setTargetId() injects into trace.computeTargetId + * - large logTail fields are truncated to TELEMETRY_MAX_FIELD_BYTES + * - sendTelemetry callback never called (disconnected relay simulation) does not throw + * + * readLogTail() edge cases (imported directly from symphony-loop.ts per T-4.3): + * - file smaller than maxBytes returns full content + * - file larger than maxBytes returns tail content + * - nonexistent file returns null + * - empty file returns null + * - partial first line dropped on mid-file offset + */ + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, test } from "node:test"; +import { + TELEMETRY_LOG_TAIL_MAX_BYTES, + TELEMETRY_MAX_FIELD_BYTES, + type TelemetryEventPayload, +} from "../src/main/telemetry-protocol.js"; +import { + TelemetryService, + type EnrichedTelemetryEvent, +} from "../src/main/telemetry-service.js"; +import { readLogTail } from "../src/server/operations/symphony-loop.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "telemetry-service-test-")); + tempDirs.push(dir); + return dir; +} + +function makeEvent(overrides: Partial = {}): TelemetryEventPayload { + return { + severity: "info", + category: "job.started", + message: "test event", + ...overrides, + } as TelemetryEventPayload; +} + +// --------------------------------------------------------------------------- +// TelemetryService.emit() +// --------------------------------------------------------------------------- + +describe("TelemetryService.emit()", () => { + test("calls sendTelemetry with a correctly structured event", () => { + const received: EnrichedTelemetryEvent[] = []; + const svc = new TelemetryService({ + sendTelemetry: (evt) => received.push(evt), + }); + + const event = makeEvent({ message: "hello", category: "job.started" }); + svc.emit(event); + + assert.equal(received.length, 1); + assert.equal(received[0].message, "hello"); + assert.equal(received[0].category, "job.started"); + assert.equal(received[0].severity, "info"); + assert.equal(received[0].schemaVersion, 1); + assert.equal(typeof received[0].timestamp, "string"); + assert.ok(received[0].timestamp!.length > 0); + }); + + test("never throws even if sendTelemetry callback throws", () => { + const svc = new TelemetryService({ + sendTelemetry: () => { + throw new Error("callback error"); + }, + }); + + // Should not throw + assert.doesNotThrow(() => { + svc.emit(makeEvent()); + }); + }); + + test("never throws even when sendTelemetry callback throws (relay error simulation)", () => { + // Simulate a relay error by using a callback that throws on invocation + let callbackInvoked = false; + const svc = new TelemetryService({ + sendTelemetry: () => { + callbackInvoked = true; + // This represents a relay that is disconnected - the callback could + // throw a connection error + throw new Error("relay disconnected"); + }, + }); + + // Even though the callback throws (simulating disconnected relay), emit() must not throw + assert.doesNotThrow(() => { + svc.emit(makeEvent()); + }); + + // The callback was invoked (emit tried to send) but the error was swallowed + assert.equal(callbackInvoked, true); + }); +}); + +// --------------------------------------------------------------------------- +// TelemetryService.setTargetId() +// --------------------------------------------------------------------------- + +describe("TelemetryService.setTargetId()", () => { + test("injects computeTargetId into trace context of emitted event", () => { + const received: EnrichedTelemetryEvent[] = []; + const svc = new TelemetryService({ + sendTelemetry: (evt) => received.push(evt), + }); + + svc.setTargetId("target-abc-123"); + svc.emit(makeEvent({ message: "after setTargetId" })); + + assert.equal(received.length, 1); + assert.equal(received[0].trace?.computeTargetId, "target-abc-123"); + }); + + test("merges computeTargetId with existing trace fields", () => { + const received: EnrichedTelemetryEvent[] = []; + const svc = new TelemetryService({ + sendTelemetry: (evt) => received.push(evt), + }); + + svc.setTargetId("target-xyz"); + svc.emit( + makeEvent({ + trace: { commandId: "cmd-1", loopId: "loop-1" }, + }) + ); + + assert.equal(received.length, 1); + assert.equal(received[0].trace?.computeTargetId, "target-xyz"); + assert.equal(received[0].trace?.commandId, "cmd-1"); + assert.equal(received[0].trace?.loopId, "loop-1"); + }); + + test("does not inject computeTargetId before setTargetId is called", () => { + const received: EnrichedTelemetryEvent[] = []; + const svc = new TelemetryService({ + sendTelemetry: (evt) => received.push(evt), + }); + + svc.emit(makeEvent()); + + assert.equal(received.length, 1); + assert.equal(received[0].trace?.computeTargetId, undefined); + }); +}); + +// --------------------------------------------------------------------------- +// logTail truncation +// --------------------------------------------------------------------------- + +describe("TelemetryService logTail truncation", () => { + test("truncates logTail larger than TELEMETRY_MAX_FIELD_BYTES", () => { + const received: EnrichedTelemetryEvent[] = []; + const svc = new TelemetryService({ + sendTelemetry: (evt) => received.push(evt), + }); + + // Build a logTail that is clearly over the 4 KiB limit + const overLimit = "x".repeat(TELEMETRY_MAX_FIELD_BYTES + 500); + svc.emit( + makeEvent({ + diagnostics: { logTail: overLimit }, + }) + ); + + assert.equal(received.length, 1); + const logTail = received[0].diagnostics?.logTail; + assert.ok(logTail !== undefined, "logTail should be present"); + const byteLength = Buffer.byteLength(logTail, "utf8"); + assert.ok( + byteLength <= TELEMETRY_MAX_FIELD_BYTES, + `logTail byte length ${byteLength} exceeds TELEMETRY_MAX_FIELD_BYTES ${TELEMETRY_MAX_FIELD_BYTES}` + ); + }); + + test("passes through logTail smaller than TELEMETRY_MAX_FIELD_BYTES unchanged", () => { + const received: EnrichedTelemetryEvent[] = []; + const svc = new TelemetryService({ + sendTelemetry: (evt) => received.push(evt), + }); + + const shortTail = "short log line\n"; + svc.emit( + makeEvent({ + diagnostics: { logTail: shortTail }, + }) + ); + + assert.equal(received.length, 1); + assert.equal(received[0].diagnostics?.logTail, shortTail); + }); + + test("preserves other diagnostics fields when truncating logTail", () => { + const received: EnrichedTelemetryEvent[] = []; + const svc = new TelemetryService({ + sendTelemetry: (evt) => received.push(evt), + }); + + const overLimit = "y".repeat(TELEMETRY_MAX_FIELD_BYTES + 100); + svc.emit( + makeEvent({ + diagnostics: { + logTail: overLimit, + errorStack: "Error: something\n at fn (file.ts:1)", + extra: { key: "value" }, + }, + }) + ); + + assert.equal(received.length, 1); + assert.equal( + received[0].diagnostics?.errorStack, + "Error: something\n at fn (file.ts:1)" + ); + assert.deepEqual(received[0].diagnostics?.extra, { key: "value" }); + }); +}); + +// --------------------------------------------------------------------------- +// readLogTail() edge cases +// --------------------------------------------------------------------------- + +describe("readLogTail()", () => { + test("returns null for nonexistent file", () => { + const dir = makeTempDir(); + const result = readLogTail(path.join(dir, "does-not-exist.log")); + assert.equal(result, null); + }); + + test("returns null for empty file", () => { + const dir = makeTempDir(); + const logPath = path.join(dir, "empty.log"); + fs.writeFileSync(logPath, ""); + const result = readLogTail(logPath); + assert.equal(result, null); + }); + + test("returns full content for file smaller than maxBytes (32 KiB)", () => { + const dir = makeTempDir(); + const logPath = path.join(dir, "small.log"); + const content = "line 1\nline 2\nline 3\n"; + fs.writeFileSync(logPath, content); + const result = readLogTail(logPath); + assert.equal(result, content); + }); + + test("returns tail content for file larger than maxBytes", () => { + const dir = makeTempDir(); + const logPath = path.join(dir, "large.log"); + + // Write more than 32 KiB (LOG_TAIL_MAX_BYTES) + const LOG_TAIL_MAX_BYTES = TELEMETRY_LOG_TAIL_MAX_BYTES; + // Build content: a "header" section followed by many lines, total > 32 KiB + const line = "a".repeat(100) + "\n"; // 101 bytes per line + const numLines = Math.ceil((LOG_TAIL_MAX_BYTES + 1000) / line.length) + 1; + const header = "HEADER_LINE\n"; + const body = line.repeat(numLines); + fs.writeFileSync(logPath, header + body); + + const result = readLogTail(logPath); + assert.ok(result !== null, "should return string for large file"); + + const byteLength = Buffer.byteLength(result, "utf8"); + assert.ok( + byteLength <= LOG_TAIL_MAX_BYTES, + `result byte length ${byteLength} exceeds LOG_TAIL_MAX_BYTES ${LOG_TAIL_MAX_BYTES}` + ); + + // The HEADER_LINE should not appear in the result (it's from the start of a big file) + assert.ok( + !result.includes("HEADER_LINE"), + "should not contain header from start of oversized file" + ); + }); + + test("drops partial first line on mid-file offset", () => { + const dir = makeTempDir(); + const logPath = path.join(dir, "mid-offset.log"); + + // Write more than 32 KiB so we're guaranteed to start mid-file + const LOG_TAIL_MAX_BYTES = TELEMETRY_LOG_TAIL_MAX_BYTES; + // Line 1 is unique and will be cut: it must start before the 32 KiB tail window + const uniquePrefix = "UNIQUE_PARTIAL_LINE_"; + // Pad to make sure the total file exceeds LOG_TAIL_MAX_BYTES + const padding = "p".repeat(LOG_TAIL_MAX_BYTES); + const tailLines = "complete line 1\ncomplete line 2\n"; + // Layout: padding (32 KiB) + partial unique line that straddles the boundary + // We'll ensure UNIQUE_PARTIAL_LINE_ falls just before the 32 KiB boundary + const content = padding + uniquePrefix + "rest_of_line\n" + tailLines; + fs.writeFileSync(logPath, content); + + const result = readLogTail(logPath); + assert.ok(result !== null, "should return string"); + + // The partial line (UNIQUE_PARTIAL_LINE_) falls right at the boundary - + // if the file is offset, the function should drop the partial first line + // The tail lines should be present since they are after the partial line + assert.ok( + result.includes("complete line 1"), + "should include complete tail lines" + ); + // The unique prefix may or may not appear depending on exact byte boundaries, + // but if it does appear, it should be a complete occurrence (not partial). + // The important property: the result doesn't start with a partial line. + const firstNewline = result.indexOf("\n"); + if (firstNewline > 0) { + // First line should be a complete line (not ending abruptly mid-word) + const firstLine = result.slice(0, firstNewline); + // A partial line from UNIQUE_PARTIAL_LINE_ would be "rest_of_line" without the prefix + assert.ok( + !firstLine.startsWith("rest_of_line"), + `first line should not be a partial tail of a split line, got: "${firstLine}"` + ); + } + }); +});