568 lines
17 KiB
TypeScript
568 lines
17 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import type {
|
|
ExtensionAPI,
|
|
ExtensionCommandContext,
|
|
ExtensionContext,
|
|
} from "@earendil-works/pi-coding-agent";
|
|
import type { WorkflowRun } from "./domain.ts";
|
|
import {
|
|
type CompleteNormalCompactionCommand,
|
|
type ConsumeContextHandoffCommand,
|
|
type ContinueContextCommand,
|
|
type DecideWorkflowCommand,
|
|
type WorkflowCommand,
|
|
type WorkflowEngine,
|
|
type WorkflowView,
|
|
} from "./engine.ts";
|
|
import { isValidContextContinuation } from "./continuation.ts";
|
|
import { deriveRunLabel, deriveRunLabels } from "./run-label.ts";
|
|
import { readVerifiedPlanArtifact, type WorkflowStore } from "./store.ts";
|
|
import { workflowInput, workflowSelect } from "./tui.ts";
|
|
|
|
const MAX_HANDOFF_BYTES = 32 * 1024;
|
|
type NewSessionOptions = NonNullable<
|
|
Parameters<ExtensionCommandContext["newSession"]>[0]
|
|
>;
|
|
type ReplacementContext = Parameters<
|
|
NonNullable<NewSessionOptions["withSession"]>
|
|
>[0];
|
|
|
|
export interface ContextTransitionRuntime {
|
|
store: WorkflowStore;
|
|
engineForContext(ctx: ExtensionContext): Promise<WorkflowEngine> | WorkflowEngine;
|
|
}
|
|
|
|
function sessionId(ctx: ExtensionContext) {
|
|
const id = ctx.sessionManager.getSessionId();
|
|
if (!id) throw new Error("Workflow context transition requires a persisted session ID");
|
|
return id;
|
|
}
|
|
|
|
function sessionFile(ctx: ExtensionContext) {
|
|
const file = ctx.sessionManager.getSessionFile();
|
|
if (!file) throw new Error("Workflow context transition requires a persisted session file");
|
|
return file;
|
|
}
|
|
|
|
function contextCommand(view: WorkflowView) {
|
|
const context = view.implementationContext;
|
|
if (!context) throw new Error("Workflow Run has no Implementation Context intent");
|
|
return {
|
|
runId: view.runId,
|
|
gateId: context.gateId,
|
|
preparationId: context.preparationId,
|
|
planRevision: context.planRevision,
|
|
contextRevision: context.runRevision,
|
|
expectedRevision: view.revision,
|
|
};
|
|
}
|
|
|
|
async function boundedPlan(view: WorkflowView) {
|
|
if (!view.planArtifact) throw new Error("Workflow Run has no persisted plan");
|
|
const plan = await readVerifiedPlanArtifact(view.planArtifact);
|
|
let bytes = 0;
|
|
const characters: string[] = [];
|
|
for (const character of plan) {
|
|
const width = Buffer.byteLength(character, "utf8");
|
|
if (bytes + width > MAX_HANDOFF_BYTES) break;
|
|
characters.push(character);
|
|
bytes += width;
|
|
}
|
|
return characters.join("");
|
|
}
|
|
|
|
function handoffMessage(view: WorkflowView, plan: string) {
|
|
return [
|
|
`Continue pi-workflow run ${deriveRunLabel(view)}.`,
|
|
`Worktree: ${view.workspace.root ?? "missing"}`,
|
|
`Plan revision: ${view.planArtifact?.revision ?? "missing"}`,
|
|
"The workflow state remains authoritative. Inspect it before taking action.",
|
|
"",
|
|
plan,
|
|
].join("\n");
|
|
}
|
|
|
|
async function inspect(
|
|
runtime: ContextTransitionRuntime,
|
|
ctx: ExtensionContext,
|
|
runId: string,
|
|
) {
|
|
const engine = await runtime.engineForContext(ctx);
|
|
return {
|
|
engine,
|
|
view: await engine.execute({ action: "inspect", runId }),
|
|
};
|
|
}
|
|
|
|
async function completeNormalCompaction(
|
|
runtime: ContextTransitionRuntime,
|
|
ctx: ExtensionContext,
|
|
view: WorkflowView,
|
|
outcome: CompleteNormalCompactionCommand["outcome"],
|
|
error?: string,
|
|
) {
|
|
const engine = await runtime.engineForContext(ctx);
|
|
const command: CompleteNormalCompactionCommand = {
|
|
action: "complete-normal-compaction",
|
|
...contextCommand(view),
|
|
outcome,
|
|
...(error ? { error } : {}),
|
|
};
|
|
await engine.execute(command);
|
|
}
|
|
|
|
async function consumeHandoff(
|
|
runtime: ContextTransitionRuntime,
|
|
replacementCtx: ReplacementContext,
|
|
runId: string,
|
|
sourceSessionFile: string,
|
|
) {
|
|
const current = await inspect(runtime, replacementCtx, runId);
|
|
const targetSessionFile = sessionFile(replacementCtx);
|
|
const plan = await boundedPlan(current.view);
|
|
await replacementCtx.sendMessage({
|
|
customType: "pi-workflow-handoff",
|
|
content: handoffMessage(current.view, plan),
|
|
display: true,
|
|
});
|
|
const command: ConsumeContextHandoffCommand = {
|
|
action: "consume-context-handoff",
|
|
...contextCommand(current.view),
|
|
targetSessionFile,
|
|
previousSessionFile: sourceSessionFile,
|
|
};
|
|
await current.engine.execute(command);
|
|
replacementCtx.ui.notify(
|
|
`Workflow ${deriveRunLabel(current.view)} context handoff is ready`,
|
|
"info",
|
|
);
|
|
}
|
|
|
|
async function continueFreshSession(
|
|
runtime: ContextTransitionRuntime,
|
|
ctx: ExtensionCommandContext,
|
|
view: WorkflowView,
|
|
) {
|
|
const currentSessionId = sessionId(ctx);
|
|
const sourceSessionFile = sessionFile(ctx);
|
|
if (view.implementationContext?.activeParentSessionId !== currentSessionId) {
|
|
throw new Error("Only the active parent session may continue this Workflow Run");
|
|
}
|
|
const engine = await runtime.engineForContext(ctx);
|
|
const command: ContinueContextCommand = {
|
|
action: "continue-context",
|
|
...contextCommand(view),
|
|
sourceSessionFile,
|
|
};
|
|
const pending = await engine.execute(command);
|
|
const result = await ctx.newSession({
|
|
parentSession: sourceSessionFile,
|
|
withSession: async (replacementCtx) => {
|
|
await consumeHandoff(runtime, replacementCtx, view.runId, sourceSessionFile);
|
|
},
|
|
});
|
|
if (result.cancelled) {
|
|
const pendingContext = pending.implementationContext;
|
|
if (pendingContext) {
|
|
await engine.execute({
|
|
action: "recover-context-intent",
|
|
runId: pending.runId,
|
|
gateId: pendingContext.gateId,
|
|
preparationId: pendingContext.preparationId,
|
|
planRevision: pendingContext.planRevision,
|
|
contextRevision: pendingContext.runRevision,
|
|
expectedRevision: pending.revision,
|
|
reason: "cancelled",
|
|
});
|
|
}
|
|
ctx.ui.notify("Workflow context handoff was cancelled", "warning");
|
|
}
|
|
}
|
|
|
|
async function continueNormalCompaction(
|
|
runtime: ContextTransitionRuntime,
|
|
ctx: ExtensionCommandContext,
|
|
view: WorkflowView,
|
|
) {
|
|
const plan = await boundedPlan(view);
|
|
const engine = await runtime.engineForContext(ctx);
|
|
const command: ContinueContextCommand = {
|
|
action: "continue-context",
|
|
...contextCommand(view),
|
|
};
|
|
const compacting = await engine.execute(command);
|
|
const instructions = [
|
|
`Prepare context for pi-workflow run ${deriveRunLabel(view)}.`,
|
|
`Preserve the worktree path, accepted Decisions, and plan revision ${view.planArtifact?.revision}.`,
|
|
"Use this approved plan as the implementation focus:",
|
|
plan,
|
|
].join("\n\n");
|
|
try {
|
|
ctx.compact({
|
|
customInstructions: instructions,
|
|
onComplete: () => {
|
|
void completeNormalCompaction(runtime, ctx, compacting, "succeeded").catch(
|
|
(error: unknown) => {
|
|
ctx.ui.notify(
|
|
`Workflow compaction callback failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
"error",
|
|
);
|
|
},
|
|
);
|
|
},
|
|
onError: (error) => {
|
|
void completeNormalCompaction(
|
|
runtime,
|
|
ctx,
|
|
compacting,
|
|
"failed",
|
|
error.message || "Normal compaction failed",
|
|
).catch((callbackError: unknown) => {
|
|
ctx.ui.notify(
|
|
`Workflow compaction callback failed: ${callbackError instanceof Error ? callbackError.message : String(callbackError)}`,
|
|
"error",
|
|
);
|
|
});
|
|
},
|
|
});
|
|
} catch (error) {
|
|
await completeNormalCompaction(
|
|
runtime,
|
|
ctx,
|
|
compacting,
|
|
"failed",
|
|
error instanceof Error ? error.message : String(error),
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function isInteractive(ctx: ExtensionCommandContext) {
|
|
return ctx.hasUI && ctx.mode === "tui";
|
|
}
|
|
|
|
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 = deriveRunLabels(runs);
|
|
return workflowSelect(
|
|
ctx,
|
|
title,
|
|
runs.map((run, index) => ({
|
|
value: run,
|
|
label: labels[index]!,
|
|
description: run.status,
|
|
})),
|
|
);
|
|
}
|
|
|
|
async function continueRun(
|
|
runtime: ContextTransitionRuntime,
|
|
runId: string,
|
|
ctx: ExtensionCommandContext,
|
|
) {
|
|
await ctx.waitForIdle();
|
|
let view: WorkflowView;
|
|
try {
|
|
({ view } = await inspect(runtime, ctx, runId));
|
|
} catch (error) {
|
|
const reason = error instanceof Error ? error.message : String(error);
|
|
throw new Error(`Workflow Run ${runId} could not be loaded: ${reason}`);
|
|
}
|
|
const status = view.implementationContext?.status;
|
|
if (status === "handoff-pending") {
|
|
await continueFreshSession(runtime, ctx, view);
|
|
return;
|
|
}
|
|
if (status === "normal-compact-pending") {
|
|
await continueNormalCompaction(runtime, ctx, view);
|
|
return;
|
|
}
|
|
throw new Error(
|
|
`Workflow Run ${deriveRunLabel(view)} 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 workflowInput(
|
|
ctx,
|
|
"Start Workflow Run",
|
|
"What do you want to start working on?",
|
|
))?.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 ${deriveRunLabel(current)} stopped.`, "info");
|
|
}
|
|
|
|
export function executeHostDecision(
|
|
pi: Pick<ExtensionAPI, "getActiveTools">,
|
|
engine: WorkflowEngine,
|
|
command: DecideWorkflowCommand,
|
|
) {
|
|
return engine.execute({
|
|
...command,
|
|
...(command.decision.kind === "report-context-preparation"
|
|
? { activeToolNames: pi.getActiveTools() }
|
|
: {}),
|
|
});
|
|
}
|
|
|
|
export function registerWorkflowContextTransitions(
|
|
pi: ExtensionAPI,
|
|
runtime: ContextTransitionRuntime,
|
|
openDashboard?: (
|
|
args: string,
|
|
ctx: ExtensionCommandContext,
|
|
) => Promise<{ continueRunId?: string }>,
|
|
) {
|
|
pi.registerCommand("workflow", {
|
|
description: "Start, stop, inspect, or continue persisted pi-workflow runs",
|
|
handler: async (args, ctx) => {
|
|
try {
|
|
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(input, ctx);
|
|
if (result.continueRunId) {
|
|
await handleContinue(runtime, result.continueRunId, ctx);
|
|
}
|
|
return;
|
|
}
|
|
await handleContinue(runtime, undefined, ctx);
|
|
} catch (error) {
|
|
ctx.ui.notify(
|
|
error instanceof Error ? error.message : String(error),
|
|
"error",
|
|
);
|
|
}
|
|
},
|
|
});
|
|
|
|
pi.on("session_start", async (event, ctx) => {
|
|
const currentSessionFile = ctx.sessionManager.getSessionFile();
|
|
if (!currentSessionFile) return;
|
|
for (const run of await runtime.store.listRuns()) {
|
|
const context = run.implementationContext;
|
|
if (run.status !== "waiting" || context?.status !== "claimed-target-awaiting-withSession") {
|
|
continue;
|
|
}
|
|
const immediateTarget =
|
|
event.reason === "new" &&
|
|
event.previousSessionFile === context.sourceSessionFile &&
|
|
currentSessionFile === context.targetSessionFile &&
|
|
(context.intentExpiresAt === undefined || context.intentExpiresAt >= Date.now());
|
|
if (immediateTarget) continue;
|
|
const engine = await runtime.engineForContext(ctx);
|
|
try {
|
|
await engine.execute({
|
|
action: "recover-context-intent",
|
|
runId: run.runId,
|
|
gateId: context.gateId,
|
|
preparationId: context.preparationId,
|
|
planRevision: context.planRevision,
|
|
contextRevision: context.runRevision,
|
|
expectedRevision: run.revision,
|
|
reason:
|
|
context.intentExpiresAt !== undefined && context.intentExpiresAt < Date.now()
|
|
? "expired"
|
|
: event.reason === "reload" ||
|
|
event.reason === "resume" ||
|
|
event.reason === "fork"
|
|
? event.reason
|
|
: "startup",
|
|
});
|
|
} catch (error) {
|
|
if (
|
|
!(error instanceof Error) ||
|
|
!/owned by live process/.test(error.message)
|
|
) {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
pi.on("session_shutdown", async (event, ctx) => {
|
|
if (event.reason !== "new" || !event.targetSessionFile) return;
|
|
const sourceSessionFile = ctx.sessionManager.getSessionFile();
|
|
const sourceSessionId = ctx.sessionManager.getSessionId();
|
|
if (!sourceSessionFile || !sourceSessionId) return;
|
|
for (const run of await runtime.store.listRuns()) {
|
|
const context = run.implementationContext;
|
|
if (
|
|
run.status !== "waiting" ||
|
|
context?.status !== "handoff-pending" ||
|
|
context.sourceParentSessionId !== sourceSessionId ||
|
|
context.sourceSessionFile !== sourceSessionFile
|
|
) {
|
|
continue;
|
|
}
|
|
const engine = await runtime.engineForContext(ctx);
|
|
await engine.execute({
|
|
action: "claim-context-handoff",
|
|
runId: run.runId,
|
|
gateId: context.gateId,
|
|
preparationId: context.preparationId,
|
|
planRevision: context.planRevision,
|
|
contextRevision: context.runRevision,
|
|
expectedRevision: run.revision,
|
|
sourceSessionFile,
|
|
targetSessionFile: event.targetSessionFile,
|
|
reason: "new",
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
export type ContextTransitionCommand = Extract<
|
|
WorkflowCommand,
|
|
| { action: "continue-context" }
|
|
| { action: "claim-context-handoff" }
|
|
| { action: "consume-context-handoff" }
|
|
| { action: "complete-normal-compaction" }
|
|
| { action: "recover-context-intent" }
|
|
>;
|
|
|
|
export function contextTransitionState(run: WorkflowRun) {
|
|
return run.implementationContext;
|
|
}
|