add persisted pi workflow extension
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import * as path from "node:path";
|
||||
import test from "node:test";
|
||||
import type {
|
||||
ExtensionAPI,
|
||||
ExtensionCommandContext,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import { visibleWidth } from "@earendil-works/pi-tui";
|
||||
import {
|
||||
DEFAULT_WORKFLOW_CONFIG,
|
||||
type WorkflowRun,
|
||||
} from "./domain.ts";
|
||||
import type { WorkflowCommand, WorkflowView } from "./engine.ts";
|
||||
import {
|
||||
FocusedWorkflowDashboard,
|
||||
openWorkflowDashboard,
|
||||
} from "./dashboard.ts";
|
||||
|
||||
function run(overrides: Partial<WorkflowRun> = {}): WorkflowRun {
|
||||
return {
|
||||
runId: "run-1",
|
||||
revision: 12,
|
||||
mode: "feature",
|
||||
status: "waiting",
|
||||
background: false,
|
||||
request: "Build search page",
|
||||
featureName: "search-page",
|
||||
parentSessionIds: ["session-1"],
|
||||
lease: {
|
||||
processId: 100,
|
||||
activeParentSessionId: "session-1",
|
||||
heartbeatAt: 1,
|
||||
},
|
||||
workspace: {
|
||||
state: "ready",
|
||||
sourceRoot: "/repo",
|
||||
root: "/repo/.worktrees/pi-workflow-search-page-run-1",
|
||||
branch: "feature/search-page",
|
||||
baseHead: "abc123",
|
||||
},
|
||||
effectiveConfig: structuredClone(DEFAULT_WORKFLOW_CONFIG),
|
||||
pinnedProfiles: [],
|
||||
preflightEligibility: [],
|
||||
facts: {
|
||||
stage: "design",
|
||||
taskKind: "feature",
|
||||
requestTerms: ["search"],
|
||||
candidatePaths: [],
|
||||
languages: [],
|
||||
tags: [],
|
||||
riskTags: [],
|
||||
},
|
||||
stages: [
|
||||
{ id: "discover", kind: "discover", status: "completed" },
|
||||
{ id: "design", kind: "design", status: "waiting" },
|
||||
{ id: "prototype", kind: "prototype", status: "pending" },
|
||||
{ id: "implement", kind: "implement", status: "pending" },
|
||||
{ id: "verify", kind: "verify", status: "pending" },
|
||||
{ id: "review", kind: "review", status: "pending" },
|
||||
],
|
||||
gates: [
|
||||
{
|
||||
id: "gate-1",
|
||||
kind: "plan-approval",
|
||||
status: "open",
|
||||
revision: 12,
|
||||
openedAt: 1,
|
||||
},
|
||||
],
|
||||
decisions: [],
|
||||
assignments: [],
|
||||
prototypeCandidates: [],
|
||||
prototypeCleanupCompleted: false,
|
||||
deliveries: [],
|
||||
commandReceipts: [],
|
||||
uiImpact: "minor",
|
||||
uiImpactEvidence: ["One visible field changes"],
|
||||
affectedUiSurfaces: ["Search page"],
|
||||
mutatorProfileNames: [],
|
||||
reviewCycles: 0,
|
||||
verificationCycles: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const theme = {
|
||||
fg: (_color: string, text: string) => text,
|
||||
bold: (text: string) => text,
|
||||
};
|
||||
|
||||
function view(workflow: WorkflowRun): WorkflowView {
|
||||
return {
|
||||
runId: workflow.runId,
|
||||
revision: workflow.revision,
|
||||
status: workflow.status,
|
||||
background: workflow.background,
|
||||
workspace: workflow.workspace,
|
||||
openGate: workflow.gates.find((gate) => gate.status === "open"),
|
||||
uiImpact: workflow.uiImpact,
|
||||
uiImpactEvidence: workflow.uiImpactEvidence,
|
||||
affectedUiSurfaces: workflow.affectedUiSurfaces,
|
||||
planArtifact: workflow.planArtifact,
|
||||
reportArtifact: workflow.reportArtifact,
|
||||
implementationContext: workflow.implementationContext,
|
||||
stages: workflow.stages,
|
||||
assignments: workflow.assignments,
|
||||
mutatorProfileNames: workflow.mutatorProfileNames,
|
||||
reviewCycles: workflow.reviewCycles,
|
||||
verificationCycles: workflow.verificationCycles,
|
||||
};
|
||||
}
|
||||
|
||||
test("focused dashboard renders compact and narrow cards within width", () => {
|
||||
let selected: string | null | undefined;
|
||||
const dashboard = new FocusedWorkflowDashboard(
|
||||
[run()],
|
||||
theme,
|
||||
(value) => {
|
||||
selected = value;
|
||||
},
|
||||
);
|
||||
dashboard.handleInput("\r");
|
||||
const wide = dashboard.render(100);
|
||||
assert.ok(wide.some((line) => line.includes("Progress")));
|
||||
assert.ok(wide.some((line) => line.includes("Worktree")));
|
||||
assert.ok(wide.every((line) => visibleWidth(line) <= 100));
|
||||
const narrow = dashboard.render(48);
|
||||
assert.equal(narrow.some((line) => line.includes("Worktree")), false);
|
||||
assert.ok(narrow.every((line) => visibleWidth(line) <= 48));
|
||||
dashboard.handleInput("\r");
|
||||
assert.equal(selected, "run-1");
|
||||
});
|
||||
|
||||
test("dashboard windows long-lived session run lists", () => {
|
||||
const runs = Array.from({ length: 30 }, (_, index) =>
|
||||
run({ runId: `run-${index}`, featureName: `feature-${index}` }),
|
||||
);
|
||||
const dashboard = new FocusedWorkflowDashboard(runs, theme, () => {});
|
||||
const lines = dashboard.render(100);
|
||||
assert.ok(lines.length <= 16);
|
||||
assert.ok(lines.some((line) => line.includes("of 30 runs")));
|
||||
});
|
||||
|
||||
test("dashboard records explicit Plan Approval through the engine", async () => {
|
||||
const workflow = run({
|
||||
planArtifact: {
|
||||
path: "/state/artifacts/plan.md",
|
||||
revision: 3,
|
||||
digest: "digest",
|
||||
},
|
||||
});
|
||||
const commands: WorkflowCommand[] = [];
|
||||
const selections = [
|
||||
"Record Gate Decision",
|
||||
"Approve plan · UI impact minor",
|
||||
];
|
||||
const ctx = {
|
||||
hasUI: true,
|
||||
mode: "tui",
|
||||
sessionManager: {
|
||||
getSessionId: () => "session-1",
|
||||
},
|
||||
ui: {
|
||||
custom: async () => "run-1",
|
||||
select: async () => selections.shift(),
|
||||
confirm: async () => true,
|
||||
notify: () => {},
|
||||
},
|
||||
} as unknown as ExtensionCommandContext;
|
||||
const pi = {
|
||||
getActiveTools: () => ["read"],
|
||||
} as unknown as ExtensionAPI;
|
||||
await openWorkflowDashboard(
|
||||
pi,
|
||||
{
|
||||
store: { listRuns: async () => [workflow] } as never,
|
||||
engineForContext: async () =>
|
||||
({
|
||||
async execute(command: WorkflowCommand) {
|
||||
commands.push(structuredClone(command));
|
||||
return view(workflow);
|
||||
},
|
||||
}) as never,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
const decision = commands[0];
|
||||
assert.equal(decision?.action, "decide");
|
||||
assert.deepEqual(
|
||||
decision?.action === "decide" ? decision.decision : undefined,
|
||||
{
|
||||
kind: "approve-plan",
|
||||
approved: true,
|
||||
acceptedUiImpact: "minor",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("historical parent sessions receive inspection-only dashboard actions", async () => {
|
||||
const workflow = run({
|
||||
parentSessionIds: ["session-1", "session-2"],
|
||||
planArtifact: { path: "/state/plan.md", revision: 3, digest: "digest" },
|
||||
});
|
||||
let actions: string[] = [];
|
||||
const ctx = {
|
||||
hasUI: true,
|
||||
mode: "tui",
|
||||
sessionManager: { getSessionId: () => "session-2" },
|
||||
ui: {
|
||||
custom: async () => "run-1",
|
||||
select: async (_title: string, choices: string[]) => {
|
||||
actions = choices;
|
||||
return undefined;
|
||||
},
|
||||
notify: () => {},
|
||||
},
|
||||
} as unknown as ExtensionCommandContext;
|
||||
await openWorkflowDashboard(
|
||||
{ getActiveTools: () => ["read"] } as unknown as ExtensionAPI,
|
||||
{
|
||||
store: { listRuns: async () => [workflow] } as never,
|
||||
engineForContext: () => Promise.reject(new Error("unused")),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
assert.equal(actions.includes("Record Gate Decision"), false);
|
||||
assert.equal(actions.includes("Continue context transition"), false);
|
||||
assert.equal(actions.includes("Open plan"), true);
|
||||
});
|
||||
|
||||
test("dashboard opens retained report content on demand", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "pi-workflow-dashboard-"));
|
||||
try {
|
||||
const reportPath = path.join(directory, "report.md");
|
||||
const reportContents = "# Completed report\n\nVerified.";
|
||||
await writeFile(reportPath, reportContents, "utf8");
|
||||
const workflow = run({
|
||||
status: "completed",
|
||||
gates: [],
|
||||
reportArtifact: {
|
||||
path: reportPath,
|
||||
digest: createHash("sha256").update(reportContents).digest("hex"),
|
||||
revision: 12,
|
||||
},
|
||||
});
|
||||
let opened = "";
|
||||
const ctx = {
|
||||
hasUI: true,
|
||||
mode: "tui",
|
||||
sessionManager: { getSessionId: () => "session-1" },
|
||||
ui: {
|
||||
custom: async () => "run-1",
|
||||
select: async () => "Open report",
|
||||
editor: async (_title: string, contents: string) => {
|
||||
opened = contents;
|
||||
return contents;
|
||||
},
|
||||
notify: () => {},
|
||||
},
|
||||
} as unknown as ExtensionCommandContext;
|
||||
await openWorkflowDashboard(
|
||||
{ getActiveTools: () => ["read"] } as unknown as ExtensionAPI,
|
||||
{
|
||||
store: { listRuns: async () => [workflow] } as never,
|
||||
engineForContext: () => Promise.reject(new Error("unused")),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
assert.match(opened, /Completed report[\s\S]*Verified/);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("dashboard verifies and opens Assignment transcripts on demand", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "pi-workflow-transcript-"));
|
||||
try {
|
||||
const transcriptPath = path.join(directory, "assignment.json");
|
||||
const transcriptContents = '[{"role":"assistant","text":"verified"}]\n';
|
||||
await writeFile(transcriptPath, transcriptContents, "utf8");
|
||||
const workflow = run({
|
||||
assignments: [
|
||||
{
|
||||
id: "assignment-1",
|
||||
stageId: "review",
|
||||
role: "review",
|
||||
profileName: "independent-reviewer",
|
||||
profileDigest: "digest",
|
||||
selection: {
|
||||
eligibleProfiles: [],
|
||||
selectedProfileName: "independent-reviewer",
|
||||
rationale: "Review",
|
||||
},
|
||||
status: "succeeded",
|
||||
mutates: false,
|
||||
ownedPaths: [],
|
||||
transcriptArtifact: {
|
||||
path: transcriptPath,
|
||||
digest: createHash("sha256")
|
||||
.update(transcriptContents)
|
||||
.digest("hex"),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const selections = [
|
||||
"Browse Assignment transcripts",
|
||||
"1. review/independent-reviewer · succeeded",
|
||||
];
|
||||
let opened = "";
|
||||
const ctx = {
|
||||
hasUI: true,
|
||||
mode: "tui",
|
||||
sessionManager: { getSessionId: () => "session-1" },
|
||||
ui: {
|
||||
custom: async () => "run-1",
|
||||
select: async () => selections.shift(),
|
||||
editor: async (_title: string, contents: string) => {
|
||||
opened = contents;
|
||||
return contents;
|
||||
},
|
||||
notify: () => {},
|
||||
},
|
||||
} as unknown as ExtensionCommandContext;
|
||||
await openWorkflowDashboard(
|
||||
{ getActiveTools: () => ["read"] } as unknown as ExtensionAPI,
|
||||
{
|
||||
store: { listRuns: async () => [workflow] } as never,
|
||||
engineForContext: () => Promise.reject(new Error("unused")),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
assert.match(opened, /assistant.*verified/);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("dashboard exposes continuation only for pending handoff intents", async () => {
|
||||
const workflow = run({
|
||||
planArtifact: { path: "/state/plan.md", revision: 3, digest: "digest" },
|
||||
gates: [
|
||||
{
|
||||
id: "context-gate",
|
||||
kind: "implementation-context",
|
||||
status: "open",
|
||||
revision: 12,
|
||||
openedAt: 1,
|
||||
},
|
||||
],
|
||||
implementationContext: {
|
||||
gateId: "context-gate",
|
||||
runRevision: 12,
|
||||
planRevision: 3,
|
||||
preparationId: "prep-1",
|
||||
activeParentSessionId: "session-1",
|
||||
strategy: "fresh-session",
|
||||
status: "handoff-pending",
|
||||
method: "fresh-session",
|
||||
},
|
||||
});
|
||||
const ctx = {
|
||||
hasUI: true,
|
||||
mode: "tui",
|
||||
sessionManager: { getSessionId: () => "session-1" },
|
||||
ui: {
|
||||
custom: async () => "run-1",
|
||||
select: async () => "Continue context transition",
|
||||
notify: () => {},
|
||||
},
|
||||
} as unknown as ExtensionCommandContext;
|
||||
const result = await openWorkflowDashboard(
|
||||
{ getActiveTools: () => ["read"] } as unknown as ExtensionAPI,
|
||||
{
|
||||
store: { listRuns: async () => [workflow] } as never,
|
||||
engineForContext: () => Promise.reject(new Error("unused")),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
assert.deepEqual(result, { continueRunId: "run-1" });
|
||||
});
|
||||
Reference in New Issue
Block a user