pi-extensible-workflows 0.3.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,17 +4,26 @@ import { access, link, mkdir, open, readFile, readdir, rename, rm, stat, writeFi
4
4
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
5
5
  import { homedir } from "node:os";
6
6
  import { promisify } from "node:util";
7
- import type { JsonValue, LaunchSnapshot, RunRecord } from "./index.js";
7
+ import type { BudgetApprovalRequest, JsonValue, LaunchSnapshot, RunRecord, WorkflowRunEvent } from "./index.js";
8
8
  import type { OwnershipRecord } from "./agent-execution.js";
9
9
  import { loadLaunchSnapshot, WorkflowError } from "./index.js";
10
10
 
11
11
  export interface NativeSessionReference { sessionId: string; sessionFile: string }
12
12
  export interface EffectiveSystemPrompt { sessionId: string; attempt: number; turn: number; sha256: string; prompt: string }
13
+ export interface ConversationHead { turn: number; sessionId: string; sessionFile: string; leafId: string; systemPrompt: string; systemPromptSha256: string; toolDefinitionsSha256: string }
14
+ export interface PersistedConversation { id: string; policy: JsonValue; head: ConversationHead }
15
+ type ConversationArtifact = { version: 1; conversations: Record<string, PersistedConversation> };
16
+ function isConversationArtifact(value: unknown): value is ConversationArtifact {
17
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
18
+ const artifact = value as { version?: unknown; conversations?: unknown };
19
+ return artifact.version === 1 && Boolean(artifact.conversations) && typeof artifact.conversations === "object" && !Array.isArray(artifact.conversations);
20
+ }
13
21
  export interface PersistedRun extends RunRecord { nativeSessions: readonly NativeSessionReference[] }
14
22
  export interface CompletedOperation { path: string; value: JsonValue }
15
23
  export interface AwaitingCheckpoint { path: string; name: string; prompt: string; context: JsonValue }
24
+ export type PendingWorkflowDecision = BudgetApprovalRequest
16
25
  export type PersistedOwnershipNode = OwnershipRecord
17
- type Journal = { completed: Record<string, CompletedOperation>; awaiting?: Record<string, AwaitingCheckpoint> };
26
+ type Journal = { completed: Record<string, CompletedOperation>; awaiting?: Record<string, AwaitingCheckpoint>; decisions?: Record<string, PendingWorkflowDecision> };
18
27
  export interface WorktreeReference { owner: string; path: string; branch: string; cwd: string; base: string }
19
28
 
20
29
  const execute = promisify(execFile);
@@ -164,8 +173,10 @@ export class RunStore {
164
173
  private stateWrite: Promise<void> = Promise.resolve();
165
174
  private worktreeWrite: Promise<void> = Promise.resolve();
166
175
  private snapshotWrite: Promise<void> = Promise.resolve();
176
+ private launchSnapshotWrite: Promise<void> = Promise.resolve();
167
177
  // ponytail: the session lease prevents concurrent RunStore writers for one run.
168
178
  private systemPromptWrite: Promise<void> = Promise.resolve();
179
+ private conversationWrite: Promise<void> = Promise.resolve();
169
180
  constructor(readonly cwd: string, readonly sessionId: string, readonly runId: string, home = homedir()) {
170
181
  this.cwd = resolve(cwd);
171
182
  this.directory = join(runsDirectory(this.cwd, sessionId, home), safePart(runId));
@@ -177,12 +188,14 @@ export class RunStore {
177
188
  await mkdir(dirname(this.directory), { recursive: true, mode: 0o700 });
178
189
  await mkdir(temporary, { mode: 0o700 });
179
190
  try {
191
+ await writeFile(join(temporary, "workflow.js"), snapshot.script, { encoding: "utf8", mode: 0o600 });
180
192
  await atomicJson(join(temporary, "snapshot.json"), snapshot);
181
- await atomicJson(join(temporary, "journal.json"), { completed: {}, awaiting: {} });
193
+ await atomicJson(join(temporary, "journal.json"), { completed: {}, awaiting: {}, decisions: {} });
182
194
  await atomicJson(join(temporary, "ownership.json"), []);
183
195
  await atomicJson(join(temporary, "worktrees.json"), []);
184
196
  await atomicJson(join(temporary, "state.json"), run);
185
197
  await atomicJson(join(temporary, "system-prompts.json"), { version: 1, entries: [] });
198
+ await atomicJson(join(temporary, "conversations.json"), { version: 1, conversations: {} });
186
199
  await rename(temporary, this.directory);
187
200
  } catch (error) {
188
201
  await rm(temporary, { recursive: true, force: true });
@@ -225,6 +238,16 @@ export class RunStore {
225
238
  return result;
226
239
  }
227
240
 
241
+ async saveSnapshot(snapshot: Readonly<LaunchSnapshot>): Promise<void> {
242
+ const write = this.launchSnapshotWrite.then(() => atomicJson(join(this.directory, "snapshot.json"), snapshot));
243
+ this.launchSnapshotWrite = write.catch(() => undefined);
244
+ await write;
245
+ }
246
+
247
+ async appendEvent(event: WorkflowRunEvent): Promise<void> {
248
+ await this.updateState((run) => ({ ...run, events: [...(run.events ?? []), ...(run.events?.some((current) => current.message === event.message) ? [] : [event])] }));
249
+ }
250
+
228
251
  async saveOwnership(nodes: readonly PersistedOwnershipNode[]): Promise<void> {
229
252
  await atomicJson(join(this.directory, "ownership.json"), nodes);
230
253
  }
@@ -251,6 +274,27 @@ export class RunStore {
251
274
  return (await json<{ version: 1; entries: EffectiveSystemPrompt[] }>(this.systemPromptPath()).catch((error: unknown) => { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { version: 1 as const, entries: [] }; throw error; })).entries;
252
275
  }
253
276
 
277
+ conversationPath(): string { return join(this.directory, "conversations.json"); }
278
+ async conversation(id: string): Promise<PersistedConversation | undefined> {
279
+ await this.conversationWrite;
280
+ let artifact: ConversationArtifact;
281
+ try { const raw = await json<unknown>(this.conversationPath()); if (!isConversationArtifact(raw)) throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation state is corrupt"); artifact = raw; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; if (error instanceof WorkflowError) throw error; throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot load conversation state: ${error instanceof Error ? error.message : String(error)}`); }
282
+ return artifact.conversations[id];
283
+ }
284
+ async saveConversation(conversation: PersistedConversation): Promise<void> {
285
+ const write = this.conversationWrite.then(async () => {
286
+ const path = this.conversationPath();
287
+ let artifact: ConversationArtifact;
288
+ try { const raw = await json<unknown>(path); if (!isConversationArtifact(raw)) throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation state is corrupt"); artifact = raw; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") artifact = { version: 1, conversations: {} }; else if (error instanceof WorkflowError) throw error; else throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot load conversation state: ${error instanceof Error ? error.message : String(error)}`); }
289
+ const previous = artifact.conversations[conversation.id];
290
+ if (previous && previous.head.turn + 1 !== conversation.head.turn) throw new WorkflowError("RESUME_INCOMPATIBLE", `Conversation head is not the previous turn: ${conversation.id}`);
291
+ if (!previous && conversation.head.turn !== 1) throw new WorkflowError("RESUME_INCOMPATIBLE", `Conversation must start at turn one: ${conversation.id}`);
292
+ artifact.conversations[conversation.id] = structuredClone(conversation);
293
+ await atomicJson(path, artifact);
294
+ });
295
+ this.conversationWrite = write.catch(() => undefined);
296
+ await write;
297
+ }
254
298
  private async updateJournal<T>(update: (journal: Journal) => T | Promise<T>): Promise<T> {
255
299
  let result!: T;
256
300
  const write = this.journalWrite.then(async () => {
@@ -291,13 +335,30 @@ export class RunStore {
291
335
  const journal = await json<Journal>(join(this.directory, "journal.json"));
292
336
  return Object.values(journal.awaiting ?? {});
293
337
  }
338
+ async requestWorkflowDecision(request: PendingWorkflowDecision): Promise<void> {
339
+ await this.updateJournal((journal) => { journal.decisions ??= {}; journal.decisions[request.proposalId] = request; });
340
+ }
341
+ async pendingWorkflowDecisions(): Promise<readonly PendingWorkflowDecision[]> {
342
+ await this.journalWrite;
343
+ const journal = await json<Journal>(join(this.directory, "journal.json"));
344
+ return Object.values(journal.decisions ?? {});
345
+ }
346
+ async answerWorkflowDecision(proposalId: string, approved: boolean): Promise<PendingWorkflowDecision | undefined> {
347
+ return this.updateJournal((journal) => {
348
+ const request = journal.decisions?.[proposalId];
349
+ if (!request) return undefined;
350
+ journal.completed[`decision/${proposalId}`] = { path: `decision/${proposalId}`, value: approved };
351
+ delete journal.decisions?.[proposalId];
352
+ return request;
353
+ });
354
+ }
294
355
 
295
356
  async answerCheckpoint(name: string, approved: boolean): Promise<AwaitingCheckpoint | undefined> {
296
357
  return this.updateJournal((journal) => {
297
- const checkpoint = Object.values(journal.awaiting as Record<string, AwaitingCheckpoint>).find((item) => item.name === name);
358
+ const checkpoint = Object.values(journal.awaiting ?? {}).find((item) => item.name === name);
298
359
  if (!checkpoint || journal.completed[checkpoint.path]) return undefined;
299
360
  journal.completed[checkpoint.path] = { path: checkpoint.path, value: approved };
300
- journal.awaiting = Object.fromEntries(Object.entries(journal.awaiting as Record<string, AwaitingCheckpoint>).filter(([path]) => path !== checkpoint.path));
361
+ journal.awaiting = Object.fromEntries(Object.entries(journal.awaiting ?? {}).filter(([path]) => path !== checkpoint.path));
301
362
  return checkpoint;
302
363
  });
303
364
  }
@@ -412,8 +473,16 @@ export class RunStore {
412
473
  try {
413
474
  const write = this.snapshotWrite.then(async () => {
414
475
  const record = await this.worktree(owner);
415
- await git(record.path, ["add", "-A"]);
416
- if ((await git(record.path, ["status", "--porcelain"])).trim()) await git(record.path, ["commit", "-m", "pi-extensible-workflows runtime snapshot"], gitIdentity);
476
+ for (let attempt = 0; attempt < 3; attempt += 1) {
477
+ await git(record.path, ["add", "-A"]);
478
+ if (!(await git(record.path, ["status", "--porcelain"])).trim()) break;
479
+ try {
480
+ await git(record.path, ["commit", "-m", "pi-extensible-workflows runtime snapshot"], gitIdentity);
481
+ break;
482
+ } catch (error) {
483
+ if (attempt === 2) throw error;
484
+ }
485
+ }
417
486
  return (await git(record.path, ["rev-parse", "HEAD"])).trim();
418
487
  });
419
488
  this.snapshotWrite = write.then(() => undefined, () => undefined);
@@ -4,13 +4,13 @@ import { emitKeypressEvents } from "node:readline";
4
4
  import { createInterface } from "node:readline/promises";
5
5
  import { stdin, stdout } from "node:process";
6
6
  import { highlightCode, initTheme, SessionManager, truncateToVisualLines, type SessionEntry, type SessionInfo } from "@earendil-works/pi-coding-agent";
7
- import { inspectWorkflowScript, type ModelSpec, type StaticWorkflowCall } from "./index.js";
7
+ import { formatBudgetStatus, inspectWorkflowScript, type AgentSetupSummary, type ModelSpec, type StaticWorkflowCall } from "./index.js";
8
8
  import { listRunIds, RunStore, type PersistedRun } from "./persistence.js";
9
9
 
10
10
  export interface ModelUsage { model: string; cost: number }
11
- export interface AttemptReport { attempt: number; prompt: string; model: string; thinking?: ModelSpec["thinking"]; cost: number; models: readonly ModelUsage[]; error?: string }
12
- export interface AgentReport { name: string; label?: string; state: string; role?: string; model: string; thinking?: ModelSpec["thinking"]; cost: number; attempts: readonly AttemptReport[] }
13
- export interface WorkflowReport { name: string; description?: string; status: string; runId?: string; script?: string; calls: readonly StaticWorkflowCall[]; parseError?: string; cost: number; models: readonly ModelUsage[]; agents: readonly AgentReport[] }
11
+ export interface AttemptReport { attempt: number; prompt: string; model: string; thinking?: ModelSpec["thinking"]; cost: number; models: readonly ModelUsage[]; error?: string; setup?: AgentSetupSummary }
12
+ export interface AgentReport { name: string; label?: string; state: string; role?: string; requestedModel?: string; model: string; thinking?: ModelSpec["thinking"]; cost: number; attempts: readonly AttemptReport[]; setup?: AgentSetupSummary }
13
+ export interface WorkflowReport { name: string; description?: string; status: string; runId?: string; script?: string; calls: readonly StaticWorkflowCall[]; parseError?: string; cost: number; models: readonly ModelUsage[]; agents: readonly AgentReport[]; budget?: PersistedRun["budget"]; budgetVersion?: number; usage?: PersistedRun["usage"]; budgetEvents?: PersistedRun["budgetEvents"]; events?: readonly { type: string; message: string }[] }
14
14
  export interface SessionReport { id: string; cwd: string; path: string; cost: number; models: readonly ModelUsage[]; workflows: readonly WorkflowReport[]; totalCost: number; totalModels: readonly ModelUsage[] }
15
15
  export interface InspectorViewState { view: "list" | "detail" | "script"; selected: number; scroll: number }
16
16
 
@@ -163,6 +163,7 @@ async function agentReport(agent: PersistedRun["agents"][number]): Promise<Agent
163
163
  cost,
164
164
  models: log.models.length ? log.models : [{ model, cost }],
165
165
  ...(attempt.error ? { error: `${attempt.error.code}: ${attempt.error.message}` } : {}),
166
+ ...(attempt.setup ? { setup: attempt.setup } : {}),
166
167
  });
167
168
  continue;
168
169
  }
@@ -175,6 +176,7 @@ async function agentReport(agent: PersistedRun["agents"][number]): Promise<Agent
175
176
  cost,
176
177
  models: [{ model: fallbackModel, cost }],
177
178
  ...(attempt.error ? { error: `${attempt.error.code}: ${attempt.error.message}` } : {}),
179
+ ...(attempt.setup ? { setup: attempt.setup } : {}),
178
180
  });
179
181
  }
180
182
  if (!attempts.length) {
@@ -182,7 +184,7 @@ async function agentReport(agent: PersistedRun["agents"][number]): Promise<Agent
182
184
  attempts.push({ attempt: 1, prompt: "(transcript unavailable)", model: fallbackModel, ...(fallbackThinking !== undefined ? { thinking: fallbackThinking } : {}), cost, models: [{ model: fallbackModel, cost }] });
183
185
  }
184
186
  const latest = attempts[attempts.length - 1];
185
- return { name: agent.name, ...(agent.label ? { label: agent.label } : {}), state: agent.state, ...(agent.role ? { role: agent.role } : {}), model: latest?.model ?? fallbackModel, ...(latest?.thinking !== undefined ? { thinking: latest.thinking } : {}), cost: attempts.reduce((sum, attempt) => sum + attempt.cost, 0), attempts };
187
+ return { name: agent.name, ...(agent.label ? { label: agent.label } : {}), state: agent.state, ...(agent.role ? { role: agent.role } : {}), ...(agent.requestedModel ? { requestedModel: agent.requestedModel } : {}), model: latest?.model ?? fallbackModel, ...(latest?.thinking !== undefined ? { thinking: latest.thinking } : {}), cost: attempts.reduce((sum, attempt) => sum + attempt.cost, 0), attempts, ...(latest?.setup ? { setup: latest.setup } : {}) };
186
188
  }
187
189
 
188
190
  export function matchSession(query: string, sessions: readonly SessionInfo[]): SessionInfo {
@@ -229,6 +231,11 @@ export async function loadSessionReport(path: string, home = homedir()): Promise
229
231
  cost: agents.reduce((sum, agent) => sum + agent.cost, 0),
230
232
  models,
231
233
  agents,
234
+ ...(loaded?.run.budget ? { budget: loaded.run.budget } : {}),
235
+ ...(loaded?.run.budgetVersion !== undefined ? { budgetVersion: loaded.run.budgetVersion } : {}),
236
+ ...(loaded?.run.usage ? { usage: loaded.run.usage } : {}),
237
+ ...(loaded?.run.budgetEvents ? { budgetEvents: loaded.run.budgetEvents } : {}),
238
+ ...(loaded?.run.events?.length ? { events: loaded.run.events } : {})
232
239
  });
233
240
  }
234
241
  const workflowCost = workflows.reduce((sum, workflow) => sum + workflow.cost, 0);
@@ -253,7 +260,9 @@ function detailLines(workflow: WorkflowReport): string[] {
253
260
  style(ansi.bold + ansi.cyan, workflow.name),
254
261
  `${workflow.status} · ${money(workflow.cost)}${workflow.runId ? ` · ${workflow.runId}` : ""}`,
255
262
  workflow.description ?? "",
263
+ ...(workflow.events?.length ? ["", style(ansi.bold, "Run events"), ...workflow.events.map((event) => `${event.type}: ${event.message}`)] : []),
256
264
  "",
265
+ ...(workflow.budget ? formatBudgetStatus({ budget: workflow.budget, ...(workflow.budgetVersion !== undefined ? { budgetVersion: workflow.budgetVersion } : {}), ...(workflow.usage ? { usage: workflow.usage } : {}), ...(workflow.budgetEvents ? { budgetEvents: workflow.budgetEvents } : {}) }).map((line) => `Budget ${line}`) : []),
257
266
  style(ansi.bold, "Models"),
258
267
  modelSummary(workflow.models),
259
268
  "",
@@ -267,9 +276,18 @@ function detailLines(workflow: WorkflowReport): string[] {
267
276
  ];
268
277
  if (!workflow.agents.length) lines.push("(no agent run was persisted)");
269
278
  for (const agent of workflow.agents) {
270
- lines.push("", style(agent.state === "completed" ? ansi.green : agent.state === "failed" ? ansi.red : ansi.yellow, `${agent.label ?? agent.name} [${agent.state}]`), `${agent.role ? `role=${agent.role} · ` : ""}${agent.model}${agent.thinking !== undefined ? `:${agent.thinking}` : ""} · ${money(agent.cost)}`);
279
+ lines.push("", style(agent.state === "completed" ? ansi.green : agent.state === "failed" ? ansi.red : ansi.yellow, `${agent.label ?? agent.name} [${agent.state}]`), `${agent.role ? `role=${agent.role} · ` : ""}${agent.requestedModel ? `requested=${agent.requestedModel} · ` : ""}${agent.model}${agent.thinking !== undefined ? `:${agent.thinking}` : ""} · ${money(agent.cost)}`);
271
280
  for (const attempt of agent.attempts) {
272
- lines.push(`Attempt ${String(attempt.attempt)} · ${attempt.model}${attempt.thinking !== undefined ? `:${attempt.thinking}` : ""} · ${money(attempt.cost)}${attempt.error ? ` · ${attempt.error}` : ""}`, `Prompt: ${attempt.prompt}`);
281
+ lines.push(`Attempt ${String(attempt.attempt)} · ${attempt.model}${attempt.thinking !== undefined ? `:${attempt.thinking}` : ""} · ${money(attempt.cost)}${attempt.error ? ` · ${attempt.error}` : ""}`, `Prompt: ${attempt.prompt}`, ...(attempt.setup ? [
282
+ `Hooks: ${attempt.setup.hookNames.join(", ") || "(none)"}`,
283
+ `Effective: model=${attempt.setup.model.provider}/${attempt.setup.model.model}${attempt.setup.model.thinking ? `:${attempt.setup.model.thinking}` : ""} tools=${attempt.setup.tools.join(",") || "(none)"} cwd=${attempt.setup.cwd}`,
284
+ ...(attempt.setup.disabledAgentResources ? [
285
+ `Disabled skills: ${attempt.setup.disabledAgentResources.skills.join(", ") || "(none)"}`,
286
+ `Disabled extensions: ${attempt.setup.disabledAgentResources.extensions.join(", ") || "(none)"}`,
287
+ `Unmatched skills: ${attempt.setup.disabledAgentResources.unmatchedSkills.join(", ") || "(none)"}`,
288
+ `Unmatched extensions: ${attempt.setup.disabledAgentResources.unmatchedExtensions.join(", ") || "(none)"}`,
289
+ ] : []),
290
+ ] : []));
273
291
  }
274
292
  }
275
293
  return lines.filter((line, index) => line || index !== 2);
@@ -95,7 +95,7 @@ export const SAFE_PARENT_EVAL_TOOLS = Object.freeze(["read", "grep", "find", "ba
95
95
  const EVAL_MODEL_TOKEN = "$EVAL_MODEL";
96
96
  const semantic = (description: string): readonly SemanticCriterion[] => [{ id: "intent", description }];
97
97
  const JSON_RESULT_TYPES = ["null", "boolean", "number", "integer", "string", "array", "object"] as const;
98
- const WORKFLOW_CALL_KINDS = ["agent", "parallel", "pipeline", "checkpoint", "phase", "withWorktree"] as const;
98
+ const WORKFLOW_CALL_KINDS = ["agent", "conversation", "parallel", "pipeline", "checkpoint", "phase", "withWorktree"] as const;
99
99
  const AGENT_OPTION_NAMES = ["role", "model", "thinking", "tools", "retries"] as const;
100
100
  const expectationKeys = ["firstSignificantAction", "firstTool", "firstBatchToolSequence", "parentToolSequence", "workflowCallCount", "requiredOperations", "forbiddenOperations", "requiredRoles", "minimumAgentCalls", "requireOutputSchema", "expectedResults", "agentPolicies", "requiredAgentOrder", "requiredAgentStructures", "requiredDataFlow"] as const;
101
101
  const caseKeys = ["id", "prompt", "timeoutMs", "maxCost", "expectations", "expectedWorkflowCalls", "semanticCriteria"] as const;