Adds a push-to-origin workflow to the merge notice system, introducing a new `useMergeAdvanceNotice` hook, a `merge-advance-push-origin` route handler, and corresponding UI affordance in the `MergeAdvanceNotice` banner component. The engine gains TOCTOU and refusal audit assertions, and coverage exp Fusion-Task-Id: FN-5359 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai> Fusion-Task-Id: FN-5359
2093 lines
77 KiB
TypeScript
2093 lines
77 KiB
TypeScript
import express, { type Router } from "express";
|
|
import { randomUUID } from "node:crypto";
|
|
import { join, dirname } from "node:path";
|
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
import { createSecureServer as createHttp2SecureServer, type Http2SecureServer } from "node:http2";
|
|
import type { Server as HttpServer } from "node:http";
|
|
import type {
|
|
Task,
|
|
TaskStore,
|
|
MergeResult,
|
|
AutomationStore,
|
|
RoutineStore,
|
|
CentralCore,
|
|
MessageStore,
|
|
AgentLogEntry,
|
|
TaskIdIntegrityReport,
|
|
} from "@fusion/core";
|
|
import { AgentStore, ChatStore } from "@fusion/core";
|
|
import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
|
|
import { createApiRoutes } from "./routes.js";
|
|
import { createSSE, disconnectSSEClient, markSSEClientAlive } from "./sse.js";
|
|
import { rateLimit, RATE_LIMITS } from "./rate-limit.js";
|
|
import { ApiError, sendErrorResponse } from "./api-error.js";
|
|
import { getOrCreateProjectStore, evictAllProjectStores, setOnProjectFirstCreated } from "./project-store-resolver.js";
|
|
import { getOrCreateScopedChatStore } from "./chat-project-services.js";
|
|
import { getTerminalService, STALE_SESSION_THRESHOLD_MS } from "./terminal-service.js";
|
|
import { WebSocketServer, type WebSocket } from "ws";
|
|
import { terminalSessionManager } from "./terminal.js";
|
|
|
|
import { WebSocketManager, type BadgeSnapshot } from "./websocket.js";
|
|
import type { BadgePubSub } from "./badge-pubsub.js";
|
|
import { createBadgePubSub, type BadgePubSubMessage } from "./badge-pubsub.js";
|
|
import { createRuntimeLogger, type RuntimeLogger } from "./runtime-logger.js";
|
|
import { registerGithubTrackingHook } from "./github-tracking-hook.js";
|
|
import { createTerminalWebSocketDiagnostics } from "./terminal-websocket-diagnostics.js";
|
|
import {
|
|
AiSessionStore,
|
|
SESSION_CLEANUP_DEFAULT_MAX_AGE_MS,
|
|
SESSION_CLEANUP_INTERVAL_MS,
|
|
} from "./ai-session-store.js";
|
|
import {
|
|
setAiSessionStore as setPlanningAiSessionStore,
|
|
rehydrateFromStore as rehydratePlanningSessions,
|
|
} from "./planning.js";
|
|
import {
|
|
setAiSessionStore as setSubtaskAiSessionStore,
|
|
rehydrateFromStore as rehydrateSubtaskSessions,
|
|
} from "./subtask-breakdown.js";
|
|
import {
|
|
setAiSessionStore as setMissionAiSessionStore,
|
|
rehydrateFromStore as rehydrateMissionSessions,
|
|
} from "./mission-interview.js";
|
|
import {
|
|
setAiSessionStore as setMilestoneSliceAiSessionStore,
|
|
rehydrateFromStore as rehydrateMilestoneSliceSessions,
|
|
} from "./milestone-slice-interview.js";
|
|
import { ChatManager } from "./chat.js";
|
|
import { stopAllDevServers } from "./dev-server-routes.js";
|
|
import type { SkillsAdapter } from "./skills-adapter.js";
|
|
import { createAuthMiddleware, authenticateUpgradeRequest, getDaemonToken } from "./auth-middleware.js";
|
|
import { validateRemoteAuthToken } from "./remote-auth.js";
|
|
import { getCliPackageVersion } from "./cli-package-version.js";
|
|
import {
|
|
dayHasSamples,
|
|
fileScopeInvariantFailuresPerDay,
|
|
inReviewDurationMetrics,
|
|
inReviewFailureRate7d,
|
|
mergeAttemptsPerMergedTask,
|
|
postMergeAuditFailuresPerDay,
|
|
recoverAlreadyMergedReviewTasksRecoveriesPerDay,
|
|
} from "./reliability-metrics.js";
|
|
import { loadViewChunkManifest } from "./view-chunk-manifest.js";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
|
function parseVersion(version: string): number[] {
|
|
return version
|
|
.split(".")
|
|
.slice(0, 3)
|
|
.map((part) => Number.parseInt(part, 10))
|
|
.map((value) => (Number.isFinite(value) ? value : 0));
|
|
}
|
|
|
|
function buildTaskIdIntegrityHealth(report: TaskIdIntegrityReport) {
|
|
return {
|
|
status: report.status,
|
|
checkedAt: report.checkedAt,
|
|
anomalies: report.anomalies,
|
|
recommendedAction:
|
|
report.status === "anomaly"
|
|
? "Pause task delegation, inspect the affected task IDs, and run the allocator audit before creating new tasks."
|
|
: null,
|
|
};
|
|
}
|
|
|
|
function buildHealthPayload(store: TaskStore, cliPackageVersion: string) {
|
|
const database = store.getDatabaseHealth();
|
|
const taskIdIntegrity = buildTaskIdIntegrityHealth(store.getTaskIdIntegrityReport());
|
|
return {
|
|
status: !database.healthy || database.corruptionDetected || taskIdIntegrity.status === "anomaly" ? "degraded" : "ok",
|
|
version: cliPackageVersion,
|
|
uptime: Math.floor(process.uptime()),
|
|
database,
|
|
taskIdIntegrity,
|
|
};
|
|
}
|
|
|
|
function isRemoteVersionNewer(remoteVersion: string, currentVersion: string): boolean {
|
|
const remote = parseVersion(remoteVersion);
|
|
const current = parseVersion(currentVersion);
|
|
const maxLength = Math.max(remote.length, current.length, 3);
|
|
|
|
for (let i = 0; i < maxLength; i += 1) {
|
|
const remotePart = remote[i] ?? 0;
|
|
const currentPart = current[i] ?? 0;
|
|
|
|
if (remotePart > currentPart) {
|
|
return true;
|
|
}
|
|
|
|
if (remotePart < currentPart) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
const DEFAULT_AI_SESSION_TTL_MS = SESSION_CLEANUP_DEFAULT_MAX_AGE_MS;
|
|
const MIN_AI_SESSION_TTL_MS = 10 * 60 * 1000;
|
|
const MAX_AI_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
|
|
const DEFAULT_AI_SESSION_CLEANUP_INTERVAL_MS = SESSION_CLEANUP_INTERVAL_MS;
|
|
const MIN_AI_SESSION_CLEANUP_INTERVAL_MS = 60 * 1000;
|
|
const MAX_AI_SESSION_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
|
|
let aiSessionCleanupIntervalHandle: ReturnType<typeof setInterval> | undefined;
|
|
|
|
function clearAiSessionCleanupInterval(): void {
|
|
if (!aiSessionCleanupIntervalHandle) {
|
|
return;
|
|
}
|
|
clearInterval(aiSessionCleanupIntervalHandle);
|
|
aiSessionCleanupIntervalHandle = undefined;
|
|
}
|
|
|
|
process.on("beforeExit", () => {
|
|
clearAiSessionCleanupInterval();
|
|
});
|
|
|
|
/**
|
|
* Scoped Realtime Contract
|
|
* ------------------------
|
|
* All realtime endpoints (/api/events, /api/ws, /api/tasks/:id/logs/stream,
|
|
* /api/terminal/ws) MUST resolve project context using resolveScopedStore:
|
|
* 1. If projectId is omitted, use the default store.
|
|
* 2. If engineManager has an engine for the project, use its TaskStore.
|
|
* 3. Otherwise fall back to getOrCreateProjectStore(projectId).
|
|
*
|
|
* Badge websocket channels MUST be keyed as `badge:{projectId}:{taskId}`
|
|
* so overlapping task IDs cannot leak across projects.
|
|
*
|
|
* @see toBadgeChannel in websocket.ts for channel key format
|
|
* @see extractPartsFromChannel in websocket.ts for channel key parsing
|
|
*/
|
|
export async function resolveScopedStore(
|
|
projectId: string | undefined,
|
|
store: TaskStore,
|
|
engineManager?: import("@fusion/engine").ProjectEngineManager,
|
|
): Promise<TaskStore> {
|
|
if (!projectId) {
|
|
return store;
|
|
}
|
|
|
|
if (engineManager) {
|
|
const engine = engineManager.getEngine(projectId);
|
|
if (engine) {
|
|
return engine.getTaskStore();
|
|
}
|
|
}
|
|
|
|
return await getOrCreateProjectStore(projectId);
|
|
}
|
|
|
|
export interface ServerOptions {
|
|
/** Optional ProjectEngine — when provided, subsystems (onMerge, automationStore,
|
|
* missionAutopilot, missionExecutionLoop, heartbeatMonitor) are derived from it.
|
|
* Explicit options still override engine-derived values.
|
|
* @deprecated Use engineManager instead for multi-project support. */
|
|
engine?: import("@fusion/engine").ProjectEngine;
|
|
/** ProjectEngineManager for uniform multi-project engine lifecycle.
|
|
* When provided, the server can resolve per-project engines for route handlers. */
|
|
engineManager?: import("@fusion/engine").ProjectEngineManager;
|
|
/** Optional HybridExecutor orchestration context for multi-project runtime plumbing. */
|
|
hybridExecutor?: import("@fusion/engine").HybridExecutor;
|
|
/** Shared CentralCore instance used by the engine manager.
|
|
* Routes that mutate central runtime state should use this instance so
|
|
* in-process listeners (for example global concurrency changes) are notified. */
|
|
centralCore?: CentralCore;
|
|
/** Custom merge handler — when provided, used instead of store.mergeTask */
|
|
onMerge?: (taskId: string) => Promise<MergeResult>;
|
|
/** When true, run API/websocket server only (skip frontend static assets + SPA fallback) */
|
|
headless?: boolean;
|
|
/** Maximum concurrent worktrees / execution slots (default 2) */
|
|
maxConcurrent?: number;
|
|
/** Optional GitHub token for PR operations — falls back to GITHUB_TOKEN env var */
|
|
githubToken?: string;
|
|
/** Optional AuthStorage instance for auth routes — if not provided, one is created internally */
|
|
authStorage?: AuthStorageLike;
|
|
/** Optional ModelRegistry instance for the models API — if not provided, the endpoint returns an empty list */
|
|
modelRegistry?: ModelRegistryLike;
|
|
/** Optional BadgePubSub adapter for cross-instance badge snapshot fan-out — if not provided, creates from env or falls back to in-memory */
|
|
badgePubSub?: BadgePubSub;
|
|
/** Optional AutomationStore for scheduled task management */
|
|
automationStore?: AutomationStore;
|
|
/** Optional RoutineStore for recurring task automation */
|
|
routineStore?: RoutineStore;
|
|
/** Optional RoutineRunner for triggering routine execution via heartbeat */
|
|
routineRunner?: {
|
|
triggerManual(routineId: string): Promise<import("@fusion/core").RoutineExecutionResult>;
|
|
triggerWebhook(routineId: string, payload: Record<string, unknown>, signature?: string): Promise<import("@fusion/core").RoutineExecutionResult>;
|
|
};
|
|
/** Optional AiSessionStore — if not provided, one is created from the default store's database */
|
|
aiSessionStore?: AiSessionStore;
|
|
/** Optional MissionAutopilot for autonomous mission progression */
|
|
missionAutopilot?: {
|
|
watchMission(missionId: string): void;
|
|
unwatchMission(missionId: string): void;
|
|
isWatching(missionId: string): boolean;
|
|
getAutopilotStatus(missionId: string): import("@fusion/core").AutopilotStatus;
|
|
checkAndStartMission(missionId: string): Promise<void>;
|
|
recoverStaleMission(missionId: string): Promise<void>;
|
|
start(): void;
|
|
stop(): void;
|
|
};
|
|
/** Optional MissionExecutionLoop for validation cycle handling */
|
|
missionExecutionLoop?: {
|
|
recoverActiveMissions(): Promise<{ recoveredCount: number }>;
|
|
isRunning(): boolean;
|
|
};
|
|
/** Optional HeartbeatMonitor for triggering agent execution runs */
|
|
heartbeatMonitor?: {
|
|
/** Project root directory this monitor is bound to. Used for scope validation. */
|
|
rootDir?: string;
|
|
startRun(agentId: string, options?: { source: import("@fusion/core").HeartbeatInvocationSource; triggerDetail?: string; contextSnapshot?: Record<string, unknown> }): Promise<import("@fusion/core").AgentHeartbeatRun>;
|
|
executeHeartbeat(options: {
|
|
agentId: string;
|
|
source: import("@fusion/core").HeartbeatInvocationSource;
|
|
triggerDetail?: string;
|
|
taskId?: string;
|
|
triggeringCommentIds?: string[];
|
|
triggeringCommentType?: "steering" | "task" | "pr";
|
|
contextSnapshot?: Record<string, unknown>;
|
|
}): Promise<import("@fusion/core").AgentHeartbeatRun>;
|
|
stopRun(agentId: string): Promise<void>;
|
|
};
|
|
selfHealingManager?: {
|
|
rootDir: string;
|
|
reconcileInReviewBranchRebind: (opts?: { includeTaskIds?: Set<string> }) => Promise<import("@fusion/engine").RebindResult>;
|
|
getActiveMergeTaskId: () => string | null;
|
|
};
|
|
/** Optional PluginStore for plugin management routes */
|
|
pluginStore?: import("@fusion/core").PluginStore;
|
|
/** Optional PluginLoader for plugin lifecycle management */
|
|
pluginLoader?: import("@fusion/core").PluginLoader;
|
|
/** Optional PluginRunner for plugin hooks, routes, and lifecycle operations */
|
|
pluginRunner?: {
|
|
getPluginRoutes(): Array<{ pluginId: string; route: import("@fusion/core").PluginRouteDefinition }>;
|
|
getPluginWorkflowStepTemplates?(): Array<{ pluginId: string; template: import("@fusion/core").WorkflowStepTemplate }>;
|
|
getRuntimeById?(runtimeId: string): unknown;
|
|
createRuntimeContext?(pluginId: string): Promise<unknown>;
|
|
reloadPlugin?(pluginId: string): Promise<unknown>;
|
|
checkPluginSetup?(pluginId: string): Promise<import("@fusion/core").PluginSetupCheckResult>;
|
|
installPluginSetup?(pluginId: string): Promise<void | { success: boolean; error?: string }>;
|
|
uninstallPluginSetup?(pluginId: string): Promise<void | { success: boolean; error?: string }>;
|
|
getPluginSetupInfo?(): Array<{
|
|
pluginId: string;
|
|
manifest: import("@fusion/core").PluginSetupManifest;
|
|
hooks: import("@fusion/core").PluginSetupHooks;
|
|
}>;
|
|
};
|
|
/** Optional ChatStore for chat session management */
|
|
chatStore?: import("@fusion/core").ChatStore;
|
|
/** Optional ChatManager for AI chat message handling */
|
|
chatManager?: import("./chat.js").ChatManager;
|
|
/**
|
|
* Called once when a secondary project (identified by projectId query param)
|
|
* is first accessed via a project-scoped API or SSE request.
|
|
*
|
|
* @deprecated This callback is a fast-path fallback for immediate engine
|
|
* startup on project access. ProjectEngineManager.startReconciliation() is
|
|
* the primary mechanism for ensuring all registered projects have engines
|
|
* started — it runs without requiring any UI or API access. This callback
|
|
* is NOT required for correctness; it only provides a potential optimization
|
|
* for projects that are accessed before the next reconciliation tick.
|
|
*/
|
|
onProjectFirstAccessed?: (projectId: string) => void;
|
|
/**
|
|
* Called after a project is successfully registered via POST /api/projects
|
|
* (dashboard-initiated project add). Invoked with the registered project's
|
|
* path *after* activation but *before* the response is sent to the client.
|
|
*
|
|
* Consumers use this to perform side-effects that belong to project setup
|
|
* (e.g. installing the fusion Claude-skill into `.claude/skills/fusion/`
|
|
* when pi-claude-cli is configured). Failures should be swallowed by the
|
|
* callback — they must not cause the HTTP response to fail.
|
|
*/
|
|
onProjectRegistered?: (project: { id: string; name: string; path: string }) => void;
|
|
/**
|
|
* Called when the user toggles the `useClaudeCli` global setting via
|
|
* PUT /api/settings/global. Invoked only on an actual transition (prev
|
|
* !== next). Consumers use this to run project-wide setup — most notably,
|
|
* installing the fusion Claude-skill into every registered project's
|
|
* `.claude/skills/fusion/` when the toggle flips on, so the user doesn't
|
|
* have to wait for a server restart to see the effect.
|
|
*
|
|
* Failures should be swallowed by the callback — they must not cause the
|
|
* settings PUT to fail.
|
|
*/
|
|
onUseClaudeCliToggled?: (prev: boolean, next: boolean) => void;
|
|
/**
|
|
* Lazily install a bundled runtime plugin (e.g. Hermes/OpenClaw/Paperclip
|
|
* runtimes) the first time the user clicks Save in Settings. The dashboard
|
|
* has no knowledge of the on-disk bundle layout, so the host (CLI) injects
|
|
* this hook. Returns true if the plugin is now registered (either freshly
|
|
* installed or already present), false if the bundle could not be resolved
|
|
* (e.g. plugin id is unknown) so the route can fall through to its standard
|
|
* "plugin not found" error.
|
|
*/
|
|
ensureBundledPluginInstalled?: (pluginId: string) => Promise<boolean>;
|
|
/**
|
|
* Returns the host's last-observed resolution of the bundled
|
|
* `@fusion/pi-claude-cli` extension. Populated by serve/daemon/dashboard
|
|
* at startup after calling `resolveClaudeCliExtensionPaths`.
|
|
*
|
|
* The shape intentionally mirrors `ClaudeCliExtensionResolution` from the
|
|
* CLI package but is described structurally so dashboard doesn't need to
|
|
* depend on `@runfusion/fusion`.
|
|
*
|
|
* Returns `null` when the host hasn't evaluated the setting yet (very
|
|
* early startup) — callers should treat null as "unknown, try again".
|
|
*/
|
|
getClaudeCliExtensionStatus?: () =>
|
|
| {
|
|
status: "ok" | "not-installed" | "missing-entry" | "error";
|
|
path?: string;
|
|
packageVersion?: string;
|
|
reason?: string;
|
|
}
|
|
| null;
|
|
/**
|
|
* Called when the user toggles the `useDroidCli` global setting via
|
|
* PUT /api/settings/global. Invoked only on an actual transition (prev
|
|
* !== next). Consumers use this to run project-wide setup for Droid CLI
|
|
* integrations without requiring a server restart.
|
|
*
|
|
* Failures should be swallowed by the callback — they must not cause the
|
|
* settings PUT to fail.
|
|
*/
|
|
onUseDroidCliToggled?: (prev: boolean, next: boolean) => void;
|
|
/** Called when the user toggles the `useLlamaCpp` global setting. */
|
|
onUseLlamaCppToggled?: (prev: boolean, next: boolean) => void;
|
|
/** Optional hook fired after a successful API-key save. */
|
|
onApiKeySaved?: (providerId: string) => Promise<{
|
|
registeredCount: number;
|
|
reason?: "no-models-from-cli" | "cli-failed" | "disabled-by-settings";
|
|
error?: string;
|
|
} | void>;
|
|
/**
|
|
* Returns the host's last-observed resolution of the bundled `droid-cli`
|
|
* extension wiring. Populated by serve/daemon/dashboard startup checks.
|
|
*
|
|
* The shape intentionally mirrors the Claude CLI extension status shape so
|
|
* provider cards and auth routes can consume a consistent contract.
|
|
*
|
|
* Returns `null` when the host hasn't evaluated the setting yet (very
|
|
* early startup) — callers should treat null as "unknown, try again".
|
|
*/
|
|
getDroidCliExtensionStatus?: () =>
|
|
| {
|
|
status: "ok" | "not-installed" | "missing-entry" | "error";
|
|
path?: string;
|
|
packageVersion?: string;
|
|
reason?: string;
|
|
}
|
|
| null;
|
|
/** Returns the host's last-observed resolution of the bundled
|
|
* `@fusion/pi-llama-cpp` extension wiring. Populated by startup checks.
|
|
*/
|
|
getLlamaCppExtensionStatus?: () =>
|
|
| {
|
|
status: "ok" | "not-installed" | "missing-entry" | "error";
|
|
path?: string;
|
|
packageVersion?: string;
|
|
reason?: string;
|
|
}
|
|
| null;
|
|
/** Optional SkillsAdapter for skills discovery, execution toggling, and catalog fetching */
|
|
skillsAdapter?: SkillsAdapter;
|
|
/** Daemon mode configuration with bearer token authentication.
|
|
* When provided, all API requests (except /api/health) require valid bearer token. */
|
|
daemon?: { token: string };
|
|
/** Explicitly disable bearer-token auth, ignoring FUSION_DAEMON_TOKEN /
|
|
* FUSION_DASHBOARD_TOKEN env vars. Used by `fn dashboard --no-auth` so a
|
|
* stale token in a project .env doesn't silently override the flag. */
|
|
noAuth?: boolean;
|
|
/** Optional runtime logger for server/routes diagnostics.
|
|
* Defaults to a console-backed logger scoped to `server` when omitted. */
|
|
runtimeLogger?: RuntimeLogger;
|
|
/** Optional TLS credentials. When provided, the server is served over HTTP/2
|
|
* with HTTP/1.1 fallback (allowHTTP1:true) — this lifts the browser's
|
|
* per-origin connection cap so long-lived SSE streams no longer starve
|
|
* regular API fetches. WebSocket upgrades continue to work because HTTP/1.1
|
|
* clients are still accepted. */
|
|
https?: {
|
|
cert: string | Buffer;
|
|
key: string | Buffer;
|
|
ca?: string | Buffer | Array<string | Buffer>;
|
|
};
|
|
}
|
|
|
|
type DashboardExpressApp = ReturnType<typeof express> & {
|
|
terminalWsServer?: WebSocketServer | null;
|
|
badgeWsServer?: WebSocketServer | null;
|
|
badgeWsManager?: WebSocketManager | null;
|
|
__fnWebSocketsAttached?: boolean;
|
|
};
|
|
|
|
function shouldForceLocalhostForTests(): boolean {
|
|
return process.env.NODE_ENV === "test";
|
|
}
|
|
|
|
function normalizeListenArgsForTests(args: unknown[]): unknown[] {
|
|
if (!shouldForceLocalhostForTests()) {
|
|
return args;
|
|
}
|
|
|
|
if (args.length === 0) {
|
|
return ["127.0.0.1"];
|
|
}
|
|
|
|
const [first, second] = args;
|
|
const secondIsHost = typeof second === "string";
|
|
const firstIsOptionsObject =
|
|
typeof first === "object" && first !== null && !Array.isArray(first);
|
|
|
|
if (firstIsOptionsObject || secondIsHost) {
|
|
return args;
|
|
}
|
|
|
|
if (typeof first === "number") {
|
|
return [first, "127.0.0.1", ...args.slice(1)];
|
|
}
|
|
|
|
if (typeof first === "string" && first.startsWith("/")) {
|
|
return args;
|
|
}
|
|
|
|
return args;
|
|
}
|
|
|
|
function resolveBoundedMs(
|
|
value: number | undefined,
|
|
fallback: number,
|
|
min: number,
|
|
max: number,
|
|
): number {
|
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
return fallback;
|
|
}
|
|
return Math.min(max, Math.max(min, value));
|
|
}
|
|
|
|
function shouldScheduleAiSessionCleanup(): boolean {
|
|
return process.env.NODE_ENV !== "test";
|
|
}
|
|
|
|
function normalizeErrorForLog(err: unknown): {
|
|
error: string;
|
|
errorName?: string;
|
|
errorMessage: string;
|
|
errorStack?: string;
|
|
} {
|
|
if (err instanceof Error) {
|
|
return {
|
|
error: err.message,
|
|
errorName: err.name,
|
|
errorMessage: err.message,
|
|
errorStack: err.stack,
|
|
};
|
|
}
|
|
|
|
const fallback = String(err);
|
|
return {
|
|
error: fallback,
|
|
errorMessage: fallback,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolve TLS credentials from environment variables, if configured.
|
|
*
|
|
* Reads either inline PEM material (`FUSION_TLS_CERT` / `FUSION_TLS_KEY`) or
|
|
* file paths (`FUSION_TLS_CERT_FILE` / `FUSION_TLS_KEY_FILE`). `FUSION_TLS_CA`
|
|
* / `FUSION_TLS_CA_FILE` are optional and set the CA bundle.
|
|
*
|
|
* Returns `undefined` when neither pair is set, which callers should treat as
|
|
* "serve plain HTTP/1.1". When a cert is set without a key (or vice versa)
|
|
* this throws — that's a config error worth surfacing.
|
|
*/
|
|
export function loadTlsCredentialsFromEnv(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): { cert: Buffer; key: Buffer; ca?: Buffer } | undefined {
|
|
const certInline = env.FUSION_TLS_CERT;
|
|
const keyInline = env.FUSION_TLS_KEY;
|
|
const certFile = env.FUSION_TLS_CERT_FILE;
|
|
const keyFile = env.FUSION_TLS_KEY_FILE;
|
|
|
|
const hasCert = Boolean(certInline || certFile);
|
|
const hasKey = Boolean(keyInline || keyFile);
|
|
if (!hasCert && !hasKey) return undefined;
|
|
if (hasCert !== hasKey) {
|
|
throw new Error(
|
|
"FUSION_TLS_* environment is incomplete: set both a cert and a key " +
|
|
"(inline via FUSION_TLS_CERT/FUSION_TLS_KEY or paths via *_FILE).",
|
|
);
|
|
}
|
|
|
|
const cert = certInline ? Buffer.from(certInline) : readFileSync(certFile!);
|
|
const key = keyInline ? Buffer.from(keyInline) : readFileSync(keyFile!);
|
|
|
|
const caInline = env.FUSION_TLS_CA;
|
|
const caFile = env.FUSION_TLS_CA_FILE;
|
|
const ca = caInline
|
|
? Buffer.from(caInline)
|
|
: caFile
|
|
? readFileSync(caFile)
|
|
: undefined;
|
|
|
|
return { cert, key, ca };
|
|
}
|
|
|
|
export function createServer(store: TaskStore, options?: ServerOptions): ReturnType<typeof express> {
|
|
// Register the universal post-create hook so every task-creation path
|
|
// (HTTP routes, CLI, pi extension, mission triage, etc.) triggers
|
|
// GitHub tracking issue creation when enabled.
|
|
try {
|
|
registerGithubTrackingHook({ githubToken: options?.githubToken });
|
|
} catch (error) {
|
|
// Some unit tests mock @fusion/core with narrow export surfaces. Keep
|
|
// server bootstrap resilient when hook registration is unavailable.
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.warn(`[github-tracking-hook] registration skipped: ${message}`);
|
|
}
|
|
const cliPackageVersion = getCliPackageVersion(import.meta.url);
|
|
// ── Derive defaults from engine when provided (explicit options override) ──
|
|
const engine = options?.engine;
|
|
if (engine) {
|
|
if (!options!.onMerge) {
|
|
options = { ...options, onMerge: (taskId: string) => engine.onMerge(taskId) };
|
|
}
|
|
if (!options!.automationStore) {
|
|
options = { ...options, automationStore: engine.getAutomationStore() };
|
|
}
|
|
if (!options!.missionAutopilot) {
|
|
const ma = engine.getRuntime().getMissionAutopilot();
|
|
if (ma) options = { ...options, missionAutopilot: ma };
|
|
}
|
|
if (!options!.missionExecutionLoop) {
|
|
const mel = engine.getRuntime().getMissionExecutionLoop();
|
|
if (mel) options = { ...options, missionExecutionLoop: mel };
|
|
}
|
|
if (!options!.heartbeatMonitor) {
|
|
const hb = engine.getHeartbeatMonitor();
|
|
if (hb) {
|
|
options = {
|
|
...options,
|
|
heartbeatMonitor: {
|
|
rootDir: engine.getWorkingDirectory(),
|
|
startRun: hb.startRun.bind(hb),
|
|
executeHeartbeat: hb.executeHeartbeat.bind(hb),
|
|
stopRun: hb.stopRun.bind(hb),
|
|
},
|
|
};
|
|
}
|
|
}
|
|
if (!options!.selfHealingManager) {
|
|
const selfHealing = engine.getSelfHealingManager();
|
|
if (selfHealing) {
|
|
options = {
|
|
...options,
|
|
selfHealingManager: {
|
|
rootDir: engine.getWorkingDirectory(),
|
|
reconcileInReviewBranchRebind: selfHealing.reconcileInReviewBranchRebind.bind(selfHealing),
|
|
getActiveMergeTaskId: selfHealing.getActiveMergeTaskId.bind(selfHealing),
|
|
},
|
|
};
|
|
}
|
|
}
|
|
if (!options!.routineStore) {
|
|
const rs = engine.getRoutineStore();
|
|
if (rs) options = { ...options, routineStore: rs };
|
|
}
|
|
if (!options!.routineRunner) {
|
|
const rr = engine.getRoutineRunner();
|
|
if (rr) {
|
|
options = {
|
|
...options,
|
|
routineRunner: {
|
|
triggerManual: rr.triggerManual.bind(rr),
|
|
triggerWebhook: rr.triggerWebhook.bind(rr),
|
|
},
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
// Register callback for lazy engine startup on secondary projects
|
|
if (options?.onProjectFirstAccessed) {
|
|
setOnProjectFirstCreated(options.onProjectFirstAccessed);
|
|
}
|
|
|
|
const app = express();
|
|
app.locals.hybridExecutor = options?.hybridExecutor;
|
|
const runtimeLogger = options?.runtimeLogger ?? createRuntimeLogger("server");
|
|
const mutationRateLimit = rateLimit(RATE_LIMITS.mutation);
|
|
const setupRateLimit = rateLimit(RATE_LIMITS.api);
|
|
const setupReadRateLimit = rateLimit(RATE_LIMITS.api);
|
|
const sseControlRateLimit = rateLimit({ windowMs: 60_000, max: 300 });
|
|
|
|
// Raw body buffer for webhook signature verification - must be before express.json()
|
|
// Only applied to the webhook route
|
|
app.use("/api/github/webhooks", express.raw({ type: "application/json" }));
|
|
|
|
// Standard JSON parsing for all other routes.
|
|
// Preserve the raw payload buffer so signed endpoints (for example
|
|
// /api/routines/:id/webhook and settings sync proxying) can verify HMAC
|
|
// signatures and forward exact request bytes.
|
|
app.use(express.json({
|
|
verify: (req, _res, buf) => {
|
|
if (buf.length > 0) {
|
|
(req as express.Request & { rawBody?: Buffer }).rawBody = Buffer.from(buf);
|
|
}
|
|
},
|
|
}));
|
|
|
|
// Daemon mode: bearer token authentication middleware
|
|
// Auth is enabled when daemon option is provided OR FUSION_DAEMON_TOKEN env var is set.
|
|
// The middleware exempts /api/health and everything outside /api/ — the SPA shell
|
|
// (index.html + built assets) is public so the browser can load the frontend JS
|
|
// that then captures ?token= from the URL and injects a Bearer header on every
|
|
// /api/* call. WebSocket upgrades are gated separately in setupTerminalWebSocket /
|
|
// setupBadgeWebSocket.
|
|
const daemonToken = options?.noAuth
|
|
? undefined
|
|
: options?.daemon?.token ?? process.env.FUSION_DAEMON_TOKEN;
|
|
if (daemonToken) {
|
|
app.use(createAuthMiddleware(daemonToken));
|
|
}
|
|
|
|
// Initialize terminal service with project root
|
|
getTerminalService(store.getRootDir());
|
|
|
|
const isHeadless = options?.headless === true;
|
|
|
|
// Serve built React app
|
|
// Resolution order:
|
|
// 1. FUSION_CLIENT_DIR env override (explicit)
|
|
// 2. Next to process.execPath (bun-compiled binary: dist/fn + dist/client/)
|
|
// 3. __dirname/../dist/client (running from src/ via tsx/ts-node)
|
|
// 4. __dirname/../client (running from dist/ after tsc)
|
|
const execDir = dirname(process.execPath);
|
|
const clientDir = process.env.FUSION_CLIENT_DIR
|
|
? process.env.FUSION_CLIENT_DIR
|
|
: existsSync(join(execDir, "client", "index.html"))
|
|
? join(execDir, "client")
|
|
: existsSync(join(__dirname, "..", "dist", "client"))
|
|
? join(__dirname, "..", "dist", "client")
|
|
: join(__dirname, "..", "client");
|
|
|
|
let cachedIndexClientDir: string | null = null;
|
|
let cachedIndexHtml: string | null = null;
|
|
let cachedIndexMtimeMs: number | null = null;
|
|
let cachedTemplatedIndexHtml: string | null = null;
|
|
|
|
const buildViewPreloadInjection = (chunkMap: Record<string, string>): string => {
|
|
const serializedChunkMap = JSON.stringify(chunkMap).replace(/<\//g, "<\\/");
|
|
return `<script>window.__FUSION_VIEW_CHUNKS__=${serializedChunkMap};(()=>{try{const chunkMap=window.__FUSION_VIEW_CHUNKS__||{};const projectId=localStorage.getItem("kb-dashboard-current-project");const scopedKey=projectId?"kb:"+projectId+":kb-dashboard-task-view":null;let taskView=(scopedKey&&localStorage.getItem(scopedKey))||localStorage.getItem("kb-dashboard-task-view");if(taskView==="devserver")taskView="dev-server";if(taskView==="roadmaps")taskView="board";if(typeof taskView!=="string"||taskView.startsWith("plugin:"))return;const chunkPath=chunkMap[taskView];if(!chunkPath)return;const link=document.createElement("link");link.rel="modulepreload";link.href=chunkPath;link.crossOrigin="";document.head.appendChild(link);}catch{}})();</script>`;
|
|
};
|
|
|
|
const renderIndexHtml = (): string => {
|
|
const resolvedClientDir = process.env.FUSION_CLIENT_DIR
|
|
? process.env.FUSION_CLIENT_DIR
|
|
: clientDir;
|
|
|
|
const indexPath = join(resolvedClientDir, "index.html");
|
|
// Invalidate the cache when index.html changes on disk (e.g. a release
|
|
// upgrade or rebuild replaces the file). Without this, the server keeps
|
|
// serving stale HTML pointing at chunk hashes that no longer exist —
|
|
// recoverable only by restarting the server.
|
|
let indexMtimeMs: number | null = null;
|
|
try {
|
|
indexMtimeMs = statSync(indexPath).mtimeMs;
|
|
} catch {
|
|
indexMtimeMs = null;
|
|
}
|
|
|
|
const dirChanged = cachedIndexClientDir !== resolvedClientDir;
|
|
const mtimeChanged = indexMtimeMs !== null && cachedIndexMtimeMs !== indexMtimeMs;
|
|
|
|
if (cachedTemplatedIndexHtml && !dirChanged && !mtimeChanged) {
|
|
return cachedTemplatedIndexHtml;
|
|
}
|
|
|
|
if (!cachedIndexHtml || dirChanged || mtimeChanged) {
|
|
cachedIndexHtml = readFileSync(indexPath, "utf8");
|
|
cachedIndexClientDir = resolvedClientDir;
|
|
cachedIndexMtimeMs = indexMtimeMs;
|
|
}
|
|
|
|
const chunkMap = loadViewChunkManifest(resolvedClientDir);
|
|
const injection = buildViewPreloadInjection(chunkMap);
|
|
const marker = "<!-- fusion:view-preload -->";
|
|
const withInjectedHead = cachedIndexHtml.includes(marker)
|
|
? cachedIndexHtml.replace(marker, `${marker}\n${injection}`)
|
|
: cachedIndexHtml.replace("</head>", `${injection}</head>`);
|
|
|
|
cachedTemplatedIndexHtml = withInjectedHead;
|
|
return withInjectedHead;
|
|
};
|
|
|
|
const serveIndexHtml = (_req: express.Request, res: express.Response): void => {
|
|
try {
|
|
const html = renderIndexHtml();
|
|
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
res.setHeader("Cache-Control", "no-store, max-age=0");
|
|
res.status(200).send(html);
|
|
} catch (err) {
|
|
console.error("[dashboard] serveIndexHtml failed:", err);
|
|
// Drop the cached HTML so the next request retries from disk rather
|
|
// than re-throwing the same failure until the server restarts.
|
|
cachedIndexHtml = null;
|
|
cachedTemplatedIndexHtml = null;
|
|
cachedIndexMtimeMs = null;
|
|
res.status(503).type("text/plain").send("Dashboard temporarily unavailable. Retrying...");
|
|
}
|
|
};
|
|
|
|
if (!isHeadless) {
|
|
app.get("/version.json", (_req, res) => {
|
|
res.setHeader("Cache-Control", "no-store, max-age=0");
|
|
res.sendFile(join(clientDir, "version.json"), (err) => {
|
|
if (err) {
|
|
res.status(404).json({ version: null });
|
|
}
|
|
});
|
|
});
|
|
if (existsSync(join(clientDir, "index.html"))) {
|
|
app.get(["/", "/index.html"], serveIndexHtml);
|
|
app.use(express.static(clientDir, { index: false }));
|
|
}
|
|
}
|
|
|
|
// Create ChatStore for chat session management (available for SSE event forwarding)
|
|
const chatStore = options?.chatStore ?? new ChatStore(store.getFusionDir(), store.getDatabase());
|
|
options?.engine?.attachChatStore?.(chatStore);
|
|
if (typeof options?.engineManager?.getAllEngines === "function") {
|
|
for (const engine of options.engineManager.getAllEngines().values()) {
|
|
engine.attachChatStore?.(chatStore);
|
|
}
|
|
}
|
|
|
|
// Lets the browser explicitly release server-side SSE listeners during page
|
|
// unload. EventSource.close() is not enough in Chrome refresh paths because
|
|
// the HTTP/1.1 transport can remain open in the browser network service.
|
|
app.post("/api/events/disconnect", sseControlRateLimit, (req, res) => {
|
|
const clientId = typeof req.query.clientId === "string" ? req.query.clientId : undefined;
|
|
const projectId = typeof req.query.projectId === "string" ? req.query.projectId : undefined;
|
|
disconnectSSEClient(clientId, projectId);
|
|
res.status(204).end();
|
|
});
|
|
|
|
app.post("/api/events/keepalive", sseControlRateLimit, (req, res) => {
|
|
const clientId = typeof req.query.clientId === "string" ? req.query.clientId : undefined;
|
|
const projectId = typeof req.query.projectId === "string" ? req.query.projectId : undefined;
|
|
markSSEClientAlive(clientId, projectId);
|
|
res.status(204).end();
|
|
});
|
|
|
|
// Rate limiting — stricter limit on SSE connections
|
|
app.get("/api/events", rateLimit(RATE_LIMITS.sse), async (req, res) => {
|
|
const projectId = typeof req.query.projectId === "string" ? req.query.projectId : undefined;
|
|
const engineManager = options?.engineManager;
|
|
|
|
if (!projectId) {
|
|
// Create AgentStore for default project SSE
|
|
const { AgentStore: AgentStoreClass } = await import("@fusion/core");
|
|
const defaultAgentStore = new AgentStoreClass({ rootDir: store.getFusionDir() });
|
|
await defaultAgentStore.init();
|
|
const defaultMessageStore = options?.engine?.getMessageStore();
|
|
createSSE(
|
|
store,
|
|
store.getMissionStore(),
|
|
aiSessionStore,
|
|
store.getPluginStore(),
|
|
undefined,
|
|
defaultAgentStore,
|
|
defaultMessageStore,
|
|
chatStore,
|
|
options?.automationStore,
|
|
)(req, res);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Prefer the engine's store when available — this ensures SSE listeners
|
|
// attach to the same EventEmitter instance that the engine writes to,
|
|
// rather than a separate store created by getOrCreateProjectStore.
|
|
let scopedStore: TaskStore;
|
|
let agentStore;
|
|
let messageStore: MessageStore | undefined;
|
|
let automationStore: AutomationStore | undefined;
|
|
let scopedChatStore = chatStore;
|
|
if (engineManager) {
|
|
const engine = engineManager.getEngine(projectId);
|
|
scopedStore = engine?.getTaskStore() ?? await getOrCreateProjectStore(projectId);
|
|
scopedChatStore = getOrCreateScopedChatStore(scopedStore, engine?.getChatStore?.());
|
|
// Use the engine's stores if available
|
|
agentStore = engine?.getAgentStore();
|
|
messageStore = engine?.getMessageStore();
|
|
automationStore = engine?.getAutomationStore();
|
|
} else {
|
|
scopedStore = await getOrCreateProjectStore(projectId);
|
|
scopedChatStore = getOrCreateScopedChatStore(scopedStore);
|
|
}
|
|
// Fallback: create AgentStore if engine doesn't have one
|
|
if (!agentStore) {
|
|
const { AgentStore: AgentStoreClass } = await import("@fusion/core");
|
|
agentStore = new AgentStoreClass({ rootDir: scopedStore.getFusionDir() });
|
|
await agentStore.init();
|
|
}
|
|
if (!automationStore) {
|
|
automationStore = options?.automationStore;
|
|
}
|
|
createSSE(
|
|
scopedStore,
|
|
scopedStore.getMissionStore(),
|
|
aiSessionStore,
|
|
scopedStore.getPluginStore(),
|
|
{
|
|
projectId,
|
|
},
|
|
agentStore,
|
|
messageStore,
|
|
scopedChatStore,
|
|
automationStore,
|
|
)(req, res);
|
|
} catch (err: unknown) {
|
|
sendErrorResponse(res, 500, err instanceof Error ? err.message : "Failed to open project event stream");
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Shared project-resolution helper for realtime endpoints.
|
|
* Uses module-level resolveScopedStore with current closure context.
|
|
*/
|
|
async function resolveProjectScopedStore(projectId: string | undefined): Promise<TaskStore> {
|
|
return resolveScopedStore(projectId, store, options?.engineManager);
|
|
}
|
|
|
|
// Per-task SSE endpoint for live agent log streaming
|
|
app.get("/api/tasks/:id/logs/stream", async (req, res) => {
|
|
const taskId = req.params.id;
|
|
const projectId = typeof req.query.projectId === "string" ? req.query.projectId : undefined;
|
|
|
|
res.setHeader("Content-Type", "text/event-stream");
|
|
res.setHeader("Cache-Control", "no-cache");
|
|
res.setHeader("Connection", "keep-alive");
|
|
res.setHeader("X-Accel-Buffering", "no");
|
|
res.flushHeaders();
|
|
|
|
res.write(": connected\n\n");
|
|
|
|
// Resolve the store for this request:
|
|
// - With projectId: use scoped store from engine or resolver (ensures multi-project isolation)
|
|
// - Without projectId: use default store (preserves existing single-project behavior)
|
|
//
|
|
// Tool-oriented detail payloads may already be clipped in storage to keep
|
|
// live log streaming responsive. The 500-entry cap is applied client-side
|
|
// in the React hooks (useAgentLogs / useMultiAgentLogs).
|
|
let scopedStore: TaskStore;
|
|
try {
|
|
scopedStore = await resolveProjectScopedStore(projectId);
|
|
} catch {
|
|
res.write(`event: error\ndata: ${JSON.stringify({ message: "Failed to resolve project store" })}\n\n`);
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
const onAgentLog = (entry: { taskId: string; text: string; type: string; timestamp: string }) => {
|
|
if (entry.taskId !== taskId) return;
|
|
res.write(`event: agent:log\ndata: ${JSON.stringify(entry)}\n\n`);
|
|
};
|
|
|
|
scopedStore.on("agent:log", onAgentLog);
|
|
|
|
const heartbeat = setInterval(() => {
|
|
res.write(": heartbeat\n\n");
|
|
}, 30_000);
|
|
|
|
req.on("close", () => {
|
|
clearInterval(heartbeat);
|
|
scopedStore.off("agent:log", onAgentLog);
|
|
});
|
|
});
|
|
|
|
// Per-run SSE endpoint for live agent log streaming.
|
|
// Mirrors the per-task endpoint above but subscribes to AgentStore's
|
|
// "run:log" event (emitted from AgentStore.appendRunLog) and filters by
|
|
// agentId + runId. We need the engine's AgentStore instance specifically,
|
|
// since that's the EventEmitter the heartbeat runtime writes to — a fresh
|
|
// store created here would never receive events.
|
|
app.get("/api/agents/:id/runs/:runId/logs/stream", async (req, res) => {
|
|
const agentId = req.params.id;
|
|
const runId = req.params.runId;
|
|
const projectId = typeof req.query.projectId === "string" ? req.query.projectId : undefined;
|
|
|
|
res.setHeader("Content-Type", "text/event-stream");
|
|
res.setHeader("Cache-Control", "no-cache");
|
|
res.setHeader("Connection", "keep-alive");
|
|
res.setHeader("X-Accel-Buffering", "no");
|
|
res.flushHeaders();
|
|
|
|
res.write(": connected\n\n");
|
|
|
|
const engineManager = options?.engineManager;
|
|
const engine = engineManager && projectId ? engineManager.getEngine(projectId) : options?.engine;
|
|
const agentStore = engine?.getAgentStore();
|
|
|
|
if (!agentStore) {
|
|
// No live engine — there is no event source to subscribe to. Close
|
|
// gracefully so the client falls back to its initial fetch.
|
|
res.write(`event: error\ndata: ${JSON.stringify({ message: "No active engine for project" })}\n\n`);
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
const onRunLog = (eventAgentId: string, eventRunId: string, entry: AgentLogEntry) => {
|
|
if (eventAgentId !== agentId || eventRunId !== runId) return;
|
|
res.write(`event: agent:log\ndata: ${JSON.stringify(entry)}\n\n`);
|
|
};
|
|
|
|
agentStore.on("run:log", onRunLog);
|
|
|
|
const heartbeat = setInterval(() => {
|
|
res.write(": heartbeat\n\n");
|
|
}, 30_000);
|
|
|
|
req.on("close", () => {
|
|
clearInterval(heartbeat);
|
|
agentStore.off("run:log", onRunLog);
|
|
});
|
|
});
|
|
|
|
// Legacy Terminal SSE endpoint (deprecated, use WebSocket instead)
|
|
app.get("/api/terminal/sessions/:id/stream", rateLimit(RATE_LIMITS.sse), (req, res) => {
|
|
const sessionId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
|
|
res.setHeader("Content-Type", "text/event-stream");
|
|
res.setHeader("Cache-Control", "no-cache");
|
|
res.setHeader("Connection", "keep-alive");
|
|
res.setHeader("X-Accel-Buffering", "no");
|
|
res.flushHeaders();
|
|
|
|
res.write(": connected\n\n");
|
|
|
|
const session = terminalSessionManager.getSession(sessionId);
|
|
|
|
// If session doesn't exist, send error and close
|
|
if (!session) {
|
|
res.write(`event: terminal:error\ndata: ${JSON.stringify({ message: "Session not found" })}\n\n`);
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
// Send existing output immediately
|
|
if (session.output.length > 0) {
|
|
const existingOutput = session.output.join("");
|
|
res.write(`event: terminal:output\ndata: ${JSON.stringify({ type: "stdout", data: existingOutput })}\n\n`);
|
|
}
|
|
|
|
// If session has already exited, send exit event
|
|
if (session.exitCode !== null) {
|
|
res.write(`event: terminal:exit\ndata: ${JSON.stringify({ exitCode: session.exitCode })}\n\n`);
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
// Listen for new output
|
|
const onOutput = (event: import("./terminal.js").TerminalOutputEvent) => {
|
|
if (event.sessionId !== sessionId) return;
|
|
|
|
if (event.type === "exit") {
|
|
res.write(`event: terminal:exit\ndata: ${JSON.stringify({ exitCode: event.exitCode })}\n\n`);
|
|
res.end();
|
|
} else {
|
|
res.write(`event: terminal:output\ndata: ${JSON.stringify({ type: event.type, data: event.data })}\n\n`);
|
|
}
|
|
};
|
|
|
|
terminalSessionManager.on("output", onOutput);
|
|
|
|
// Heartbeat to keep connection alive
|
|
const heartbeat = setInterval(() => {
|
|
res.write(": heartbeat\n\n");
|
|
}, 30_000);
|
|
|
|
req.on("close", () => {
|
|
clearInterval(heartbeat);
|
|
terminalSessionManager.off("output", onOutput);
|
|
});
|
|
});
|
|
|
|
// Rate limiting — avoid throttling normal dashboard reads, which are often
|
|
// driven by polling, but keep targeted limits for setup flows, writes, and SSE.
|
|
app.use("/api", (req, res, next) => {
|
|
const isSetupRead =
|
|
req.method === "GET" && (
|
|
req.path === "/browse-directory" ||
|
|
req.path === "/setup-state" ||
|
|
req.path === "/first-run-status"
|
|
);
|
|
|
|
const isSetupMutation =
|
|
req.method === "POST" && (
|
|
req.path === "/projects" ||
|
|
req.path === "/projects/detect" ||
|
|
req.path === "/complete-setup"
|
|
);
|
|
|
|
if (isSetupRead) {
|
|
setupReadRateLimit(req, res, next);
|
|
return;
|
|
}
|
|
|
|
if (isSetupMutation) {
|
|
setupRateLimit(req, res, next);
|
|
return;
|
|
}
|
|
|
|
if (["POST", "PUT", "PATCH", "DELETE"].includes(req.method)) {
|
|
mutationRateLimit(req, res, next);
|
|
return;
|
|
}
|
|
|
|
next();
|
|
});
|
|
|
|
// Planning route diagnostics for production/runtime debugging. Disabled by default.
|
|
if (process.env.FUSION_DEBUG_PLANNING_ROUTES === "1") {
|
|
const planningLogger = runtimeLogger.child("planning");
|
|
app.use("/api/planning", (req, _res, next) => {
|
|
planningLogger.info("request", {
|
|
method: req.method,
|
|
path: req.path,
|
|
originalUrl: req.originalUrl,
|
|
contentType: req.headers["content-type"],
|
|
});
|
|
next();
|
|
});
|
|
}
|
|
|
|
// Create AiSessionStore for background task persistence
|
|
const aiSessionStore = options?.aiSessionStore ?? new AiSessionStore(store.getDatabase());
|
|
aiSessionStore.recoverStaleSessions();
|
|
setPlanningAiSessionStore(aiSessionStore);
|
|
setSubtaskAiSessionStore(aiSessionStore);
|
|
setMissionAiSessionStore(aiSessionStore);
|
|
setMilestoneSliceAiSessionStore(aiSessionStore);
|
|
|
|
const planningRehydratedCount = rehydratePlanningSessions(aiSessionStore);
|
|
const subtaskRehydratedCount = rehydrateSubtaskSessions(aiSessionStore);
|
|
const missionRehydratedCount = rehydrateMissionSessions(aiSessionStore);
|
|
const milestoneSliceRehydratedCount = rehydrateMilestoneSliceSessions(aiSessionStore);
|
|
const totalRehydrated =
|
|
planningRehydratedCount + subtaskRehydratedCount + missionRehydratedCount + milestoneSliceRehydratedCount;
|
|
if (totalRehydrated > 0) {
|
|
runtimeLogger.info("AI session rehydrate summary", {
|
|
message: "Rehydrated AI sessions from SQLite",
|
|
planningRehydratedCount,
|
|
subtaskRehydratedCount,
|
|
missionRehydratedCount,
|
|
milestoneSliceRehydratedCount,
|
|
totalRehydrated,
|
|
});
|
|
}
|
|
|
|
// Create AgentStore for chat prompt enrichment (initialized lazily by ChatManager)
|
|
const chatAgentStore = new AgentStore({ rootDir: store.getFusionDir() });
|
|
|
|
// Create ChatManager for AI chat message handling
|
|
const chatManager = options?.chatManager ?? new ChatManager(
|
|
chatStore,
|
|
store.getRootDir(),
|
|
chatAgentStore,
|
|
options?.pluginRunner,
|
|
() => store.getSettings(),
|
|
options?.engine?.getMessageStore(),
|
|
);
|
|
|
|
const runAiSessionCleanup = (maxAgeMs: number, source: "initial" | "scheduled") => {
|
|
const result = aiSessionStore.cleanupStaleSessions(maxAgeMs);
|
|
runtimeLogger.info("AI session cleanup summary", {
|
|
message: "Removed stale AI sessions",
|
|
source,
|
|
ttlMs: maxAgeMs,
|
|
terminalDeleted: result.terminalDeleted,
|
|
orphanedDeleted: result.orphanedDeleted,
|
|
totalDeleted: result.totalDeleted,
|
|
});
|
|
return result;
|
|
};
|
|
|
|
const scheduleAiSessionCleanup = (cleanupIntervalMs: number, maxAgeMs: number) => {
|
|
clearAiSessionCleanupInterval();
|
|
aiSessionCleanupIntervalHandle = setInterval(() => {
|
|
try {
|
|
runAiSessionCleanup(maxAgeMs, "scheduled");
|
|
} catch (err) {
|
|
runtimeLogger.error("AI session cleanup failed", {
|
|
message: "Scheduled AI session cleanup failed",
|
|
source: "scheduled",
|
|
ttlMs: maxAgeMs,
|
|
cleanupIntervalMs,
|
|
...normalizeErrorForLog(err),
|
|
});
|
|
}
|
|
}, cleanupIntervalMs);
|
|
aiSessionCleanupIntervalHandle.unref?.();
|
|
};
|
|
|
|
if (shouldScheduleAiSessionCleanup()) {
|
|
const loadSettings = (store as { getSettings?: () => Promise<{ aiSessionTtlMs?: number; aiSessionCleanupIntervalMs?: number }> }).getSettings;
|
|
if (typeof loadSettings === "function") {
|
|
void loadSettings
|
|
.call(store)
|
|
.then((settings) => {
|
|
const ttlMs = resolveBoundedMs(
|
|
settings.aiSessionTtlMs,
|
|
DEFAULT_AI_SESSION_TTL_MS,
|
|
MIN_AI_SESSION_TTL_MS,
|
|
MAX_AI_SESSION_TTL_MS,
|
|
);
|
|
const cleanupIntervalMs = resolveBoundedMs(
|
|
settings.aiSessionCleanupIntervalMs,
|
|
DEFAULT_AI_SESSION_CLEANUP_INTERVAL_MS,
|
|
MIN_AI_SESSION_CLEANUP_INTERVAL_MS,
|
|
MAX_AI_SESSION_CLEANUP_INTERVAL_MS,
|
|
);
|
|
|
|
void Promise.resolve()
|
|
.then(() => runAiSessionCleanup(ttlMs, "initial"))
|
|
.catch((err) => {
|
|
runtimeLogger.error("AI session cleanup failed", {
|
|
message: "Initial AI session cleanup failed",
|
|
source: "initial",
|
|
ttlMs,
|
|
...normalizeErrorForLog(err),
|
|
});
|
|
});
|
|
|
|
scheduleAiSessionCleanup(cleanupIntervalMs, ttlMs);
|
|
})
|
|
.catch((err) => {
|
|
runtimeLogger.warn("AI session cleanup settings fallback", {
|
|
message: "Failed to load settings for AI session cleanup; using defaults",
|
|
fallbackTtlMs: DEFAULT_AI_SESSION_TTL_MS,
|
|
fallbackCleanupIntervalMs: DEFAULT_AI_SESSION_CLEANUP_INTERVAL_MS,
|
|
...normalizeErrorForLog(err),
|
|
});
|
|
|
|
void Promise.resolve()
|
|
.then(() => runAiSessionCleanup(DEFAULT_AI_SESSION_TTL_MS, "initial"))
|
|
.catch((cleanupErr) => {
|
|
runtimeLogger.error("AI session cleanup failed", {
|
|
message: "Initial AI session cleanup failed",
|
|
source: "initial",
|
|
ttlMs: DEFAULT_AI_SESSION_TTL_MS,
|
|
...normalizeErrorForLog(cleanupErr),
|
|
});
|
|
});
|
|
|
|
scheduleAiSessionCleanup(
|
|
DEFAULT_AI_SESSION_CLEANUP_INTERVAL_MS,
|
|
DEFAULT_AI_SESSION_TTL_MS,
|
|
);
|
|
});
|
|
} else {
|
|
void Promise.resolve()
|
|
.then(() => runAiSessionCleanup(DEFAULT_AI_SESSION_TTL_MS, "initial"))
|
|
.catch((err) => {
|
|
runtimeLogger.error("AI session cleanup failed", {
|
|
message: "Initial AI session cleanup failed",
|
|
source: "initial",
|
|
ttlMs: DEFAULT_AI_SESSION_TTL_MS,
|
|
...normalizeErrorForLog(err),
|
|
});
|
|
});
|
|
|
|
scheduleAiSessionCleanup(
|
|
DEFAULT_AI_SESSION_CLEANUP_INTERVAL_MS,
|
|
DEFAULT_AI_SESSION_TTL_MS,
|
|
);
|
|
}
|
|
}
|
|
|
|
app.get("/api/health", (_req, res) => {
|
|
res.json(buildHealthPayload(store, cliPackageVersion));
|
|
});
|
|
|
|
app.get("/api/health/reliability", async (req, res) => {
|
|
const rawWindowDays = req.query.windowDays;
|
|
const parsedWindowDays = rawWindowDays === undefined ? 7 : Number.parseInt(String(rawWindowDays), 10);
|
|
|
|
if (!Number.isInteger(parsedWindowDays) || parsedWindowDays < 1 || parsedWindowDays > 30) {
|
|
res.status(400).json({
|
|
error: "Invalid windowDays",
|
|
message: "windowDays must be an integer between 1 and 30",
|
|
});
|
|
return;
|
|
}
|
|
|
|
const settings = await store.getSettings();
|
|
const resetAt = typeof settings.reliabilityStatsResetAt === "string" ? settings.reliabilityStatsResetAt : null;
|
|
|
|
const nowMs = Date.now();
|
|
const windowStartMs = nowMs - parsedWindowDays * 86_400_000;
|
|
const resetAtMs = resetAt ? Date.parse(resetAt) : Number.NaN;
|
|
const effectiveStartMs = Number.isFinite(resetAtMs) ? Math.max(windowStartMs, resetAtMs) : windowStartMs;
|
|
const startIso = new Date(effectiveStartMs).toISOString();
|
|
const endIso = new Date(nowMs).toISOString();
|
|
|
|
const [runAuditEvents, enteredByDay, bouncedByDay, durationEvents, mergedTaskIds] = await Promise.all([
|
|
Promise.resolve(store.getRunAuditEvents({ startTime: startIso, endTime: endIso, limit: 50_000 })),
|
|
store.getTaskMovedCountsByDay({ since: startIso, until: endIso, toColumn: "in-review" }),
|
|
store.getTaskMovedCountsByDay({ since: startIso, until: endIso, fromColumn: "in-review", toColumn: "in-progress" }),
|
|
store.getInReviewDurationEvents({ since: startIso, until: endIso }),
|
|
store.getTaskMergedTaskIds({ since: startIso, until: endIso }),
|
|
]);
|
|
|
|
const postMergeByDay = postMergeAuditFailuresPerDay(runAuditEvents, effectiveStartMs, nowMs);
|
|
const fileScopeByDay = fileScopeInvariantFailuresPerDay(runAuditEvents, effectiveStartMs, nowMs);
|
|
const recoveriesByDay = recoverAlreadyMergedReviewTasksRecoveriesPerDay(runAuditEvents, effectiveStartMs, nowMs);
|
|
const duration = inReviewDurationMetrics(durationEvents, effectiveStartMs, nowMs);
|
|
const mergeAttempts = mergeAttemptsPerMergedTask(runAuditEvents, mergedTaskIds, effectiveStartMs, nowMs);
|
|
const headline = inReviewFailureRate7d(enteredByDay, bouncedByDay, nowMs);
|
|
|
|
const perDay: Array<{
|
|
date: string;
|
|
tasksEnteredInReview: number;
|
|
tasksBouncedToInProgress: number;
|
|
postMergeAuditFailures: { block: number; warn: number; off: number } | null;
|
|
fileScopeInvariantFailures: number | null;
|
|
recoverAlreadyMergedReviewTasksRecoveries: number | null;
|
|
hasSamples: boolean;
|
|
}> = [];
|
|
|
|
const dayCursor = new Date(effectiveStartMs);
|
|
const dayEnd = new Date(endIso);
|
|
dayCursor.setUTCHours(0, 0, 0, 0);
|
|
dayEnd.setUTCHours(0, 0, 0, 0);
|
|
|
|
while (dayCursor.getTime() <= dayEnd.getTime()) {
|
|
const day = dayCursor.toISOString().slice(0, 10);
|
|
const tasksEnteredInReview = enteredByDay[day] ?? 0;
|
|
const tasksBouncedToInProgress = bouncedByDay[day] ?? 0;
|
|
const postMergeAuditFailures = postMergeByDay.value ? (postMergeByDay.value[day] ?? { block: 0, warn: 0, off: 0 }) : null;
|
|
const fileScopeInvariantFailures = fileScopeByDay.value ? (fileScopeByDay.value[day] ?? 0) : null;
|
|
const recoverAlreadyMergedReviewTasksRecoveries = recoveriesByDay.value ? (recoveriesByDay.value[day] ?? 0) : null;
|
|
const hasSamples = dayHasSamples({
|
|
tasksEnteredInReview,
|
|
tasksBouncedToInProgress,
|
|
postMergeAuditFailures,
|
|
fileScopeInvariantFailures,
|
|
recoverAlreadyMergedReviewTasksRecoveries,
|
|
});
|
|
|
|
perDay.push({
|
|
date: day,
|
|
tasksEnteredInReview,
|
|
tasksBouncedToInProgress,
|
|
postMergeAuditFailures,
|
|
fileScopeInvariantFailures,
|
|
recoverAlreadyMergedReviewTasksRecoveries,
|
|
hasSamples,
|
|
});
|
|
dayCursor.setUTCDate(dayCursor.getUTCDate() + 1);
|
|
}
|
|
|
|
res.json({
|
|
windowDays: parsedWindowDays,
|
|
generatedAt: new Date(nowMs).toISOString(),
|
|
resetAt,
|
|
headline: {
|
|
inReviewFailureRate7d: headline.value,
|
|
...(headline.reason ? { reason: headline.reason } : {}),
|
|
},
|
|
perDay,
|
|
perDayNonEmpty: perDay.filter((row) => row.hasSamples),
|
|
duration: {
|
|
p50Ms: duration.p50Ms,
|
|
p95Ms: duration.p95Ms,
|
|
sampleCount: duration.sampleCount,
|
|
...(duration.reason ? { reason: duration.reason } : {}),
|
|
},
|
|
mergeAttempts: {
|
|
mean: mergeAttempts.mean,
|
|
max: mergeAttempts.max,
|
|
histogram: mergeAttempts.histogram,
|
|
...(mergeAttempts.reason ? { reason: mergeAttempts.reason } : {}),
|
|
},
|
|
});
|
|
});
|
|
|
|
app.post("/api/health/reliability/reset", async (_req, res) => {
|
|
const resetAt = new Date().toISOString();
|
|
await store.updateSettings({ reliabilityStatsResetAt: resetAt });
|
|
res.json({ resetAt });
|
|
});
|
|
|
|
app.post("/api/health/refresh", (_req, res) => {
|
|
const report = store.refreshTaskIdIntegrityReport();
|
|
const database = store.refreshDatabaseHealth();
|
|
res.json({
|
|
status: !database.healthy || database.corruptionDetected || report.status === "anomaly" ? "degraded" : "ok",
|
|
version: cliPackageVersion,
|
|
uptime: Math.floor(process.uptime()),
|
|
database,
|
|
taskIdIntegrity: buildTaskIdIntegrityHealth(report),
|
|
});
|
|
});
|
|
|
|
app.get("/api/updates/check", async (_req, res) => {
|
|
const currentVersion = cliPackageVersion;
|
|
res.set("Cache-Control", "no-store");
|
|
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 10_000);
|
|
|
|
try {
|
|
const response = await fetch("https://registry.npmjs.org/@runfusion/fusion/latest", {
|
|
signal: controller.signal,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`registry request failed: ${response.status}`);
|
|
}
|
|
|
|
const payload = (await response.json()) as { version?: unknown };
|
|
if (typeof payload.version !== "string" || payload.version.trim().length === 0) {
|
|
throw new Error("registry response missing version");
|
|
}
|
|
|
|
const latestVersion = payload.version;
|
|
res.json({
|
|
currentVersion,
|
|
latestVersion,
|
|
updateAvailable: isRemoteVersionNewer(latestVersion, currentVersion),
|
|
});
|
|
} catch {
|
|
res.status(200).json({
|
|
currentVersion,
|
|
latestVersion: null,
|
|
updateAvailable: false,
|
|
error: "Failed to check for updates",
|
|
});
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
});
|
|
|
|
app.get("/remote-login", async (req, res) => {
|
|
const remoteToken = typeof req.query.rt === "string" ? req.query.rt : undefined;
|
|
|
|
let settings: Awaited<ReturnType<typeof store.getSettings>>;
|
|
try {
|
|
settings = await store.getSettings();
|
|
} catch {
|
|
res.status(401).json({ error: "Unauthorized", code: "remote_token_invalid" });
|
|
return;
|
|
}
|
|
|
|
const remoteAccess = settings.remoteAccess;
|
|
if (!remoteAccess) {
|
|
res.status(401).json({ error: "Unauthorized", code: "remote_token_invalid" });
|
|
return;
|
|
}
|
|
|
|
const result = validateRemoteAuthToken(remoteToken, remoteAccess);
|
|
if (result.status !== "valid") {
|
|
const codeByStatus: Record<string, string> = {
|
|
missing: "remote_token_missing",
|
|
expired: "remote_token_expired",
|
|
invalid: "remote_token_invalid",
|
|
disabled: "remote_token_invalid",
|
|
};
|
|
|
|
res.status(401).json({
|
|
error: "Unauthorized",
|
|
code: codeByStatus[result.status] ?? "remote_token_invalid",
|
|
});
|
|
return;
|
|
}
|
|
|
|
const daemonTokenForRedirect = getDaemonToken(options);
|
|
if (daemonTokenForRedirect) {
|
|
const redirectUrl = new URL("/", `${req.protocol}://${req.get("host")}`);
|
|
redirectUrl.searchParams.set("token", daemonTokenForRedirect);
|
|
res.redirect(302, redirectUrl.pathname + redirectUrl.search);
|
|
return;
|
|
}
|
|
|
|
res.redirect(302, "/");
|
|
});
|
|
|
|
// REST API
|
|
const apiRouter = createApiRoutes(store, {
|
|
...options,
|
|
runtimeLogger,
|
|
aiSessionStore,
|
|
chatStore,
|
|
chatManager,
|
|
skillsAdapter: options?.skillsAdapter,
|
|
});
|
|
app.use("/api", apiRouter);
|
|
|
|
// API 404 Handler - Return JSON for unmatched API routes (instead of falling through to SPA)
|
|
app.use("/api", (_req: express.Request, res: express.Response) => {
|
|
sendErrorResponse(res, 404, "Not found");
|
|
});
|
|
|
|
// API Error Handling Middleware - MUST be after API routes but before SPA fallback
|
|
// This ensures API errors return JSON instead of falling through to the SPA fallback (which returns HTML)
|
|
|
|
app.use("/api", (err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
|
if (res.headersSent) {
|
|
return;
|
|
}
|
|
|
|
if (err instanceof ApiError) {
|
|
sendErrorResponse(res, err.statusCode, err.message, { details: err.details });
|
|
return;
|
|
}
|
|
|
|
const fallbackMessage = "Internal server error";
|
|
const message =
|
|
process.env.NODE_ENV === "production"
|
|
? fallbackMessage
|
|
: err instanceof Error && err.message
|
|
? err.message
|
|
: fallbackMessage;
|
|
|
|
sendErrorResponse(res, 500, message);
|
|
});
|
|
|
|
if (!isHeadless) {
|
|
app.get("/tasks/:id", (req, res, next) => {
|
|
const taskId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
if (!taskId || !/^[A-Z]+-\d+$/.test(taskId)) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
const params = new URLSearchParams();
|
|
params.set("task", taskId);
|
|
const project = typeof req.query.project === "string" ? req.query.project : undefined;
|
|
if (project) {
|
|
params.set("project", project);
|
|
}
|
|
|
|
res.redirect(301, `/?${params.toString()}`);
|
|
});
|
|
|
|
// SPA fallback. Only serve index.html for navigation requests — never for
|
|
// hashed asset URLs (/assets/*, /icons/*, /fonts/*) or any path that looks
|
|
// like a static file. Returning index.html for a missing JS chunk poisons
|
|
// the page with a text/html module script (strict MIME failure → blank
|
|
// shell on reload). A real 404 lets versionCheck detect the stale chunk
|
|
// and recover.
|
|
const STATIC_PREFIXES = ["/assets/", "/icons/", "/fonts/", "/brands/"];
|
|
app.get("/{*splat}", (req, res) => {
|
|
const path = req.path;
|
|
if (STATIC_PREFIXES.some((p) => path.startsWith(p)) || /\.[a-z0-9]+$/i.test(path)) {
|
|
res.status(404).end();
|
|
return;
|
|
}
|
|
serveIndexHtml(req, res);
|
|
});
|
|
}
|
|
|
|
const dashboardApp = app as DashboardExpressApp;
|
|
dashboardApp.terminalWsServer = null;
|
|
dashboardApp.badgeWsServer = null;
|
|
dashboardApp.badgeWsManager = null;
|
|
dashboardApp.__fnWebSocketsAttached = false;
|
|
|
|
const originalListen = dashboardApp.listen.bind(dashboardApp);
|
|
const httpsCreds = options?.https;
|
|
dashboardApp.listen = ((...args: Parameters<typeof dashboardApp.listen>) => {
|
|
const normalizedArgs = normalizeListenArgsForTests(args) as Parameters<typeof originalListen>;
|
|
|
|
let server: HttpServer | Http2SecureServer;
|
|
if (httpsCreds) {
|
|
// HTTP/2 with HTTP/1.1 fallback. allowHTTP1 is required so that:
|
|
// 1. WebSocket upgrades (HTTP/1.1-only) keep working.
|
|
// 2. Older clients and curl continue to connect.
|
|
// Express 5's request pipeline is compatible with both h1 and h2 req/res.
|
|
const h2 = createHttp2SecureServer(
|
|
{
|
|
cert: httpsCreds.cert,
|
|
key: httpsCreds.key,
|
|
ca: httpsCreds.ca,
|
|
allowHTTP1: true,
|
|
},
|
|
dashboardApp as unknown as Parameters<typeof createHttp2SecureServer>[1],
|
|
);
|
|
server = h2;
|
|
h2.listen(...(normalizedArgs as Parameters<Http2SecureServer["listen"]>));
|
|
} else {
|
|
server = originalListen(...normalizedArgs);
|
|
}
|
|
|
|
server.once("close", () => {
|
|
clearAiSessionCleanupInterval();
|
|
aiSessionStore.stopScheduledCleanup();
|
|
(apiRouter as Router & { dispose?: () => void }).dispose?.();
|
|
void stopAllDevServers().catch((error) => {
|
|
runtimeLogger.warn("Failed to shutdown dev-server managers", {
|
|
message: "Failed to shutdown dev-server managers",
|
|
...normalizeErrorForLog(error),
|
|
});
|
|
});
|
|
});
|
|
|
|
if (!dashboardApp.__fnWebSocketsAttached) {
|
|
dashboardApp.__fnWebSocketsAttached = true;
|
|
const websocketOptions = { ...options, runtimeLogger };
|
|
setupTerminalWebSocket(dashboardApp, server as HttpServer, store, websocketOptions);
|
|
setupBadgeWebSocket(dashboardApp, server as HttpServer, store, websocketOptions);
|
|
}
|
|
|
|
return server as HttpServer;
|
|
}) as typeof dashboardApp.listen;
|
|
|
|
return dashboardApp;
|
|
}
|
|
|
|
/**
|
|
* Setup WebSocket terminal server
|
|
* Call this after creating the HTTP server to attach WebSocket handling
|
|
*/
|
|
export function setupTerminalWebSocket(
|
|
app: ReturnType<typeof express>,
|
|
server: import("http").Server,
|
|
store: TaskStore,
|
|
options?: ServerOptions,
|
|
): void {
|
|
const wss = new WebSocketServer({ noServer: true });
|
|
|
|
// Default terminal service for stale eviction (uses default store's root dir)
|
|
const defaultTerminalService = getTerminalService(store.getRootDir());
|
|
|
|
// Resolve the daemon token once so every upgrade picks up the same value.
|
|
const wsDaemonToken = getDaemonToken(options);
|
|
const terminalDiagnostics = createTerminalWebSocketDiagnostics(options?.runtimeLogger);
|
|
|
|
server.on("upgrade", (req, socket, head) => {
|
|
const pathname = new URL(req.url || "", `http://${req.headers.host}`).pathname;
|
|
if (pathname !== "/api/terminal/ws") {
|
|
return;
|
|
}
|
|
|
|
// When daemon auth is active, refuse WebSocket upgrades that don't
|
|
// carry a valid bearer token. The token can come from the Authorization
|
|
// header (rare for browser WebSocket clients) or the `fn_token` query
|
|
// param (what our own client uses).
|
|
if (wsDaemonToken && !options?.noAuth && !authenticateUpgradeRequest(wsDaemonToken, req)) {
|
|
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
|
|
wss.handleUpgrade(req, socket, head, (upgraded) => {
|
|
wss.emit("connection", upgraded, req);
|
|
});
|
|
});
|
|
|
|
// Store reference on app for access
|
|
(app as DashboardExpressApp).terminalWsServer = wss;
|
|
|
|
wss.on("connection", async (ws: WebSocket, req) => {
|
|
// Parse query params from URL
|
|
const url = new URL(req.url || "", `http://${req.headers.host}`);
|
|
const sessionId = url.searchParams.get("sessionId");
|
|
const projectId = url.searchParams.get("projectId") ?? undefined;
|
|
|
|
if (!sessionId) {
|
|
ws.close(4000, "Missing sessionId");
|
|
return;
|
|
}
|
|
|
|
// Resolve the scoped terminal service
|
|
let terminalService: ReturnType<typeof getTerminalService>;
|
|
let scopedRootDir: string;
|
|
|
|
try {
|
|
if (projectId) {
|
|
// When projectId is provided, resolve the scoped store and get its root dir
|
|
const scopedStore = await resolveScopedStore(projectId, store, options?.engineManager);
|
|
scopedRootDir = scopedStore.getRootDir();
|
|
terminalService = getTerminalService(scopedRootDir);
|
|
} else {
|
|
// Without projectId, use the default store's root dir
|
|
scopedRootDir = store.getRootDir();
|
|
terminalService = getTerminalService(scopedRootDir);
|
|
}
|
|
} catch (err) {
|
|
terminalDiagnostics.scopeResolutionFailed({
|
|
projectId,
|
|
error: err,
|
|
});
|
|
ws.close(4510, "Failed to resolve project scope");
|
|
return;
|
|
}
|
|
|
|
const session = terminalService.getSession(sessionId);
|
|
if (!session) {
|
|
ws.close(4004, "Session not found");
|
|
return;
|
|
}
|
|
|
|
// Security check: reject sessions that don't belong to this project's root
|
|
// Session cwd must be within the resolved project root
|
|
if (!session.cwd.startsWith(scopedRootDir)) {
|
|
terminalDiagnostics.crossProjectCwdRejected({
|
|
sessionId,
|
|
projectId,
|
|
sessionCwd: session.cwd,
|
|
scopedRootDir,
|
|
});
|
|
ws.close(4503, "Session does not belong to this project");
|
|
return;
|
|
}
|
|
|
|
const MAX_MISSED_PONGS = 2; // Allow 2 missed pongs (~90s) before terminating
|
|
|
|
// Track if connection is alive
|
|
let isAlive = true;
|
|
let missedPongs = 0; // Track consecutive missed pongs
|
|
let dataUnsub: (() => void) | null = null;
|
|
let exitUnsub: (() => void) | null = null;
|
|
|
|
// Detect potentially stale sessions on reconnect
|
|
const idleMs = Date.now() - session.lastActivityAt.getTime();
|
|
if (idleMs > STALE_SESSION_THRESHOLD_MS) {
|
|
terminalDiagnostics.staleReconnect({
|
|
sessionId,
|
|
idleMs,
|
|
staleThresholdMs: STALE_SESSION_THRESHOLD_MS,
|
|
});
|
|
}
|
|
|
|
// Send scrollback buffer first
|
|
const scrollback = terminalService.getScrollbackAndClearPending(sessionId);
|
|
if (scrollback) {
|
|
ws.send(JSON.stringify({ type: "scrollback", data: scrollback }));
|
|
}
|
|
|
|
// Send connection info
|
|
ws.send(JSON.stringify({
|
|
type: "connected",
|
|
shell: session.shell,
|
|
cwd: session.cwd,
|
|
}));
|
|
|
|
// Subscribe to data events
|
|
dataUnsub = terminalService.onData((id, data) => {
|
|
if (id === sessionId && isAlive) {
|
|
try {
|
|
ws.send(JSON.stringify({ type: "data", data }));
|
|
} catch {
|
|
// WebSocket might be closing
|
|
}
|
|
}
|
|
});
|
|
|
|
// Subscribe to exit events
|
|
exitUnsub = terminalService.onExit((id, exitCode) => {
|
|
if (id === sessionId && isAlive) {
|
|
try {
|
|
ws.send(JSON.stringify({ type: "exit", exitCode }));
|
|
const idleSec = id ? Math.round((Date.now() - (terminalService.getSession(id)?.lastActivityAt?.getTime() ?? Date.now())) / 1000) : 0;
|
|
terminalDiagnostics.ptyExit({
|
|
sessionId: id,
|
|
exitCode,
|
|
idleSeconds: idleSec,
|
|
});
|
|
} catch {
|
|
// WebSocket might be closing
|
|
}
|
|
}
|
|
});
|
|
|
|
// Heartbeat ping/pong
|
|
const pingInterval = setInterval(() => {
|
|
if (!isAlive) {
|
|
missedPongs++;
|
|
if (missedPongs >= MAX_MISSED_PONGS) {
|
|
terminalDiagnostics.heartbeatTerminating({
|
|
sessionId,
|
|
missedPongs,
|
|
maxMissedPongs: MAX_MISSED_PONGS,
|
|
});
|
|
ws.terminate();
|
|
return;
|
|
}
|
|
terminalDiagnostics.heartbeatMissed({
|
|
sessionId,
|
|
missedPongs,
|
|
maxMissedPongs: MAX_MISSED_PONGS,
|
|
});
|
|
return;
|
|
}
|
|
isAlive = false;
|
|
try {
|
|
ws.send(JSON.stringify({ type: "ping" }));
|
|
} catch {
|
|
ws.terminate();
|
|
}
|
|
}, 30000);
|
|
|
|
ws.on("pong", () => {
|
|
isAlive = true;
|
|
missedPongs = 0; // Reset on successful pong
|
|
});
|
|
|
|
ws.on("message", (message: Buffer) => {
|
|
try {
|
|
const msg = JSON.parse(message.toString());
|
|
|
|
switch (msg.type) {
|
|
case "input":
|
|
if (typeof msg.data === "string") {
|
|
terminalService.write(sessionId, msg.data);
|
|
}
|
|
break;
|
|
case "resize":
|
|
if (typeof msg.cols === "number" && typeof msg.rows === "number") {
|
|
terminalService.resize(sessionId, msg.cols, msg.rows);
|
|
}
|
|
break;
|
|
case "ping":
|
|
ws.send(JSON.stringify({ type: "pong" }));
|
|
break;
|
|
case "pong":
|
|
isAlive = true;
|
|
missedPongs = 0; // Reset on successful pong
|
|
break;
|
|
}
|
|
} catch {
|
|
// Ignore malformed messages
|
|
}
|
|
});
|
|
|
|
ws.on("close", () => {
|
|
isAlive = false;
|
|
clearInterval(pingInterval);
|
|
if (dataUnsub) dataUnsub();
|
|
if (exitUnsub) exitUnsub();
|
|
// Do NOT kill the PTY session on WebSocket close — the session should
|
|
// survive transient disconnects and modal close/reopen cycles. Sessions
|
|
// are cleaned up through explicit kill paths (tab close, restart, shell
|
|
// exit) or stale-session eviction.
|
|
});
|
|
|
|
ws.on("error", () => {
|
|
isAlive = false;
|
|
clearInterval(pingInterval);
|
|
if (dataUnsub) dataUnsub();
|
|
if (exitUnsub) exitUnsub();
|
|
// Do NOT kill the PTY session on WebSocket error — same rationale as
|
|
// close: the session should persist for reconnection attempts.
|
|
});
|
|
});
|
|
|
|
// Periodic stale-session eviction (every 60 s) so that PTY sessions are
|
|
// eventually cleaned up when clients disconnect permanently without going
|
|
// through explicit kill paths. The eviction threshold is defined by
|
|
// TerminalService (default 5 minutes of inactivity).
|
|
const staleEvictionInterval = setInterval(() => {
|
|
try {
|
|
defaultTerminalService.evictStaleSessions();
|
|
} catch (err) {
|
|
terminalDiagnostics.staleEvictionFailed({ error: err });
|
|
}
|
|
}, 60_000);
|
|
|
|
// Stop eviction timer when the server shuts down
|
|
server.once("close", () => {
|
|
clearInterval(staleEvictionInterval);
|
|
});
|
|
|
|
terminalDiagnostics.mounted({ path: "/api/terminal/ws" });
|
|
}
|
|
|
|
export function setupBadgeWebSocket(
|
|
app: ReturnType<typeof express>,
|
|
server: import("http").Server,
|
|
store: TaskStore,
|
|
options?: ServerOptions,
|
|
): void {
|
|
const dashboardApp = app as DashboardExpressApp;
|
|
const wsManager = new WebSocketManager();
|
|
|
|
// Structured badge snapshot cache for local subscriptions and pub/sub sync
|
|
// Maps "{projectId}:{taskId}" -> BadgeSnapshot with timestamp
|
|
// Uses "default" for unscoped/default project
|
|
const badgeSnapshots = new Map<string, BadgeSnapshot>();
|
|
|
|
// Server instance ID for pub/sub deduplication
|
|
const serverId = randomUUID();
|
|
|
|
// Use injected badgePubSub or create from environment
|
|
const badgePubSub = options?.badgePubSub ?? createBadgePubSub({ sourceId: serverId });
|
|
void badgePubSub.start();
|
|
|
|
// Track scoped stores for multi-project support
|
|
const scopedStores = new Map<string, TaskStore>();
|
|
|
|
// Helper to get or create a scoped store
|
|
const getScopedStore = async (projectId: string): Promise<TaskStore> => {
|
|
// Always use the default store for the "default" scope
|
|
if (projectId === "default") {
|
|
return store;
|
|
}
|
|
|
|
let scopedStore = scopedStores.get(projectId);
|
|
if (scopedStore) {
|
|
return scopedStore;
|
|
}
|
|
|
|
// Create scoped store
|
|
scopedStore = await resolveScopedStore(projectId, store, options?.engineManager);
|
|
scopedStores.set(projectId, scopedStore);
|
|
return scopedStore;
|
|
};
|
|
|
|
// Prime cache with existing tasks from default store
|
|
void store.listTasks({ slim: true, includeArchived: false, startupMemo: true }).then((tasks) => {
|
|
for (const task of tasks) {
|
|
badgeSnapshots.set(`default:${task.id}`, {
|
|
prInfo: task.prInfo ?? null,
|
|
issueInfo: task.issueInfo ?? null,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
}
|
|
}).catch(() => {
|
|
// Best-effort cache prime only
|
|
});
|
|
|
|
const wss = new WebSocketServer({ noServer: true });
|
|
|
|
// Resolve the daemon token once per server so every upgrade picks up the
|
|
// same value. See the equivalent block in setupTerminalWebSocket above.
|
|
const badgeWsDaemonToken = getDaemonToken(options);
|
|
|
|
server.on("upgrade", (req, socket, head) => {
|
|
const pathname = new URL(req.url || "", `http://${req.headers.host}`).pathname;
|
|
if (pathname !== "/api/ws") {
|
|
return;
|
|
}
|
|
|
|
if (badgeWsDaemonToken && !options?.noAuth && !authenticateUpgradeRequest(badgeWsDaemonToken, req)) {
|
|
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
|
|
wss.handleUpgrade(req, socket, head, (upgraded) => {
|
|
wss.emit("connection", upgraded, req);
|
|
});
|
|
});
|
|
|
|
dashboardApp.badgeWsServer = wss;
|
|
dashboardApp.badgeWsManager = wsManager;
|
|
|
|
/**
|
|
* Broadcast a badge snapshot to subscribed clients within a project scope.
|
|
*/
|
|
const broadcastBadgeSnapshot = (taskId: string, snapshot: BadgeSnapshot, projectId: string = "default"): void => {
|
|
wsManager.broadcastBadgeUpdate(taskId, snapshot, projectId);
|
|
};
|
|
|
|
/**
|
|
* Get or create scoped store and attach badge listeners.
|
|
* Returns cleanup function.
|
|
*/
|
|
const attachScopedListeners = async (
|
|
projectId: string,
|
|
scopedStore: TaskStore
|
|
): Promise<() => void> => {
|
|
const scopeKey = projectId === "default" ? "default" : projectId;
|
|
|
|
const onTaskUpdated = (task: Task) => {
|
|
const cacheKey = `${scopeKey}:${task.id}`;
|
|
const previousSnapshot = badgeSnapshots.get(cacheKey);
|
|
const nextSnapshot: BadgeSnapshot = {
|
|
prInfo: task.prInfo ?? null,
|
|
issueInfo: task.issueInfo ?? null,
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
|
|
// Update local cache immediately
|
|
badgeSnapshots.set(cacheKey, nextSnapshot);
|
|
|
|
// Check if badge data actually changed
|
|
if (snapshotsEqual(previousSnapshot, nextSnapshot)) {
|
|
return;
|
|
}
|
|
|
|
// Always publish to shared bus (even if no local subscribers)
|
|
// This ensures other instances receive the update
|
|
const pubSubMessage: BadgePubSubMessage = {
|
|
sourceId: serverId,
|
|
projectId,
|
|
taskId: task.id,
|
|
timestamp: nextSnapshot.timestamp,
|
|
prInfo: nextSnapshot.prInfo,
|
|
issueInfo: nextSnapshot.issueInfo,
|
|
};
|
|
void badgePubSub.publish(pubSubMessage);
|
|
|
|
// Broadcast to local websocket subscribers if any
|
|
if (wsManager.getSubscriptionCount(task.id, projectId) > 0) {
|
|
broadcastBadgeSnapshot(task.id, nextSnapshot, projectId);
|
|
}
|
|
};
|
|
|
|
const onTaskCreated = (task: Task) => {
|
|
const cacheKey = `${scopeKey}:${task.id}`;
|
|
badgeSnapshots.set(cacheKey, {
|
|
prInfo: task.prInfo ?? null,
|
|
issueInfo: task.issueInfo ?? null,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
};
|
|
|
|
const onTaskDeleted = (task: Task) => {
|
|
const cacheKey = `${scopeKey}:${task.id}`;
|
|
badgeSnapshots.delete(cacheKey);
|
|
};
|
|
|
|
scopedStore.on("task:updated", onTaskUpdated);
|
|
scopedStore.on("task:created", onTaskCreated);
|
|
scopedStore.on("task:deleted", onTaskDeleted);
|
|
|
|
return () => {
|
|
scopedStore.off("task:updated", onTaskUpdated);
|
|
scopedStore.off("task:created", onTaskCreated);
|
|
scopedStore.off("task:deleted", onTaskDeleted);
|
|
};
|
|
};
|
|
|
|
// Store cleanup functions for scoped listeners
|
|
const scopedCleanups = new Map<string, () => void>();
|
|
|
|
// Attach listeners to default store
|
|
void (async () => {
|
|
const cleanup = await attachScopedListeners("default", store);
|
|
scopedCleanups.set("default", cleanup);
|
|
})();
|
|
|
|
/**
|
|
* Ensure scoped listeners are attached for a project.
|
|
*/
|
|
const ensureScopedListeners = async (projectId: string): Promise<void> => {
|
|
if (scopedCleanups.has(projectId)) {
|
|
return;
|
|
}
|
|
|
|
const scopedStore = await getScopedStore(projectId);
|
|
const cleanup = await attachScopedListeners(projectId, scopedStore);
|
|
scopedCleanups.set(projectId, cleanup);
|
|
};
|
|
|
|
// Handle remote badge updates from other instances via pub/sub
|
|
badgePubSub.on("message", (message: BadgePubSubMessage) => {
|
|
// Use provided projectId or default scope
|
|
const projectId = message.projectId ?? "default";
|
|
const cacheKey = `${projectId}:${message.taskId}`;
|
|
|
|
// Update local cache with remote snapshot
|
|
const remoteSnapshot: BadgeSnapshot = {
|
|
prInfo: message.prInfo,
|
|
issueInfo: message.issueInfo,
|
|
timestamp: message.timestamp,
|
|
};
|
|
badgeSnapshots.set(cacheKey, remoteSnapshot);
|
|
|
|
// Rebroadcast to local websocket subscribers
|
|
// (No need to check for echo - pub/sub adapter already filtered our own messages)
|
|
if (wsManager.getSubscriptionCount(message.taskId, projectId) > 0) {
|
|
broadcastBadgeSnapshot(message.taskId, remoteSnapshot, projectId);
|
|
}
|
|
});
|
|
|
|
wsManager.on("subscription:changed", (taskId, subscriberCount, projectId) => {
|
|
// Send cached snapshot to late subscriber if available
|
|
// This ensures a client subscribing after a remote update still sees the latest state
|
|
if (subscriberCount > 0) {
|
|
const cacheKey = `${projectId}:${taskId}`;
|
|
const cachedSnapshot = badgeSnapshots.get(cacheKey);
|
|
if (cachedSnapshot) {
|
|
broadcastBadgeSnapshot(taskId, cachedSnapshot, projectId);
|
|
}
|
|
}
|
|
});
|
|
|
|
wss.on("connection", (ws: WebSocket, req) => {
|
|
// Parse projectId from URL query params
|
|
const url = new URL(req.url || "", `http://${req.headers.host}`);
|
|
const projectId = url.searchParams.get("projectId") ?? "default";
|
|
|
|
// Ensure scoped listeners are attached for this project
|
|
void ensureScopedListeners(projectId);
|
|
|
|
// Add client bound to this project scope
|
|
wsManager.addClient(ws, randomUUID(), projectId);
|
|
});
|
|
|
|
server.once("close", () => {
|
|
// Clean up all scoped listeners
|
|
for (const cleanup of scopedCleanups.values()) {
|
|
cleanup();
|
|
}
|
|
scopedCleanups.clear();
|
|
|
|
for (const scopedStore of scopedStores.values()) {
|
|
// Don't close the default store - it's managed externally
|
|
if (scopedStore !== store) {
|
|
scopedStore.stopWatching?.();
|
|
scopedStore.close?.();
|
|
}
|
|
}
|
|
scopedStores.clear();
|
|
|
|
for (const client of wss.clients) {
|
|
client.terminate();
|
|
}
|
|
|
|
wsManager.dispose();
|
|
void badgePubSub.dispose();
|
|
wss.close();
|
|
// Clean up cached project-scoped stores (stop watchers, close DB connections)
|
|
evictAllProjectStores();
|
|
dashboardApp.terminalWsServer = null;
|
|
dashboardApp.badgeWsServer = null;
|
|
dashboardApp.badgeWsManager = null;
|
|
dashboardApp.__fnWebSocketsAttached = false;
|
|
});
|
|
}
|
|
|
|
/** Compare two badge snapshots for equality */
|
|
function snapshotsEqual(a: BadgeSnapshot | undefined, b: BadgeSnapshot | undefined): boolean {
|
|
if (!a && !b) return true;
|
|
if (!a || !b) return false;
|
|
|
|
// Compare prInfo
|
|
if (a.prInfo?.url !== b.prInfo?.url) return false;
|
|
if (a.prInfo?.status !== b.prInfo?.status) return false;
|
|
if (a.prInfo?.number !== b.prInfo?.number) return false;
|
|
if (a.prInfo?.title !== b.prInfo?.title) return false;
|
|
|
|
// Compare issueInfo
|
|
if (a.issueInfo?.url !== b.issueInfo?.url) return false;
|
|
if (a.issueInfo?.state !== b.issueInfo?.state) return false;
|
|
if (a.issueInfo?.number !== b.issueInfo?.number) return false;
|
|
if (a.issueInfo?.title !== b.issueInfo?.title) return false;
|
|
|
|
return true;
|
|
}
|