Files

557 lines
19 KiB
TypeScript

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<typeof WorkflowParameters>;
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<string, number>;
}
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<string, unknown>)
.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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, number> = {};
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<ReturnType<ContextTransitionRuntime["engineForContext"]>>,
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<never>((_resolve, reject) => {
rejectCancellation = reject;
});
let cancellationWork: Promise<void> | 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,
};
},
});
}