242 lines
6.9 KiB
TypeScript
242 lines
6.9 KiB
TypeScript
/**
|
|
* Shared workflow run model + formatting helpers, used by the tool renderers
|
|
* (index.ts) and the /workflows dashboard (dashboard.ts).
|
|
*/
|
|
|
|
import * as os from "node:os";
|
|
import {
|
|
truncateHead,
|
|
type ExtensionContext,
|
|
} from "@earendil-works/pi-coding-agent";
|
|
import { formatContextUtilization } from "../shared/context-utilization.ts";
|
|
import { safeStringify } from "./serialization.ts";
|
|
|
|
export type Theme = ExtensionContext["ui"]["theme"];
|
|
|
|
export const RESULT_JSON_MAX_BYTES = 24 * 1024;
|
|
export const RESULT_JSON_MAX_LINES = 600;
|
|
|
|
export interface AgentUsage {
|
|
input: number;
|
|
output: number;
|
|
cacheRead: number;
|
|
cacheWrite: number;
|
|
cost: number;
|
|
/** Latest compaction-aware conversation occupancy, not cumulative billing. */
|
|
contextTokens?: number;
|
|
turns: number;
|
|
}
|
|
|
|
export function emptyUsage(): AgentUsage {
|
|
return {
|
|
input: 0,
|
|
output: 0,
|
|
cacheRead: 0,
|
|
cacheWrite: 0,
|
|
cost: 0,
|
|
turns: 0,
|
|
};
|
|
}
|
|
|
|
export type AgentState = "running" | "done" | "error";
|
|
export type WorkflowStatus = "running" | "completed" | "failed" | "aborted";
|
|
|
|
export type TranscriptRole =
|
|
"user" | "assistant" | "thinking" | "tool" | "toolResult";
|
|
|
|
export interface TranscriptEntry {
|
|
role: TranscriptRole;
|
|
text: string;
|
|
/** Tool name for tool calls/results. */
|
|
name?: string;
|
|
/** Stable tool-call identifier used to pair calls, results, and timings. */
|
|
toolCallId?: string;
|
|
isError?: boolean;
|
|
/** Original message timestamp, when provided by the model/session. */
|
|
timestamp?: number;
|
|
/** Tool execution lifecycle timestamps, measured by the child session. */
|
|
startedAt?: number;
|
|
finishedAt?: number;
|
|
durationMs?: number;
|
|
}
|
|
|
|
export interface AgentRecord {
|
|
index: number;
|
|
label: string;
|
|
phase?: string;
|
|
state: AgentState;
|
|
model?: string;
|
|
/** Context capacity of the active model used for this agent. */
|
|
contextWindow?: number;
|
|
startedAt: number;
|
|
finishedAt?: number;
|
|
error?: string;
|
|
preview: string;
|
|
usage: AgentUsage;
|
|
/** Normalized, serializable subagent conversation shown by /workflows. */
|
|
transcript: TranscriptEntry[];
|
|
}
|
|
|
|
export interface WorkflowDetails {
|
|
runId: string;
|
|
/** Pi session that launched this run. */
|
|
sessionId?: string;
|
|
name?: string;
|
|
description?: string;
|
|
background: boolean;
|
|
status: WorkflowStatus;
|
|
startedAt: number;
|
|
finishedAt?: number;
|
|
phases: { title: string; detail?: string }[];
|
|
currentPhase?: string;
|
|
agents: AgentRecord[];
|
|
result?: unknown;
|
|
resultArtifact?: string;
|
|
transcriptArtifact?: string;
|
|
error?: string;
|
|
}
|
|
|
|
/** Colored square state indicator (no emojis/glyphs). */
|
|
export const SQUARE = "■";
|
|
|
|
export function stateSquare(state: AgentState, theme: Theme): string {
|
|
if (state === "done") return theme.fg("success", SQUARE);
|
|
if (state === "error") return theme.fg("error", SQUARE);
|
|
return theme.fg("warning", SQUARE);
|
|
}
|
|
|
|
export function statusSquare(status: WorkflowStatus, theme: Theme): string {
|
|
if (status === "completed") return theme.fg("success", SQUARE);
|
|
if (status === "running") return theme.fg("warning", SQUARE);
|
|
return theme.fg("error", SQUARE);
|
|
}
|
|
|
|
export function statusWord(status: WorkflowStatus): string {
|
|
return status === "completed" ? "done" : status;
|
|
}
|
|
|
|
export function statusColor(
|
|
status: WorkflowStatus,
|
|
): "success" | "warning" | "error" {
|
|
if (status === "completed") return "success";
|
|
if (status === "running") return "warning";
|
|
return "error";
|
|
}
|
|
|
|
export function shortenHome(p: string): string {
|
|
const home = os.homedir();
|
|
return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
|
|
}
|
|
|
|
export function formatTokens(count: number): string {
|
|
if (count < 1000) return count.toString();
|
|
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
return `${(count / 1000000).toFixed(1)}M`;
|
|
}
|
|
|
|
export function formatUsage(usage: AgentUsage, model?: string): string {
|
|
const parts: string[] = [];
|
|
if (usage.turns)
|
|
parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
|
|
if (usage.input) parts.push(`${formatTokens(usage.input)} in`);
|
|
if (usage.output) parts.push(`${formatTokens(usage.output)} out`);
|
|
if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
|
|
if (model) parts.push(model);
|
|
return parts.join(" · ");
|
|
}
|
|
|
|
/** Current per-agent context-window utilization, e.g. "7%/372k". */
|
|
export function agentContext(agent: AgentRecord): string {
|
|
return formatContextUtilization({
|
|
tokens: agent.usage.contextTokens,
|
|
contextWindow: agent.contextWindow,
|
|
});
|
|
}
|
|
|
|
export function formatElapsed(startedAt: number, finishedAt?: number): string {
|
|
const totalSeconds = Math.max(
|
|
0,
|
|
Math.round(((finishedAt ?? Date.now()) - startedAt) / 1000),
|
|
);
|
|
const minutes = Math.floor(totalSeconds / 60);
|
|
const seconds = totalSeconds % 60;
|
|
return minutes > 0
|
|
? `${minutes}m${seconds.toString().padStart(2, "0")}s`
|
|
: `${seconds}s`;
|
|
}
|
|
|
|
export function aggregateUsage(agents: AgentRecord[]): AgentUsage {
|
|
const total = emptyUsage();
|
|
for (const agent of agents) {
|
|
total.input += agent.usage.input;
|
|
total.output += agent.usage.output;
|
|
total.cacheRead += agent.usage.cacheRead;
|
|
total.cacheWrite += agent.usage.cacheWrite;
|
|
total.cost += agent.usage.cost;
|
|
total.turns += agent.usage.turns;
|
|
}
|
|
return total;
|
|
}
|
|
|
|
export function countStates(details: WorkflowDetails) {
|
|
let done = 0;
|
|
let failed = 0;
|
|
let running = 0;
|
|
for (const agent of details.agents) {
|
|
if (agent.state === "done") done++;
|
|
else if (agent.state === "error") failed++;
|
|
else running++;
|
|
}
|
|
return { done, failed, running };
|
|
}
|
|
|
|
export interface PhaseGroup {
|
|
title: string;
|
|
agents: AgentRecord[];
|
|
}
|
|
|
|
/**
|
|
* Group agents by phase in declared phase order. With `includeEmpty`, phases
|
|
* that have no agents yet are still listed (used by the dashboard sidebar).
|
|
*/
|
|
export function phaseGroups(
|
|
details: WorkflowDetails,
|
|
includeEmpty = false,
|
|
): PhaseGroup[] {
|
|
const byPhase = new Map<string, AgentRecord[]>();
|
|
for (const agent of details.agents) {
|
|
const key = agent.phase ?? "(unphased)";
|
|
const list = byPhase.get(key) ?? [];
|
|
list.push(agent);
|
|
byPhase.set(key, list);
|
|
}
|
|
const groups: PhaseGroup[] = [];
|
|
for (const phase of details.phases) {
|
|
const agents = byPhase.get(phase.title);
|
|
if (agents || includeEmpty)
|
|
groups.push({ title: phase.title, agents: agents ?? [] });
|
|
byPhase.delete(phase.title);
|
|
}
|
|
for (const [title, agents] of byPhase) groups.push({ title, agents });
|
|
return groups;
|
|
}
|
|
|
|
export function resultJson(value: unknown): string {
|
|
const text =
|
|
typeof value === "string"
|
|
? value
|
|
: safeStringify(value, {
|
|
maxBytes: RESULT_JSON_MAX_BYTES * 2,
|
|
maxDepth: 16,
|
|
maxNodes: 10_000,
|
|
});
|
|
const truncation = truncateHead(text ?? "", {
|
|
maxLines: RESULT_JSON_MAX_LINES,
|
|
maxBytes: RESULT_JSON_MAX_BYTES,
|
|
});
|
|
return truncation.truncated
|
|
? `${truncation.content}\n…[result truncated; bounded result artifact in result.json]`
|
|
: truncation.content;
|
|
}
|