Files
fusion/packages/dashboard/src/devserver-manager.ts
gsxdsm cca13737b6 FN-8603: reduce steady-state diagnostic log noise
Route routine core, engine, and dashboard diagnostics through debug-gated shared loggers.

- Demote steady-state diagnostic sites while preserving warnings and errors for actionable failures.
- Add cross-package severity contracts and manifest coverage for demoted log sites.
- Document logging severity guidance and add a patch changeset.

Files changed:
 .changeset/fn-8603-log-severity.md                 |  7 ++
 docs/diagnostics.md                                | 20 ++++--
 .../__tests__/log-severity-spam-contract.test.ts   | 71 ++++++++++++++++++
 packages/core/src/activity-analytics.ts            |  5 +-
 packages/core/src/ai-summarize.ts                  | 61 +++++++---------
 packages/core/src/async-mission-store.ts           |  5 +-
 packages/core/src/async-secrets-store.ts           |  7 +-
 packages/core/src/central-core.ts                  | 17 ++---
 packages/core/src/docker-provisioning.ts           | 13 ++--
 packages/core/src/index.ts                         |  1 +
 packages/core/src/master-key.ts                    |  9 ++-
 packages/core/src/memory-compaction.ts             | 29 ++++----
 packages/core/src/memory-insights.ts               |  7 +-
 packages/core/src/migration-orchestrator.ts        |  7 +-
 packages/core/src/mission-store.ts                 |  5 +-
 packages/core/src/node-discovery.ts                |  7 +-
 packages/core/src/notification/dispatcher.ts       |  9 ++-
 .../core/src/plugins/bundled-plugin-install.ts     | 11 +--
 packages/core/src/reflection-store.ts              |  5 +-
 packages/core/src/secrets-store.ts                 |  7 +-
 packages/core/src/task-store/agent-logs.ts         | 21 +++---
 packages/core/src/task-store/async-events.ts       |  5 +-
 packages/core/src/task-store/async-maintenance.ts  |  7 +-
 packages/core/src/task-store/comments-ops.ts       |  7 +-
 packages/core/src/task-store/task-mutation-ops.ts  | 11 +--
 packages/core/src/task-store/workflow-integrity.ts |  9 ++-
 packages/core/src/types/merge-policy.ts            |  5 +-
 packages/core/src/usage-events.ts                  |  5 +-
 .../__tests__/log-severity-spam-contract.test.ts   | 48 +++++++++++++
 packages/dashboard/src/ai-refine.ts                |  5 +-
 packages/dashboard/src/ai-session-diagnostics.ts   | 10 +--
 packages/dashboard/src/chat.ts                     |  8 ++-
 packages/dashboard/src/devserver-manager.ts        |  9 ++-
 packages/dashboard/src/file-service.ts             |  5 +-
 packages/dashboard/src/github-tracking-comments.ts |  7 +-
 .../dashboard/src/github-tracking-reconciler.ts    |  5 +-
 packages/dashboard/src/github-tracking-state.ts    |  5 +-
 packages/dashboard/src/gitlab-lifecycle.ts         |  5 +-
 packages/dashboard/src/insights-routes.ts          |  9 ++-
 packages/dashboard/src/issue-image-attachments.ts  |  5 +-
 packages/dashboard/src/knowledge-index.ts          |  5 +-
 packages/dashboard/src/plugin-routes.ts            |  7 +-
 packages/dashboard/src/routes/board-workflows.ts   |  5 +-
 packages/dashboard/src/routes/context.ts           |  5 +-
 .../dashboard/src/routes/register-auth-routes.ts   | 13 ++--
 .../routes/register-docker-provisioning-routes.ts  |  7 +-
 .../dashboard/src/routes/register-git-github.ts    | 21 +++---
 packages/dashboard/src/routes/register-gitlab.ts   |  7 +-
 .../src/routes/register-session-diff-routes.ts     |  9 ++-
 .../src/routes/register-settings-memory-routes.ts  |  7 +-
 .../src/routes/register-setup-activity-routes.ts   |  7 +-
 .../dashboard/src/routes/register-signal-routes.ts |  5 +-
 .../src/routes/register-task-workflow-routes.ts    | 11 +--
 packages/dashboard/src/runtime-logger.ts           | 11 +--
 packages/dashboard/src/server.ts                   |  7 +-
 packages/dashboard/src/sse.ts                      |  8 ++-
 packages/dashboard/src/terminal-service.ts         | 34 ++++-----
 packages/dashboard/src/view-chunk-manifest.ts      |  5 +-
 .../engine/src/__tests__/log-severity-manifest.ts  | 83 ++++++++++++++++++++++
 .../__tests__/log-severity-spam-contract.test.ts   | 40 ++++++++++-
 .../src/__tests__/logger-debug-gating.test.ts      |  7 +-
 packages/engine/src/goal-anchoring-audit.ts        |  5 +-
 packages/engine/src/plugin-runner.ts               | 44 ++++++------
 packages/engine/src/pty-native.ts                  |  9 ++-
 .../engine/src/runtimes/child-process-worker.ts    |  4 +-
 packages/engine/src/self-healing.ts                | 12 ++--
 packages/engine/src/worktree-hooks.ts              | 10 ++-
 67 files changed, 632 insertions(+), 250 deletions(-)

Fusion-Task-Id: FN-8603

Fusion-Task-Lineage: 53901db6-1af2-4bd7-b5ea-49507e048ef2

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-26 12:01:19 -07:00

472 lines
12 KiB
TypeScript

import { createLogger } from "@fusion/core";
const severityAuditLog = createLogger("dashboard-devserver-manager");
import { spawn, type ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import { createConnection } from "node:net";
import path from "node:path";
import {
MAX_LOG_ENTRIES,
type DevServerConfig,
type DevServerId,
type DevServerLogEntry,
type DevServerSession,
type DevServerSessionMap,
type DevServerStatus,
} from "./devserver-types.js";
const PORT_PROBE_DELAY_MS = 10_000;
const PORT_PROBE_TIMEOUT_MS = 500;
const PORT_PROBE_DEADLINE_MS = 3_000;
const STOP_TIMEOUT_MS = 5_000;
const COMMON_DEV_PORTS = [3000, 4173, 5173, 6006, 8080, 8888];
export interface DevServerManagerEvents {
log: [id: DevServerId, entry: DevServerLogEntry];
status: [id: DevServerId, status: DevServerStatus];
preview: [id: DevServerId, url: string | null];
exit: [id: DevServerId, exitCode: number];
}
export class DevServerManager extends EventEmitter<DevServerManagerEvents> {
private readonly sessions: DevServerSessionMap = new Map();
private readonly processes = new Map<string, ChildProcess>();
private readonly portProbes = new Map<string, NodeJS.Timeout>();
private readonly stopping = new Set<string>();
private readonly isWindows: boolean;
constructor(_projectRoot: string) {
super();
this.isWindows = process.platform === "win32";
}
async startServer(config: DevServerConfig): Promise<DevServerSession> {
const existing = this.sessions.get(config.id);
if (existing && (existing.status === "running" || existing.status === "starting")) {
throw new Error(`Dev server ${config.id} is already ${existing.status}`);
}
const session: DevServerSession = {
config,
status: "starting",
logHistory: [],
};
this.sessions.set(config.id, session);
this.emit("status", config.id, "starting");
const { command, args } = parseCommand(config.command);
if (!command) {
session.status = "failed";
this.emit("status", config.id, "failed");
throw new Error(`Invalid command for dev server ${config.id}`);
}
const child = spawn(command, args, {
cwd: config.cwd,
env: {
...process.env,
...(config.env ?? {}),
},
stdio: ["pipe", "pipe", "pipe"],
});
this.processes.set(config.id, child);
session.runtime = {
pid: child.pid ?? 0,
startedAt: new Date().toISOString(),
};
const handleChunk = (stream: "stdout" | "stderr", chunk: Buffer | string) => {
const activeSession = this.sessions.get(config.id);
if (!activeSession) {
return;
}
const text = chunk.toString();
if (!text) {
return;
}
if (activeSession.status === "starting") {
activeSession.status = "running";
this.emit("status", config.id, "running");
}
const entry: DevServerLogEntry = {
timestamp: new Date().toISOString(),
stream,
text,
};
activeSession.logHistory.push(entry);
if (activeSession.logHistory.length > MAX_LOG_ENTRIES) {
activeSession.logHistory.splice(0, activeSession.logHistory.length - MAX_LOG_ENTRIES);
}
this.emit("log", config.id, entry);
this.detectPreviewUrl(config.id, text);
};
child.stdout?.on("data", (chunk: Buffer | string) => {
handleChunk("stdout", chunk);
});
child.stderr?.on("data", (chunk: Buffer | string) => {
handleChunk("stderr", chunk);
});
child.on("error", (error) => {
const activeSession = this.sessions.get(config.id);
if (!activeSession) {
return;
}
severityAuditLog.error("[devserver] process error", {
id: config.id,
error: error.message,
});
activeSession.status = "failed";
this.emit("status", config.id, "failed");
});
child.on("close", (exitCode) => {
const activeSession = this.sessions.get(config.id);
if (!activeSession) {
return;
}
const normalizedExitCode = typeof exitCode === "number" ? exitCode : 0;
const wasStopping = this.stopping.delete(config.id);
activeSession.status = wasStopping || normalizedExitCode === 0 ? "stopped" : "failed";
if (activeSession.runtime) {
activeSession.runtime.exitCode = normalizedExitCode;
}
this.emit("status", config.id, activeSession.status);
this.emit("exit", config.id, normalizedExitCode);
this.processes.delete(config.id);
this.clearPortProbe(config.id);
console.info("[devserver] process closed", {
id: config.id,
exitCode: normalizedExitCode,
status: activeSession.status,
});
});
this.startPortProbe(config.id);
return session;
}
async stopServer(id: DevServerId): Promise<void> {
const session = this.sessions.get(id);
if (!session) {
throw new Error(`Dev server ${id} is not registered`);
}
const child = this.processes.get(id);
if (!child) {
throw new Error(`Dev server ${id} is not running`);
}
this.stopping.add(id);
session.status = "stopping";
this.emit("status", id, "stopping");
const closePromise = waitForClose(child);
this.sendTerminate(child);
const closeResult = await Promise.race([
closePromise,
delay<{ timedOut: true }>(STOP_TIMEOUT_MS, { timedOut: true }),
]);
if (closeResult.timedOut) {
this.sendKill(child);
await Promise.race([closePromise, delay(1_000, null)]);
}
const activeSession = this.sessions.get(id);
if (activeSession && activeSession.status !== "failed") {
activeSession.status = "stopped";
this.emit("status", id, "stopped");
}
this.processes.delete(id);
this.clearPortProbe(id);
}
async restartServer(id: DevServerId): Promise<DevServerSession> {
const session = this.sessions.get(id);
if (!session) {
throw new Error(`Dev server ${id} is not registered`);
}
if (this.processes.has(id)) {
await this.stopServer(id);
}
return this.startServer(session.config);
}
getSession(id: DevServerId): DevServerSession | undefined {
return this.sessions.get(id);
}
listSessions(): DevServerSession[] {
return Array.from(this.sessions.values());
}
getLogs(id: DevServerId, opts?: { tail?: number }): DevServerLogEntry[] {
const session = this.sessions.get(id);
if (!session) {
return [];
}
if (opts?.tail === undefined) {
return [...session.logHistory];
}
return session.logHistory.slice(-Math.max(0, opts.tail));
}
setPreviewUrl(id: DevServerId, url: string | null): void {
const session = this.sessions.get(id);
if (!session) {
return;
}
if (url === null) {
delete session.previewUrl;
if (session.runtime) {
delete session.runtime.previewUrl;
}
this.emit("preview", id, null);
return;
}
session.previewUrl = url;
if (session.runtime) {
session.runtime.previewUrl = url;
}
this.emit("preview", id, url);
}
destroy(): void {
for (const timeout of this.portProbes.values()) {
clearTimeout(timeout);
}
this.portProbes.clear();
for (const [id, child] of this.processes.entries()) {
this.stopping.add(id);
this.sendTerminate(child);
}
this.processes.clear();
this.sessions.clear();
this.stopping.clear();
this.removeAllListeners();
}
private detectPreviewUrl(id: DevServerId, text: string): void {
const match = text.match(/https?:\/\/(localhost|127\.0\.0\.1)(?::(\d+))?/);
if (!match) {
return;
}
const host = match[1];
const port = match[2] ? `:${match[2]}` : "";
const url = `http://${host}${port}`;
this.setPreviewUrl(id, url);
}
private startPortProbe(id: DevServerId): void {
this.clearPortProbe(id);
const timer = setTimeout(() => {
void this.runPortProbe(id);
}, PORT_PROBE_DELAY_MS);
this.portProbes.set(id, timer);
}
private async runPortProbe(id: DevServerId): Promise<void> {
try {
const session = this.sessions.get(id);
if (!session || session.status !== "running" || session.previewUrl) {
return;
}
const probePromise = this.findFirstReachablePort(COMMON_DEV_PORTS);
const result = await Promise.race([
probePromise,
delay<number | null>(PORT_PROBE_DEADLINE_MS, null),
]);
if (result === null) {
return;
}
this.setPreviewUrl(id, `http://localhost:${result}`);
} finally {
this.clearPortProbe(id);
}
}
private async findFirstReachablePort(ports: number[]): Promise<number | null> {
for (const port of ports) {
const reachable = await probePort(port);
if (reachable) {
return port;
}
}
return null;
}
private clearPortProbe(id: DevServerId): void {
const timer = this.portProbes.get(id);
if (timer) {
clearTimeout(timer);
this.portProbes.delete(id);
}
}
private sendTerminate(child: ChildProcess): void {
try {
if (this.isWindows) {
if (typeof child.pid === "number") {
process.kill(child.pid);
}
} else {
child.kill("SIGTERM");
}
} catch (error) {
severityAuditLog.warn("[devserver] failed to send SIGTERM", { error });
}
}
private sendKill(child: ChildProcess): void {
try {
if (this.isWindows) {
if (typeof child.pid === "number") {
process.kill(child.pid);
}
} else {
child.kill("SIGKILL");
}
} catch (error) {
severityAuditLog.warn("[devserver] failed to send SIGKILL", { error });
}
}
}
async function probePort(port: number): Promise<boolean> {
return new Promise((resolve) => {
const socket = createConnection({ host: "127.0.0.1", port, timeout: PORT_PROBE_TIMEOUT_MS });
let settled = false;
const finish = (reachable: boolean) => {
if (settled) {
return;
}
settled = true;
socket.removeAllListeners();
socket.destroy();
resolve(reachable);
};
socket.once("connect", () => finish(true));
socket.once("timeout", () => finish(false));
socket.once("error", () => finish(false));
});
}
function waitForClose(child: ChildProcess): Promise<{ timedOut: false; exitCode: number | null }> {
return new Promise((resolve) => {
child.once("close", (exitCode) => {
resolve({ timedOut: false, exitCode });
});
});
}
function delay<T>(ms: number, value: T): Promise<T> {
return new Promise((resolve) => {
setTimeout(() => resolve(value), ms);
});
}
function parseCommand(rawCommand: string): { command: string; args: string[] } {
const input = rawCommand.trim();
if (!input) {
return { command: "", args: [] };
}
const args: string[] = [];
let current = "";
let quote: '"' | "'" | null = null;
for (let index = 0; index < input.length; index += 1) {
const char = input[index] as string;
if ((char === '"' || char === "'") && quote === null) {
quote = char;
continue;
}
if (quote !== null && char === quote) {
quote = null;
continue;
}
if (char === " " && quote === null) {
if (current.length > 0) {
args.push(current);
current = "";
}
continue;
}
current += char;
}
if (current.length > 0) {
args.push(current);
}
const [command = "", ...rest] = args;
return { command, args: rest };
}
const managerInstances = new Map<string, DevServerManager>();
export function getDevServerManager(projectRoot: string): DevServerManager {
const resolvedRoot = path.resolve(projectRoot);
const existing = managerInstances.get(resolvedRoot);
if (existing) {
return existing;
}
const manager = new DevServerManager(resolvedRoot);
managerInstances.set(resolvedRoot, manager);
return manager;
}
export function destroyDevServerManager(projectRoot: string): void {
const resolvedRoot = path.resolve(projectRoot);
const manager = managerInstances.get(resolvedRoot);
if (!manager) {
return;
}
manager.destroy();
managerInstances.delete(resolvedRoot);
}
export function destroyAllDevServerManagers(): void {
for (const manager of managerInstances.values()) {
manager.destroy();
}
managerInstances.clear();
}