add persisted pi workflow extension
This commit is contained in:
@@ -0,0 +1,399 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user