feat(pi-workflow): add lifecycle slash commands

This commit is contained in:
jb
2026-08-25 18:06:02 -04:00
parent 5e4d72f35a
commit ddb989fdd1
4 changed files with 542 additions and 26 deletions
@@ -43,10 +43,16 @@ function fakeApi() {
let command: CommandHandler | undefined;
let shutdown: ShutdownHandler | undefined;
let start: StartHandler | undefined;
const commandNames: string[] = [];
const userMessages: string[] = [];
const api = {
registerCommand(_name: string, options: { handler: CommandHandler }) {
registerCommand(name: string, options: { handler: CommandHandler }) {
commandNames.push(name);
command = options.handler;
},
sendUserMessage(message: string) {
userMessages.push(message);
},
on(event: string, handler: ShutdownHandler | StartHandler) {
if (event === "session_shutdown") shutdown = handler as ShutdownHandler;
if (event === "session_start") start = handler as StartHandler;
@@ -54,6 +60,8 @@ function fakeApi() {
} as unknown as ExtensionAPI;
return {
api,
commandNames,
userMessages,
command: () => {
assert.ok(command);
return command;
@@ -117,7 +125,10 @@ function commandContext(options: {
sessionFile: string;
compact?: ExtensionCommandContext["compact"];
newSession?: ExtensionCommandContext["newSession"];
sent?: unknown[];
mode?: ExtensionCommandContext["mode"];
hasUI?: boolean;
input?: (title: string, placeholder?: string) => Promise<string | undefined>;
select?: (title: string, options: string[]) => Promise<string | undefined>;
}) {
const notifications: string[] = [];
const context = {
@@ -126,10 +137,14 @@ function commandContext(options: {
getSessionId: () => options.sessionId,
getSessionFile: () => options.sessionFile,
},
mode: options.mode ?? "print",
hasUI: options.hasUI ?? false,
ui: {
notify(message: string) {
notifications.push(message);
},
input: options.input ?? (async () => undefined),
select: options.select ?? (async () => undefined),
},
async waitForIdle() {},
compact: options.compact ?? (() => {}),
@@ -167,6 +182,331 @@ test("/workflow routes non-continuation arguments to the dashboard", async () =>
assert.deepEqual(opened, ["run-1"]);
});
test("/workflow start routes the exact request through the assistant", async () => {
const registered = fakeApi();
registerWorkflowContextTransitions(registered.api, {
store: { listRuns: async () => [] },
engineForContext: () => {
throw new Error("start should route through the assistant");
},
} as unknown as ContextTransitionRuntime);
await registered.command()(
"start Build searchable workflow cards",
commandContext({
sessionId: "session-1",
sessionFile: "/sessions/source.jsonl",
}).context,
);
assert.equal(registered.userMessages.length, 1);
assert.match(registered.userMessages[0]!, /invoke pi_workflow/);
assert.ok(
registered.userMessages[0]!.endsWith("\n\nBuild searchable workflow cards"),
);
});
test("the misspelled /worlflow alias is not registered", () => {
const registered = fakeApi();
registerWorkflowContextTransitions(registered.api, {
store: { listRuns: async () => [] },
engineForContext: () => {
throw new Error("command registration should not create an engine");
},
} as unknown as ContextTransitionRuntime);
assert.deepEqual(registered.commandNames, ["workflow"]);
});
test("/workflow start prompts in the TUI and requires a message otherwise", async () => {
const registered = fakeApi();
registerWorkflowContextTransitions(registered.api, {
store: { listRuns: async () => [] },
engineForContext: () => {
throw new Error("start should route through the assistant");
},
} as unknown as ContextTransitionRuntime);
let prompt = "";
await registered.command()(
"start",
commandContext({
sessionId: "session-1",
sessionFile: "/sessions/source.jsonl",
mode: "tui",
hasUI: true,
input: async (title) => {
prompt = title;
return "Add workflow lifecycle commands";
},
}).context,
);
assert.equal(prompt, "What do you want to start working on?");
assert.ok(
registered.userMessages[0]!.endsWith("\n\nAdd workflow lifecycle commands"),
);
const fallback = commandContext({
sessionId: "session-1",
sessionFile: "/sessions/source.jsonl",
});
await registered.command()("start", fallback.context);
assert.ok(fallback.notifications.some((message) => message.includes("required")));
});
test("/workflow continue lists only valid runs and resume is an alias", async () => {
const validRun = (runId: string, activeParentSessionId = "session-1") =>
({
runId,
revision: 10,
status: "waiting",
request: `Request ${runId}`,
parentSessionIds: [activeParentSessionId],
lease: { activeParentSessionId },
planArtifact: { path: "/plan.md", revision: 1, digest: "digest" },
implementationContext: {
gateId: `gate-${runId}`,
runRevision: 10,
planRevision: 1,
preparationId: `prep-${runId}`,
activeParentSessionId,
strategy: "fresh-session",
status: "handoff-pending",
method: "fresh-session",
},
gates: [
{
id: `gate-${runId}`,
kind: "implementation-context",
status: "open",
revision: 10,
openedAt: 1,
},
],
assignments: [],
}) as unknown as WorkflowRun;
const selectedTitles: string[] = [];
const selectedOptions: string[][] = [];
const inspected: string[] = [];
const registered = fakeApi();
registerWorkflowContextTransitions(registered.api, {
store: {
listRuns: async () => [
validRun("run-1"),
validRun("run-2"),
validRun("historical", "session-2"),
],
},
engineForContext: () =>
({
async execute(command: WorkflowCommand) {
if (command.action === "inspect") {
inspected.push(command.runId);
return {
runId: command.runId,
revision: 1,
status: "running",
background: false,
workspace: { state: "ready", sourceRoot: "/repo" },
stages: [],
assignments: [],
mutatorProfileNames: [],
reviewCycles: 0,
verificationCycles: 0,
uiImpactEvidence: [],
affectedUiSurfaces: [],
} as WorkflowView;
}
throw new Error(`Unexpected command ${command.action}`);
},
}) as WorkflowEngine,
} as unknown as ContextTransitionRuntime);
const interactive = commandContext({
sessionId: "session-1",
sessionFile: "/sessions/source.jsonl",
mode: "tui",
hasUI: true,
select: async (title, options) => {
selectedTitles.push(title);
selectedOptions.push(options);
return undefined;
},
});
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.ok(
selectedOptions.every((options) =>
options.every((label) => !label.includes("historical")),
),
);
await registered.command()("resume run-2", interactive.context);
assert.deepEqual(inspected, ["run-2"]);
});
test("/workflow continue without a run ID gives actionable non-UI guidance", async () => {
const registered = fakeApi();
registerWorkflowContextTransitions(registered.api, {
store: { listRuns: async () => [] },
engineForContext: () => {
throw new Error("non-UI fallback should not create an engine");
},
} as unknown as ContextTransitionRuntime);
const command = commandContext({
sessionId: "session-1",
sessionFile: "/sessions/source.jsonl",
});
await registered.command()("continue", command.context);
assert.ok(
command.notifications.some((message) =>
message.includes("/workflow continue <run-id>"),
),
);
});
test("/workflow stop selects among active runs owned by the current session", async () => {
const run = (
runId: string,
status: WorkflowRun["status"],
activeParentSessionId: string,
) =>
({
runId,
revision: 4,
status,
request: `Request ${runId}`,
parentSessionIds: [activeParentSessionId],
lease: { activeParentSessionId },
}) as unknown as WorkflowRun;
const commands: WorkflowCommand[] = [];
const registered = fakeApi();
registerWorkflowContextTransitions(registered.api, {
store: {
listRuns: async () => [
run("run-1", "running", "session-1"),
run("run-2", "waiting", "session-1"),
run("completed", "completed", "session-1"),
run("historical", "running", "session-2"),
],
},
engineForContext: () =>
({
async execute(command: WorkflowCommand) {
commands.push(command);
if (command.action !== "inspect" && command.action !== "abort") {
throw new Error(`Unexpected command ${command.action}`);
}
return {
runId: command.runId,
revision: 4,
status: command.action === "abort" ? "aborted" : "waiting",
} as WorkflowView;
},
}) as WorkflowEngine,
} as unknown as ContextTransitionRuntime);
let choices: string[] = [];
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];
},
});
await registered.command()("stop", command.context);
assert.equal(choices.length, 2);
assert.ok(choices.every((label) => !label.includes("completed")));
assert.deepEqual(
commands.map((entry) =>
`${entry.action}:${"runId" in entry ? entry.runId : ""}`,
),
["inspect:run-2", "abort:run-2"],
);
const abort = commands[1];
assert.match(
abort?.action === "abort" ? abort.idempotencyKey : "",
/^workflow-stop-run-2-4-/,
);
});
test("/workflow stop immediately stops the only active run", async () => {
const commands: WorkflowCommand[] = [];
const registered = fakeApi();
registerWorkflowContextTransitions(registered.api, {
store: {
listRuns: async () => [
{
runId: "run-1",
revision: 2,
status: "running",
request: "Only active run",
lease: { activeParentSessionId: "session-1" },
} as unknown as WorkflowRun,
],
},
engineForContext: () =>
({
async execute(command: WorkflowCommand) {
commands.push(command);
if (command.action !== "inspect" && command.action !== "abort") {
throw new Error(`Unexpected command ${command.action}`);
}
return {
runId: command.runId,
revision: 2,
status: command.action === "abort" ? "aborted" : "running",
} as WorkflowView;
},
}) as WorkflowEngine,
} as unknown as ContextTransitionRuntime);
const command = commandContext({
sessionId: "session-1",
sessionFile: "/sessions/source.jsonl",
});
await registered.command()("stop", command.context);
assert.deepEqual(
commands.map((entry) => entry.action),
["inspect", "abort"],
);
assert.ok(command.notifications.some((message) => message.includes("stopped")));
});
test("/workflow stop requires a run ID for multiple active runs without UI", async () => {
const registered = fakeApi();
registerWorkflowContextTransitions(registered.api, {
store: {
listRuns: async () =>
["run-1", "run-2"].map(
(runId) =>
({
runId,
status: "running",
request: runId,
lease: { activeParentSessionId: "session-1" },
}) as unknown as WorkflowRun,
),
},
engineForContext: () => {
throw new Error("selection fallback should not create an engine");
},
} as unknown as ContextTransitionRuntime);
const command = commandContext({
sessionId: "session-1",
sessionFile: "/sessions/source.jsonl",
});
await registered.command()("stop", command.context);
assert.ok(
command.notifications.some((message) =>
message.includes("/workflow stop <run-id>"),
),
);
});
test("/workflow continue invokes and fences normal compaction callbacks", async () => {
await withTempDir(async (directory) => {
const planPath = path.join(directory, "plan.md");
+174 -19
View File
@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import type {
ExtensionAPI,
ExtensionCommandContext,
@@ -13,6 +14,7 @@ import {
type WorkflowEngine,
type WorkflowView,
} from "./engine.ts";
import { isValidContextContinuation } from "./continuation.ts";
import {
readVerifiedPlanArtifact,
type WorkflowStore,
@@ -233,19 +235,45 @@ async function continueNormalCompaction(
}
}
async function handleContinue(
function isInteractive(ctx: ExtensionCommandContext) {
return ctx.hasUI && ctx.mode === "tui";
}
function runLabel(run: WorkflowRun) {
const name = run.featureName ?? run.request;
return `${name} · ${run.runId} · ${run.status}`;
}
function belongsToActiveParent(run: WorkflowRun, sessionId: string) {
return run.lease?.activeParentSessionId === sessionId;
}
function canContinue(run: WorkflowRun, currentSessionId: string) {
return isValidContextContinuation(
{
...run,
openGate: run.gates.find((gate) => gate.status === "open"),
},
currentSessionId,
);
}
async function selectRun(
ctx: ExtensionCommandContext,
title: string,
runs: WorkflowRun[],
) {
const labels = runs.map(runLabel);
const selected = await ctx.ui.select(title, labels);
return runs[labels.indexOf(selected ?? "")];
}
async function continueRun(
runtime: ContextTransitionRuntime,
args: string,
runId: string,
ctx: ExtensionCommandContext,
) {
const match = args.trim().match(/^continue\s+(\S+)$/);
if (!match) {
ctx.ui.notify("Usage: /workflow continue <run-id>", "error");
return;
}
await ctx.waitForIdle();
const runId = match[1];
if (!runId) throw new Error("Workflow Run ID is missing");
const { view } = await inspect(runtime, ctx, runId);
const status = view.implementationContext?.status;
if (status === "handoff-pending") {
@@ -259,6 +287,114 @@ async function handleContinue(
throw new Error(`Workflow Run ${runId} has no pending context continuation`);
}
async function handleContinue(
runtime: ContextTransitionRuntime,
runId: string | undefined,
ctx: ExtensionCommandContext,
) {
if (runId) {
await continueRun(runtime, runId, ctx);
return;
}
if (!isInteractive(ctx)) {
ctx.ui.notify(
"Selecting a Workflow Run to continue requires interactive UI. Use /workflow continue <run-id>.",
"error",
);
return;
}
const currentSessionId = sessionId(ctx);
const runs = (await runtime.store.listRuns()).filter((run) =>
canContinue(run, currentSessionId),
);
if (runs.length === 0) {
ctx.ui.notify("No Workflow Runs are ready to continue.", "info");
return;
}
const selected = await selectRun(ctx, "Continue Workflow Run", runs);
if (selected) await continueRun(runtime, selected.runId, ctx);
}
async function handleStart(
pi: ExtensionAPI,
request: string,
ctx: ExtensionCommandContext,
) {
let initialRequest = request.trim();
if (!initialRequest) {
if (!isInteractive(ctx)) {
ctx.ui.notify(
"A workflow request is required outside interactive UI. Use /workflow start <message>.",
"error",
);
return;
}
initialRequest =
(await ctx.ui.input(
"What do you want to start working on?",
"Describe the feature request",
))?.trim() ?? "";
if (!initialRequest) return;
}
await ctx.waitForIdle();
await pi.sendUserMessage(
[
"Start a managed pi-workflow for the request below.",
"Use the request text verbatim as the initial workflow request, choose a descriptive lowercase kebab-case featureName, create a unique idempotencyKey, and invoke pi_workflow with action \"start\" through the normal assistant tool path.",
"",
initialRequest,
].join("\n"),
);
}
async function handleStop(
runtime: ContextTransitionRuntime,
requestedRunId: string | undefined,
ctx: ExtensionCommandContext,
) {
const currentSessionId = sessionId(ctx);
const activeRuns = (await runtime.store.listRuns()).filter(
(run) =>
(run.status === "running" || run.status === "waiting") &&
belongsToActiveParent(run, currentSessionId),
);
let selected = requestedRunId
? activeRuns.find((run) => run.runId === requestedRunId)
: activeRuns.length === 1
? activeRuns[0]
: undefined;
if (requestedRunId && !selected) {
ctx.ui.notify(
`Workflow Run ${requestedRunId} is not active for this session.`,
"error",
);
return;
}
if (!requestedRunId && activeRuns.length === 0) {
ctx.ui.notify("No active Workflow Runs for this session.", "info");
return;
}
if (!selected && !isInteractive(ctx)) {
ctx.ui.notify(
"Selecting a Workflow Run to stop requires interactive UI. Use /workflow stop <run-id>.",
"error",
);
return;
}
selected ??= await selectRun(ctx, "Stop Workflow Run", activeRuns);
if (!selected) return;
await ctx.waitForIdle();
const engine = await runtime.engineForContext(ctx);
const current = await engine.execute({ action: "inspect", runId: selected.runId });
await engine.execute({
action: "abort",
runId: current.runId,
expectedRevision: current.revision,
idempotencyKey: `workflow-stop-${current.runId}-${current.revision}-${randomUUID()}`,
});
ctx.ui.notify(`Workflow ${current.runId} stopped.`, "info");
}
export function executeHostDecision(
pi: Pick<ExtensionAPI, "getActiveTools">,
engine: WorkflowEngine,
@@ -281,25 +417,44 @@ export function registerWorkflowContextTransitions(
) => Promise<{ continueRunId?: string }>,
) {
pi.registerCommand("workflow", {
description: "Inspect and continue persisted pi-workflow runs",
description: "Start, stop, inspect, or continue persisted pi-workflow runs",
handler: async (args, ctx) => {
try {
if (/^continue\s+\S+$/.test(args.trim())) {
await handleContinue(runtime, args, ctx);
const input = args.trim();
const command = input.match(/^(start|stop|continue|resume)(?:\s+([\s\S]*))?$/);
if (command?.[1] === "start") {
await handleStart(pi, command[2] ?? "", ctx);
return;
}
if (command?.[1] === "stop") {
const runId = command[2]?.trim();
if (runId && /\s/.test(runId)) {
ctx.ui.notify("Usage: /workflow stop [run-id]", "error");
return;
}
await handleStop(runtime, runId || undefined, ctx);
return;
}
if (command?.[1] === "continue" || command?.[1] === "resume") {
const runId = command[2]?.trim();
if (runId && /\s/.test(runId)) {
ctx.ui.notify(
`Usage: /workflow ${command[1]} [run-id]`,
"error",
);
return;
}
await handleContinue(runtime, runId || undefined, ctx);
return;
}
if (openDashboard) {
const result = await openDashboard(args.trim(), ctx);
const result = await openDashboard(input, ctx);
if (result.continueRunId) {
await handleContinue(
runtime,
`continue ${result.continueRunId}`,
ctx,
);
await handleContinue(runtime, result.continueRunId, ctx);
}
return;
}
await handleContinue(runtime, args, ctx);
await handleContinue(runtime, undefined, ctx);
} catch (error) {
ctx.ui.notify(
error instanceof Error ? error.message : String(error),