557 lines
19 KiB
TypeScript
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,
|
|
};
|
|
},
|
|
});
|
|
}
|