import { StringEnum } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionContext, } from "@earendil-works/pi-coding-agent"; import { Type, type Static } from "typebox"; import { isValidContextContinuation } from "./continuation.ts"; import type { GateDecision } from "./domain.ts"; import { executeHostDecision, type ContextTransitionRuntime, } from "./context-transition.ts"; import type { WorkflowCommand, WorkflowView } from "./engine.ts"; import { deriveRunLabel } from "./run-label.ts"; import { workflowConfirm } from "./tui.ts"; import { WorkspaceValidationError } from "./workspace.ts"; const ACTIONS = ["start", "inspect", "decide", "abort"] as const; const WORKFLOW_MODES = ["feature", "feature-script", "analysis-script"] as const; const INSERTION_POINTS = [ "before-design", "after-design", "before-review", ] as const; const DECISION_KINDS = [ "answer-questions", "approve-plan", "revise-plan", "accept-prototype", "refine-prototypes", "select-implementation-context", "report-context-preparation", "recover", ] as const; const WorkflowDefinition = Type.Object( { mode: StringEnum(WORKFLOW_MODES), insertionPoint: Type.Optional(StringEnum(INSERTION_POINTS)), source: Type.Optional(Type.String()), args: Type.Optional(Type.String()), }, { additionalProperties: false }, ); const GateDecisionInput = Type.Object( { kind: StringEnum(DECISION_KINDS), answers: Type.Optional(Type.Record(Type.String(), Type.String())), approved: Type.Optional(Type.Boolean()), acceptedUiImpact: Type.Optional( StringEnum(["none", "minor", "non-trivial"] as const), ), comment: Type.Optional(Type.String()), feedback: Type.Optional(Type.String()), candidateId: Type.Optional(Type.String()), candidateRevision: Type.Optional(Type.Integer({ minimum: 0 })), browserReviewed: Type.Optional(Type.Boolean()), strategy: Type.Optional( StringEnum(["fresh-session", "smart-compact", "current-session"] as const), ), preparationId: Type.Optional(Type.String()), planRevision: Type.Optional(Type.Integer({ minimum: 0 })), method: Type.Optional(StringEnum(["smart_compact"] as const)), outcome: Type.Optional( StringEnum(["unavailable", "succeeded", "no-op", "failed"] as const), ), error: Type.Optional(Type.String()), resolution: Type.Optional( StringEnum(["retry", "inspect", "abort"] as const), ), }, { additionalProperties: false }, ); export const WorkflowParameters = Type.Object( { action: StringEnum(ACTIONS), request: Type.Optional(Type.String()), featureName: Type.Optional(Type.String()), workflow: Type.Optional(WorkflowDefinition), concurrency: Type.Optional(Type.Integer({ minimum: 1 })), background: Type.Optional(Type.Boolean()), allowDirtySource: Type.Optional(Type.Boolean()), runId: Type.Optional(Type.String()), gateId: Type.Optional(Type.String()), expectedRevision: Type.Optional(Type.Integer({ minimum: 0 })), decision: Type.Optional(GateDecisionInput), idempotencyKey: Type.Optional(Type.String()), }, { additionalProperties: false }, ); export type WorkflowInput = Static; function requiredString(value: unknown, label: string) { if (typeof value !== "string" || value.trim().length === 0) { throw new Error(`${label} is required`); } return value.trim(); } function scriptArgs(value: string | undefined) { if (value === undefined) return undefined; try { return JSON.parse(value) as unknown; } catch { return value; } } export function workflowCommandFromTool( input: WorkflowInput, activeToolNames: string[], ): WorkflowCommand { if (input.action === "start") { const request = requiredString(input.request, "request"); const idempotencyKey = requiredString( input.idempotencyKey, "idempotencyKey", ); const definition = input.workflow ?? { mode: "feature" as const }; if (definition.mode === "analysis-script") { return { action: "start-analysis", request, source: requiredString(definition.source, "workflow.source"), ...(definition.args !== undefined ? { args: scriptArgs(definition.args) } : {}), activeToolNames: [...activeToolNames], idempotencyKey, }; } const featureName = requiredString(input.featureName, "featureName"); if ( definition.mode === "feature-script" && (!definition.insertionPoint || !definition.source) ) { throw new Error( "feature-script requires workflow.source and workflow.insertionPoint", ); } return { action: "start", request, featureName, idempotencyKey, ...(input.concurrency !== undefined ? { concurrency: input.concurrency } : {}), ...(input.background !== undefined ? { background: input.background } : {}), ...(input.allowDirtySource !== undefined ? { allowDirtySource: input.allowDirtySource } : {}), ...(definition.mode === "feature-script" ? { script: { source: requiredString(definition.source, "workflow.source"), ...(definition.args !== undefined ? { args: scriptArgs(definition.args) } : {}), insertionPoint: definition.insertionPoint!, activeToolNames: [...activeToolNames], }, } : {}), }; } const runId = requiredString(input.runId, "runId"); if (input.action === "inspect") return { action: "inspect", runId }; const expectedRevision = input.expectedRevision; if (!Number.isInteger(expectedRevision) || expectedRevision! < 0) { throw new Error("expectedRevision is required"); } const idempotencyKey = requiredString( input.idempotencyKey, "idempotencyKey", ); if (input.action === "abort") { return { action: "abort", runId, expectedRevision: expectedRevision!, idempotencyKey, }; } if (!input.decision) throw new Error("decision is required"); return { action: "decide", runId, gateId: requiredString(input.gateId, "gateId"), expectedRevision: expectedRevision!, decision: input.decision as GateDecision, idempotencyKey, }; } export interface WorkflowToolDetails { action: WorkflowInput["action"]; runId: string; revision: number; status: WorkflowView["status"]; openGate?: { id: string; kind: string; revision: number; payload?: unknown; }; continuation?: { command: string; runId: string }; planPath?: string; worktreePath?: string; assignmentCounts: Record; } function boundedValue(value: unknown, budget = { characters: 24_000 }, depth = 0): unknown { if (budget.characters <= 0) return "[truncated]"; if (typeof value === "string") { const kept = [...value].slice(0, budget.characters).join(""); budget.characters -= [...kept].length; return kept.length < value.length ? `${kept}[truncated]` : kept; } if ( value === null || typeof value === "number" || typeof value === "boolean" ) { return value; } if (depth >= 6) return "[depth truncated]"; if (Array.isArray(value)) { return value .slice(0, 100) .map((entry) => boundedValue(entry, budget, depth + 1)); } if (typeof value === "object") { return Object.fromEntries( Object.entries(value as Record) .slice(0, 100) .map(([key, entry]) => [ key.slice(0, 200), boundedValue(entry, budget, depth + 1), ]), ); } return String(value); } function boundedString(value: unknown, max = 2_000) { if (typeof value !== "string") return undefined; return [...value].slice(0, max).join(""); } function compactGatePayload(view: WorkflowView) { const gate = view.openGate; const payload = gate?.payload; if (!gate) return undefined; if (gate.kind === "implementation-context") { const context = view.implementationContext; return context ? { preparationId: context.preparationId, planRevision: context.planRevision, contextRevision: context.runRevision, strategy: context.strategy, status: context.status, method: context.method, outcome: context.outcome, error: boundedString(context.error), } : undefined; } if (!payload || typeof payload !== "object") return payload; const record = payload as Record; if (gate.kind === "questions") { const questions = Array.isArray(record.questions) ? record.questions : []; return { questions: questions.slice(0, 20).flatMap((question) => { if (!question || typeof question !== "object") return []; const item = question as Record; const id = boundedString(item.id, 160); const prompt = boundedString(item.prompt); return id && prompt ? [{ id, prompt }] : []; }), }; } if (gate.kind === "plan-approval") { return { planArtifact: boundedValue(record.planArtifact), uiImpact: record.uiImpact, uiImpactEvidence: Array.isArray(record.uiImpactEvidence) ? record.uiImpactEvidence.slice(0, 20).map((entry) => boundedString(entry, 500)) : [], affectedUiSurfaces: Array.isArray(record.affectedUiSurfaces) ? record.affectedUiSurfaces.slice(0, 20).map((entry) => boundedString(entry, 500)) : [], }; } if (gate.kind === "prototype-acceptance") { const candidates = Array.isArray(record.candidates) ? record.candidates : []; return { candidates: candidates.slice(-3).map((candidate) => { if (!candidate || typeof candidate !== "object") return candidate; const item = candidate as Record; return { id: item.id, revision: item.revision, route: boundedString(item.route), structuralRationale: boundedString(item.structuralRationale), launchInstructions: boundedString(item.launchInstructions), artifactPath: boundedString(item.artifactPath), retainedArtifacts: Array.isArray(item.retainedArtifacts) ? item.retainedArtifacts .slice(0, 10) .map((entry) => boundedString(entry, 1_000)) : [], }; }), }; } return boundedValue(record); } export function compactWorkflowDetails( action: WorkflowInput["action"], view: WorkflowView, sessionId?: string, ): WorkflowToolDetails { const assignmentCounts: Record = {}; const gatePayload = compactGatePayload(view); for (const assignment of view.assignments) { assignmentCounts[assignment.status] = (assignmentCounts[assignment.status] ?? 0) + 1; } const continuation = isValidContextContinuation(view, sessionId) ? { command: "/workflow continue", runId: view.runId } : undefined; return { action, runId: view.runId, revision: view.revision, status: view.status, ...(view.openGate ? { openGate: { id: view.openGate.id, kind: view.openGate.kind, revision: view.openGate.revision, ...(gatePayload !== undefined ? { payload: gatePayload } : {}), }, } : {}), ...(continuation ? { continuation } : {}), ...(view.planArtifact ? { planPath: view.planArtifact.path } : {}), ...(view.workspace.root ? { worktreePath: view.workspace.root } : {}), assignmentCounts, }; } function boundToolOutput(contents: string) { const maxBytes = 48 * 1024; if (Buffer.byteLength(contents, "utf8") <= maxBytes) return contents; const marker = "\n[tool output truncated]"; const limit = maxBytes - Buffer.byteLength(marker, "utf8"); let bytes = 0; const characters: string[] = []; for (const character of contents) { const width = Buffer.byteLength(character, "utf8"); if (bytes + width > limit) break; characters.push(character); bytes += width; } return `${characters.join("")}${marker}`; } function resultText(details: WorkflowToolDetails, label: string) { const lines = [ `Workflow ${label}: ${details.status} at revision ${details.revision}.`, ]; if (details.openGate) { lines.push( `Open Gate: ${details.openGate.kind} (${details.openGate.id}). Obtain the user's explicit Decision before deciding it.`, ); if (details.openGate.payload !== undefined) { lines.push(`Gate context: ${JSON.stringify(details.openGate.payload)}`); } } if (details.continuation) { lines.push(`${details.continuation.command} ${details.continuation.runId}`); } if (details.worktreePath) lines.push(`Worktree: ${details.worktreePath}`); if (details.planPath) lines.push(`Plan: ${details.planPath}`); return boundToolOutput(lines.join("\n")); } async function cancelToolWorkflow( runtime: ContextTransitionRuntime, engine: Awaited>, input: WorkflowInput, toolCallId: string, ) { const runId = input.runId; if (!runId) return; const current = await runtime.store.loadRun(runId).catch(() => undefined); if ( !current || current.status === "completed" || current.status === "failed" || current.status === "aborted" ) { return; } await engine.execute({ action: "abort", runId, expectedRevision: current.revision, idempotencyKey: `tool-cancel-${toolCallId}`, }).catch(() => {}); } function dirtySourceWarning(error: WorkspaceValidationError) { const visibleChanges = error.sourceChanges.slice(0, 6); const changes = visibleChanges.map( (change) => `${change.status} ${change.path}`, ); const omitted = error.sourceChanges.length - visibleChanges.length + error.sourceChangesOmitted; if (omitted > 0) { changes.push(`[${omitted} additional paths omitted]`); } return [ "The workflow worktree will start at the repository's current HEAD.", "These staged, unstaged, or untracked source-only changes will be omitted:", ...changes, "Continue only if the accepted plan should exclude those changes.", ].join("\n"); } export function registerWorkflowTool( pi: ExtensionAPI, runtime: ContextTransitionRuntime, ) { pi.registerTool({ name: "workflow", label: "Pi Workflow", description: "Start, inspect, decide, or abort a persisted gated feature or read-only analysis Workflow Run. Noninteractive starts from a dirty source require allowDirtySource=true. Mutating actions require idempotency keys and expected revisions. Gate Decisions must come from the user; never infer approval.", promptSnippet: "Plan and build features through persisted Gates, worktrees, verification, and independent review", promptGuidelines: [ "Use workflow when the user asks to plan or build a non-trivial feature through the managed workflow.", "Never use workflow decide to infer a Gate Decision; obtain the user's explicit answer, plan approval, prototype acceptance, context choice, or recovery choice first.", "Use workflow inspect before every state-changing call and pass its current revision and Gate ID.", ], parameters: WorkflowParameters, async execute(toolCallId, params, signal, _onUpdate, ctx: ExtensionContext) { const engine = await runtime.engineForContext(ctx); const command = workflowCommandFromTool(params, pi.getActiveTools()); const executionSignal = signal ?? new AbortController().signal; const cancellationError = () => executionSignal.reason instanceof Error ? executionSignal.reason : new Error("workflow tool call was cancelled"); const operation = (async () => { if (executionSignal.aborted) throw cancellationError(); if (command.action === "decide") { return executeHostDecision(pi, engine, command); } const initial = command.action === "start" && ctx.mode === "tui" && command.allowDirtySource === undefined ? { ...command, allowDirtySource: false } : command; try { return await engine.execute(initial); } catch (error) { if ( command.action !== "start" || ctx.mode !== "tui" || command.allowDirtySource !== undefined || !(error instanceof WorkspaceValidationError) || error.code !== "dirty-source" ) { throw error; } const confirmed = await workflowConfirm( ctx, "Dirty source checkout", dirtySourceWarning(error), ); if (executionSignal.aborted) throw cancellationError(); if (!confirmed) { throw new Error("Workflow start cancelled because source changes would be omitted"); } return engine.execute({ ...command, allowDirtySource: true }); } })(); let rejectCancellation = (_error: unknown) => {}; const cancellation = new Promise((_resolve, reject) => { rejectCancellation = reject; }); let cancellationWork: Promise | undefined; const onAbort = () => { cancellationWork = command.action === "start" || command.action === "start-analysis" ? engine.cancelStart( command.idempotencyKey, `tool-cancel-${toolCallId}`, cancellationError(), ) : cancelToolWorkflow(runtime, engine, params, toolCallId); void cancellationWork.then( () => rejectCancellation(cancellationError()), () => rejectCancellation(cancellationError()), ); }; executionSignal.addEventListener("abort", onAbort, { once: true }); if (executionSignal.aborted) onAbort(); let view: WorkflowView; try { view = await Promise.race([operation, cancellation]); if (executionSignal.aborted) { await cancellationWork; throw cancellationError(); } } catch (error) { if (params.action === "inspect" && params.runId) { const reason = error instanceof Error ? error.message : String(error); throw new Error( `Workflow Run ${params.runId} could not be loaded: ${reason}`, ); } throw error; } finally { executionSignal.removeEventListener("abort", onAbort); } const details = compactWorkflowDetails( params.action, view, ctx.sessionManager.getSessionId(), ); return { content: [ { type: "text" as const, text: resultText(details, deriveRunLabel(view)) }, ], details, }; }, }); }