125 lines
3.6 KiB
TypeScript
125 lines
3.6 KiB
TypeScript
import type {
|
|
ExtensionAPI,
|
|
ExtensionContext,
|
|
Theme,
|
|
} from "@earendil-works/pi-coding-agent";
|
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
import type { ContextTransitionRuntime } from "./context-transition.ts";
|
|
import type { WorkflowRun } from "./domain.ts";
|
|
import { deriveRunLabels } from "./run-label.ts";
|
|
|
|
const WIDGET_KEY = "workflow-active-run";
|
|
const CORE_STAGES = new Set([
|
|
"discover",
|
|
"design",
|
|
"prototype",
|
|
"implement",
|
|
"verify",
|
|
"review",
|
|
]);
|
|
|
|
export interface WorkflowProgress {
|
|
runId: string;
|
|
label: string;
|
|
stage: string;
|
|
completed: number;
|
|
total: number;
|
|
gate?: string;
|
|
}
|
|
|
|
function activityTime(run: WorkflowRun) {
|
|
return Math.max(
|
|
run.lease?.heartbeatAt ?? 0,
|
|
...run.gates.map((gate) => gate.openedAt),
|
|
...run.stages.flatMap((stage) => [stage.startedAt ?? 0, stage.finishedAt ?? 0]),
|
|
);
|
|
}
|
|
|
|
function readableGate(kind: string) {
|
|
return kind
|
|
.split("-")
|
|
.map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`)
|
|
.join(" ");
|
|
}
|
|
|
|
export function newestAttachedActiveRun(
|
|
runs: WorkflowRun[],
|
|
sessionId: string | undefined,
|
|
) {
|
|
if (!sessionId) return undefined;
|
|
return runs
|
|
.filter(
|
|
(run) =>
|
|
(run.status === "running" || run.status === "waiting") &&
|
|
run.parentSessionIds.includes(sessionId),
|
|
)
|
|
.sort((left, right) => activityTime(right) - activityTime(left))[0];
|
|
}
|
|
|
|
export function projectWorkflowProgress(
|
|
runs: WorkflowRun[],
|
|
sessionId: string | undefined,
|
|
): WorkflowProgress | undefined {
|
|
const run = newestAttachedActiveRun(runs, sessionId);
|
|
if (!run) return undefined;
|
|
const active = [...run.stages]
|
|
.reverse()
|
|
.find((stage) => stage.status === "running" || stage.status === "waiting");
|
|
const gate = [...run.gates].reverse().find((entry) => entry.status === "open");
|
|
const label = deriveRunLabels(runs)[runs.indexOf(run)] ?? run.runId.slice(0, 8);
|
|
const stages = run.stages.filter((stage) => CORE_STAGES.has(stage.kind));
|
|
return {
|
|
runId: run.runId,
|
|
label,
|
|
stage: String(active?.kind ?? run.status).toUpperCase(),
|
|
completed: stages.filter((stage) => stage.status === "completed").length,
|
|
total: stages.length,
|
|
...(gate ? { gate: readableGate(gate.kind) } : {}),
|
|
};
|
|
}
|
|
|
|
export function renderWorkflowProgress(
|
|
progress: WorkflowProgress,
|
|
width: number,
|
|
theme?: Pick<Theme, "fg">,
|
|
) {
|
|
const paint = (token: "accent" | "dim" | "muted" | "text", value: string) =>
|
|
theme ? theme.fg(token, value) : value;
|
|
const base = `${paint("accent", "workflow")} ${paint("text", progress.label)} ${paint("muted", progress.stage)} ${progress.completed}/${progress.total}`;
|
|
const full = progress.gate ? `${base} ${paint("dim", `Gate: ${progress.gate}`)}` : base;
|
|
return truncateToWidth(full, Math.max(1, width), "");
|
|
}
|
|
|
|
export async function refreshWorkflowProgress(
|
|
runtime: ContextTransitionRuntime,
|
|
ctx: ExtensionContext,
|
|
) {
|
|
if (ctx.mode !== "tui") return;
|
|
const progress = projectWorkflowProgress(
|
|
await runtime.store.listRuns(),
|
|
ctx.sessionManager.getSessionId(),
|
|
);
|
|
if (!progress) {
|
|
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
return;
|
|
}
|
|
ctx.ui.setWidget(WIDGET_KEY, (_tui, theme) => ({
|
|
render(width: number) {
|
|
return [renderWorkflowProgress(progress, width, theme)];
|
|
},
|
|
invalidate() {},
|
|
}));
|
|
}
|
|
|
|
export function registerWorkflowProgress(
|
|
pi: ExtensionAPI,
|
|
runtime: ContextTransitionRuntime,
|
|
) {
|
|
pi.on("session_start", async (_event, ctx) => {
|
|
await refreshWorkflowProgress(runtime, ctx);
|
|
});
|
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
if (ctx.mode === "tui") ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
});
|
|
}
|