open-design/apps/daemon/src/diagnostics-export.ts
lefarcen b8cfee0c60
fix(diagnostics): capture daemon/web logs in packaged bundles (#3126)
Packaged diagnostics bundles never contained the daemon or web
`latest.log` — the very logs that hold the agent/critique run flow — so
support exports could not explain "sent prompt to the agent, then
nothing happened" reports.

Root cause: the sidecar `base` means different things per launch path.
tools-dev passes the pre-namespace source root, so
`resolveNamespaceRoot(base, namespace)` is correct. But the packaged
orchestrator launches every child with `base = <namespaceRoot>/runtime`
(apps/packaged/src/{paths,sidecars}.ts) while logs live a level up at
`<namespaceRoot>/logs`. The diagnostics builders re-appended the
namespace and resolved every log to
`<namespaceRoot>/runtime/<namespace>/logs/...` → ENOENT. renderer.log
only survived by accident: the desktop main process wrote it to the
same wrong path the reader looked in.

Add `resolveRuntimeNamespaceRoot(runtime, contract, runtimeMode)` to
`@open-design/sidecar` which walks up out of the `runtime/` dir in
packaged (runtime-mode) launches and falls back to the dev layout
otherwise. Route the desktop renderer-log path and both diagnostics
exporters (desktop IPC + daemon HTTP) through it so writer and reader
stay in lockstep and renderer.log lands next to the desktop log dir.

Tests: sidecar unit specs for both layouts; a daemon export spec that
writes a real `<namespaceRoot>/logs/daemon/latest.log` and asserts the
bundle captures its contents (red on main → ENOENT placeholder, green
here).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:48:14 +00:00

142 lines
5 KiB
TypeScript

import { homedir, userInfo } from 'node:os';
import { dirname } from 'node:path';
import type { RequestHandler } from 'express';
import {
buildDiagnosticsZip,
DIAGNOSTICS_CONTENT_TYPE,
DIAGNOSTICS_FILENAME_PREFIX,
diagnosticsFileName,
type LogSource,
} from '@open-design/diagnostics';
import {
APP_KEYS,
OPEN_DESIGN_SIDECAR_CONTRACT,
SIDECAR_MODES,
type SidecarStamp,
} from '@open-design/sidecar-proto';
import {
resolveLogFilePath,
resolveRuntimeNamespaceRoot,
type SidecarRuntimeContext,
} from '@open-design/sidecar';
import { readCurrentAppVersionInfo } from './app-version.js';
export interface DiagnosticsHandlerOptions {
/** Sidecar runtime context, present when daemon is launched via tools-dev or packaged sidecar. */
runtime: SidecarRuntimeContext<SidecarStamp> | null;
/** Project root used to derive crash-report match strings. */
projectRoot: string;
}
const TAIL_BYTES_PER_LOG = 4 * 1024 * 1024;
function safeUsername(): string | undefined {
try {
const info = userInfo();
return info?.username && info.username.length > 0 ? info.username : undefined;
} catch {
return undefined;
}
}
export const STANDALONE_LAUNCH_WARNING =
"Daemon started without a sidecar runtime (plain `od` / standalone launch); " +
"file-based logs are not captured. Re-run via `pnpm tools-dev` or the packaged " +
"desktop app to include daemon/web/desktop log files in the bundle.";
function buildSidecarLogSources(runtime: SidecarRuntimeContext<SidecarStamp> | null): LogSource[] {
if (runtime == null) return [];
// In packaged builds `runtime.base` is `<namespaceRoot>/runtime`, so the log
// tree lives a level UP at `<namespaceRoot>/logs`; `resolveRuntimeNamespaceRoot`
// accounts for that (a plain `resolveNamespaceRoot` here resolved every
// daemon/web log to an ENOENT phantom path and captured none of them).
const namespaceRoot = resolveRuntimeNamespaceRoot({
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
runtime,
runtimeMode: SIDECAR_MODES.RUNTIME,
});
const apps = [APP_KEYS.DAEMON, APP_KEYS.WEB, APP_KEYS.DESKTOP];
const sources: LogSource[] = [];
for (const app of apps) {
const absolutePath = resolveLogFilePath({
app,
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
runtimeRoot: namespaceRoot,
});
sources.push({
name: `logs/${app}/latest.log`,
absolutePath,
kind: 'text',
tailBytes: TAIL_BYTES_PER_LOG,
});
// Only desktop runs an Electron renderer that writes `renderer.log`
// (see apps/desktop/src/main/runtime.ts). daemon and web are pure Node
// services with no renderer process, so listing the file there only
// produces missing-file placeholders and manifest warnings.
if (app === APP_KEYS.DESKTOP) {
sources.push({
name: `logs/${app}/renderer.log`,
absolutePath: `${dirname(absolutePath)}/renderer.log`,
kind: 'text',
tailBytes: TAIL_BYTES_PER_LOG,
});
}
}
return sources;
}
export function createDiagnosticsExportHandler(options: DiagnosticsHandlerOptions): RequestHandler {
return async (_req, res) => {
try {
const versionInfo = await readCurrentAppVersionInfo().catch(() => null);
const sources = buildSidecarLogSources(options.runtime);
const username = safeUsername();
const home = homedir();
const result = await buildDiagnosticsZip({
context: {
app: {
name: 'open-design',
version: versionInfo?.version,
channel: versionInfo?.channel,
packaged: versionInfo?.packaged,
},
source: 'daemon-http',
namespace: options.runtime?.namespace,
extra: {
runtimeAvailable: options.runtime != null,
sourceTag: options.runtime?.source ?? null,
mode: options.runtime?.mode ?? null,
base: options.runtime?.base ?? null,
projectRoot: options.projectRoot,
},
warnings: options.runtime == null ? [STANDALONE_LAUNCH_WARNING] : undefined,
},
sources,
redaction: { username },
crashReports: {
// Restrict to Open Design's own process names. A generic "Electron"
// substring would sweep up crash reports from any other Electron
// app on the host (VS Code, Slack, …) and leak unrelated user data
// into the support bundle.
matchSubstrings: ['Open Design', 'open-design'],
withinDays: 7,
maxReports: 10,
homeDir: home,
},
});
const filename = diagnosticsFileName(DIAGNOSTICS_FILENAME_PREFIX);
res.setHeader('Content-Type', DIAGNOSTICS_CONTENT_TYPE);
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Cache-Control', 'no-store');
res.status(200).end(result.zip);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
res.status(500).json({ error: 'DIAGNOSTICS_EXPORT_FAILED', message });
}
};
}