400 lines
12 KiB
TypeScript
400 lines
12 KiB
TypeScript
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 {
|
|
readVerifiedPlanArtifact,
|
|
type WorkflowStore,
|
|
} from "./store.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 ${view.runId}.`,
|
|
`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 ${runId} 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 ${view.runId}.`,
|
|
`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;
|
|
}
|
|
}
|
|
|
|
async function handleContinue(
|
|
runtime: ContextTransitionRuntime,
|
|
args: 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") {
|
|
await continueFreshSession(runtime, ctx, view);
|
|
return;
|
|
}
|
|
if (status === "normal-compact-pending") {
|
|
await continueNormalCompaction(runtime, ctx, view);
|
|
return;
|
|
}
|
|
throw new Error(`Workflow Run ${runId} has no pending context continuation`);
|
|
}
|
|
|
|
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: "Inspect and continue persisted pi-workflow runs",
|
|
handler: async (args, ctx) => {
|
|
try {
|
|
if (/^continue\s+\S+$/.test(args.trim())) {
|
|
await handleContinue(runtime, args, ctx);
|
|
return;
|
|
}
|
|
if (openDashboard) {
|
|
const result = await openDashboard(args.trim(), ctx);
|
|
if (result.continueRunId) {
|
|
await handleContinue(
|
|
runtime,
|
|
`continue ${result.continueRunId}`,
|
|
ctx,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
await handleContinue(runtime, args, 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;
|
|
}
|