add persisted pi workflow extension

This commit is contained in:
jb
2026-08-25 16:07:44 -04:00
parent bfb07282d6
commit c5c3316e49
46 changed files with 20642 additions and 94 deletions
+557
View File
@@ -0,0 +1,557 @@
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) : {};
}