feat: package Pi extensions as Bun workspaces
This commit is contained in:
@@ -0,0 +1,556 @@
|
||||
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,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user