feat(pi-workflow): add lifecycle slash commands
This commit is contained in:
@@ -4,7 +4,15 @@
|
|||||||
|
|
||||||
## Start a feature
|
## Start a feature
|
||||||
|
|
||||||
Ask the assistant to use `pi_workflow` with:
|
Start with:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/workflow start <message>
|
||||||
|
```
|
||||||
|
|
||||||
|
The command sends the message through the assistant, which chooses the descriptive feature name and idempotency key before invoking `pi_workflow` through the normal tool path. In interactive TUI mode, `/workflow start` without a message asks what you want to start working on. Non-interactive modes require the message in the command.
|
||||||
|
|
||||||
|
You can also ask the assistant to use `pi_workflow` with:
|
||||||
|
|
||||||
- `action: "start"`
|
- `action: "start"`
|
||||||
- `workflow: { mode: "feature" }` (or omit `workflow`)
|
- `workflow: { mode: "feature" }` (or omit `workflow`)
|
||||||
@@ -31,13 +39,24 @@ The assistant must inspect the current run before each state-changing call and o
|
|||||||
|
|
||||||
Use `/workflow` for the focused-card dashboard. It lists runs attached to the current session and shows persisted status, progress, Gate, plan, report, branch, and worktree information.
|
Use `/workflow` for the focused-card dashboard. It lists runs attached to the current session and shows persisted status, progress, Gate, plan, report, branch, and worktree information.
|
||||||
|
|
||||||
Use:
|
Use `/workflow continue` to select a run with a pending fresh-session handoff or normal-compaction continuation. `/workflow resume` is an alias. A run ID may be supplied directly:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/workflow continue <run-id>
|
/workflow continue <run-id>
|
||||||
|
/workflow resume <run-id>
|
||||||
```
|
```
|
||||||
|
|
||||||
only when the dashboard or `pi_workflow` reports a pending fresh-session handoff or normal-compaction continuation.
|
Selection requires interactive TUI mode. In non-interactive modes, provide the run ID.
|
||||||
|
|
||||||
|
## Stop active work
|
||||||
|
|
||||||
|
Use `/workflow stop` to abort an active (`running` or `waiting`) Workflow Run owned by the current parent session. If more than one is active, interactive TUI mode presents a selection list. A run can also be named directly, including in non-interactive modes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/workflow stop <run-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
Terminal runs and runs owned by historical parent sessions are not offered. The misspelled `/worlflow` alias is not supported.
|
||||||
|
|
||||||
## Script modes
|
## Script modes
|
||||||
|
|
||||||
|
|||||||
@@ -149,7 +149,9 @@ type GateDecision =
|
|||||||
|
|
||||||
Feature and feature-script starts require `featureName` as a concise lowercase kebab-case name that describes what the feature does, such as `searchable-workflow-dashboard`. Analysis starts omit it. The host validates the name before persisting launch intent.
|
Feature and feature-script starts require `featureName` as a concise lowercase kebab-case name that describes what the feature does, such as `searchable-workflow-dashboard`. Analysis starts omit it. The host validates the name before persisting launch intent.
|
||||||
|
|
||||||
The `pi_workflow` tool calls a single internal `execute(command)` interface. `/workflow` opens the dashboard and may accept a run ID for direct inspection. `/workflow continue <run-id>` performs a pending fresh-session handoff or normal-compaction fallback after the run is safely waiting; it cannot approve plans, select prototypes, or make other Decisions.
|
The `pi_workflow` tool calls a single internal `execute(command)` interface. `/workflow` opens the dashboard and may accept a run ID for direct inspection. `/workflow start <message>` sends the initial request through the assistant so the normal `pi_workflow` tool path owns feature naming and idempotency; in interactive TUI mode, omitting the message opens an input prompt. `/workflow continue` presents eligible pending context transitions for selection, while `/workflow continue <run-id>` continues one directly; `/workflow resume` is an alias for both forms. `/workflow stop [run-id]` aborts a non-terminal run owned by the active parent session and presents a selector when multiple owned runs are active. The misspelled `/worlflow` alias is intentionally unsupported. Continue and stop selectors require interactive TUI mode and provide direct-run-ID guidance otherwise.
|
||||||
|
|
||||||
|
Continuation performs only a pending fresh-session handoff or normal-compaction fallback after the run is safely waiting; it cannot approve plans, select prototypes, or make other Decisions. Stop treats `running` and `waiting` runs as active and excludes terminal runs and runs owned only by historical parent sessions.
|
||||||
|
|
||||||
When a run reaches a Gate, the tool returns a structured waiting view. The parent agent asks the user through its normal interaction tools, then calls `pi_workflow` with `action: "decide"`. Child agents never receive `ask_user`.
|
When a run reaches a Gate, the tool returns a structured waiting view. The parent agent asks the user through its normal interaction tools, then calls `pi_workflow` with `action: "decide"`. Child agents never receive `ask_user`.
|
||||||
|
|
||||||
@@ -614,7 +616,7 @@ Extend the Phase 2 worktree scaffold with the Implementation Context Gate, `/wor
|
|||||||
- Workspace creation intent is persisted first; creation failure rolls back safely or remains reconcilable after restart.
|
- Workspace creation intent is persisted first; creation failure rolls back safely or remains reconcilable after restart.
|
||||||
- Every feature run receives a distinct leased worktree and worktrees are retained until explicitly removed.
|
- Every feature run receives a distinct leased worktree and worktrees are retained until explicitly removed.
|
||||||
- A versioned `plan.md` is persisted before the Implementation Context Gate and revised with an accepted prototype when applicable.
|
- A versioned `plan.md` is persisted before the Implementation Context Gate and revised with an accepted prototype when applicable.
|
||||||
- Phase 4 registers `/workflow continue`; its `ExtensionCommandContext` performs both fresh-session replacement and approved normal-compaction fallback rather than deferring either path to dashboard integration.
|
- Phase 4 registers `/workflow start`, `/workflow stop`, `/workflow continue`, and its `/workflow resume` alias. Start routes through the assistant's normal `pi_workflow` tool path, stop is scoped to active runs owned by the current parent session, and continuation without a run ID selects from valid candidates. Its `ExtensionCommandContext` performs both fresh-session replacement and approved normal-compaction fallback rather than deferring either path to dashboard integration.
|
||||||
- `fresh-session` persists a Gate-, revision-, plan-, preparation-, and source-session-bound handoff intent before `newSession`; only the replacement `withSession` callback consumes it, attaches the new active parent session, seeds the bounded plan handoff, and resumes.
|
- `fresh-session` persists a Gate-, revision-, plan-, preparation-, and source-session-bound handoff intent before `newSession`; only the replacement `withSession` callback consumes it, attaches the new active parent session, seeds the bounded plan handoff, and resumes.
|
||||||
- `smart-compact` uses a Gate-bound `report-context-preparation` transition with distinct `unavailable`, `succeeded`, `no-op`, and `failed` outcomes; `failed` requires a non-empty actionable error. The host validates `unavailable` against `pi.getActiveTools()`; only that outcome permits plan-focused `ctx.compact({ onComplete, onError })`, whose intent-bound callback persists success or failure.
|
- `smart-compact` uses a Gate-bound `report-context-preparation` transition with distinct `unavailable`, `succeeded`, `no-op`, and `failed` outcomes; `failed` requires a non-empty actionable error. The host validates `unavailable` against `pi.getActiveTools()`; only that outcome permits plan-focused `ctx.compact({ onComplete, onError })`, whose intent-bound callback persists success or failure.
|
||||||
- An available-but-failed `smart_compact` call does not silently fall back and keeps the Implementation Context Gate waiting with an actionable error; `current-session` preserves context unchanged.
|
- An available-but-failed `smart_compact` call does not silently fall back and keeps the Implementation Context Gate waiting with an actionable error; `current-session` preserves context unchanged.
|
||||||
|
|||||||
@@ -43,10 +43,16 @@ function fakeApi() {
|
|||||||
let command: CommandHandler | undefined;
|
let command: CommandHandler | undefined;
|
||||||
let shutdown: ShutdownHandler | undefined;
|
let shutdown: ShutdownHandler | undefined;
|
||||||
let start: StartHandler | undefined;
|
let start: StartHandler | undefined;
|
||||||
|
const commandNames: string[] = [];
|
||||||
|
const userMessages: string[] = [];
|
||||||
const api = {
|
const api = {
|
||||||
registerCommand(_name: string, options: { handler: CommandHandler }) {
|
registerCommand(name: string, options: { handler: CommandHandler }) {
|
||||||
|
commandNames.push(name);
|
||||||
command = options.handler;
|
command = options.handler;
|
||||||
},
|
},
|
||||||
|
sendUserMessage(message: string) {
|
||||||
|
userMessages.push(message);
|
||||||
|
},
|
||||||
on(event: string, handler: ShutdownHandler | StartHandler) {
|
on(event: string, handler: ShutdownHandler | StartHandler) {
|
||||||
if (event === "session_shutdown") shutdown = handler as ShutdownHandler;
|
if (event === "session_shutdown") shutdown = handler as ShutdownHandler;
|
||||||
if (event === "session_start") start = handler as StartHandler;
|
if (event === "session_start") start = handler as StartHandler;
|
||||||
@@ -54,6 +60,8 @@ function fakeApi() {
|
|||||||
} as unknown as ExtensionAPI;
|
} as unknown as ExtensionAPI;
|
||||||
return {
|
return {
|
||||||
api,
|
api,
|
||||||
|
commandNames,
|
||||||
|
userMessages,
|
||||||
command: () => {
|
command: () => {
|
||||||
assert.ok(command);
|
assert.ok(command);
|
||||||
return command;
|
return command;
|
||||||
@@ -117,7 +125,10 @@ function commandContext(options: {
|
|||||||
sessionFile: string;
|
sessionFile: string;
|
||||||
compact?: ExtensionCommandContext["compact"];
|
compact?: ExtensionCommandContext["compact"];
|
||||||
newSession?: ExtensionCommandContext["newSession"];
|
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 notifications: string[] = [];
|
||||||
const context = {
|
const context = {
|
||||||
@@ -126,10 +137,14 @@ function commandContext(options: {
|
|||||||
getSessionId: () => options.sessionId,
|
getSessionId: () => options.sessionId,
|
||||||
getSessionFile: () => options.sessionFile,
|
getSessionFile: () => options.sessionFile,
|
||||||
},
|
},
|
||||||
|
mode: options.mode ?? "print",
|
||||||
|
hasUI: options.hasUI ?? false,
|
||||||
ui: {
|
ui: {
|
||||||
notify(message: string) {
|
notify(message: string) {
|
||||||
notifications.push(message);
|
notifications.push(message);
|
||||||
},
|
},
|
||||||
|
input: options.input ?? (async () => undefined),
|
||||||
|
select: options.select ?? (async () => undefined),
|
||||||
},
|
},
|
||||||
async waitForIdle() {},
|
async waitForIdle() {},
|
||||||
compact: options.compact ?? (() => {}),
|
compact: options.compact ?? (() => {}),
|
||||||
@@ -167,6 +182,331 @@ test("/workflow routes non-continuation arguments to the dashboard", async () =>
|
|||||||
assert.deepEqual(opened, ["run-1"]);
|
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 () => {
|
test("/workflow continue invokes and fences normal compaction callbacks", async () => {
|
||||||
await withTempDir(async (directory) => {
|
await withTempDir(async (directory) => {
|
||||||
const planPath = path.join(directory, "plan.md");
|
const planPath = path.join(directory, "plan.md");
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import type {
|
import type {
|
||||||
ExtensionAPI,
|
ExtensionAPI,
|
||||||
ExtensionCommandContext,
|
ExtensionCommandContext,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
type WorkflowEngine,
|
type WorkflowEngine,
|
||||||
type WorkflowView,
|
type WorkflowView,
|
||||||
} from "./engine.ts";
|
} from "./engine.ts";
|
||||||
|
import { isValidContextContinuation } from "./continuation.ts";
|
||||||
import {
|
import {
|
||||||
readVerifiedPlanArtifact,
|
readVerifiedPlanArtifact,
|
||||||
type WorkflowStore,
|
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,
|
runtime: ContextTransitionRuntime,
|
||||||
args: string,
|
runId: string,
|
||||||
ctx: ExtensionCommandContext,
|
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();
|
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 { view } = await inspect(runtime, ctx, runId);
|
||||||
const status = view.implementationContext?.status;
|
const status = view.implementationContext?.status;
|
||||||
if (status === "handoff-pending") {
|
if (status === "handoff-pending") {
|
||||||
@@ -259,6 +287,114 @@ async function handleContinue(
|
|||||||
throw new Error(`Workflow Run ${runId} has no pending context continuation`);
|
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(
|
export function executeHostDecision(
|
||||||
pi: Pick<ExtensionAPI, "getActiveTools">,
|
pi: Pick<ExtensionAPI, "getActiveTools">,
|
||||||
engine: WorkflowEngine,
|
engine: WorkflowEngine,
|
||||||
@@ -281,25 +417,44 @@ export function registerWorkflowContextTransitions(
|
|||||||
) => Promise<{ continueRunId?: string }>,
|
) => Promise<{ continueRunId?: string }>,
|
||||||
) {
|
) {
|
||||||
pi.registerCommand("workflow", {
|
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) => {
|
handler: async (args, ctx) => {
|
||||||
try {
|
try {
|
||||||
if (/^continue\s+\S+$/.test(args.trim())) {
|
const input = args.trim();
|
||||||
await handleContinue(runtime, args, ctx);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (openDashboard) {
|
if (openDashboard) {
|
||||||
const result = await openDashboard(args.trim(), ctx);
|
const result = await openDashboard(input, ctx);
|
||||||
if (result.continueRunId) {
|
if (result.continueRunId) {
|
||||||
await handleContinue(
|
await handleContinue(runtime, result.continueRunId, ctx);
|
||||||
runtime,
|
|
||||||
`continue ${result.continueRunId}`,
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await handleContinue(runtime, args, ctx);
|
await handleContinue(runtime, undefined, ctx);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ctx.ui.notify(
|
ctx.ui.notify(
|
||||||
error instanceof Error ? error.message : String(error),
|
error instanceof Error ? error.message : String(error),
|
||||||
|
|||||||
Reference in New Issue
Block a user