519 lines
17 KiB
TypeScript
519 lines
17 KiB
TypeScript
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;
|
||
}
|
||
}
|