feat(FN-2168): add persistent dev-server dashboard flow

- Add a durable dev-server store and manager with persisted state/log history, process reconciliation, and SSE event buffering
- Wire new backend lifecycle APIs for status/history/start/stop/restart/stream and initialize/shutdown manager handling in server startup
- Add dashboard Dev Server view, hook-based hydration/stream handling, nav integration, and supporting styles
- Expand coverage with backend persistence/route tests and frontend DevServerView/useDevServer tests
- Document the dev-server persistence architecture in docs/architecture.md
This commit is contained in:
Fusion
2026-04-19 14:55:14 -07:00
committed by gsxdsm
parent 66ad0769c4
commit 10f10c5a45
20 changed files with 2822 additions and 1266 deletions

View File

@@ -0,0 +1,296 @@
import { access, appendFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
export type DevServerStatus = "idle" | "starting" | "running" | "stopped" | "failed";
export interface DevServerPersistedState {
serverKey: string;
status: DevServerStatus;
command: string | null;
scriptName: string | null;
cwd: string | null;
pid: number | null;
startedAt: string | null;
updatedAt: string;
previewUrl: string | null;
previewProtocol: string | null;
previewHost: string | null;
previewPort: number | null;
previewPath: string | null;
exitCode: number | null;
exitSignal: string | null;
exitedAt: string | null;
failureReason: string | null;
}
export type DevServerLogSource = "stdout" | "stderr" | "system";
export interface DevServerPersistedLogEntry {
serverKey: string;
source: DevServerLogSource;
message: string;
timestamp: string;
}
interface DevServerStateFile {
version: 1;
state: DevServerPersistedState;
}
function devServerFusionDir(rootDir: string): string {
return join(resolve(rootDir), ".fusion");
}
export function projectDevServerStateFile(rootDir: string): string {
return join(devServerFusionDir(rootDir), "dev-server-state.json");
}
export function projectDevServerLogFile(rootDir: string): string {
return join(devServerFusionDir(rootDir), "dev-server.log");
}
export class DevServerStore {
private state: DevServerPersistedState | null = null;
constructor(
readonly stateFilePath: string,
readonly logFilePath: string,
) {}
async loadState(): Promise<DevServerPersistedState | null> {
try {
const raw = await readFile(this.stateFilePath, "utf-8");
const parsed = JSON.parse(raw) as Partial<DevServerStateFile>;
const state = parsed?.state;
if (!state || typeof state !== "object") {
this.state = null;
return null;
}
const normalized = normalizePersistedState(state as Partial<DevServerPersistedState>);
if (!normalized) {
this.state = null;
return null;
}
this.state = normalized;
return structuredClone(normalized);
} catch {
this.state = null;
return null;
}
}
getState(): DevServerPersistedState | null {
return this.state ? structuredClone(this.state) : null;
}
async saveState(state: DevServerPersistedState): Promise<void> {
const normalized = normalizePersistedState(state);
if (!normalized) {
throw new Error("Invalid dev-server persisted state");
}
const payload: DevServerStateFile = {
version: 1,
state: normalized,
};
await mkdirDirForFile(this.stateFilePath);
const tempPath = `${this.stateFilePath}.${process.pid}.${Date.now()}.tmp`;
await writeFile(tempPath, `${JSON.stringify(payload, null, 2)}\n`, "utf-8");
await rename(tempPath, this.stateFilePath);
this.state = normalized;
}
async appendLog(entry: DevServerPersistedLogEntry): Promise<void> {
const normalized = normalizeLogEntry(entry);
if (!normalized) {
return;
}
await mkdirDirForFile(this.logFilePath);
await appendFile(this.logFilePath, `${JSON.stringify(normalized)}\n`, "utf-8");
}
async appendLogs(entries: DevServerPersistedLogEntry[]): Promise<void> {
const normalized = entries
.map((entry) => normalizeLogEntry(entry))
.filter((entry): entry is DevServerPersistedLogEntry => entry !== null);
if (normalized.length === 0) {
return;
}
await mkdirDirForFile(this.logFilePath);
const payload = normalized.map((entry) => JSON.stringify(entry)).join("\n");
await appendFile(this.logFilePath, `${payload}\n`, "utf-8");
}
async readLogTail(limit = 200): Promise<DevServerPersistedLogEntry[]> {
if (!Number.isFinite(limit) || limit <= 0) {
return [];
}
try {
const raw = await readFile(this.logFilePath, "utf-8");
const lines = raw.split("\n").filter((line) => line.trim().length > 0);
const tail = lines.slice(-Math.floor(limit));
const parsed: DevServerPersistedLogEntry[] = [];
for (const line of tail) {
try {
const maybeEntry = JSON.parse(line) as Partial<DevServerPersistedLogEntry>;
const normalized = normalizeLogEntry(maybeEntry);
if (normalized) {
parsed.push(normalized);
}
} catch {
// Ignore malformed lines to keep reads tolerant.
}
}
return parsed;
} catch {
return [];
}
}
async clearState(): Promise<void> {
this.state = null;
await rm(this.stateFilePath, { force: true });
}
async clearLogs(): Promise<void> {
await rm(this.logFilePath, { force: true });
}
}
async function mkdirDirForFile(filePath: string): Promise<void> {
const lastSlash = filePath.lastIndexOf("/");
const dir = lastSlash >= 0 ? filePath.slice(0, lastSlash) : filePath;
try {
await access(dir);
} catch {
await mkdir(dir, { recursive: true });
}
}
function normalizePersistedState(state: Partial<DevServerPersistedState>): DevServerPersistedState | null {
const status = normalizeStatus(state.status);
const serverKey = typeof state.serverKey === "string" && state.serverKey.trim().length > 0
? state.serverKey.trim()
: "default";
const updatedAt = normalizeDateString(state.updatedAt) ?? new Date().toISOString();
if (!status) {
return null;
}
return {
serverKey,
status,
command: normalizeNullableString(state.command),
scriptName: normalizeNullableString(state.scriptName),
cwd: normalizeNullableString(state.cwd),
pid: normalizeNullableNumber(state.pid),
startedAt: normalizeDateString(state.startedAt),
updatedAt,
previewUrl: normalizeNullableString(state.previewUrl),
previewProtocol: normalizeNullableString(state.previewProtocol),
previewHost: normalizeNullableString(state.previewHost),
previewPort: normalizeNullableNumber(state.previewPort),
previewPath: normalizeNullableString(state.previewPath),
exitCode: normalizeNullableNumber(state.exitCode),
exitSignal: normalizeNullableString(state.exitSignal),
exitedAt: normalizeDateString(state.exitedAt),
failureReason: normalizeNullableString(state.failureReason),
};
}
function normalizeLogEntry(entry: Partial<DevServerPersistedLogEntry>): DevServerPersistedLogEntry | null {
const message = normalizeNullableString(entry.message);
if (!message) {
return null;
}
const source = normalizeSource(entry.source);
if (!source) {
return null;
}
const timestamp = normalizeDateString(entry.timestamp) ?? new Date().toISOString();
const serverKey = typeof entry.serverKey === "string" && entry.serverKey.trim().length > 0
? entry.serverKey.trim()
: "default";
return {
serverKey,
source,
message,
timestamp,
};
}
function normalizeStatus(value: unknown): DevServerStatus | null {
if (value === "idle" || value === "starting" || value === "running" || value === "stopped" || value === "failed") {
return value;
}
return null;
}
function normalizeSource(value: unknown): DevServerLogSource | null {
if (value === "stdout" || value === "stderr" || value === "system") {
return value;
}
return null;
}
function normalizeNullableString(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function normalizeNullableNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function normalizeDateString(value: unknown): string | null {
if (typeof value !== "string" || value.trim().length === 0) {
return null;
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return null;
}
return parsed.toISOString();
}
const storeInstances = new Map<string, DevServerStore>();
export async function loadDevServerStore(rootDir: string): Promise<DevServerStore> {
const stateFile = projectDevServerStateFile(rootDir);
let store = storeInstances.get(stateFile);
if (!store) {
store = new DevServerStore(stateFile, projectDevServerLogFile(rootDir));
storeInstances.set(stateFile, store);
await store.loadState();
}
return store;
}
export function resetDevServerStore(): void {
storeInstances.clear();
}