feat(FN-2270): add interactive TUI mode for fn dashboard
- Add a new dashboard TUI renderer with logs, system, utilities, stats, and settings sections - Wire runDashboard to auto-enable TUI in TTY sessions with reactive task/agent updates and utility keybindings - Keep non-TTY behavior unchanged by falling back to the existing plain-text startup output - Add CLI tests and docs for TUI behavior, keyboard shortcuts, and auth/usage guidance - Ensure dashboard WebSocket auth checks respect --no-auth consistently
This commit is contained in:
205
packages/cli/src/commands/dashboard-tui.test.ts
Normal file
205
packages/cli/src/commands/dashboard-tui.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
LogRingBuffer,
|
||||
DashboardLogSink,
|
||||
isTTYAvailable,
|
||||
type SystemInfo,
|
||||
type TaskStats,
|
||||
type SettingsValues,
|
||||
} from "./dashboard-tui.js";
|
||||
|
||||
// ── LogRingBuffer Tests ────────────────────────────────────────────────────
|
||||
|
||||
describe("LogRingBuffer", () => {
|
||||
let buffer: LogRingBuffer;
|
||||
|
||||
beforeEach(() => {
|
||||
buffer = new LogRingBuffer();
|
||||
});
|
||||
|
||||
it("stores entries and reports total count", () => {
|
||||
buffer.push({ timestamp: new Date(), level: "info", message: "test1" });
|
||||
buffer.push({ timestamp: new Date(), level: "warn", message: "test2" });
|
||||
expect(buffer.total).toBe(2);
|
||||
});
|
||||
|
||||
it("returns all entries in chronological order", () => {
|
||||
buffer.push({ timestamp: new Date("2026-01-01T10:00:00"), level: "info", message: "first" });
|
||||
buffer.push({ timestamp: new Date("2026-01-01T11:00:00"), level: "info", message: "second" });
|
||||
const entries = buffer.getAll();
|
||||
expect(entries.length).toBe(2);
|
||||
expect(entries[0].message).toBe("first");
|
||||
expect(entries[1].message).toBe("second");
|
||||
});
|
||||
|
||||
it("caps at MAX_LOG_ENTRIES (1000)", () => {
|
||||
for (let i = 0; i < 1500; i++) {
|
||||
buffer.push({ timestamp: new Date(), level: "info", message: `entry-${i}` });
|
||||
}
|
||||
const entries = buffer.getAll();
|
||||
expect(entries.length).toBe(1000);
|
||||
expect(buffer.total).toBe(1500);
|
||||
});
|
||||
|
||||
it("maintains chronological order when overwriting", () => {
|
||||
// Add 1500 entries to force overwrites
|
||||
for (let i = 0; i < 1500; i++) {
|
||||
buffer.push({ timestamp: new Date(2026, 0, 1, 0, i), level: "info", message: `entry-${i}` });
|
||||
}
|
||||
const entries = buffer.getAll();
|
||||
expect(entries.length).toBe(1000);
|
||||
// First entry should be the 500th (since 1000 entries were added before wrap)
|
||||
expect(entries[0].message).toBe("entry-500");
|
||||
// Last entry should be the 1499th
|
||||
expect(entries[entries.length - 1].message).toBe("entry-1499");
|
||||
});
|
||||
|
||||
it("clears all entries", () => {
|
||||
buffer.push({ timestamp: new Date(), level: "info", message: "test" });
|
||||
buffer.clear();
|
||||
expect(buffer.getAll().length).toBe(0);
|
||||
expect(buffer.total).toBe(0);
|
||||
});
|
||||
|
||||
it("stores entries with different levels", () => {
|
||||
buffer.push({ timestamp: new Date(), level: "info", message: "info msg" });
|
||||
buffer.push({ timestamp: new Date(), level: "warn", message: "warn msg" });
|
||||
buffer.push({ timestamp: new Date(), level: "error", message: "error msg" });
|
||||
const entries = buffer.getAll();
|
||||
expect(entries[0].level).toBe("info");
|
||||
expect(entries[1].level).toBe("warn");
|
||||
expect(entries[2].level).toBe("error");
|
||||
});
|
||||
|
||||
it("stores entries with prefix", () => {
|
||||
buffer.push({ timestamp: new Date(), level: "info", message: "msg", prefix: "engine" });
|
||||
const entries = buffer.getAll();
|
||||
expect(entries[0].prefix).toBe("engine");
|
||||
});
|
||||
});
|
||||
|
||||
// ── DashboardLogSink Tests ─────────────────────────────────────────────────
|
||||
|
||||
describe("DashboardLogSink", () => {
|
||||
it("logs to console in non-TTY mode", () => {
|
||||
const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const sink = new DashboardLogSink();
|
||||
|
||||
sink.log("test message");
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith("test message");
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("includes prefix in non-TTY mode", () => {
|
||||
const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const sink = new DashboardLogSink();
|
||||
|
||||
sink.log("test message", "dashboard");
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith("[dashboard] test message");
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("warns to console.warn in non-TTY mode", () => {
|
||||
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const sink = new DashboardLogSink();
|
||||
|
||||
sink.warn("warning message");
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith("warning message");
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("errors to console.error in non-TTY mode", () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const sink = new DashboardLogSink();
|
||||
|
||||
sink.error("error message");
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith("error message");
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("handles empty message", () => {
|
||||
const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const sink = new DashboardLogSink();
|
||||
|
||||
sink.log("");
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith("");
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ── isTTYAvailable Tests ─────────────────────────────────────────────────
|
||||
|
||||
describe("isTTYAvailable", () => {
|
||||
it("returns boolean based on TTY status", () => {
|
||||
const result = isTTYAvailable();
|
||||
expect(typeof result).toBe("boolean");
|
||||
});
|
||||
|
||||
it("checks both stdout and stdin are TTY", () => {
|
||||
// isTTYAvailable checks process.stdout.isTTY && process.stdin.isTTY
|
||||
// In test environment these may be undefined, resulting in falsy
|
||||
const result = isTTYAvailable();
|
||||
expect(typeof result).toBe("boolean");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Type exports verification ─────────────────────────────────────────────
|
||||
|
||||
describe("Type exports", () => {
|
||||
it("exports LogEntry type", () => {
|
||||
const entry = {
|
||||
timestamp: new Date(),
|
||||
level: "info" as const,
|
||||
message: "test",
|
||||
prefix: "test",
|
||||
};
|
||||
expect(entry.timestamp).toBeInstanceOf(Date);
|
||||
expect(entry.level).toBe("info");
|
||||
});
|
||||
|
||||
it("accepts valid SystemInfo", () => {
|
||||
const info: SystemInfo = {
|
||||
host: "localhost",
|
||||
port: 4040,
|
||||
baseUrl: "http://localhost:4040",
|
||||
authEnabled: true,
|
||||
authToken: "token123",
|
||||
tokenizedUrl: "http://localhost:4040/?token=token123",
|
||||
engineMode: "active",
|
||||
fileWatcher: true,
|
||||
startTimeMs: Date.now() - 60000,
|
||||
};
|
||||
expect(info.host).toBe("localhost");
|
||||
expect(info.engineMode).toBe("active");
|
||||
});
|
||||
|
||||
it("accepts valid TaskStats", () => {
|
||||
const stats: TaskStats = {
|
||||
total: 42,
|
||||
byColumn: { triage: 5, todo: 10, "in-progress": 8, "in-review": 2, done: 17 },
|
||||
active: 10,
|
||||
agents: { idle: 3, active: 2, running: 1, error: 0 },
|
||||
};
|
||||
expect(stats.total).toBe(42);
|
||||
expect(stats.agents.idle).toBe(3);
|
||||
});
|
||||
|
||||
it("accepts valid SettingsValues", () => {
|
||||
const settings: SettingsValues = {
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
autoMerge: true,
|
||||
mergeStrategy: "direct",
|
||||
pollIntervalMs: 60000,
|
||||
enginePaused: false,
|
||||
globalPause: false,
|
||||
};
|
||||
expect(settings.autoMerge).toBe(true);
|
||||
expect(settings.enginePaused).toBe(false);
|
||||
});
|
||||
});
|
||||
1014
packages/cli/src/commands/dashboard-tui.ts
Normal file
1014
packages/cli/src/commands/dashboard-tui.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,10 +10,11 @@ import {
|
||||
processPullRequestMergeTask,
|
||||
} from "./task-lifecycle.js";
|
||||
import { promptForPort } from "./port-prompt.js";
|
||||
import { createReadOnlyProviderSettingsView, createProjectSettingsPersistence } from "./provider-settings.js";
|
||||
import { createReadOnlyProviderSettingsView } from "./provider-settings.js";
|
||||
import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
|
||||
import { getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
|
||||
import { resolveProject } from "../project-context.js";
|
||||
import { DashboardTUI, DashboardLogSink, isTTYAvailable, type SystemInfo } from "./dashboard-tui.js";
|
||||
|
||||
// Re-export for backward compatibility with tests
|
||||
export { promptForPort };
|
||||
@@ -234,7 +235,96 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
}
|
||||
}
|
||||
const cwd = await resolveRuntimeProjectPath();
|
||||
const store = new TaskStore(cwd);
|
||||
|
||||
// ── TTY Detection & TUI Initialization ─────────────────────────────
|
||||
//
|
||||
// When both stdout and stdin are TTY, we activate the interactive TUI
|
||||
// instead of plain console output. The TUI provides 5 sections:
|
||||
// logs, system, utilities, stats, settings with keyboard navigation.
|
||||
//
|
||||
// In non-TTY mode (CI, piped output), we fall back to plain console
|
||||
// output to maintain compatibility with automated workflows.
|
||||
//
|
||||
const isTTY = isTTYAvailable();
|
||||
let tui: DashboardTUI | undefined;
|
||||
const dashboardStartedAt = Date.now();
|
||||
|
||||
// Declare store and agentStore early so callbacks can safely reference them
|
||||
// (they're assigned after initialization, but the variables exist from the start)
|
||||
let store: TaskStore | undefined;
|
||||
let agentStore: AgentStore | undefined;
|
||||
|
||||
// Create a log sink that routes to TUI in TTY mode, or console otherwise
|
||||
const logSink = new DashboardLogSink();
|
||||
|
||||
if (isTTY) {
|
||||
tui = new DashboardTUI();
|
||||
// Set up callbacks for utility actions
|
||||
tui.setCallbacks({
|
||||
onRefreshStats: async () => {
|
||||
if (store && agentStore) {
|
||||
const tasks = await store.listTasks({ slim: true, includeArchived: false });
|
||||
const counts = new Map<string, number>();
|
||||
for (const task of tasks) {
|
||||
counts.set(task.column, (counts.get(task.column) ?? 0) + 1);
|
||||
}
|
||||
const active = tasks.filter((task) =>
|
||||
task.column === "in-progress" || task.column === "in-review"
|
||||
).length;
|
||||
const agents = await agentStore.listAgents();
|
||||
const agentStats = { idle: 0, active: 0, running: 0, error: 0 };
|
||||
for (const agent of agents) {
|
||||
const state = agent.state as keyof typeof agentStats;
|
||||
if (state in agentStats) {
|
||||
agentStats[state]++;
|
||||
}
|
||||
}
|
||||
tui!.setTaskStats({
|
||||
total: tasks.length,
|
||||
byColumn: Object.fromEntries(counts),
|
||||
active,
|
||||
agents: agentStats,
|
||||
});
|
||||
}
|
||||
},
|
||||
onClearLogs: () => {
|
||||
// Logs are already cleared in TUI, this is for external notification
|
||||
},
|
||||
onTogglePause: async (paused: boolean) => {
|
||||
if (store) {
|
||||
await store.updateSettings({ enginePaused: paused });
|
||||
tui!.log(`Engine ${paused ? "paused" : "resumed"}`);
|
||||
const fullSettings = await store.getSettings();
|
||||
// Return SettingsValues subset for TUI
|
||||
return {
|
||||
maxConcurrent: fullSettings.maxConcurrent ?? 1,
|
||||
maxWorktrees: fullSettings.maxWorktrees ?? 2,
|
||||
autoMerge: fullSettings.autoMerge ?? false,
|
||||
mergeStrategy: fullSettings.mergeStrategy ?? "direct",
|
||||
pollIntervalMs: fullSettings.pollIntervalMs ?? 60_000,
|
||||
enginePaused: fullSettings.enginePaused ?? false,
|
||||
globalPause: fullSettings.globalPause ?? false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
maxConcurrent: 1,
|
||||
maxWorktrees: 2,
|
||||
autoMerge: false,
|
||||
mergeStrategy: "direct",
|
||||
pollIntervalMs: 60_000,
|
||||
enginePaused: paused,
|
||||
globalPause: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
// Start the TUI
|
||||
await tui.start();
|
||||
|
||||
// Wire the TUI into the log sink so all console output routes through TUI
|
||||
logSink.setTUI(tui);
|
||||
}
|
||||
|
||||
store = new TaskStore(cwd);
|
||||
await store.init();
|
||||
await store.watch();
|
||||
|
||||
@@ -251,6 +341,88 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
"agent:log": store.listenerCount("agent:log"),
|
||||
}));
|
||||
|
||||
// ── Reactive TUI Updates ─────────────────────────────────────────────
|
||||
//
|
||||
// Subscribe to store and agent events to keep the TUI Stats/Settings
|
||||
// panels in sync without manual refresh.
|
||||
//
|
||||
let tuiRefreshPending = false;
|
||||
let tuiRefreshDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/**
|
||||
* Debounced refresh of TUI stats - batches rapid task updates
|
||||
*/
|
||||
async function refreshTUIStats(): Promise<void> {
|
||||
if (!tui || !isTTY) return;
|
||||
if (!store || !agentStore) return;
|
||||
|
||||
// Mark pending to prevent duplicate refreshes
|
||||
if (tuiRefreshPending) return;
|
||||
tuiRefreshPending = true;
|
||||
|
||||
try {
|
||||
const tasks = await store.listTasks({ slim: true, includeArchived: false });
|
||||
const counts = new Map<string, number>();
|
||||
for (const task of tasks) {
|
||||
counts.set(task.column, (counts.get(task.column) ?? 0) + 1);
|
||||
}
|
||||
const active = tasks.filter((task) =>
|
||||
task.column === "in-progress" || task.column === "in-review"
|
||||
).length;
|
||||
const agents = await agentStore.listAgents();
|
||||
const agentStats = { idle: 0, active: 0, running: 0, error: 0 };
|
||||
for (const agent of agents) {
|
||||
const state = agent.state as keyof typeof agentStats;
|
||||
if (state in agentStats) {
|
||||
agentStats[state]++;
|
||||
}
|
||||
}
|
||||
tui.setTaskStats({
|
||||
total: tasks.length,
|
||||
byColumn: Object.fromEntries(counts),
|
||||
active,
|
||||
agents: agentStats,
|
||||
});
|
||||
} finally {
|
||||
tuiRefreshPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced settings refresh
|
||||
*/
|
||||
async function refreshTUISettings(): Promise<void> {
|
||||
if (!tui || !isTTY) return;
|
||||
if (!store) return;
|
||||
|
||||
try {
|
||||
const settings = await store.getSettings();
|
||||
tui.setSettings({
|
||||
maxConcurrent: settings.maxConcurrent ?? 1,
|
||||
maxWorktrees: settings.maxWorktrees ?? 2,
|
||||
autoMerge: settings.autoMerge ?? false,
|
||||
mergeStrategy: settings.mergeStrategy ?? "direct",
|
||||
pollIntervalMs: settings.pollIntervalMs ?? 60_000,
|
||||
enginePaused: settings.enginePaused ?? false,
|
||||
globalPause: settings.globalPause ?? false,
|
||||
});
|
||||
} catch {
|
||||
// Ignore errors refreshing settings
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a debounced stats refresh (batches rapid changes)
|
||||
*/
|
||||
function scheduleStatsRefresh(): void {
|
||||
if (tuiRefreshDebounceTimer) {
|
||||
clearTimeout(tuiRefreshDebounceTimer);
|
||||
}
|
||||
tuiRefreshDebounceTimer = setTimeout(() => {
|
||||
void refreshTUIStats();
|
||||
}, 500); // 500ms debounce
|
||||
}
|
||||
|
||||
const handlers: Array<{
|
||||
target: NodeJS.EventEmitter;
|
||||
event: string | symbol;
|
||||
@@ -259,12 +431,16 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
const disposeCallbacks: Array<() => void> = [];
|
||||
let disposed = false;
|
||||
let shutdownInProgress = false;
|
||||
const dashboardStartedAt = Date.now();
|
||||
|
||||
async function logShutdownDiagnostics(reason: string): Promise<void> {
|
||||
const uptimeSeconds = Math.round((Date.now() - dashboardStartedAt) / 1000);
|
||||
let taskSummary = "tasks=unknown";
|
||||
try {
|
||||
if (!store) {
|
||||
taskSummary = "tasks=unavailable (store not initialized)";
|
||||
console.log(`[dashboard] shutdown requested reason=${reason} pid=${process.pid} ppid=${process.ppid} uptime=${uptimeSeconds}s ${taskSummary}`);
|
||||
return;
|
||||
}
|
||||
const tasks = await store.listTasks({ slim: true, includeArchived: false });
|
||||
const counts = new Map<string, number>();
|
||||
for (const task of tasks) {
|
||||
@@ -305,9 +481,32 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
// and are properly managed throughout their lifecycle (creation, state
|
||||
// transitions, termination). Passed to TaskExecutor for agent spawning.
|
||||
//
|
||||
const agentStore = new AgentStore({ rootDir: store.getFusionDir() });
|
||||
agentStore = new AgentStore({ rootDir: store.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
// ── Reactive TUI Updates ─────────────────────────────────────────────
|
||||
//
|
||||
// Subscribe to store and agent events to keep the TUI Stats/Settings
|
||||
// panels in sync without manual refresh.
|
||||
//
|
||||
if (tui && isTTY) {
|
||||
// Subscribe to task events for reactive stats updates
|
||||
registerHandler(store, "task:created", scheduleStatsRefresh);
|
||||
registerHandler(store, "task:moved", scheduleStatsRefresh);
|
||||
registerHandler(store, "task:updated", scheduleStatsRefresh);
|
||||
registerHandler(store, "task:deleted", scheduleStatsRefresh);
|
||||
|
||||
// Subscribe to settings updates
|
||||
registerHandler(store, "settings:updated", () => {
|
||||
void refreshTUISettings();
|
||||
});
|
||||
|
||||
// Subscribe to agent events via agentStore
|
||||
registerHandler(agentStore, "agent:created", scheduleStatsRefresh);
|
||||
registerHandler(agentStore, "agent:updated", scheduleStatsRefresh);
|
||||
registerHandler(agentStore, "agent:deleted", scheduleStatsRefresh);
|
||||
}
|
||||
|
||||
// ── PluginStore: plugin installation management ─────────────────────
|
||||
//
|
||||
// SQLite-backed plugin persistence for the Settings → Plugins experience.
|
||||
@@ -347,7 +546,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
// Set enginePaused if starting in paused mode
|
||||
if (opts.paused) {
|
||||
await store.updateSettings({ enginePaused: true });
|
||||
console.log("[engine] Starting in paused mode — automation disabled");
|
||||
logSink.log("Starting in paused mode — automation disabled", "engine");
|
||||
}
|
||||
|
||||
// ── onMerge: AI-powered merge ─────────────────────────────────────
|
||||
@@ -427,7 +626,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
);
|
||||
|
||||
for (const { path, error } of extensionsResult.errors) {
|
||||
console.log(`[extensions] Failed to load ${path}: ${error}`);
|
||||
logSink.log(`Failed to load ${path}: ${error}`, "extensions");
|
||||
}
|
||||
|
||||
for (const { name, config, extensionPath } of extensionsResult.runtime.pendingProviderRegistrations) {
|
||||
@@ -435,7 +634,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
modelRegistry.registerProvider(name, config);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.log(`[extensions] Failed to register provider from ${extensionPath}: ${message}`);
|
||||
logSink.log(`Failed to register provider from ${extensionPath}: ${message}`, "extensions");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,15 +677,15 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
api: "openai-completions",
|
||||
models: orModels,
|
||||
});
|
||||
console.log(`[openrouter] Synced ${orModels.length} models from OpenRouter API`);
|
||||
logSink.log(`Synced ${orModels.length} models from OpenRouter API`, "openrouter");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.log(`[openrouter] Failed to sync models: ${message}`);
|
||||
logSink.log(`Failed to sync models: ${message}`, "openrouter");
|
||||
}
|
||||
})();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.log(`[extensions] Failed to discover extensions: ${message}`);
|
||||
logSink.log(`Failed to discover extensions: ${message}`, "extensions");
|
||||
createExtensionRuntime();
|
||||
modelRegistry.refresh();
|
||||
}
|
||||
@@ -509,6 +708,17 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
|
||||
// Clear pending debounce timer
|
||||
if (tuiRefreshDebounceTimer) {
|
||||
clearTimeout(tuiRefreshDebounceTimer);
|
||||
tuiRefreshDebounceTimer = null;
|
||||
}
|
||||
|
||||
// Stop TUI if active
|
||||
if (tui) {
|
||||
void tui.stop();
|
||||
}
|
||||
|
||||
for (const { target, event, handler } of handlers) {
|
||||
target.off(event, handler);
|
||||
}
|
||||
@@ -727,10 +937,10 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
taskStore: store,
|
||||
rootDir: cwd,
|
||||
onMissed: (agentId) => {
|
||||
console.log(`[engine] Agent ${agentId} missed heartbeat`);
|
||||
logSink.log(`Agent ${agentId} missed heartbeat`, "engine");
|
||||
},
|
||||
onTerminated: (agentId) => {
|
||||
console.log(`[engine] Agent ${agentId} terminated (unresponsive)`);
|
||||
logSink.log(`Agent ${agentId} terminated (unresponsive)`, "engine");
|
||||
},
|
||||
});
|
||||
heartbeatMonitorImpl.start();
|
||||
@@ -762,9 +972,10 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
|
||||
const agents = await agentStore.listAgents();
|
||||
for (const agent of agents) {
|
||||
// State drives whether a timer is armed. Arm timers only for
|
||||
// non-ephemeral agents currently in active/running — transitions
|
||||
// after startup are handled by the scheduler's agent:updated listener.
|
||||
// State is the source of truth: arm timers only for non-ephemeral
|
||||
// agents that are currently active/running. Transitions into
|
||||
// tickable states while the scheduler is already running are
|
||||
// handled by the scheduler's own agent:updated listener.
|
||||
if (isEphemeralAgent(agent)) continue;
|
||||
if (agent.state !== "active" && agent.state !== "running") continue;
|
||||
const rc = agent.runtimeConfig;
|
||||
@@ -774,10 +985,11 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
});
|
||||
}
|
||||
if (agents.length > 0) {
|
||||
console.log(`[engine] Registered ${triggerScheduler.getRegisteredAgents().length} agents for heartbeat triggers`);
|
||||
logSink.log(`Registered ${triggerScheduler.getRegisteredAgents().length} agents for heartbeat triggers`, "engine");
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(`[engine] HeartbeatMonitor initialization failed (continuing without agent monitoring):`, err);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logSink.log(`HeartbeatMonitor initialization failed (continuing without agent monitoring): ${message}`, "engine");
|
||||
}
|
||||
|
||||
// Dev mode: no engine, pass individual proxy objects to createServer
|
||||
@@ -956,32 +1168,106 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
? `${baseUrl}/?token=${encodeURIComponent(dashboardAuthToken)}`
|
||||
: baseUrl;
|
||||
|
||||
console.log();
|
||||
console.log(` fn board`);
|
||||
console.log(` ────────────────────────`);
|
||||
console.log(` → ${baseUrl}`);
|
||||
if (dashboardAuthToken) {
|
||||
console.log(` Auth: bearer token required`);
|
||||
console.log(` Token: ${dashboardAuthToken}`);
|
||||
console.log(` Open: ${tokenizedUrl}`);
|
||||
console.log(` (the browser stores the token so you only need to click once)`);
|
||||
// ── TTY Mode: Set system info on TUI ───────────────────────────────
|
||||
//
|
||||
// In TTY mode, we populate the TUI System panel instead of printing
|
||||
// the plain-text banner. The TUI provides navigation and real-time
|
||||
// log streaming.
|
||||
//
|
||||
if (isTTY && tui) {
|
||||
// Determine engine mode
|
||||
const settings = await store.getSettings();
|
||||
const engineMode = opts.dev ? "dev" : settings.enginePaused ? "paused" : "active";
|
||||
|
||||
const systemInfo: SystemInfo = {
|
||||
host: displayHost,
|
||||
port: actualPort,
|
||||
baseUrl,
|
||||
authEnabled: Boolean(dashboardAuthToken),
|
||||
authToken: dashboardAuthToken,
|
||||
tokenizedUrl: dashboardAuthToken ? tokenizedUrl : undefined,
|
||||
engineMode,
|
||||
fileWatcher: true,
|
||||
startTimeMs: dashboardStartedAt,
|
||||
};
|
||||
tui.setSystemInfo(systemInfo);
|
||||
tui.setSettings({
|
||||
maxConcurrent: settings.maxConcurrent ?? 1,
|
||||
maxWorktrees: settings.maxWorktrees ?? 2,
|
||||
autoMerge: settings.autoMerge ?? false,
|
||||
mergeStrategy: settings.mergeStrategy ?? "direct",
|
||||
pollIntervalMs: settings.pollIntervalMs ?? 60_000,
|
||||
enginePaused: settings.enginePaused ?? false,
|
||||
globalPause: settings.globalPause ?? false,
|
||||
});
|
||||
|
||||
// Populate initial stats
|
||||
const tasks = await store.listTasks({ slim: true, includeArchived: false });
|
||||
const counts = new Map<string, number>();
|
||||
for (const task of tasks) {
|
||||
counts.set(task.column, (counts.get(task.column) ?? 0) + 1);
|
||||
}
|
||||
const active = tasks.filter((task) =>
|
||||
task.column === "in-progress" || task.column === "in-review"
|
||||
).length;
|
||||
const agents = await agentStore.listAgents();
|
||||
const agentStats = { idle: 0, active: 0, running: 0, error: 0 };
|
||||
for (const agent of agents) {
|
||||
const state = agent.state as keyof typeof agentStats;
|
||||
if (state in agentStats) {
|
||||
agentStats[state]++;
|
||||
}
|
||||
}
|
||||
tui.setTaskStats({
|
||||
total: tasks.length,
|
||||
byColumn: Object.fromEntries(counts),
|
||||
active,
|
||||
agents: agentStats,
|
||||
});
|
||||
|
||||
// Log startup messages to TUI
|
||||
tui.log(`Dashboard started at ${baseUrl}`);
|
||||
if (engineMode === "active") {
|
||||
tui.log("AI engine active");
|
||||
} else if (engineMode === "dev") {
|
||||
tui.log("AI engine disabled (dev mode)");
|
||||
} else {
|
||||
tui.log("AI engine paused");
|
||||
}
|
||||
tui.log("File watcher active");
|
||||
} else {
|
||||
console.log(` Auth: disabled (--no-auth)`);
|
||||
// ── Non-TTY Mode: Print plain-text banner ───────────────────────────
|
||||
//
|
||||
// Preserve the original banner format for CI/automated workflows
|
||||
// and backward compatibility.
|
||||
//
|
||||
console.log();
|
||||
console.log(` fn board`);
|
||||
console.log(` ────────────────────────`);
|
||||
console.log(` → ${baseUrl}`);
|
||||
if (dashboardAuthToken) {
|
||||
console.log(` Auth: bearer token required`);
|
||||
console.log(` Token: ${dashboardAuthToken}`);
|
||||
console.log(` Open: ${tokenizedUrl}`);
|
||||
console.log(` (the browser stores the token so you only need to click once)`);
|
||||
} else {
|
||||
console.log(` Auth: disabled (--no-auth)`);
|
||||
}
|
||||
console.log();
|
||||
console.log(` Tasks stored in .fusion/tasks/`);
|
||||
console.log(` Merge: AI-assisted (conflict resolution + commit messages)`);
|
||||
if (opts.dev) {
|
||||
console.log(` AI engine: ✗ disabled (dev mode)`);
|
||||
} else {
|
||||
console.log(` AI engine: ✓ active`);
|
||||
console.log(` • triage: auto-specifying tasks`);
|
||||
console.log(` • scheduler: dependency-aware execution`);
|
||||
console.log(` • cron: scheduled task execution`);
|
||||
}
|
||||
console.log(` File watcher: ✓ active`);
|
||||
console.log(` Press Ctrl+C to stop`);
|
||||
console.log();
|
||||
}
|
||||
console.log();
|
||||
console.log(` Tasks stored in .fusion/tasks/`);
|
||||
console.log(` Merge: AI-assisted (conflict resolution + commit messages)`);
|
||||
if (opts.dev) {
|
||||
console.log(` AI engine: ✗ disabled (dev mode)`);
|
||||
} else {
|
||||
console.log(` AI engine: ✓ active`);
|
||||
console.log(` • triage: auto-specifying tasks`);
|
||||
console.log(` • scheduler: dependency-aware execution`);
|
||||
console.log(` • cron: scheduled task execution`);
|
||||
}
|
||||
console.log(` File watcher: ✓ active`);
|
||||
console.log(` Press Ctrl+C to stop`);
|
||||
console.log();
|
||||
});
|
||||
|
||||
return { dispose };
|
||||
|
||||
@@ -889,7 +889,7 @@ export function setupTerminalWebSocket(
|
||||
// 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 && !authenticateUpgradeRequest(wsDaemonToken, req)) {
|
||||
if (wsDaemonToken && !options?.noAuth && !authenticateUpgradeRequest(wsDaemonToken, req)) {
|
||||
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
|
||||
socket.destroy();
|
||||
return;
|
||||
@@ -1163,7 +1163,7 @@ export function setupBadgeWebSocket(
|
||||
return;
|
||||
}
|
||||
|
||||
if (badgeWsDaemonToken && !authenticateUpgradeRequest(badgeWsDaemonToken, req)) {
|
||||
if (badgeWsDaemonToken && !options?.noAuth && !authenticateUpgradeRequest(badgeWsDaemonToken, req)) {
|
||||
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
|
||||
socket.destroy();
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user