feat(pi-workflow): improve workflow usability and recovery

This commit is contained in:
jb
2026-08-26 00:20:24 -04:00
parent c291ca083c
commit ea74dbc6a7
23 changed files with 4422 additions and 913 deletions
+115 -37
View File
@@ -131,8 +131,16 @@ function commandContext(options: {
hasUI?: boolean;
input?: (title: string, placeholder?: string) => Promise<string | undefined>;
select?: (title: string, options: string[]) => Promise<string | undefined>;
customResults?: unknown[];
onCustomRender?: (lines: string[]) => void;
}) {
const notifications: string[] = [];
const customResults = [...(options.customResults ?? [])];
const overlayTheme = {
fg: (_color: string, text: string) => text,
bg: (_color: string, text: string) => text,
bold: (text: string) => text,
};
const context = {
cwd: "/repo",
sessionManager: {
@@ -145,6 +153,24 @@ function commandContext(options: {
notify(message: string) {
notifications.push(message);
},
custom: async (
factory: (
tui: { requestRender(): void; terminal: { rows: number } },
theme: typeof overlayTheme,
keybindings: object,
done: (value: unknown) => void,
) => { render(width: number): string[] },
) => {
let completed: unknown;
const component = factory(
{ requestRender() {}, terminal: { rows: 24 } },
overlayTheme,
{},
(value) => { completed = value; },
);
options.onCustomRender?.(component.render(100));
return customResults.length > 0 ? customResults.shift() : completed;
},
input: options.input ?? (async () => undefined),
select: options.select ?? (async () => undefined),
},
@@ -233,13 +259,11 @@ test("/workflow start prompts in the TUI and requires a message otherwise", asyn
sessionFile: "/sessions/source.jsonl",
mode: "tui",
hasUI: true,
input: async (title) => {
prompt = title;
return "Add workflow lifecycle commands";
},
customResults: ["Add workflow lifecycle commands"],
onCustomRender: (lines) => { prompt = lines.join("\n"); },
}).context,
);
assert.equal(prompt, "What do you want to start working on?");
assert.match(prompt, /What do you want to start working on\?/);
assert.ok(
registered.userMessages[0]!.endsWith("\n\nAdd workflow lifecycle commands"),
);
@@ -284,8 +308,7 @@ test("/workflow continue lists only valid runs and resume is an alias", async ()
],
assignments: [],
}) as unknown as WorkflowRun;
const selectedTitles: string[] = [];
const selectedOptions: string[][] = [];
const renderedSelections: string[] = [];
const inspected: string[] = [];
const registered = fakeApi();
registerWorkflowContextTransitions(registered.api, {
@@ -325,29 +348,17 @@ test("/workflow continue lists only valid runs and resume is an alias", async ()
sessionFile: "/sessions/source.jsonl",
mode: "tui",
hasUI: true,
select: async (title, options) => {
selectedTitles.push(title);
selectedOptions.push(options);
return undefined;
},
onCustomRender: (lines) => renderedSelections.push(lines.join("\n")),
});
await registered.command()("continue", interactive.context);
await registered.command()("resume", interactive.context);
assert.deepEqual(selectedTitles, [
"Continue Workflow Run",
"Continue Workflow Run",
]);
assert.equal(selectedOptions[0]?.length, 2);
assert.equal(selectedOptions[1]?.length, 2);
assert.deepEqual(selectedOptions[0], [
"shared-feature · run-1 · waiting",
"shared-feature · run-2 · waiting",
]);
assert.ok(
selectedOptions.every((options) =>
options.every((label) => !label.includes("historical")),
),
);
assert.equal(renderedSelections.length, 2);
for (const rendered of renderedSelections) {
assert.match(rendered, /Continue Workflow Run/);
assert.match(rendered, /shared-feature · run-1/);
assert.match(rendered, /shared-feature · run-2/);
assert.doesNotMatch(rendered, /historical/);
}
await registered.command()("resume run-2", interactive.context);
assert.deepEqual(inspected, ["run-2"]);
@@ -437,23 +448,20 @@ test("/workflow stop selects among active runs owned by the current session", as
},
}) as WorkflowEngine,
} as unknown as ContextTransitionRuntime);
let choices: string[] = [];
let choices = "";
const command = commandContext({
sessionId: "session-1",
sessionFile: "/sessions/source.jsonl",
mode: "tui",
hasUI: true,
select: async (title, options) => {
assert.equal(title, "Stop Workflow Run");
choices = options;
return options[1];
},
customResults: [run("run-2", "waiting", "session-1")],
onCustomRender: (lines) => { choices = lines.join("\n"); },
});
await registered.command()("stop", command.context);
assert.deepEqual(choices, [
"duplicate-label · run-1 · running",
"duplicate-label · run-2 · waiting",
]);
assert.match(choices, /Stop Workflow Run/);
assert.match(choices, /duplicate-label · run-1/);
assert.match(choices, /duplicate-label · run-2/);
assert.doesNotMatch(choices, /completed|historical/);
assert.deepEqual(
commands.map((entry) =>
`${entry.action}:${"runId" in entry ? entry.runId : ""}`,
@@ -686,6 +694,76 @@ test("cancelled fresh-session replacement clears the source-bound intent", async
});
});
test("dashboard continuation immediately enters the fresh-session transition", async () => {
await withTempDir(async (directory) => {
const planPath = path.join(directory, "plan.md");
await writeFile(planPath, "# Plan", "utf8");
const implementationContext: ImplementationContextState = {
gateId: "gate-1",
runRevision: 10,
planRevision: 1,
preparationId: "prep-1",
activeParentSessionId: "session-1",
sourceParentSessionId: "session-1",
strategy: "fresh-session",
status: "handoff-pending",
method: "fresh-session",
};
let view = workflowView(planPath, 10, implementationContext);
const commands: WorkflowCommand[] = [];
const engine = {
async execute(command: WorkflowCommand) {
commands.push(structuredClone(command));
if (command.action === "inspect") return structuredClone(view);
if (command.action === "continue-context") {
const next = structuredClone(view.implementationContext!);
next.sourceSessionFile = command.sourceSessionFile;
next.runRevision = 11;
view = workflowView(planPath, 11, next);
return structuredClone(view);
}
if (command.action === "recover-context-intent") {
const next = structuredClone(view.implementationContext!);
next.sourceSessionFile = undefined;
next.runRevision = 12;
view = workflowView(planPath, 12, next);
return structuredClone(view);
}
throw new Error(`Unexpected command ${command.action}`);
},
} as WorkflowEngine;
const registered = fakeApi();
const dashboardArguments: string[] = [];
registerWorkflowContextTransitions(
registered.api,
{
store: { listRuns: async () => [] },
engineForContext: () => engine,
} as unknown as ContextTransitionRuntime,
async (args) => {
dashboardArguments.push(args);
return { continueRunId: "run-1" };
},
);
let newSessionCalls = 0;
const command = commandContext({
sessionId: "session-1",
sessionFile: "/sessions/source.jsonl",
newSession: (async () => {
newSessionCalls++;
return { cancelled: true };
}) as ExtensionCommandContext["newSession"],
});
await registered.command()("run-1", command.context);
assert.deepEqual(dashboardArguments, ["run-1"]);
assert.equal(newSessionCalls, 1);
assert.deepEqual(
commands.map((entry) => entry.action),
["inspect", "continue-context", "recover-context-intent"],
);
});
});
test("a later startup recovers an unconsumed claimed handoff", async () => {
await withTempDir(async (directory) => {
const planPath = path.join(directory, "plan.md");