690 lines
19 KiB
TypeScript
690 lines
19 KiB
TypeScript
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 {
|
|
DEFAULT_WORKFLOW_CONFIG,
|
|
type WorkflowRun,
|
|
} from "./domain.ts";
|
|
import type { WorkflowCommand, WorkflowView } from "./engine.ts";
|
|
import { 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/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,
|
|
};
|
|
}
|
|
|
|
function prototypeCandidate() {
|
|
return {
|
|
id: "candidate-1",
|
|
revision: 2,
|
|
assignmentId: "prototype-assignment-1",
|
|
profileName: "frontend-prototyper",
|
|
status: "succeeded" as const,
|
|
path: "/repo/prototypes/candidate-1",
|
|
route: "/search?variant=1",
|
|
structuralRationale: "Compact drill-down flow",
|
|
responsiveChecks: ["mobile", "desktop"],
|
|
browserEvidence: ["validated in browser"],
|
|
launchInstructions: "Run the prototype server.",
|
|
visualDigest: "visual-digest",
|
|
artifactPath: "/state/artifacts/candidate-1.md",
|
|
retainedArtifacts: ["/state/artifacts/candidate-1.png"],
|
|
};
|
|
}
|
|
|
|
function view(workflow: WorkflowRun): WorkflowView {
|
|
return {
|
|
runId: workflow.runId,
|
|
featureName: workflow.featureName,
|
|
request: workflow.request,
|
|
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,
|
|
};
|
|
}
|
|
|
|
function tuiContext(results: unknown[], sessionId = "session-1") {
|
|
const notifications: string[] = [];
|
|
const renders: string[][] = [];
|
|
let customCalls = 0;
|
|
const theme = {
|
|
fg: (_color: string, text: string) => text,
|
|
bg: (_color: string, text: string) => text,
|
|
bold: (text: string) => text,
|
|
};
|
|
const unsupported = () => {
|
|
throw new Error("built-in bounded dialog must not be called");
|
|
};
|
|
const context = {
|
|
hasUI: true,
|
|
mode: "tui",
|
|
sessionManager: {
|
|
getSessionId: () => sessionId,
|
|
},
|
|
ui: {
|
|
custom: async (
|
|
factory: (
|
|
tui: { requestRender(): void; terminal: { columns: number; rows: number } },
|
|
overlayTheme: typeof theme,
|
|
keybindings: object,
|
|
done: (value: unknown) => void,
|
|
) => { render(width: number): string[] },
|
|
) => {
|
|
customCalls++;
|
|
const component = factory(
|
|
{
|
|
requestRender() {},
|
|
terminal: { columns: 100, rows: 24 },
|
|
},
|
|
theme,
|
|
{},
|
|
() => {},
|
|
);
|
|
renders.push(component.render(100));
|
|
return results.shift();
|
|
},
|
|
select: unsupported,
|
|
confirm: unsupported,
|
|
input: unsupported,
|
|
editor: unsupported,
|
|
notify: (message: string) => notifications.push(message),
|
|
},
|
|
} as unknown as ExtensionCommandContext;
|
|
return { context, notifications, renders, customCalls: () => customCalls };
|
|
}
|
|
|
|
function interactiveTuiContext(inputs: string[][], sessionId = "session-1") {
|
|
let customCalls = 0;
|
|
const theme = {
|
|
fg: (_color: string, text: string) => text,
|
|
bg: (_color: string, text: string) => text,
|
|
bold: (text: string) => text,
|
|
};
|
|
const context = {
|
|
hasUI: true,
|
|
mode: "tui",
|
|
sessionManager: { getSessionId: () => sessionId },
|
|
ui: {
|
|
async custom(
|
|
factory: (
|
|
tui: { requestRender(): void; terminal: { columns: number; rows: number } },
|
|
overlayTheme: typeof theme,
|
|
keybindings: object,
|
|
done: (value: unknown) => void,
|
|
) => {
|
|
render(width: number): string[];
|
|
handleInput?(data: string): void;
|
|
},
|
|
) {
|
|
customCalls++;
|
|
let completed: unknown;
|
|
const component = factory(
|
|
{
|
|
requestRender() {},
|
|
terminal: { columns: 80, rows: 16 },
|
|
},
|
|
theme,
|
|
{},
|
|
(value) => { completed = value; },
|
|
);
|
|
component.render(56);
|
|
for (const input of inputs.shift() ?? []) component.handleInput?.(input);
|
|
return completed;
|
|
},
|
|
notify() {},
|
|
},
|
|
} as unknown as ExtensionCommandContext;
|
|
return { context, customCalls: () => customCalls };
|
|
}
|
|
|
|
const pi = {
|
|
getActiveTools: () => ["read"],
|
|
} as unknown as ExtensionAPI;
|
|
|
|
test("dashboard fresh-session action returns an immediate continuation result", 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 tui = tuiContext(["run-1", "continue"]);
|
|
const result = await openWorkflowDashboard(
|
|
pi,
|
|
{
|
|
store: { listRuns: async () => [workflow] } as never,
|
|
engineForContext: () => Promise.reject(new Error("unused")),
|
|
},
|
|
tui.context,
|
|
);
|
|
assert.deepEqual(result, { continueRunId: "run-1" });
|
|
assert.equal(tui.customCalls(), 2);
|
|
});
|
|
|
|
test("dashboard records Plan Approval using only custom TUI overlays", async () => {
|
|
const workflow = run({
|
|
planArtifact: {
|
|
path: "/state/artifacts/plan.md",
|
|
revision: 3,
|
|
digest: "digest",
|
|
},
|
|
});
|
|
const commands: WorkflowCommand[] = [];
|
|
const tui = tuiContext([
|
|
"run-1",
|
|
"decide",
|
|
"approve",
|
|
true,
|
|
undefined,
|
|
undefined,
|
|
]);
|
|
await openWorkflowDashboard(
|
|
pi,
|
|
{
|
|
store: { listRuns: async () => [workflow] } as never,
|
|
engineForContext: async () =>
|
|
({
|
|
async execute(command: WorkflowCommand) {
|
|
commands.push(structuredClone(command));
|
|
return view(workflow);
|
|
},
|
|
}) as never,
|
|
},
|
|
tui.context,
|
|
);
|
|
const decision = commands[0];
|
|
assert.equal(decision?.action, "decide");
|
|
assert.deepEqual(
|
|
decision?.action === "decide" ? decision.decision : undefined,
|
|
{
|
|
kind: "approve-plan",
|
|
approved: true,
|
|
acceptedUiImpact: "minor",
|
|
},
|
|
);
|
|
assert.ok(tui.customCalls() >= 4);
|
|
});
|
|
|
|
test("prototype reader Escape returns to candidate actions without accepting", async () => {
|
|
const candidate = prototypeCandidate();
|
|
const workflow = run({
|
|
stages: run().stages.map((stage) =>
|
|
stage.kind === "prototype"
|
|
? { ...stage, status: "waiting" as const }
|
|
: stage,
|
|
),
|
|
gates: [
|
|
{
|
|
id: "prototype-gate",
|
|
kind: "prototype-acceptance",
|
|
status: "open",
|
|
revision: 12,
|
|
openedAt: 1,
|
|
},
|
|
],
|
|
prototypeCandidates: [candidate],
|
|
});
|
|
const commands: WorkflowCommand[] = [];
|
|
const tui = tuiContext([
|
|
"run-1",
|
|
"decide",
|
|
{ kind: "candidate", candidate },
|
|
"review",
|
|
undefined,
|
|
"back",
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
]);
|
|
await openWorkflowDashboard(
|
|
pi,
|
|
{
|
|
store: { listRuns: async () => [workflow] } as never,
|
|
engineForContext: async () =>
|
|
({
|
|
async execute(command: WorkflowCommand) {
|
|
commands.push(structuredClone(command));
|
|
return view(workflow);
|
|
},
|
|
}) as never,
|
|
},
|
|
tui.context,
|
|
);
|
|
assert.deepEqual(commands, []);
|
|
assert.ok(
|
|
tui.renders.filter((lines) => lines.join("\n").includes("Prototype 2"))
|
|
.length >= 2,
|
|
);
|
|
});
|
|
|
|
test("prototype acceptance requires the explicit candidate Accept action", async () => {
|
|
const candidate = prototypeCandidate();
|
|
const workflow = run({
|
|
gates: [
|
|
{
|
|
id: "prototype-gate",
|
|
kind: "prototype-acceptance",
|
|
status: "open",
|
|
revision: 12,
|
|
openedAt: 1,
|
|
},
|
|
],
|
|
prototypeCandidates: [candidate],
|
|
});
|
|
const commands: WorkflowCommand[] = [];
|
|
const tui = tuiContext([
|
|
"run-1",
|
|
"decide",
|
|
{ kind: "candidate", candidate },
|
|
"review",
|
|
undefined,
|
|
"accept",
|
|
true,
|
|
"",
|
|
undefined,
|
|
undefined,
|
|
]);
|
|
await openWorkflowDashboard(
|
|
pi,
|
|
{
|
|
store: { listRuns: async () => [workflow] } as never,
|
|
engineForContext: async () =>
|
|
({
|
|
async execute(command: WorkflowCommand) {
|
|
commands.push(structuredClone(command));
|
|
return view(workflow);
|
|
},
|
|
}) as never,
|
|
},
|
|
tui.context,
|
|
);
|
|
const decision = commands[0];
|
|
assert.deepEqual(
|
|
decision?.action === "decide" ? decision.decision : undefined,
|
|
{
|
|
kind: "accept-prototype",
|
|
candidateId: candidate.id,
|
|
candidateRevision: candidate.revision,
|
|
browserReviewed: true,
|
|
},
|
|
);
|
|
});
|
|
|
|
test("Escape from run detail returns one level to the run list", async () => {
|
|
const tui = tuiContext(["run-1", undefined, undefined]);
|
|
const result = await openWorkflowDashboard(
|
|
pi,
|
|
{
|
|
store: { listRuns: async () => [run()] } as never,
|
|
engineForContext: () => Promise.reject(new Error("unused")),
|
|
},
|
|
tui.context,
|
|
);
|
|
assert.deepEqual(result, {});
|
|
assert.equal(tui.customCalls(), 3);
|
|
});
|
|
|
|
test("dashboard handleInput drives selection and Escape back-stack navigation", async () => {
|
|
const tui = interactiveTuiContext([["\r"], ["\x1b"], ["\x1b"]]);
|
|
const result = await openWorkflowDashboard(
|
|
pi,
|
|
{
|
|
store: { listRuns: async () => [run()] } as never,
|
|
engineForContext: () => Promise.reject(new Error("unused")),
|
|
},
|
|
tui.context,
|
|
);
|
|
assert.deepEqual(result, {});
|
|
assert.equal(tui.customCalls(), 3);
|
|
});
|
|
|
|
test("dashboard duplicate labels retain their canonical run selection IDs", async () => {
|
|
const workflows = [
|
|
run({ runId: "12345678-aaaa", featureName: "search-page" }),
|
|
run({ runId: "12345678-bbbb", featureName: "search-page" }),
|
|
];
|
|
const tui = tuiContext(["12345678-bbbb", undefined, undefined]);
|
|
await openWorkflowDashboard(
|
|
pi,
|
|
{
|
|
store: { listRuns: async () => workflows } as never,
|
|
engineForContext: () => Promise.reject(new Error("unused")),
|
|
},
|
|
tui.context,
|
|
);
|
|
const list = tui.renders[0]?.join("\n") ?? "";
|
|
assert.match(list, /search-page · 12345678-a/);
|
|
assert.match(list, /search-page · 12345678-b/);
|
|
assert.match(tui.renders[1]?.join("\n") ?? "", /search-page · 12345678-b/);
|
|
});
|
|
|
|
test("dashboard disambiguates duplicate labels across the full run list", async () => {
|
|
const workflows = Array.from({ length: 30 }, (_, index) =>
|
|
run({
|
|
runId:
|
|
index === 0
|
|
? "12345678-aaaa"
|
|
: index === 29
|
|
? "12345678-bbbb"
|
|
: `run-${index}`,
|
|
featureName:
|
|
index === 0 || index === 29 ? "shared-label" : `feature-${index}`,
|
|
}),
|
|
);
|
|
const tui = tuiContext([undefined]);
|
|
await openWorkflowDashboard(
|
|
pi,
|
|
{
|
|
store: { listRuns: async () => workflows } as never,
|
|
engineForContext: () => Promise.reject(new Error("unused")),
|
|
},
|
|
tui.context,
|
|
);
|
|
const rendered = tui.renders[0]?.join("\n") ?? "";
|
|
assert.match(rendered, /shared-label · 12345678-a/);
|
|
assert.match(rendered, /1-12 of 30/);
|
|
assert.doesNotMatch(rendered, /shared-label\s+WAITING/);
|
|
});
|
|
|
|
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" },
|
|
});
|
|
const tui = tuiContext(["run-1", undefined, undefined], "session-2");
|
|
await openWorkflowDashboard(
|
|
pi,
|
|
{
|
|
store: { listRuns: async () => [workflow] } as never,
|
|
engineForContext: () => Promise.reject(new Error("unused")),
|
|
},
|
|
tui.context,
|
|
);
|
|
const actions = tui.renders[1]?.join("\n") ?? "";
|
|
assert.match(actions, /Review verified plan/);
|
|
assert.doesNotMatch(actions, /Record Gate Decision/);
|
|
assert.doesNotMatch(actions, /Continue context transition/);
|
|
});
|
|
|
|
test("dashboard verifies and opens retained report content", async () => {
|
|
const directory = await mkdtemp(path.join(tmpdir(), "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,
|
|
},
|
|
});
|
|
const tui = tuiContext([
|
|
"run-1",
|
|
"report",
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
]);
|
|
await openWorkflowDashboard(
|
|
pi,
|
|
{
|
|
store: { listRuns: async () => [workflow] } as never,
|
|
engineForContext: () => Promise.reject(new Error("unused")),
|
|
},
|
|
tui.context,
|
|
);
|
|
assert.ok(
|
|
tui.renders.some((lines) =>
|
|
lines.join("\n").includes("Completed report"),
|
|
),
|
|
);
|
|
assert.ok(tui.renders.some((lines) => lines.join("\n").includes("Verified.")));
|
|
} finally {
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("retained artifact reader Escape returns through artifact and candidate selection", async () => {
|
|
const candidate = prototypeCandidate();
|
|
const workflow = run({ prototypeCandidates: [candidate] });
|
|
const tui = tuiContext([
|
|
"run-1",
|
|
"prototypes",
|
|
candidate,
|
|
candidate.artifactPath,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
]);
|
|
await openWorkflowDashboard(
|
|
pi,
|
|
{
|
|
store: { listRuns: async () => [workflow] } as never,
|
|
engineForContext: () => Promise.reject(new Error("unused")),
|
|
},
|
|
tui.context,
|
|
);
|
|
assert.ok(
|
|
tui.renders.filter((lines) => lines.join("\n").includes("Retained artifact"))
|
|
.length >= 2,
|
|
);
|
|
assert.ok(
|
|
tui.renders.filter((lines) => lines.join("\n").includes("Prototype evidence"))
|
|
.length >= 2,
|
|
);
|
|
});
|
|
|
|
test("dashboard verifies and opens Assignment transcripts", async () => {
|
|
const directory = await mkdtemp(path.join(tmpdir(), "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 assignment = workflow.assignments[0];
|
|
assert.ok(assignment);
|
|
const tui = tuiContext([
|
|
"run-1",
|
|
"transcripts",
|
|
assignment,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
]);
|
|
await openWorkflowDashboard(
|
|
pi,
|
|
{
|
|
store: { listRuns: async () => [workflow] } as never,
|
|
engineForContext: () => Promise.reject(new Error("unused")),
|
|
},
|
|
tui.context,
|
|
);
|
|
assert.ok(
|
|
tui.renders.some((lines) =>
|
|
lines.join("\n").includes('"text":"verified"'),
|
|
),
|
|
);
|
|
assert.ok(
|
|
tui.renders.filter((lines) =>
|
|
lines.join("\n").includes("Assignment transcripts"),
|
|
).length >= 2,
|
|
);
|
|
} finally {
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("RPC and print dashboards stay linear and never call custom", async () => {
|
|
for (const mode of ["rpc", "print", "json"] as const) {
|
|
const notifications: string[] = [];
|
|
const ctx = {
|
|
hasUI: mode === "rpc",
|
|
mode,
|
|
sessionManager: { getSessionId: () => "session-1" },
|
|
ui: {
|
|
custom: async () => {
|
|
throw new Error("custom must not be called outside TUI mode");
|
|
},
|
|
notify: (message: string) => notifications.push(message),
|
|
},
|
|
} as unknown as ExtensionCommandContext;
|
|
assert.deepEqual(
|
|
await openWorkflowDashboard(
|
|
pi,
|
|
{
|
|
store: { listRuns: async () => [run()] } as never,
|
|
engineForContext: () => Promise.reject(new Error("unused")),
|
|
},
|
|
ctx,
|
|
),
|
|
{},
|
|
);
|
|
if (mode === "rpc") assert.match(notifications[0] ?? "", /search-page/);
|
|
}
|
|
});
|
|
|
|
test("dashboard reports the supplied run ID when direct inspection cannot resolve it", async () => {
|
|
const tui = tuiContext([]);
|
|
const result = await openWorkflowDashboard(
|
|
pi,
|
|
{
|
|
store: { listRuns: async () => [run()] } as never,
|
|
engineForContext: () => Promise.reject(new Error("unused")),
|
|
},
|
|
tui.context,
|
|
"missing-run-id",
|
|
);
|
|
assert.deepEqual(result, {});
|
|
assert.deepEqual(tui.notifications, [
|
|
"Workflow Run missing-run-id was not found for this session.",
|
|
]);
|
|
});
|