feat(pi-workflow): improve workflow usability and recovery
This commit is contained in:
+322
-374
@@ -5,12 +5,6 @@ import type {
|
||||
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,
|
||||
@@ -27,36 +21,29 @@ import {
|
||||
readVerifiedReportArtifact,
|
||||
readVerifiedTranscriptArtifact,
|
||||
} from "./store.ts";
|
||||
import {
|
||||
workflowConfirm,
|
||||
workflowEditor,
|
||||
workflowInput,
|
||||
workflowPlanReader,
|
||||
workflowSelect,
|
||||
} from "./tui.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;
|
||||
}
|
||||
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
|
||||
.find((stage) => stage.status === "running" || stage.status === "waiting")
|
||||
?.kind ?? run.status
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,152 +51,29 @@ function selectedGate(run: WorkflowRun) {
|
||||
return [...run.gates].reverse().find((gate) => gate.status === "open");
|
||||
}
|
||||
|
||||
function stageLine(run: WorkflowRun, theme: DashboardTheme) {
|
||||
function stageSummary(run: WorkflowRun) {
|
||||
return run.stages
|
||||
.filter((stage) =>
|
||||
["discover", "design", "prototype", "implement", "verify", "review"].includes(
|
||||
stage.kind,
|
||||
),
|
||||
)
|
||||
.filter((stage) => CORE_STAGES.includes(stage.kind as (typeof CORE_STAGES)[number]))
|
||||
.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 readonly labels: string[];
|
||||
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;
|
||||
this.labels = deriveRunLabels(runs);
|
||||
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 = this.labels[index]!;
|
||||
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(this.labels[this.selected]!));
|
||||
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, 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}`] : []),
|
||||
@@ -243,8 +107,7 @@ async function decide(
|
||||
}
|
||||
|
||||
async function questionDecision(ctx: ExtensionContext, run: WorkflowRun) {
|
||||
const gate = selectedGate(run);
|
||||
const payload = gate?.payload;
|
||||
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;
|
||||
@@ -254,7 +117,7 @@ async function questionDecision(ctx: ExtensionContext, run: WorkflowRun) {
|
||||
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");
|
||||
const answer = await workflowInput(ctx, "Required question", prompt);
|
||||
if (!answer?.trim()) return undefined;
|
||||
answers[id] = answer.trim();
|
||||
}
|
||||
@@ -263,24 +126,33 @@ async function questionDecision(ctx: ExtensionContext, run: WorkflowRun) {
|
||||
|
||||
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(
|
||||
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}?`,
|
||||
);
|
||||
@@ -292,78 +164,121 @@ async function planDecision(ctx: ExtensionContext, run: WorkflowRun) {
|
||||
}
|
||||
: 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;
|
||||
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() } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
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",
|
||||
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",
|
||||
},
|
||||
]);
|
||||
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;
|
||||
return strategy
|
||||
? { kind: "select-implementation-context" as const, strategy }
|
||||
: 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",
|
||||
};
|
||||
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) {
|
||||
@@ -386,128 +301,161 @@ function validContinuation(run: WorkflowRun, sessionId?: string) {
|
||||
}
|
||||
|
||||
function isActiveParent(run: WorkflowRun, sessionId?: string) {
|
||||
return Boolean(
|
||||
sessionId && run.lease?.activeParentSessionId === sessionId,
|
||||
);
|
||||
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 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,
|
||||
) {
|
||||
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),
|
||||
);
|
||||
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,
|
||||
run: WorkflowRun,
|
||||
initialRun: WorkflowRun,
|
||||
label: string,
|
||||
) {
|
||||
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(label, actions);
|
||||
if (action === "Inspect run") ctx.ui.notify(runSummary(run, label), "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 ${label} 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 ${label}: ${result.status}`, "info");
|
||||
): 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 pi_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;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export interface DashboardResult {
|
||||
@@ -523,12 +471,13 @@ export async function openWorkflowDashboard(
|
||||
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);
|
||||
.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",
|
||||
);
|
||||
ctx.ui.notify(`Workflow Run ${initialRunId} was not found for this session.`, "error");
|
||||
return {};
|
||||
}
|
||||
const labels = deriveRunLabels(runs);
|
||||
@@ -537,34 +486,33 @@ export async function openWorkflowDashboard(
|
||||
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]` : ""
|
||||
}`
|
||||
? `${visibleRuns.map((run, index) => runSummary(run, labels[index]!)).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,
|
||||
|
||||
let selectedRunId = initialRunId;
|
||||
while (true) {
|
||||
if (!selectedRunId) {
|
||||
selectedRunId = await workflowSelect(
|
||||
ctx,
|
||||
"pi-workflow",
|
||||
runs.map((run, index) => ({
|
||||
value: run.runId,
|
||||
label: `${labels[index]} ${run.status.toUpperCase()} ${String(activeStage(run))}`,
|
||||
description: selectedGate(run)?.kind,
|
||||
})),
|
||||
);
|
||||
return {
|
||||
render: (width) => dashboard.render(width),
|
||||
invalidate: () => dashboard.invalidate(),
|
||||
handleInput: (data) => {
|
||||
dashboard.handleInput(data);
|
||||
tui.requestRender();
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
if (!selected) return {};
|
||||
const index = runs.findIndex((candidate) => candidate.runId === selected);
|
||||
const run = runs[index];
|
||||
return run ? actionSheet(pi, runtime, ctx, run, labels[index]!) : {};
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user