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

519 lines
17 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 { isValidContextContinuation } from "./continuation.ts";
import type {
GateDecision,
PrototypeCandidate,
WorkflowRun,
} from "./domain.ts";
import {
executeHostDecision,
type ContextTransitionRuntime,
} from "./context-transition.ts";
import { deriveRunLabels } from "./run-label.ts";
import {
readVerifiedPlanArtifact,
readVerifiedReportArtifact,
readVerifiedTranscriptArtifact,
} from "./store.ts";
import {
workflowConfirm,
workflowEditor,
workflowInput,
workflowPlanReader,
workflowSelect,
} from "./tui.ts";
const CORE_STAGES = [
"discover",
"design",
"prototype",
"implement",
"verify",
"review",
] 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 stageSummary(run: WorkflowRun) {
return run.stages
.filter((stage) => CORE_STAGES.includes(stage.kind as (typeof CORE_STAGES)[number]))
.map((stage) => {
const marker =
stage.status === "completed"
? "✓"
: stage.status === "failed"
? "×"
: stage.status === "running" || stage.status === "waiting"
? "●"
: "·";
return `${marker} ${stage.kind}`;
})
.join(" ");
}
function runSummary(run: WorkflowRun, label: string) {
const gate = selectedGate(run);
return [
`${label} · ${run.status} · revision ${run.revision}`,
`Stage: ${activeStage(run)}`,
`Progress: ${stageSummary(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 payload = selectedGate(run)?.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 workflowInput(ctx, "Required question", prompt);
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;
while (true) {
const action = await workflowSelect(ctx, "Plan Approval", [
{ value: "inspect" as const, label: "Review verified plan" },
{
value: "approve" as const,
label: `Approve plan · UI impact ${run.uiImpact}`,
},
{ value: "revise" as const, label: "Request revision" },
]);
if (!action) return undefined;
if (action === "inspect") {
const plan = await readVerifiedPlanArtifact(run.planArtifact);
await workflowPlanReader(ctx, run.featureName ?? run.runId, plan);
continue;
}
if (action === "revise") {
const feedback = await workflowEditor(
ctx,
"Plan revision feedback",
"Describe the required plan changes.",
);
return feedback?.trim()
? { kind: "revise-plan" as const, feedback: feedback.trim() }
: undefined;
}
const approved = await workflowConfirm(
ctx,
"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;
}
}
async function prototypeDecision(ctx: ExtensionContext, run: WorkflowRun) {
const candidates = run.prototypeCandidates.slice(-3);
while (true) {
const action = await workflowSelect<
| { kind: "candidate"; candidate: PrototypeCandidate }
| { kind: "refine" }
| { kind: "abort" }
>(ctx, "Prototype Acceptance", [
...candidates.map((candidate, index) => ({
value: { kind: "candidate" as const, candidate },
label: `${index + 1}. ${candidate.structuralRationale}`,
description: candidate.route,
})),
{ value: { kind: "refine" as const }, label: "Request refinement" },
{ value: { kind: "abort" as const }, label: "Abort run" },
]);
if (!action) return undefined;
if (action.kind === "refine") {
const feedback = await workflowEditor(
ctx,
"Prototype refinement feedback",
"Describe what the next candidates must change.",
);
return feedback?.trim()
? { kind: "refine-prototypes" as const, feedback: feedback.trim() }
: undefined;
}
if (action.kind === "abort") {
return (await workflowConfirm(
ctx,
"Abort Workflow Run",
"Abort this run and preserve its current evidence?",
))
? ("abort" as const)
: undefined;
}
const candidate = action.candidate;
while (true) {
const candidateAction = await workflowSelect(
ctx,
`Prototype ${candidate.revision}`,
[
{ value: "review" as const, label: "Review candidate evidence" },
{ value: "accept" as const, label: "Accept this candidate" },
{ value: "back" as const, label: "Back to candidates" },
],
);
if (!candidateAction || candidateAction === "back") break;
if (candidateAction === "review") {
await workflowPlanReader(
ctx,
`prototype ${candidate.revision}`,
[
candidate.launchInstructions,
`Route: ${candidate.route}`,
`Evidence: ${candidate.retainedArtifacts.join(", ")}`,
].join("\n\n"),
);
continue;
}
const browserReviewed = await workflowConfirm(
ctx,
"Accept prototype",
"Confirm you reviewed this exact candidate in the browser or application at mobile and desktop sizes.",
);
if (!browserReviewed) continue;
const feedback = await workflowInput(
ctx,
"Acceptance feedback",
"Optional feedback. Submit an empty value for none.",
);
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 workflowSelect(ctx, "Implementation Context", [
{
value: "fresh-session" as const,
label: "Fresh plan-seeded session",
description: "Replace this session and continue immediately.",
},
{
value: "smart-compact" as const,
label: "Compact current session",
},
{
value: "current-session" as const,
label: "Keep current context unchanged",
},
]);
return strategy
? { kind: "select-implementation-context" as const, strategy }
: undefined;
}
async function recoveryDecision(ctx: ExtensionContext) {
const resolution = await workflowSelect(ctx, "Recovery", [
{ value: "retry" as const, label: "Retry" },
{ value: "inspect" as const, label: "Inspect" },
{ value: "abort" as const, label: "Abort" },
]);
return resolution
? { kind: "recover" as const, resolution }
: undefined;
}
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) {
while (true) {
const candidate = await workflowSelect(
ctx,
"Prototype evidence",
run.prototypeCandidates.map((entry, index) => ({
value: entry,
label: `${index + 1}. ${entry.structuralRationale}`,
description: entry.route,
})),
);
if (!candidate) return;
while (true) {
const artifact = await workflowSelect(
ctx,
"Retained artifact",
[candidate.artifactPath, ...candidate.retainedArtifacts].map((entry) => ({
value: entry,
label: entry,
})),
);
if (!artifact) break;
if (artifact.endsWith(".json")) {
await workflowPlanReader(
ctx,
"prototype evidence",
await readFile(artifact, "utf8"),
);
} else {
await workflowPlanReader(
ctx,
"prototype evidence",
`${candidate.launchInstructions}\n\nRoute: ${candidate.route}\n\nArtifact: ${artifact}`,
);
}
}
}
}
async function browseAssignmentTranscripts(ctx: ExtensionContext, run: WorkflowRun) {
const assignments = run.assignments.filter(
(assignment) => assignment.transcriptArtifact,
);
while (true) {
const assignment = await workflowSelect(
ctx,
"Assignment transcripts",
assignments.map((entry, index) => ({
value: entry,
label: `${index + 1}. ${entry.role}/${entry.profileName}`,
description: entry.status,
})),
);
if (!assignment?.transcriptArtifact) return;
const transcript = await readVerifiedTranscriptArtifact(
assignment.transcriptArtifact,
);
await workflowPlanReader(ctx, `${assignment.role} transcript`, transcript);
}
}
type DashboardActionResult =
| { kind: "back" }
| { kind: "continue"; runId: string }
| { kind: "closed" };
async function actionSheet(
pi: ExtensionAPI,
runtime: ContextTransitionRuntime,
ctx: ExtensionCommandContext,
initialRun: WorkflowRun,
label: string,
): Promise<DashboardActionResult> {
let run = initialRun;
while (true) {
const actions = [
{ value: "inspect" as const, label: "Inspect run", description: runSummary(run, label) },
...(selectedGate(run) && isActiveParent(run, ctx.sessionManager.getSessionId())
? [{ value: "decide" as const, label: "Record Gate Decision", description: selectedGate(run)?.kind }]
: []),
...(validContinuation(run, ctx.sessionManager.getSessionId())
? [{ value: "continue" as const, label: "Continue context transition", description: "Enter the selected implementation context now." }]
: []),
...(run.planArtifact ? [{ value: "plan" as const, label: "Review verified plan" }] : []),
...(run.reportArtifact ? [{ value: "report" as const, label: "Open report" }] : []),
...(run.prototypeCandidates.length > 0
? [{ value: "prototypes" as const, label: "Browse prototype evidence" }]
: []),
...(run.assignments.some((assignment) => assignment.transcriptArtifact)
? [{ value: "transcripts" as const, label: "Browse Assignment transcripts" }]
: []),
...(run.workspace.root ? [{ value: "worktree" as const, label: "Show worktree path" }] : []),
];
const action = await workflowSelect(ctx, label, actions);
if (!action) return { kind: "back" };
if (action === "continue") return { kind: "continue", runId: run.runId };
if (action === "inspect") {
await workflowPlanReader(ctx, label, runSummary(run, label));
} else if (action === "plan") {
await workflowPlanReader(
ctx,
label,
await readVerifiedPlanArtifact(run.planArtifact!),
);
} else if (action === "report") {
await workflowPlanReader(
ctx,
`${label} report`,
await readVerifiedReportArtifact(run.reportArtifact!),
);
} else if (action === "prototypes") {
await browsePrototypeArtifacts(ctx, run);
} else if (action === "transcripts") {
await browseAssignmentTranscripts(ctx, run);
} else if (action === "worktree") {
await workflowPlanReader(ctx, "worktree", run.workspace.root!);
} else if (action === "decide") {
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 ${label} is ready for smart_compact. Ask the assistant to invoke it and report the Gate-bound outcome through workflow.`,
"info",
);
} else if (
decision.kind === "select-implementation-context" &&
decision.strategy === "fresh-session"
) {
return { kind: "continue", runId: run.runId };
} else {
ctx.ui.notify(`Workflow ${label}: ${result.status}`, "info");
}
}
const refreshed = (await runtime.store.listRuns()).find((candidate) => candidate.runId === run.runId);
if (!refreshed || ["completed", "failed", "aborted"].includes(refreshed.status)) {
return { kind: "closed" };
}
run = refreshed;
}
}
}
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) => {
const leftTime = Math.max(left.lease?.heartbeatAt ?? 0, ...left.gates.map((gate) => gate.openedAt));
const rightTime = Math.max(right.lease?.heartbeatAt ?? 0, ...right.gates.map((gate) => gate.openedAt));
return rightTime - leftTime;
});
if (initialRunId && !runs.some((run) => run.runId === initialRunId)) {
ctx.ui.notify(`Workflow Run ${initialRunId} was not found for this session.`, "error");
return {};
}
const labels = deriveRunLabels(runs);
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((run, index) => runSummary(run, labels[index]!)).join("\n\n")}${omitted > 0 ? `\n\n[${omitted} older runs omitted]` : ""}`
: "No Workflow Runs.",
"info",
);
return {};
}
let selectedRunId = initialRunId;
while (true) {
if (!selectedRunId) {
selectedRunId = await workflowSelect(
ctx,
"workflow",
runs.map((run, index) => ({
value: run.runId,
label: `${labels[index]} ${run.status.toUpperCase()} ${String(activeStage(run))}`,
description: selectedGate(run)?.kind,
})),
);
if (!selectedRunId) return {};
}
const index = runs.findIndex((candidate) => candidate.runId === selectedRunId);
const run = runs[index];
if (!run) return {};
const result = await actionSheet(pi, runtime, ctx, run, labels[index]!);
if (result.kind === "continue") return { continueRunId: result.runId };
if (result.kind === "closed") return {};
selectedRunId = undefined;
}
}