feat(pi-workflow): improve workflow usability and recovery
This commit is contained in:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user