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:
5
.changeset/add-dashboard-tui.md
Normal file
5
.changeset/add-dashboard-tui.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add interactive TUI to `fn dashboard` with five navigable sections: logs, system, utilities, stats, and settings. Keyboard shortcuts enable quick in-terminal navigation (1-5, arrows, q, Ctrl+C, ? for help). The TUI activates automatically in interactive terminal sessions; non-TTY mode (CI, piped output) retains the existing plain-text banner/log behavior.
|
||||||
@@ -63,6 +63,36 @@ fn dashboard --dev
|
|||||||
| `--interactive` | Interactive port selection. |
|
| `--interactive` | Interactive port selection. |
|
||||||
| `--dev` | Start dashboard only (no AI engine, no triage/scheduler). |
|
| `--dev` | Start dashboard only (no AI engine, no triage/scheduler). |
|
||||||
|
|
||||||
|
### Interactive Terminal UI (TTY Mode)
|
||||||
|
|
||||||
|
When running in an interactive terminal (TTY), `fn dashboard` starts an
|
||||||
|
interactive TUI with five sections:
|
||||||
|
|
||||||
|
| Section | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Logs** | Real-time log entries with timestamps and severity levels |
|
||||||
|
| **System** | Host, port, URL, auth mode, token, engine status, uptime |
|
||||||
|
| **Utilities** | Actions: refresh stats, clear logs, toggle engine pause |
|
||||||
|
| **Stats** | Task counts by column, active task count, agent state counts |
|
||||||
|
| **Settings** | Key settings from the task store |
|
||||||
|
|
||||||
|
**Keyboard Navigation:**
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|---|---|
|
||||||
|
| `1-5` | Switch to tab by number |
|
||||||
|
| `n` or `→` | Next tab |
|
||||||
|
| `p` or `←` | Previous tab |
|
||||||
|
| `r` | Refresh stats (in Utilities tab) |
|
||||||
|
| `c` | Clear logs (in Utilities tab) |
|
||||||
|
| `t` | Toggle engine pause (in Utilities tab) |
|
||||||
|
| `?` or `h` | Toggle help overlay |
|
||||||
|
| `q` | Quit |
|
||||||
|
| `Ctrl+C` | Force quit |
|
||||||
|
|
||||||
|
In non-TTY mode (CI, piped output, scripts), the dashboard falls back to
|
||||||
|
plain console output to maintain compatibility with automated workflows.
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
Unless `--no-auth` is passed, the dashboard API (including the terminal
|
Unless `--no-auth` is passed, the dashboard API (including the terminal
|
||||||
|
|||||||
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,
|
processPullRequestMergeTask,
|
||||||
} from "./task-lifecycle.js";
|
} from "./task-lifecycle.js";
|
||||||
import { promptForPort } from "./port-prompt.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 { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
|
||||||
import { getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
|
import { getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
|
||||||
import { resolveProject } from "../project-context.js";
|
import { resolveProject } from "../project-context.js";
|
||||||
|
import { DashboardTUI, DashboardLogSink, isTTYAvailable, type SystemInfo } from "./dashboard-tui.js";
|
||||||
|
|
||||||
// Re-export for backward compatibility with tests
|
// Re-export for backward compatibility with tests
|
||||||
export { promptForPort };
|
export { promptForPort };
|
||||||
@@ -234,7 +235,96 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const cwd = await resolveRuntimeProjectPath();
|
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.init();
|
||||||
await store.watch();
|
await store.watch();
|
||||||
|
|
||||||
@@ -251,6 +341,88 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
|||||||
"agent:log": store.listenerCount("agent:log"),
|
"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<{
|
const handlers: Array<{
|
||||||
target: NodeJS.EventEmitter;
|
target: NodeJS.EventEmitter;
|
||||||
event: string | symbol;
|
event: string | symbol;
|
||||||
@@ -259,12 +431,16 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
|||||||
const disposeCallbacks: Array<() => void> = [];
|
const disposeCallbacks: Array<() => void> = [];
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
let shutdownInProgress = false;
|
let shutdownInProgress = false;
|
||||||
const dashboardStartedAt = Date.now();
|
|
||||||
|
|
||||||
async function logShutdownDiagnostics(reason: string): Promise<void> {
|
async function logShutdownDiagnostics(reason: string): Promise<void> {
|
||||||
const uptimeSeconds = Math.round((Date.now() - dashboardStartedAt) / 1000);
|
const uptimeSeconds = Math.round((Date.now() - dashboardStartedAt) / 1000);
|
||||||
let taskSummary = "tasks=unknown";
|
let taskSummary = "tasks=unknown";
|
||||||
try {
|
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 tasks = await store.listTasks({ slim: true, includeArchived: false });
|
||||||
const counts = new Map<string, number>();
|
const counts = new Map<string, number>();
|
||||||
for (const task of tasks) {
|
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
|
// and are properly managed throughout their lifecycle (creation, state
|
||||||
// transitions, termination). Passed to TaskExecutor for agent spawning.
|
// transitions, termination). Passed to TaskExecutor for agent spawning.
|
||||||
//
|
//
|
||||||
const agentStore = new AgentStore({ rootDir: store.getFusionDir() });
|
agentStore = new AgentStore({ rootDir: store.getFusionDir() });
|
||||||
await agentStore.init();
|
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 ─────────────────────
|
// ── PluginStore: plugin installation management ─────────────────────
|
||||||
//
|
//
|
||||||
// SQLite-backed plugin persistence for the Settings → Plugins experience.
|
// 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
|
// Set enginePaused if starting in paused mode
|
||||||
if (opts.paused) {
|
if (opts.paused) {
|
||||||
await store.updateSettings({ enginePaused: true });
|
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 ─────────────────────────────────────
|
// ── 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) {
|
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) {
|
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);
|
modelRegistry.registerProvider(name, config);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(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",
|
api: "openai-completions",
|
||||||
models: orModels,
|
models: orModels,
|
||||||
});
|
});
|
||||||
console.log(`[openrouter] Synced ${orModels.length} models from OpenRouter API`);
|
logSink.log(`Synced ${orModels.length} models from OpenRouter API`, "openrouter");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(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) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(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();
|
createExtensionRuntime();
|
||||||
modelRegistry.refresh();
|
modelRegistry.refresh();
|
||||||
}
|
}
|
||||||
@@ -509,6 +708,17 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
|||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
disposed = true;
|
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) {
|
for (const { target, event, handler } of handlers) {
|
||||||
target.off(event, handler);
|
target.off(event, handler);
|
||||||
}
|
}
|
||||||
@@ -727,10 +937,10 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
|||||||
taskStore: store,
|
taskStore: store,
|
||||||
rootDir: cwd,
|
rootDir: cwd,
|
||||||
onMissed: (agentId) => {
|
onMissed: (agentId) => {
|
||||||
console.log(`[engine] Agent ${agentId} missed heartbeat`);
|
logSink.log(`Agent ${agentId} missed heartbeat`, "engine");
|
||||||
},
|
},
|
||||||
onTerminated: (agentId) => {
|
onTerminated: (agentId) => {
|
||||||
console.log(`[engine] Agent ${agentId} terminated (unresponsive)`);
|
logSink.log(`Agent ${agentId} terminated (unresponsive)`, "engine");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
heartbeatMonitorImpl.start();
|
heartbeatMonitorImpl.start();
|
||||||
@@ -762,9 +972,10 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
|||||||
|
|
||||||
const agents = await agentStore.listAgents();
|
const agents = await agentStore.listAgents();
|
||||||
for (const agent of agents) {
|
for (const agent of agents) {
|
||||||
// State drives whether a timer is armed. Arm timers only for
|
// State is the source of truth: arm timers only for non-ephemeral
|
||||||
// non-ephemeral agents currently in active/running — transitions
|
// agents that are currently active/running. Transitions into
|
||||||
// after startup are handled by the scheduler's agent:updated listener.
|
// tickable states while the scheduler is already running are
|
||||||
|
// handled by the scheduler's own agent:updated listener.
|
||||||
if (isEphemeralAgent(agent)) continue;
|
if (isEphemeralAgent(agent)) continue;
|
||||||
if (agent.state !== "active" && agent.state !== "running") continue;
|
if (agent.state !== "active" && agent.state !== "running") continue;
|
||||||
const rc = agent.runtimeConfig;
|
const rc = agent.runtimeConfig;
|
||||||
@@ -774,10 +985,11 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (agents.length > 0) {
|
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) {
|
} 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
|
// Dev mode: no engine, pass individual proxy objects to createServer
|
||||||
@@ -956,6 +1168,79 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
|||||||
? `${baseUrl}/?token=${encodeURIComponent(dashboardAuthToken)}`
|
? `${baseUrl}/?token=${encodeURIComponent(dashboardAuthToken)}`
|
||||||
: baseUrl;
|
: baseUrl;
|
||||||
|
|
||||||
|
// ── 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 {
|
||||||
|
// ── Non-TTY Mode: Print plain-text banner ───────────────────────────
|
||||||
|
//
|
||||||
|
// Preserve the original banner format for CI/automated workflows
|
||||||
|
// and backward compatibility.
|
||||||
|
//
|
||||||
console.log();
|
console.log();
|
||||||
console.log(` fn board`);
|
console.log(` fn board`);
|
||||||
console.log(` ────────────────────────`);
|
console.log(` ────────────────────────`);
|
||||||
@@ -982,6 +1267,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
|||||||
console.log(` File watcher: ✓ active`);
|
console.log(` File watcher: ✓ active`);
|
||||||
console.log(` Press Ctrl+C to stop`);
|
console.log(` Press Ctrl+C to stop`);
|
||||||
console.log();
|
console.log();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { dispose };
|
return { dispose };
|
||||||
|
|||||||
@@ -889,7 +889,7 @@ export function setupTerminalWebSocket(
|
|||||||
// carry a valid bearer token. The token can come from the Authorization
|
// carry a valid bearer token. The token can come from the Authorization
|
||||||
// header (rare for browser WebSocket clients) or the `fn_token` query
|
// header (rare for browser WebSocket clients) or the `fn_token` query
|
||||||
// param (what our own client uses).
|
// 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.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
return;
|
return;
|
||||||
@@ -1163,7 +1163,7 @@ export function setupBadgeWebSocket(
|
|||||||
return;
|
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.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user