Files
harness-tools/extensions/pi-workflow/dashboard.ts
T

558 lines
18 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 { randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import type {
ExtensionAPI,
ExtensionCommandContext,
ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import {
Key,
matchesKey,
truncateToWidth,
type Component,
} from "@earendil-works/pi-tui";
import { isValidContextContinuation } from "./continuation.ts";
import type {
GateDecision,
PrototypeCandidate,
WorkflowRun,
} from "./domain.ts";
import {
executeHostDecision,
type ContextTransitionRuntime,
} from "./context-transition.ts";
import {
readVerifiedPlanArtifact,
readVerifiedReportArtifact,
readVerifiedTranscriptArtifact,
} from "./store.ts";
interface DashboardTheme {
fg(
color:
| "accent"
| "dim"
| "error"
| "muted"
| "success"
| "text"
| "warning",
text: string,
): string;
bold(text: string): string;
}
function statusColor(status: WorkflowRun["status"]) {
if (status === "completed") return "success" as const;
if (status === "failed" || status === "aborted") return "error" as const;
if (status === "waiting") return "warning" as const;
return "accent" as const;
}
function activeStage(run: WorkflowRun) {
return (
[...run.stages]
.reverse()
.find((stage) =>
stage.status === "running" || stage.status === "waiting"
)?.kind ?? run.status
);
}
function selectedGate(run: WorkflowRun) {
return [...run.gates].reverse().find((gate) => gate.status === "open");
}
function stageLine(run: WorkflowRun, theme: DashboardTheme) {
return run.stages
.filter((stage) =>
["discover", "design", "prototype", "implement", "verify", "review"].includes(
stage.kind,
),
)
.map((stage) => {
const marker =
stage.status === "completed"
? theme.fg("success", "✓")
: stage.status === "failed"
? theme.fg("error", "×")
: stage.status === "running" || stage.status === "waiting"
? theme.fg("accent", "●")
: theme.fg("dim", "·");
return `${marker} ${stage.kind}`;
})
.join(" ");
}
export class FocusedWorkflowDashboard implements Component {
private readonly runs: WorkflowRun[];
private readonly theme: DashboardTheme;
private readonly done: (result: string | null) => void;
private selected = 0;
private detail = false;
constructor(
runs: WorkflowRun[],
theme: DashboardTheme,
done: (result: string | null) => void,
initialRunId?: string,
) {
this.runs = runs;
this.theme = theme;
this.done = done;
const initial = initialRunId
? runs.findIndex((run) => run.runId === initialRunId)
: -1;
if (initial >= 0) {
this.selected = initial;
this.detail = true;
}
}
handleInput(data: string) {
if (matchesKey(data, Key.escape)) {
if (this.detail) this.detail = false;
else this.done(null);
return;
}
if (matchesKey(data, Key.up) && this.selected > 0) {
this.selected--;
return;
}
if (matchesKey(data, Key.down) && this.selected < this.runs.length - 1) {
this.selected++;
return;
}
if (matchesKey(data, Key.enter)) {
const run = this.runs[this.selected];
if (!run) return;
if (this.detail) this.done(run.runId);
else this.detail = true;
}
}
render(width: number) {
const lines = [this.theme.fg("accent", this.theme.bold("pi-workflow")), ""];
if (this.runs.length === 0) {
lines.push(this.theme.fg("muted", "No Workflow Runs are attached to this session."));
lines.push("", this.theme.fg("dim", "esc close"));
return lines.map((line) => truncateToWidth(line, width));
}
const windowSize = 10;
const windowStart = Math.max(
0,
Math.min(
this.selected - Math.floor(windowSize / 2),
Math.max(0, this.runs.length - windowSize),
),
);
const visibleRuns = this.runs.slice(windowStart, windowStart + windowSize);
for (const [visibleIndex, run] of visibleRuns.entries()) {
const index = windowStart + visibleIndex;
const prefix = index === this.selected ? "> " : " ";
const name = run.featureName ?? run.request.slice(0, 24);
const status = this.theme.fg(
statusColor(run.status),
run.status.toUpperCase().padEnd(9),
);
lines.push(
`${prefix}${name.padEnd(24)} ${status} ${String(activeStage(run)).padEnd(20)} r${run.revision}`,
);
}
if (this.runs.length > windowSize) {
lines.push(
this.theme.fg(
"dim",
`${windowStart + 1}-${windowStart + visibleRuns.length} of ${this.runs.length} runs`,
),
);
}
if (this.detail) {
const run = this.runs[this.selected]!;
const gate = selectedGate(run);
lines.push("", this.theme.fg("dim", "─".repeat(Math.max(1, width))));
lines.push(this.theme.bold(run.featureName ?? run.request));
if (run.workspace.branch) lines.push(this.theme.fg("muted", run.workspace.branch));
lines.push("", `${this.theme.bold("Progress")} ${stageLine(run, this.theme)}`);
if (gate) {
lines.push(
"",
`${this.theme.bold("Gate")} ${gate.kind} · revision ${gate.revision}`,
);
}
if (run.planArtifact) {
lines.push(`${this.theme.bold("Plan")} ${run.planArtifact.path}`);
}
if (run.reportArtifact) {
lines.push(`${this.theme.bold("Report")} ${run.reportArtifact.path}`);
}
if (width >= 80 && run.workspace.root) {
lines.push(`${this.theme.bold("Worktree")} ${run.workspace.root}`);
}
lines.push(
"",
this.theme.fg("dim", "enter actions esc runs"),
);
} else {
lines.push("", this.theme.fg("dim", "↑↓ run enter details esc close"));
}
return lines.map((line) => truncateToWidth(line, width));
}
invalidate() {}
}
function runSummary(run: WorkflowRun) {
const gate = selectedGate(run);
return [
`${run.runId} · ${run.status} · revision ${run.revision}`,
`Stage: ${activeStage(run)}`,
...(gate ? [`Gate: ${gate.kind} (${gate.id})`] : []),
...(run.workspace.branch ? [`Branch: ${run.workspace.branch}`] : []),
...(run.workspace.root ? [`Worktree: ${run.workspace.root}`] : []),
...(run.planArtifact ? [`Plan: ${run.planArtifact.path}`] : []),
...(run.reportArtifact ? [`Report: ${run.reportArtifact.path}`] : []),
].join("\n");
}
function idempotencyKey(run: WorkflowRun, decision: GateDecision) {
return `dashboard-${run.runId}-${run.revision}-${decision.kind}-${randomUUID()}`;
}
async function decide(
pi: Pick<ExtensionAPI, "getActiveTools">,
runtime: ContextTransitionRuntime,
ctx: ExtensionContext,
run: WorkflowRun,
decision: GateDecision,
) {
const gate = selectedGate(run);
if (!gate) throw new Error("Workflow Run has no open Gate");
const engine = await runtime.engineForContext(ctx);
return executeHostDecision(pi, engine, {
action: "decide",
runId: run.runId,
gateId: gate.id,
expectedRevision: run.revision,
decision,
idempotencyKey: idempotencyKey(run, decision),
});
}
async function questionDecision(ctx: ExtensionContext, run: WorkflowRun) {
const gate = selectedGate(run);
const payload = gate?.payload;
if (!payload || typeof payload !== "object") return undefined;
const questions = (payload as { questions?: unknown }).questions;
if (!Array.isArray(questions)) return undefined;
const answers: Record<string, string> = {};
for (const item of questions) {
if (!item || typeof item !== "object") continue;
const id = (item as { id?: unknown }).id;
const prompt = (item as { prompt?: unknown }).prompt;
if (typeof id !== "string" || typeof prompt !== "string") continue;
const answer = await ctx.ui.input(prompt, "Required answer");
if (!answer?.trim()) return undefined;
answers[id] = answer.trim();
}
return { kind: "answer-questions" as const, answers };
}
async function planDecision(ctx: ExtensionContext, run: WorkflowRun) {
if (!run.planArtifact || !run.uiImpact) return undefined;
const action = await ctx.ui.select("Plan Approval", [
"Inspect plan",
`Approve plan · UI impact ${run.uiImpact}`,
"Request revision",
]);
if (action === "Inspect plan") {
const plan = await readVerifiedPlanArtifact(run.planArtifact);
await ctx.ui.editor("Approved plan (read-only copy)", plan.slice(0, 32 * 1024));
return undefined;
}
if (action === "Request revision") {
const feedback = await ctx.ui.editor("Plan revision feedback", "");
return feedback?.trim()
? { kind: "revise-plan" as const, feedback: feedback.trim() }
: undefined;
}
if (action?.startsWith("Approve plan")) {
const approved = await ctx.ui.confirm(
"Approve plan",
`Approve plan revision ${run.planArtifact.revision} and explicitly accept UI impact ${run.uiImpact}?`,
);
return approved
? {
kind: "approve-plan" as const,
approved: true as const,
acceptedUiImpact: run.uiImpact,
}
: undefined;
}
return undefined;
}
async function prototypeDecision(ctx: ExtensionContext, run: WorkflowRun) {
const candidates = run.prototypeCandidates.slice(-3);
const items = candidates.map(
(candidate, index) =>
`${index + 1}. ${candidate.structuralRationale} · ${candidate.route}`,
);
const action = await ctx.ui.select("Prototype Acceptance", [
...items,
"Request refinement",
"Abort run",
]);
if (action === "Request refinement") {
const feedback = await ctx.ui.editor("Prototype refinement feedback", "");
return feedback?.trim()
? { kind: "refine-prototypes" as const, feedback: feedback.trim() }
: undefined;
}
if (action === "Abort run") return "abort" as const;
const index = items.indexOf(action ?? "");
const candidate: PrototypeCandidate | undefined = candidates[index];
if (!candidate) return undefined;
ctx.ui.notify(
[
candidate.launchInstructions,
`Route: ${candidate.route}`,
`Evidence: ${candidate.retainedArtifacts.join(", ")}`,
].join("\n"),
"info",
);
const browserReviewed = await ctx.ui.confirm(
"Accept prototype",
"Confirm you reviewed this exact candidate in the browser or application at mobile and desktop sizes.",
);
if (!browserReviewed) return undefined;
const feedback = await ctx.ui.input("Acceptance feedback", "Optional");
return {
kind: "accept-prototype" as const,
candidateId: candidate.id,
candidateRevision: candidate.revision,
browserReviewed: true as const,
...(feedback?.trim() ? { feedback: feedback.trim() } : {}),
};
}
async function implementationContextDecision(ctx: ExtensionContext) {
const strategy = await ctx.ui.select("Implementation Context", [
"Fresh plan-seeded session",
"Compact current session",
"Keep current context unchanged",
]);
if (strategy === "Fresh plan-seeded session") {
return { kind: "select-implementation-context" as const, strategy: "fresh-session" as const };
}
if (strategy === "Compact current session") {
return { kind: "select-implementation-context" as const, strategy: "smart-compact" as const };
}
if (strategy === "Keep current context unchanged") {
return { kind: "select-implementation-context" as const, strategy: "current-session" as const };
}
return undefined;
}
async function recoveryDecision(ctx: ExtensionContext) {
const resolution = await ctx.ui.select("Recovery", ["Retry", "Inspect", "Abort"]);
if (!resolution) return undefined;
return {
kind: "recover" as const,
resolution: resolution.toLowerCase() as "retry" | "inspect" | "abort",
};
}
async function gateDecision(ctx: ExtensionContext, run: WorkflowRun) {
const gate = selectedGate(run);
if (!gate) return undefined;
if (gate.kind === "questions") return questionDecision(ctx, run);
if (gate.kind === "plan-approval") return planDecision(ctx, run);
if (gate.kind === "prototype-acceptance") return prototypeDecision(ctx, run);
if (gate.kind === "implementation-context") {
return implementationContextDecision(ctx);
}
return recoveryDecision(ctx);
}
function validContinuation(run: WorkflowRun, sessionId?: string) {
return isValidContextContinuation(
{ ...run, openGate: selectedGate(run) },
sessionId,
);
}
function isActiveParent(run: WorkflowRun, sessionId?: string) {
return Boolean(
sessionId && run.lease?.activeParentSessionId === sessionId,
);
}
async function browsePrototypeArtifacts(
ctx: ExtensionContext,
run: WorkflowRun,
) {
const candidates = run.prototypeCandidates;
const labels = candidates.map(
(candidate, index) =>
`${index + 1}. ${candidate.structuralRationale} · ${candidate.route}`,
);
const selected = await ctx.ui.select("Prototype evidence", labels);
const candidate = candidates[labels.indexOf(selected ?? "")];
if (!candidate) return;
ctx.ui.notify(
`${candidate.launchInstructions}\nRoute: ${candidate.route}`,
"info",
);
const artifacts = [candidate.artifactPath, ...candidate.retainedArtifacts];
const artifact = await ctx.ui.select("Retained artifact", artifacts);
if (!artifact) return;
if (artifact.endsWith(".json")) {
const contents = await readFile(artifact, "utf8");
await ctx.ui.editor("Retained prototype evidence", contents.slice(0, 32 * 1024));
} else {
ctx.ui.notify(artifact, "info");
}
}
async function browseAssignmentTranscripts(
ctx: ExtensionContext,
run: WorkflowRun,
) {
const assignments = run.assignments.filter(
(assignment) => assignment.transcriptArtifact,
);
const labels = assignments.map(
(assignment, index) =>
`${index + 1}. ${assignment.role}/${assignment.profileName} · ${assignment.status}`,
);
const selected = await ctx.ui.select("Assignment transcripts", labels);
const assignment = assignments[labels.indexOf(selected ?? "")];
if (!assignment?.transcriptArtifact) return;
const transcript = await readVerifiedTranscriptArtifact(
assignment.transcriptArtifact,
);
await ctx.ui.editor(
`${assignment.role} transcript`,
transcript.slice(0, 32 * 1024),
);
}
async function actionSheet(
pi: ExtensionAPI,
runtime: ContextTransitionRuntime,
ctx: ExtensionCommandContext,
run: WorkflowRun,
) {
const actions = [
"Inspect run",
...(selectedGate(run) &&
isActiveParent(run, ctx.sessionManager.getSessionId())
? ["Record Gate Decision"]
: []),
...(validContinuation(run, ctx.sessionManager.getSessionId())
? ["Continue context transition"]
: []),
...(run.planArtifact ? ["Open plan"] : []),
...(run.reportArtifact ? ["Open report"] : []),
...(run.prototypeCandidates.length > 0 ? ["Browse prototype evidence"] : []),
...(run.assignments.some((assignment) => assignment.transcriptArtifact)
? ["Browse Assignment transcripts"]
: []),
...(run.workspace.root ? ["Show worktree path"] : []),
];
const action = await ctx.ui.select(run.featureName ?? run.runId, actions);
if (action === "Inspect run") ctx.ui.notify(runSummary(run), "info");
else if (action === "Open plan") {
const plan = await readVerifiedPlanArtifact(run.planArtifact!);
await ctx.ui.editor("Workflow plan", plan.slice(0, 32 * 1024));
} else if (action === "Open report") {
const report = await readVerifiedReportArtifact(run.reportArtifact!);
await ctx.ui.editor("Workflow report", report.slice(0, 32 * 1024));
} else if (action === "Browse prototype evidence") {
await browsePrototypeArtifacts(ctx, run);
} else if (action === "Browse Assignment transcripts") {
await browseAssignmentTranscripts(ctx, run);
} else if (action === "Show worktree path") {
ctx.ui.notify(run.workspace.root!, "info");
}
else if (action === "Continue context transition") {
return { continueRunId: run.runId };
} else if (action === "Record Gate Decision") {
const decision = await gateDecision(ctx, run);
if (decision === "abort") {
const engine = await runtime.engineForContext(ctx);
await engine.execute({
action: "abort",
runId: run.runId,
expectedRevision: run.revision,
idempotencyKey: `dashboard-abort-${run.runId}-${run.revision}-${randomUUID()}`,
});
} else if (decision) {
const result = await decide(pi, runtime, ctx, run, decision);
if (
decision.kind === "select-implementation-context" &&
decision.strategy === "smart-compact"
) {
ctx.ui.notify(
`Workflow ${run.runId} is ready for smart_compact. Ask the assistant to invoke it and report the Gate-bound outcome through pi_workflow.`,
"info",
);
} else {
ctx.ui.notify(`Workflow ${run.runId}: ${result.status}`, "info");
}
}
}
return {};
}
export interface DashboardResult {
continueRunId?: string;
}
export async function openWorkflowDashboard(
pi: ExtensionAPI,
runtime: ContextTransitionRuntime,
ctx: ExtensionCommandContext,
initialRunId?: string,
): Promise<DashboardResult> {
const sessionId = ctx.sessionManager.getSessionId();
const runs = (await runtime.store.listRuns())
.filter((run) => !sessionId || run.parentSessionIds.includes(sessionId))
.sort((left, right) => right.revision - left.revision);
if (!ctx.hasUI || ctx.mode !== "tui") {
const visibleRuns = runs.slice(0, 20);
const omitted = runs.length - visibleRuns.length;
ctx.ui.notify(
runs.length > 0
? `${visibleRuns.map(runSummary).join("\n\n")}${
omitted > 0 ? `\n\n[${omitted} older runs omitted]` : ""
}`
: "No Workflow Runs.",
"info",
);
return {};
}
const selected = await ctx.ui.custom<string | null>(
(tui, theme, _keybindings, done) => {
const dashboard = new FocusedWorkflowDashboard(
runs,
theme as DashboardTheme,
done,
initialRunId,
);
return {
render: (width) => dashboard.render(width),
invalidate: () => dashboard.invalidate(),
handleInput: (data) => {
dashboard.handleInput(data);
tui.requestRender();
},
};
},
);
if (!selected) return {};
const run = runs.find((candidate) => candidate.runId === selected);
return run ? actionSheet(pi, runtime, ctx, run) : {};
}