Files
pi-config/extensions/pi-crew/src/ui/run-dashboard.ts

461 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as fs from "node:fs";
import type { TeamRunManifest, TeamTaskState, UsageState } from "../state/types.ts";
import { readCrewAgents } from "../runtime/crew-agent-records.ts";
import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
import { isDisplayActiveRun, isLikelyOrphanedActiveRun } from "../runtime/process-status.ts";
import { readJsonFileCoalesced } from "../utils/file-coalescer.ts";
import type { CrewTheme } from "./theme-adapter.ts";
import { asCrewTheme, subscribeThemeChange } from "./theme-adapter.ts";
import { applyStatusColor, iconForStatus, type RunStatus } from "./status-colors.ts";
import { pad, truncate } from "../utils/visual.ts";
import { Box, Text } from "./layout-primitives.ts";
import { DynamicCrewBorder } from "./dynamic-border.ts";
import { CrewFooter } from "./crew-footer.ts";
import { aggregateUsage } from "../state/usage.ts";
import { logInternalError } from "../utils/internal-error.ts";
import { renderAgentsPane } from "./dashboard-panes/agents-pane.ts";
import { renderMailboxPane } from "./dashboard-panes/mailbox-pane.ts";
import { renderProgressPane } from "./dashboard-panes/progress-pane.ts";
import { renderTranscriptPane } from "./dashboard-panes/transcript-pane.ts";
import { renderHealthPane } from "./dashboard-panes/health-pane.ts";
import { renderMetricsPane } from "./dashboard-panes/metrics-pane.ts";
import { dashboardActionForKey } from "./keybinding-map.ts";
import type { RunSnapshotCache, RunUiSnapshot } from "./snapshot-types.ts";
import { spinnerBucket, spinnerFrame } from "./spinner.ts";
import type { MetricRegistry } from "../observability/metric-registry.ts";
import { resolveRealContainedPath } from "../utils/safe-paths.ts";
interface DashboardComponent {
invalidate(): void;
render(width: number): string[];
handleInput(data: string): void;
}
export interface RunDashboardOptions {
placement?: "center" | "right";
showModel?: boolean;
showTokens?: boolean;
showTools?: boolean;
snapshotCache?: RunSnapshotCache;
runProvider?: () => TeamRunManifest[];
registry?: MetricRegistry;
}
export type RunDashboardAction = "status" | "summary" | "artifacts" | "api" | "events" | "agents" | "agent-events" | "agent-output" | "agent-transcript" | "mailbox" | "reload" | "mailbox-detail" | "health-recovery" | "health-kill-stale" | "health-diagnostic-export" | "notifications-dismiss";
export interface RunDashboardSelection {
runId: string;
action: RunDashboardAction;
}
const TASK_READ_TTL_MS = 200;
function formatAge(iso: string | undefined): string | undefined {
if (!iso) return undefined;
const ms = Math.max(0, Date.now() - new Date(iso).getTime());
if (!Number.isFinite(ms)) return undefined;
if (ms < 1000) return "now";
if (ms < 60_000) return `${Math.floor(ms / 1000)}s`;
if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m`;
return `${Math.floor(ms / 3_600_000)}h`;
}
function renderLines(lines: string[], width: number): string[] {
const box = new Box(0, 0);
for (const line of lines) {
box.addChild(new Text(line));
}
return box.render(width);
}
function readProgressPreview(run: TeamRunManifest, maxLines = 5): string[] {
const progress = [...run.artifacts].reverse().find((artifact) => artifact.kind === "progress");
if (!progress) return ["Progress: (none)"];
try {
const progressPath = resolveRealContainedPath(run.artifactsRoot, progress.path);
if (!fs.existsSync(progressPath)) return ["Progress: (none)"];
return ["Progress:", ...fs.readFileSync(progressPath, "utf-8").split(/\r?\n/).filter(Boolean).slice(0, maxLines)];
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return [`Progress: failed to read (${message})`];
}
}
function formatTokens(usage: UsageState | undefined): string | undefined {
if (!usage) return undefined;
const total = (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
if (!total) return undefined;
const compact = total >= 1000 ? `${(total / 1000).toFixed(total >= 10_000 ? 0 : 1)}k` : `${total}`;
const parts = [`tok=${compact}`];
if (usage.input) parts.push(`in=${usage.input}`);
if (usage.output) parts.push(`out=${usage.output}`);
if (usage.cacheRead) parts.push(`cache=${usage.cacheRead}`);
if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
return parts.join("/");
}
function snapshotFor(run: TeamRunManifest, snapshotCache?: RunSnapshotCache): RunUiSnapshot | undefined {
try {
return snapshotCache?.refreshIfStale(run.runId);
} catch {
return snapshotCache?.get(run.runId);
}
}
function readRunTasks(run: TeamRunManifest, snapshotCache?: RunSnapshotCache): TeamTaskState[] {
const snapshot = snapshotFor(run, snapshotCache);
if (snapshot) return snapshot.tasks;
const parse = () => {
if (!fs.existsSync(run.tasksPath)) return [];
const parsed = JSON.parse(fs.readFileSync(run.tasksPath, "utf-8"));
return Array.isArray(parsed) ? (parsed as TeamTaskState[]) : [];
};
try {
return readJsonFileCoalesced(run.tasksPath, TASK_READ_TTL_MS, parse);
} catch {
return [];
}
}
function taskForAgent(tasks: TeamTaskState[], agent: CrewAgentRecord): TeamTaskState | undefined {
return tasks.find((task) => task.id === agent.taskId);
}
function modelForTask(task: TeamTaskState | undefined): string | undefined {
const attempts = task?.modelAttempts;
if (!attempts?.length) return undefined;
return attempts.find((attempt) => attempt.success)?.model ?? attempts.at(-1)?.model;
}
function modelForAgent(agent: CrewAgentRecord, task: TeamTaskState | undefined): string | undefined {
return modelForTask(task) ?? agent.model;
}
function usageForAgent(agent: CrewAgentRecord, task: TeamTaskState | undefined): UsageState | undefined {
return task?.usage ?? agent.usage;
}
function agentPreviewLine(agent: CrewAgentRecord, task: TeamTaskState | undefined, options: RunDashboardOptions): string {
const stats = [
agent.progress?.activityState,
options.showModel !== false && modelForAgent(agent, task) ? `model=${modelForAgent(agent, task)}` : undefined,
options.showTokens !== false
? formatTokens(usageForAgent(agent, task)) ?? (agent.progress?.tokens !== undefined ? `tok=${agent.progress.tokens}` : undefined)
: undefined,
options.showTools !== false && agent.progress?.currentTool ? `tool=${agent.progress.currentTool}` : undefined,
options.showTools !== false && agent.toolUses !== undefined ? `${agent.toolUses} tools` : undefined,
agent.progress?.turns !== undefined ? `${agent.progress.turns} turns` : undefined,
agent.progress?.failedTool ? `failedTool=${agent.progress.failedTool}` : undefined,
agent.startedAt ? `age=${formatAge(agent.completedAt ?? agent.startedAt)}` : undefined,
].filter((part): part is string => Boolean(part));
const recent = agent.progress?.recentOutput?.at(-1);
const icon = iconForStatus(agent.status, { runningGlyph: spinnerFrame(agent.taskId) });
return `Agent: ${icon} ${agent.taskId} ${agent.role}->${agent.agent}${stats.length ? ` · ${stats.join(" · ")}` : ""}${recent ? `${recent}` : ""}`;
}
function readAgentPreview(run: TeamRunManifest, maxLines = 5, options: RunDashboardOptions = {}): string[] {
try {
const snapshot = snapshotFor(run, options.snapshotCache);
const agents = snapshot?.agents ?? readCrewAgents(run);
const tasks = snapshot?.tasks ?? readRunTasks(run, options.snapshotCache);
if (!agents.length) return ["Agents: (none)"];
const totals = tasks.reduce((acc, task) => {
acc.input += task.usage?.input ?? 0;
acc.output += task.usage?.output ?? 0;
acc.cacheRead += task.usage?.cacheRead ?? 0;
acc.cacheWrite += task.usage?.cacheWrite ?? 0;
acc.cost += task.usage?.cost ?? 0;
return acc;
}, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 } as { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number });
const header = formatTokens(totals) ? `Agents: ${formatTokens(totals)}` : "Agents:";
return [
header,
...agents
.slice(0, maxLines)
.map((agent) => agentPreviewLine(agent, taskForAgent(tasks, agent), options)),
...(agents.length > maxLines ? [`Agents: +${agents.length - maxLines} more`] : []),
];
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return [`Agents: failed to read (${message})`];
}
}
function agentsFor(run: TeamRunManifest, snapshotCache?: RunSnapshotCache): CrewAgentRecord[] {
const snapshot = snapshotFor(run, snapshotCache);
if (snapshot) return snapshot.agents;
try {
return readCrewAgents(run);
} catch {
return [];
}
}
function runLabel(run: TeamRunManifest, selected: boolean, snapshotCache?: RunSnapshotCache): string {
const agents = agentsFor(run, snapshotCache);
const stale = isLikelyOrphanedActiveRun(run, agents);
const running = agents.find((agent) => agent.status === "running");
const queued = agents.find((agent) => agent.status === "queued");
const step = stale ? "orphaned queued run" : running ? `step ${running.taskId.replace(/[\x00-\x1f\x7f-\x9f]/g, "")}` : queued ? `queued ${queued.taskId.replace(/[\x00-\x1f\x7f-\x9f]/g, "")}` : `agents ${agents.length}`;
const status: RunStatus = stale ? "stale" : (run.status as RunStatus);
const marker = selected ? "" : " ";
return `${marker} ${iconForStatus(status, { runningGlyph: spinnerFrame(run.runId) })} ${run.runId.slice(-8)} ${status} | ${run.team}/${run.workflow ?? "none"} | ${step} | ${run.goal}`;
}
interface ResolvedRun {
manifest: TeamRunManifest;
snapshot: RunUiSnapshot | undefined;
agents: CrewAgentRecord[];
status: RunStatus;
}
function resolveRuns(runs: TeamRunManifest[], snapshotCache?: RunSnapshotCache): Map<string, ResolvedRun> {
const map = new Map<string, ResolvedRun>();
for (const run of runs) {
const snapshot = snapshotFor(run, snapshotCache);
const agents = snapshot?.agents ?? agentsFor(run, snapshotCache);
const displayRun = snapshot?.manifest ?? run;
const status: RunStatus = isLikelyOrphanedActiveRun(displayRun, agents) ? "stale" : (displayRun.status as RunStatus);
map.set(run.runId, { manifest: run, snapshot, agents, status });
}
return map;
}
function groupedRuns(runs: TeamRunManifest[], snapshotCache?: RunSnapshotCache): Array<{ label: string; run?: TeamRunManifest }> {
const resolved = resolveRuns(runs, snapshotCache);
const rows: Array<{ label: string; run?: TeamRunManifest }> = [];
const active = runs.filter((run) => isDisplayActiveRun(resolved.get(run.runId)?.snapshot?.manifest ?? run, resolved.get(run.runId)?.agents ?? []));
const rest = runs.filter((run) => !isDisplayActiveRun(resolved.get(run.runId)?.snapshot?.manifest ?? run, resolved.get(run.runId)?.agents ?? []));
if (active.length) rows.push({ label: "Active" }, ...active.map((run) => ({ label: run.runId, run })));
if (rest.length) rows.push({ label: "Recent" }, ...rest.map((run) => ({ label: run.runId, run })));
return rows;
}
function selectedRunFromGrouped(runs: TeamRunManifest[], selected: number, snapshotCache?: RunSnapshotCache): TeamRunManifest | undefined {
return groupedRuns(runs, snapshotCache).filter((row) => row.run)[selected]?.run;
}
function countByStatus(runs: TeamRunManifest[], snapshotCache?: RunSnapshotCache): string {
const resolved = resolveRuns(runs, snapshotCache);
const counts = new Map<RunStatus, number>();
for (const r of resolved.values()) counts.set(r.status, (counts.get(r.status) ?? 0) + 1);
return [...counts.entries()].map(([status, count]) => `${status}=${count}`).join(", ") || "none";
}
export class RunDashboard implements DashboardComponent {
private selected = 0;
private showFullProgress = false;
private activePane: "agents" | "progress" | "mailbox" | "output" | "health" | "metrics" = "agents";
private runs: TeamRunManifest[];
private readonly done: (selection: RunDashboardSelection | undefined) => void;
private readonly theme: CrewTheme;
private readonly options: RunDashboardOptions;
private cachedWidth = 0;
private cachedVersion = "";
private cachedLines: string[] = [];
private readonly unsubscribeTheme: () => void;
constructor(
runs: TeamRunManifest[],
done: (selection: RunDashboardSelection | undefined) => void,
theme: unknown = {},
options: RunDashboardOptions = {},
) {
this.runs = runs;
this.done = done;
this.theme = asCrewTheme(theme);
this.options = options;
this.unsubscribeTheme = subscribeThemeChange(theme, () => this.invalidate());
}
private refreshRuns(): void {
if (!this.options.runProvider) return;
const selectedRunId = this.selectedRunId();
const next = this.options.runProvider();
this.runs = Array.isArray(next) ? next : this.runs;
if (selectedRunId) {
const nextIndex = groupedRuns(this.runs, this.options.snapshotCache).filter((row) => row.run).findIndex((row) => row.run?.runId === selectedRunId);
if (nextIndex >= 0) this.selected = nextIndex;
else this.selected = 0;
}
}
private buildSignature(): string {
let hasRunning = false;
const statuses = this.runs.map((run) => {
const snapshot = snapshotFor(run, this.options.snapshotCache);
const displayRun = snapshot?.manifest ?? run;
const agents = snapshot?.agents ?? agentsFor(run, this.options.snapshotCache);
const stale = isLikelyOrphanedActiveRun(displayRun, agents);
const status: RunStatus = stale ? "stale" : (displayRun.status as RunStatus);
if (status === "running" || agents.some((agent) => agent.status === "running")) hasRunning = true;
return snapshot?.signature ?? `${displayRun.runId}:${displayRun.status}:${displayRun.updatedAt}:${status}`;
}).join("|");
const metricsSig = this.activePane === "metrics" ? `:metrics=${this.options.registry?.snapshot().length ?? 0}:${spinnerBucket()}` : "";
return `${this.selected}:${this.showFullProgress ? 1 : 0}:${this.activePane}:${statuses}${hasRunning ? `:spin=${spinnerBucket()}` : ""}${metricsSig}`;
}
invalidate(): void {
this.cachedVersion = "";
this.cachedLines = [];
}
dispose(): void {
this.unsubscribeTheme();
}
private selectedRunId(): string | undefined {
return selectedRunFromGrouped(this.runs, this.selected, this.options.snapshotCache)?.runId;
}
render(width: number): string[] {
try {
return this.renderUnsafe(width);
} catch (error) {
logInternalError("run-dashboard.render", error);
return renderLines(["Dashboard error — see logs for details."], width);
}
}
private renderUnsafe(width: number): string[] {
this.refreshRuns();
const signature = this.buildSignature();
if (signature !== this.cachedVersion || this.cachedWidth !== width) {
const innerWidth = Math.max(20, width - 4);
const borderWidth = Math.min(innerWidth, Math.max(0, width - 2));
const fg = (color: Parameters<CrewTheme["fg"]>[0], text: string) => this.theme.fg(color, text);
const borderFill = (count: number) => new DynamicCrewBorder(this.theme).render(count)[0];
const border = (left: string, right: string) => `${fg("border", left)}${borderFill(borderWidth)}${fg("border", right)}`;
const lines = [
border("╭", "╮"),
`${pad(truncate(`${fg("accent", "▐")} ${this.theme.bold(this.options.placement === "right" ? "pi-crew right sidebar (anchored top-right)" : "pi-crew dashboard")}`, innerWidth - 1), innerWidth - 1)}`,
`${pad(truncate(`Runs: ${this.runs.length}${countByStatus(this.runs, this.options.snapshotCache)}`, innerWidth - 1), innerWidth - 1)}`,
`${pad(truncate(`↑/↓ select • 1 agents 2 progress 3 mailbox 4 output 5 health 6 metrics • s/u/a/i actions • R/K/D health • H hush`, innerWidth - 1), innerWidth - 1)}`,
border("├", "┤"),
];
if (this.runs.length === 0) {
lines.push(`${pad(truncate("No runs found.", innerWidth - 1), innerWidth - 1)}`);
} else {
const rows = groupedRuns(this.runs, this.options.snapshotCache).slice(0, 16);
const selectableRuns = rows.filter((row) => row.run);
for (const row of rows) {
if (!row.run) {
lines.push(`${pad(truncate(fg("accent", row.label), innerWidth - 1), innerWidth - 1)}`);
continue;
}
const index = selectableRuns.findIndex((candidate) => candidate.run?.runId === row.run?.runId);
const rowSnapshot = snapshotFor(row.run, this.options.snapshotCache);
const rowRun = rowSnapshot?.manifest ?? row.run;
const rowAgents = rowSnapshot?.agents ?? agentsFor(row.run, this.options.snapshotCache);
const rowStatus = isLikelyOrphanedActiveRun(rowRun, rowAgents) ? "stale" : (rowRun.status as RunStatus);
const label = runLabel(rowRun, index === this.selected, this.options.snapshotCache);
lines.push(`${pad(applyStatusColor(this.theme, rowStatus, label), innerWidth - 1)}`);
}
const selectedRun = selectedRunFromGrouped(this.runs, this.selected, this.options.snapshotCache);
if (selectedRun) {
const selectedSnapshot = snapshotFor(selectedRun, this.options.snapshotCache);
const selectedDisplayRun = selectedSnapshot?.manifest ?? selectedRun;
const selectedAgents = selectedSnapshot?.agents ?? agentsFor(selectedRun, this.options.snapshotCache);
lines.push(border("├", "┤"));
const details = [
`Selected: ${selectedDisplayRun.runId}`,
`Status: ${isLikelyOrphanedActiveRun(selectedDisplayRun, selectedAgents) ? "stale" : selectedDisplayRun.status} | Team: ${selectedDisplayRun.team} | Workflow: ${selectedDisplayRun.workflow ?? "none"}`,
`Created: ${selectedDisplayRun.createdAt}`,
`Updated: ${selectedDisplayRun.updatedAt}`,
`Artifacts: ${selectedDisplayRun.artifacts.length} | Workspace: ${selectedDisplayRun.workspaceMode}`,
selectedDisplayRun.async ? `Async: pid=${selectedDisplayRun.async.pid ?? "unknown"} log=${selectedDisplayRun.async.logPath}` : "Async: no",
`Goal: ${selectedDisplayRun.goal}`,
];
const paneLines = selectedSnapshot
? this.activePane === "agents"
? renderAgentsPane(selectedSnapshot, this.options)
: this.activePane === "progress"
? renderProgressPane(selectedSnapshot)
: this.activePane === "mailbox"
? renderMailboxPane(selectedSnapshot)
: this.activePane === "health"
? renderHealthPane(selectedSnapshot, { isForeground: selectedDisplayRun.async ? false : true })
: this.activePane === "metrics"
? renderMetricsPane(selectedSnapshot, { registry: this.options.registry })
: renderTranscriptPane(selectedSnapshot)
: [
...readAgentPreview(selectedDisplayRun, this.showFullProgress ? 20 : 8, this.options),
...readProgressPreview(selectedDisplayRun, this.showFullProgress ? 20 : 5),
];
for (const detail of [
...details,
`Pane: ${this.activePane}`,
...paneLines,
...(this.showFullProgress ? readProgressPreview(selectedDisplayRun, 20) : []),
]) {
lines.push(`${pad(truncate(detail, innerWidth - 1), innerWidth - 1)}`);
}
const selectedTasks = selectedSnapshot?.tasks ?? readRunTasks(selectedDisplayRun, this.options.snapshotCache);
const footer = new CrewFooter({
pwd: selectedDisplayRun.cwd,
runId: selectedDisplayRun.runId,
status: isLikelyOrphanedActiveRun(selectedDisplayRun, selectedAgents) ? "stale" : selectedDisplayRun.status,
usage: aggregateUsage(selectedTasks),
badges: [`team ${selectedDisplayRun.team}`, `workflow ${selectedDisplayRun.workflow ?? "none"}`, `${selectedDisplayRun.artifacts.length} artifacts`, selectedDisplayRun.workspaceMode],
}, this.theme);
lines.push(border("├", "┤"));
for (const footerLine of footer.render(innerWidth - 1)) {
lines.push(`${pad(truncate(footerLine, innerWidth - 1), innerWidth - 1)}`);
}
}
}
lines.push(border("╰", "╯"));
this.cachedLines = renderLines(lines.map((line) => truncate(line, width)), width);
this.cachedVersion = signature;
this.cachedWidth = width;
}
return this.cachedLines;
}
handleInput(data: string): void {
const action = dashboardActionForKey(data, this.activePane);
const selectedRunId = this.selectedRunId();
if (action === "close") {
this.done(undefined);
return;
}
if (action === "select") {
this.done(selectedRunId ? { runId: selectedRunId, action: "status" } : undefined);
return;
}
if (action === "summary" || action === "artifacts" || action === "api" || action === "agents" || action === "mailbox" || action === "reload" || action === "mailbox-detail" || action === "health-recovery" || action === "health-kill-stale" || action === "health-diagnostic-export" || action === "notifications-dismiss") {
this.done(selectedRunId ? { runId: selectedRunId, action } : action === "reload" ? { runId: "", action } : undefined);
return;
}
if (action === "events") {
this.done(selectedRunId ? { runId: selectedRunId, action: "agent-events" } : undefined);
return;
}
if (action === "output") {
this.done(selectedRunId ? { runId: selectedRunId, action: "agent-output" } : undefined);
return;
}
if (action === "transcript") {
this.done(selectedRunId ? { runId: selectedRunId, action: "agent-transcript" } : undefined);
return;
}
if (action === "progressToggle") {
this.showFullProgress = !this.showFullProgress;
this.invalidate();
return;
}
if (action === "pane-agents") this.activePane = "agents";
else if (action === "pane-progress") this.activePane = "progress";
else if (action === "pane-mailbox") this.activePane = "mailbox";
else if (action === "pane-output") this.activePane = "output";
else if (action === "pane-health") this.activePane = "health";
else if (action === "pane-metrics") this.activePane = "metrics";
else if (action === "up") this.selected = Math.max(0, this.selected - 1);
else if (action === "down") {
const selectableCount = groupedRuns(this.runs, this.options.snapshotCache).filter((row) => row.run).length;
this.selected = Math.min(Math.max(0, selectableCount - 1), this.selected + 1);
}
if (action) this.invalidate();
}
}