Files

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 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: "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 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 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 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 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;
}