feat(pi-workflow): add lifecycle slash commands
This commit is contained in:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user