pi-extensible-workflows 2.0.0 → 3.1.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.
- package/README.md +18 -11
- package/dist/src/agent-execution.d.ts +4 -94
- package/dist/src/agent-execution.js +34 -208
- package/dist/src/budget.d.ts +38 -0
- package/dist/src/budget.js +160 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +60 -8
- package/dist/src/doctor-cleanup.d.ts +41 -0
- package/dist/src/doctor-cleanup.js +597 -0
- package/dist/src/doctor.d.ts +7 -2
- package/dist/src/doctor.js +127 -22
- package/dist/src/execution.d.ts +17 -0
- package/dist/src/execution.js +630 -0
- package/dist/src/host.d.ts +101 -0
- package/dist/src/host.js +3084 -0
- package/dist/src/index.d.ts +13 -634
- package/dist/src/index.js +10 -4432
- package/dist/src/persistence.d.ts +9 -19
- package/dist/src/persistence.js +175 -61
- package/dist/src/registry.d.ts +43 -0
- package/dist/src/registry.js +279 -0
- package/dist/src/session-inspector.js +5 -3
- package/dist/src/types.d.ts +692 -0
- package/dist/src/types.js +27 -0
- package/dist/src/utils.d.ts +32 -0
- package/dist/src/utils.js +168 -0
- package/dist/src/validation.d.ts +28 -0
- package/dist/src/validation.js +832 -0
- package/package.json +3 -2
- package/skills/pi-extensible-workflows/SKILL.md +69 -34
- package/src/agent-execution.ts +37 -189
- package/src/budget.ts +75 -0
- package/src/cli.ts +47 -7
- package/src/doctor-cleanup.ts +337 -0
- package/src/doctor.ts +117 -24
- package/src/execution.ts +540 -0
- package/src/host.ts +2598 -0
- package/src/index.ts +14 -3856
- package/src/persistence.ts +122 -37
- package/src/registry.ts +240 -0
- package/src/session-inspector.ts +5 -3
- package/src/types.ts +158 -0
- package/src/utils.ts +142 -0
- package/src/validation.ts +688 -0
package/src/host.ts
ADDED
|
@@ -0,0 +1,2598 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
7
|
+
import { Value } from "typebox/value";
|
|
8
|
+
import { copyToClipboard, getAgentDir, highlightCode, truncateToVisualLines, type ExtensionAPI, type Theme } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { createNativeAgentSession, FairAgentScheduler, WorkflowAgentExecutor, type AgentActivity, type AgentAttempt, type AgentDefinition, type AgentProgress, type AgentProviderFailure, type AgentProviderRecovery, type SessionFactory } from "./agent-execution.js";
|
|
10
|
+
import { herdrPaneId, openHerdrPane } from "./herdr.js";
|
|
11
|
+
import { acquireSessionLease, listRunIds, RunStore, SessionLease, structuralPath as operationPath } from "./persistence.js";
|
|
12
|
+
import type { AwaitingCheckpoint, PersistedRun, WorktreeReference } from "./persistence.js";
|
|
13
|
+
import { budgetRelaxed, budgetUsage, mergeBudget, resumeBudgetAllowed, validateBudget, validateBudgetPatch, WorkflowBudgetRuntime } from "./budget.js";
|
|
14
|
+
import { asWorkflowError, aliasDrift, createLaunchSnapshot, deepFreeze, errorCode, errorText, fail, isWorkflowAuthored, jsonValue, modelAliasErrorName, modelCapability, object, parseModelReference, parseThinking, positiveInteger, resolveModelReference, validateModelAliases } from "./utils.js";
|
|
15
|
+
import { launchScriptForSnapshot, loadAgentDefinitions, preflight, resolveAgentResourcePolicy, resolveWorkflowSettings, saveModelAliases, validateAgentOptions, validateCheckpoint, validateModelAliasAvailability, validateShellOptions, validateWorkflowLaunchWithRegistry, workflowProjectSettingsPath, workflowPrompt, workflowSettingsPath } from "./validation.js";
|
|
16
|
+
import { beginWorkflowExtensionLoading, loadingRegistry, resetWorkflowRegistry, type WorkflowRegistryApi } from "./registry.js";
|
|
17
|
+
import { agentIdentityPath, agentWorktree, encoded, executeShellCommand, persistActiveAgentAttempt, persistAgentAttempts, readShellResult, runWorkflow, shellIdentityPath } from "./execution.js";
|
|
18
|
+
import { ERROR_CODES, LAUNCH_SNAPSHOT_IDENTITY_VERSION, WORKFLOW_AGENT_STATE_CHANGED_EVENT, WORKFLOW_BUDGET_EVENT, WORKFLOW_CHECKPOINT_STATE_CHANGED_EVENT, WORKFLOW_PHASE_CHANGED_EVENT, WORKFLOW_RUN_COMPLETED_EVENT, WORKFLOW_RUN_FAILED_EVENT, WORKFLOW_RUN_RESUMED_EVENT, WORKFLOW_RUN_STARTED_EVENT, WORKFLOW_RUN_STATE_CHANGED_EVENT, WORKFLOW_WORKTREE_CREATED_EVENT, WorkflowError, type AgentAttemptSummary, type AgentOptions, type AgentRecord, type AgentResourcePolicy, type BudgetApprovalRequest, type BudgetEvent, type JsonValue, type LaunchSnapshot, type ModelSpec, type RunState, type ShellIdentity, type ShellOptions, type ShellResult, type WorkflowBridge, type WorkflowCatalogFunction, type WorkflowCatalogIndex, type WorkflowCatalogVariable, type WorkflowCheckpointState, type WorkflowErrorCode, type WorkflowErrorShape, type WorkflowEventBase, type WorkflowFailureAgent, type WorkflowFailureDiagnostics, type WorkflowFunctionContext, type WorkflowExecution, type WorkflowMetadata, type WorkflowModelAliasResolverContext, type WorkflowRetryProvenance, type WorkflowRunContext, type WorkflowSettings, type WorkflowSettingsResolution, type WorkflowSiblingAgent, type WorkflowWorktreeReference } from "./types.js";
|
|
19
|
+
const SETTLED_AGENT_STATES: ReadonlySet<import("./types.js").AgentState> = new Set(["completed", "failed", "cancelled"]);
|
|
20
|
+
export interface WorkflowProgressStyles {
|
|
21
|
+
accent(text: string): string;
|
|
22
|
+
success(text: string): string;
|
|
23
|
+
error(text: string): string;
|
|
24
|
+
warning(text: string): string;
|
|
25
|
+
muted(text: string): string;
|
|
26
|
+
dim(text: string): string;
|
|
27
|
+
bold(text: string): string;
|
|
28
|
+
}
|
|
29
|
+
function snapshotResourcePolicy(snapshot: Readonly<LaunchSnapshot>, cwd: string, projectTrusted: boolean, globalSettingsPath: string): AgentResourcePolicy {
|
|
30
|
+
const empty = { skills: [], extensions: [] };
|
|
31
|
+
return { globalSettingsPath, projectSettingsPath: join(cwd, ".pi", "pi-extensible-workflows", "settings.json"), projectTrusted, global: empty, project: empty, effective: snapshot.settings.disabledAgentResources ?? empty, unmatchedSkills: [], unmatchedExtensions: [] };
|
|
32
|
+
}
|
|
33
|
+
const PLAIN_WORKFLOW_PROGRESS_STYLES: WorkflowProgressStyles = { accent: (text) => text, success: (text) => text, error: (text) => text, warning: (text) => text, muted: (text) => text, dim: (text) => text, bold: (text) => text };
|
|
34
|
+
type WorkflowLaunchSettings = { settings: Readonly<WorkflowSettings>; resolution: WorkflowSettingsResolution; resourcePolicy: AgentResourcePolicy; modelSettingsPath: string };
|
|
35
|
+
function workflowLaunchSettings(cwd: string, projectTrusted: boolean, globalSettingsPath: string, concurrency?: number): WorkflowLaunchSettings {
|
|
36
|
+
const resolution = resolveWorkflowSettings(cwd, projectTrusted, globalSettingsPath);
|
|
37
|
+
const settings = Object.freeze({ ...resolution.effective, ...(concurrency === undefined ? {} : { concurrency }) });
|
|
38
|
+
return { settings, resolution, resourcePolicy: resolveAgentResourcePolicy(cwd, projectTrusted, globalSettingsPath), modelSettingsPath: resolution.sources.modelAliases };
|
|
39
|
+
}
|
|
40
|
+
function frozenResourcePolicy(policy: AgentResourcePolicy): () => AgentResourcePolicy { return () => structuredClone(policy); }
|
|
41
|
+
function resumedSnapshotSettings(snapshot: Readonly<LaunchSnapshot>, resolution: WorkflowSettingsResolution, modelAliases: Readonly<Record<string, string>>): { settings: WorkflowSettings; settingsSources?: NonNullable<LaunchSnapshot["settingsSources"]> } {
|
|
42
|
+
const settings: WorkflowSettings = { ...snapshot.settings, concurrency: snapshot.settingsSources === undefined || snapshot.settingsSources.concurrency === "per-run options" ? snapshot.settings.concurrency : resolution.effective.concurrency, modelAliases };
|
|
43
|
+
if (resolution.effective.disabledAgentResources === undefined) delete settings.disabledAgentResources;
|
|
44
|
+
else settings.disabledAgentResources = resolution.effective.disabledAgentResources;
|
|
45
|
+
const settingsSources = snapshot.settingsSources === undefined ? undefined : { ...snapshot.settingsSources, modelAliases: resolution.sources.modelAliases, disabledAgentResources: resolution.sources.disabledAgentResources, concurrency: snapshot.settingsSources.concurrency === "per-run options" ? "per-run options" : resolution.sources.concurrency };
|
|
46
|
+
return { settings, ...(settingsSources === undefined ? {} : { settingsSources }) };
|
|
47
|
+
}
|
|
48
|
+
const WORKFLOW_FAILURE_DIAGNOSTICS = Symbol("workflowFailureDiagnostics");
|
|
49
|
+
|
|
50
|
+
function workflowDetail(message: string): string {
|
|
51
|
+
const detail = message.trim().replace(new RegExp(`\\b(?:${ERROR_CODES.join("|")})\\b:?\\s*`, "g"), "").replace(/^\s*[A-Z][A-Z0-9_]+:\s*/, "").split("\n").filter((line) => !/^\s*at\s/.test(line)).join("\n").replace(/^Run \S+(?=\s(?:exceeded|is))/i, "Run").replace(/\b[0-9a-f]{8}-[0-9a-f-]{27,}\b/gi, "the workflow").replace(/^(?:Pi )session \S+(?=\s(?:is|has))/i, "session").replace(/^(Unknown scheduler run|Missing production ownership record|Persisted agent belongs to another run):\s*\S+/i, "$1").replace(/\b(?:runId|sessionId|callSite|occurrence|failedAt|id)[:=]\s*\S+/gi, "").replace(/\s{2,}/g, " ").trim();
|
|
52
|
+
return detail || "No further details were provided";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const WORKFLOW_ERROR_PROSE: Record<WorkflowErrorCode, (detail: string) => string> = {
|
|
56
|
+
CONFIG_ERROR: (detail) => `The workflow configuration is invalid: ${detail}.`,
|
|
57
|
+
INVALID_SETTINGS: (detail) => `The workflow settings are invalid: ${detail}.`,
|
|
58
|
+
INVALID_SYNTAX: (detail) => `The workflow source is invalid: ${detail}.`,
|
|
59
|
+
INVALID_METADATA: (detail) => `The workflow metadata is invalid: ${detail}.`,
|
|
60
|
+
DUPLICATE_NAME: (detail) => `The workflow contains a duplicate name: ${detail}.`,
|
|
61
|
+
INVALID_SCHEMA: (detail) => `The workflow schema is invalid: ${detail}.`,
|
|
62
|
+
REGISTRY_FROZEN: (detail) => `Workflow extension registration is closed: ${detail}.`,
|
|
63
|
+
GLOBAL_COLLISION: (detail) => `The workflow global name is already in use: ${detail}.`,
|
|
64
|
+
MISSING_WORKFLOW: (detail) => `The registered workflow function is unavailable: ${detail}.`,
|
|
65
|
+
UNKNOWN_MODEL: (detail) => `The workflow requested the unavailable model ${detail.replace(/^(?:Unknown model(?: for role [^:]+)?|Invalid model spec):\s*/, "")}.`,
|
|
66
|
+
UNKNOWN_TOOL: (detail) => `The workflow requested the unavailable tool ${detail.replace(/^Unknown tool:\s*/, "")}.`,
|
|
67
|
+
UNKNOWN_AGENT_TYPE: (detail) => `The workflow requested the unavailable agent role ${detail.replace(/^Unknown agent role:\s*/, "")}.`,
|
|
68
|
+
RUN_OWNED: (detail) => /already owned|active ownership/.test(detail) ? "The workflow session is already in use." : `The workflow session is already in use: ${detail}.`,
|
|
69
|
+
RPC_LIMIT_EXCEEDED: (detail) => `The workflow communication data exceeded its size limit: ${detail}.`,
|
|
70
|
+
SHELL_FAILED: (detail) => `The workflow shell command failed: ${detail}.`,
|
|
71
|
+
AGENT_TIMEOUT: (detail) => `The workflow agent timed out: ${detail}.`,
|
|
72
|
+
AGENT_FAILED: (detail) => `The workflow agent failed: ${detail}.`,
|
|
73
|
+
RESULT_INVALID: (detail) => `The workflow produced an invalid result: ${detail}.`,
|
|
74
|
+
CANCELLED: (detail) => `The workflow was cancelled: ${detail}.`,
|
|
75
|
+
WORKER_UNRESPONSIVE: (detail) => `The workflow worker stopped responding: ${detail}.`,
|
|
76
|
+
WORKTREE_FAILED: (detail) => `The workflow worktree operation failed: ${detail}.`,
|
|
77
|
+
RESUME_INCOMPATIBLE: (detail) => `The workflow cannot resume this run: ${detail}.`,
|
|
78
|
+
BUDGET_EXHAUSTED: (detail) => `The workflow budget was exhausted: ${detail}.`,
|
|
79
|
+
INTERNAL_ERROR: (detail) => `The workflow encountered an internal error: ${detail}.`,
|
|
80
|
+
};
|
|
81
|
+
export function formatWorkflowFailure(error: unknown): string {
|
|
82
|
+
if (isWorkflowAuthored(error)) return errorText(error);
|
|
83
|
+
const code = errorCode(error);
|
|
84
|
+
if (code) return WORKFLOW_ERROR_PROSE[code](workflowDetail(errorText(error)));
|
|
85
|
+
if (error instanceof Error) return error.message || "The workflow failed without an error message.";
|
|
86
|
+
return `The workflow failed with value ${String(error)}.`;
|
|
87
|
+
}
|
|
88
|
+
function mainAgentError(error: unknown): WorkflowError {
|
|
89
|
+
const typed = asWorkflowError(error);
|
|
90
|
+
const presented = new WorkflowError(typed.code, formatWorkflowFailure(typed));
|
|
91
|
+
Object.assign(presented, typed);
|
|
92
|
+
presented.message = formatWorkflowFailure(typed);
|
|
93
|
+
return presented;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export class RunLifecycle {
|
|
97
|
+
#state: RunState;
|
|
98
|
+
#active = 0;
|
|
99
|
+
#waiters: Array<() => void> = [];
|
|
100
|
+
|
|
101
|
+
constructor(state: RunState = "running", private readonly changed?: (state: RunState, previousState: RunState, reason?: string) => void | Promise<void>) { this.#state = state; }
|
|
102
|
+
get state(): RunState { return this.#state; }
|
|
103
|
+
|
|
104
|
+
async enter(): Promise<void> {
|
|
105
|
+
while (this.#state === "pausing" || this.#state === "paused" || this.#state === "awaiting_input") await new Promise<void>((resolve) => { this.#waiters.push(resolve); });
|
|
106
|
+
if (this.#state !== "running") throw new WorkflowError("CANCELLED", `Run is ${this.#state}`);
|
|
107
|
+
this.#active += 1;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async leave(): Promise<void> {
|
|
111
|
+
if (this.#active > 0) this.#active -= 1;
|
|
112
|
+
if (this.#state === "pausing" && this.#active === 0) await this.#set("paused", "pause");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async enterAwaitingInput(): Promise<void> {
|
|
116
|
+
while (this.#state === "pausing" || this.#state === "paused") await new Promise<void>((resolve) => { this.#waiters.push(resolve); });
|
|
117
|
+
if (this.#state === "awaiting_input") return;
|
|
118
|
+
if (this.#state !== "running") throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot await input for ${this.#state} run`);
|
|
119
|
+
await this.#set("awaiting_input", "awaiting_input");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async resolveAwaitingInput(): Promise<void> {
|
|
123
|
+
if (this.#state !== "awaiting_input") return;
|
|
124
|
+
await this.#set("running", "checkpoint_resolved");
|
|
125
|
+
for (const resolve of this.#waiters.splice(0)) resolve();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async pause(): Promise<void> {
|
|
129
|
+
if (this.#state !== "running") throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot pause ${this.#state} run`);
|
|
130
|
+
await this.#set("pausing", "pause");
|
|
131
|
+
if (this.#active === 0 && this.state === "pausing") await this.#set("paused", "pause");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async resume(): Promise<void> {
|
|
135
|
+
if (this.#state !== "paused" && this.#state !== "interrupted" && this.#state !== "budget_exhausted") throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot resume ${this.#state} run`);
|
|
136
|
+
await this.#set("running", "resume");
|
|
137
|
+
for (const resolve of this.#waiters.splice(0)) resolve();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async providerPause(): Promise<void> {
|
|
141
|
+
await this.leave();
|
|
142
|
+
if (this.#state === "running") await this.pause();
|
|
143
|
+
await this.enter();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async terminal(state: "completed" | "failed" | "stopped" | "interrupted" | "budget_exhausted", reason?: string): Promise<void> {
|
|
147
|
+
if (["completed", "failed", "stopped"].includes(this.#state)) throw new WorkflowError("RESUME_INCOMPATIBLE", `${this.#state} runs are terminal`);
|
|
148
|
+
await this.#set(state, reason ?? state);
|
|
149
|
+
for (const resolve of this.#waiters.splice(0)) resolve();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async #set(state: RunState, reason?: string): Promise<void> {
|
|
153
|
+
const previousState = this.#state;
|
|
154
|
+
this.#state = state;
|
|
155
|
+
await this.changed?.(state, previousState, reason);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function formatWorkflowPreview(args: { script?: unknown; workflow?: unknown; name?: unknown; description?: unknown }): string {
|
|
160
|
+
const explicitName = typeof args.name === "string" && args.name.trim() ? args.name.trim() : undefined;
|
|
161
|
+
const registeredName = typeof args.workflow === "string" && args.workflow.trim() ? args.workflow.trim() : undefined;
|
|
162
|
+
const name = registeredName ?? explicitName ?? "workflow";
|
|
163
|
+
if (typeof args.script !== "string" || !args.script.trim()) return `workflow ${name}${registeredName ? "\nRegistered function" : ""}`;
|
|
164
|
+
return [`workflow ${name}`, typeof args.description === "string" && args.description.trim() ? args.description.trim() : ""].filter(Boolean).join("\n");
|
|
165
|
+
}
|
|
166
|
+
export const WORKFLOW_TOOL_LABEL = "Workflow";
|
|
167
|
+
export const WORKFLOW_TOOL_DESCRIPTION = "Run a deterministic JavaScript workflow";
|
|
168
|
+
export const WORKFLOW_TOOL_PROMPT_SNIPPET = "Run a deterministic, resumable JavaScript workflow that orchestrates subagents. Inline launches require an explicit non-empty name; registered function launches reject name and use workflow as the run name. Runs in the background by default; completion arrives as a follow-up message. Foreground results include the completed run ID. Use workflow_retry with an explicit failed run ID to replay completed structural operations; parentRunId only reuses named worktrees.";
|
|
169
|
+
export const WORKFLOW_TOOL_PARAMETERS = Type.Object({
|
|
170
|
+
name: Type.Optional(Type.String({ description: "Required non-empty name for inline workflow runs; invalid for registered function launches" })),
|
|
171
|
+
description: Type.Optional(Type.String({ description: "Optional human-readable workflow description" })),
|
|
172
|
+
script: Type.Optional(Type.String({ description: "Immutable workflow source without metadata" })),
|
|
173
|
+
workflow: Type.Optional(Type.String({ description: "Registered reusable function as an unqualified name" })),
|
|
174
|
+
args: Type.Optional(Type.Unknown({ description: "JSON-compatible workflow arguments" })),
|
|
175
|
+
foreground: Type.Optional(Type.Boolean({ description: "Wait for completion instead of running in the background" })),
|
|
176
|
+
concurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: 16 })),
|
|
177
|
+
budget: Type.Optional(Type.Unknown({ description: "Optional aggregate soft and hard run budgets" })),
|
|
178
|
+
parentRunId: Type.Optional(Type.String({ description: "Terminal run whose named worktrees may be reused" })),
|
|
179
|
+
});
|
|
180
|
+
export const WORKFLOW_RETRY_PARAMETERS = Type.Object({ runId: Type.String({ description: "Explicit failed workflow run ID" }) });
|
|
181
|
+
|
|
182
|
+
type WorkflowToolUpdate = { content: [{ type: "text"; text: string }]; details: { runId: string; run: PersistedRun } };
|
|
183
|
+
export type WorkflowPhaseState = "not started" | "running" | "completed" | "failed" | "cancelled" | "interrupted" | "budget_exhausted";
|
|
184
|
+
export interface WorkflowPhaseAgentCounts { total: number; completed: number; running: number; failed: number; cancelled: number; pending: number }
|
|
185
|
+
export interface WorkflowPhaseView { id: string; name: string; occurrence: number; state: WorkflowPhaseState; status: WorkflowPhaseState; observed: boolean; afterAgent?: number; agents: readonly AgentRecord[]; counts: WorkflowPhaseAgentCounts }
|
|
186
|
+
export interface WorkflowPhaseModel { declaredPhases: readonly string[]; phases: readonly WorkflowPhaseView[]; currentPhaseIndex?: number; currentPhaseId?: string; counts: Readonly<Partial<Record<WorkflowPhaseState, number>>>; unassignedAgents?: readonly AgentRecord[] }
|
|
187
|
+
type WorkflowPhaseSource = readonly string[] | Pick<LaunchSnapshot, "phases"> | undefined;
|
|
188
|
+
function phaseNames(source: WorkflowPhaseSource): string[] {
|
|
189
|
+
const phases: readonly unknown[] = source === undefined ? [] : Array.isArray(source) ? source : (source as Pick<LaunchSnapshot, "phases">).phases ?? [];
|
|
190
|
+
return phases.filter((phase): phase is string => typeof phase === "string" && phase.trim() !== "").map((phase) => phase.trim());
|
|
191
|
+
}
|
|
192
|
+
function phaseAgentCounts(agents: readonly AgentRecord[]): WorkflowPhaseAgentCounts {
|
|
193
|
+
const counts: WorkflowPhaseAgentCounts = { total: agents.length, completed: 0, running: 0, failed: 0, cancelled: 0, pending: 0 };
|
|
194
|
+
for (const agent of agents) {
|
|
195
|
+
if (agent.state === "completed") counts.completed += 1;
|
|
196
|
+
else if (agent.state === "running") counts.running += 1;
|
|
197
|
+
else if (agent.state === "failed") counts.failed += 1;
|
|
198
|
+
else if (agent.state === "cancelled") counts.cancelled += 1;
|
|
199
|
+
else counts.pending += 1;
|
|
200
|
+
}
|
|
201
|
+
return counts;
|
|
202
|
+
}
|
|
203
|
+
function phaseState(runState: RunState, counts: WorkflowPhaseAgentCounts, isLatest: boolean): WorkflowPhaseState {
|
|
204
|
+
if (!isLatest) return "completed";
|
|
205
|
+
if (runState === "failed") return "failed";
|
|
206
|
+
if (runState === "stopped") return "cancelled";
|
|
207
|
+
if (runState === "interrupted") return "interrupted";
|
|
208
|
+
if (runState === "budget_exhausted") return "budget_exhausted";
|
|
209
|
+
if (counts.failed > 0) return "failed";
|
|
210
|
+
if (counts.cancelled > 0) return "cancelled";
|
|
211
|
+
if (counts.running > 0 || counts.pending > 0) return "running";
|
|
212
|
+
return runState === "completed" ? "completed" : "running";
|
|
213
|
+
}
|
|
214
|
+
export function buildWorkflowPhaseModel(run: Pick<PersistedRun, "state" | "phase" | "phaseHistory" | "agents">, source?: WorkflowPhaseSource): WorkflowPhaseModel {
|
|
215
|
+
const declaredPhases = phaseNames(source);
|
|
216
|
+
const rawHistory: readonly unknown[] = Array.isArray(run.phaseHistory) ? run.phaseHistory : [];
|
|
217
|
+
const observed: Array<{ name: string; afterAgent: number }> = [];
|
|
218
|
+
let boundary = 0;
|
|
219
|
+
for (const record of rawHistory) {
|
|
220
|
+
if (!object(record) || typeof record.phase !== "string" || !record.phase.trim() || typeof record.afterAgent !== "number" || !Number.isSafeInteger(record.afterAgent)) continue;
|
|
221
|
+
boundary = Math.max(boundary, Math.min(run.agents.length, Math.max(0, record.afterAgent)));
|
|
222
|
+
observed.push({ name: record.phase.trim(), afterAgent: boundary });
|
|
223
|
+
}
|
|
224
|
+
if (!observed.length && typeof run.phase === "string" && run.phase.trim()) observed.push({ name: run.phase.trim(), afterAgent: 0 });
|
|
225
|
+
const observedEntries = observed.map((entry, index) => ({ ...entry, index, agents: run.agents.slice(entry.afterAgent, observed[index + 1]?.afterAgent ?? run.agents.length) }));
|
|
226
|
+
const matchedDeclarations = new Set<number>();
|
|
227
|
+
const declarationIndices = observedEntries.map((entry) => {
|
|
228
|
+
const index = declaredPhases.findIndex((name, candidate) => !matchedDeclarations.has(candidate) && name === entry.name);
|
|
229
|
+
if (index >= 0) matchedDeclarations.add(index);
|
|
230
|
+
return index >= 0 ? index : undefined;
|
|
231
|
+
});
|
|
232
|
+
const entries: Array<{ name: string; observedIndex?: number; declarationIndex?: number }> = observedEntries.map((entry, index) => ({ name: entry.name, observedIndex: index, ...(declarationIndices[index] === undefined ? {} : { declarationIndex: declarationIndices[index] }) }));
|
|
233
|
+
for (const [declarationIndex, name] of declaredPhases.entries()) {
|
|
234
|
+
if (matchedDeclarations.has(declarationIndex)) continue;
|
|
235
|
+
const insertion = entries.findIndex((entry) => entry.declarationIndex !== undefined && entry.declarationIndex > declarationIndex);
|
|
236
|
+
const pending = { name };
|
|
237
|
+
if (insertion < 0) entries.push(pending); else entries.splice(insertion, 0, pending);
|
|
238
|
+
}
|
|
239
|
+
const occurrences = new Map<string, number>();
|
|
240
|
+
const phases = entries.map((entry) => {
|
|
241
|
+
const occurrence = (occurrences.get(entry.name) ?? 0) + 1;
|
|
242
|
+
occurrences.set(entry.name, occurrence);
|
|
243
|
+
const observation = entry.observedIndex === undefined ? undefined : observedEntries[entry.observedIndex];
|
|
244
|
+
const agents = observation?.agents ?? [];
|
|
245
|
+
const counts = phaseAgentCounts(agents);
|
|
246
|
+
const state = observation ? phaseState(run.state, counts, entry.observedIndex === observedEntries.length - 1) : "not started";
|
|
247
|
+
return { id: `${entry.name}#${String(occurrence)}`, name: entry.name, occurrence, state, status: state, observed: observation !== undefined, ...(observation ? { afterAgent: observation.afterAgent } : {}), agents, counts };
|
|
248
|
+
});
|
|
249
|
+
let currentPhaseIndex: number | undefined;
|
|
250
|
+
for (let index = phases.length - 1; index >= 0; index -= 1) { if (phases[index]?.observed) { currentPhaseIndex = index; break; } }
|
|
251
|
+
const counts: Partial<Record<WorkflowPhaseState, number>> = {};
|
|
252
|
+
for (const phase of phases) counts[phase.state] = (counts[phase.state] ?? 0) + 1;
|
|
253
|
+
const current = currentPhaseIndex === undefined ? undefined : phases[currentPhaseIndex];
|
|
254
|
+
const assigned = new Set(observedEntries.flatMap(({ agents }) => agents.map((agent) => agent.id)));
|
|
255
|
+
const unassignedAgents = run.agents.filter((agent) => !assigned.has(agent.id));
|
|
256
|
+
const result: WorkflowPhaseModel = { declaredPhases, phases, counts };
|
|
257
|
+
if (current !== undefined && currentPhaseIndex !== undefined) { result.currentPhaseIndex = currentPhaseIndex; result.currentPhaseId = current.id; }
|
|
258
|
+
if (unassignedAgents.length) result.unassignedAgents = unassignedAgents;
|
|
259
|
+
return result;
|
|
260
|
+
}
|
|
261
|
+
export interface WorkflowPhaseSelection { phaseId?: string | undefined; agentId?: string | undefined }
|
|
262
|
+
export function preserveWorkflowPhaseSelection(model: WorkflowPhaseModel, selection: WorkflowPhaseSelection): WorkflowPhaseSelection {
|
|
263
|
+
const phase = model.phases.find((candidate) => candidate.id === selection.phaseId) ?? (model.currentPhaseIndex === undefined ? undefined : model.phases[model.currentPhaseIndex]) ?? model.phases[0];
|
|
264
|
+
if (!phase) return {};
|
|
265
|
+
const agentId = phase.agents.some((agent) => agent.id === selection.agentId) ? selection.agentId : phase.agents[0]?.id;
|
|
266
|
+
return { phaseId: phase.id, ...(agentId === undefined ? {} : { agentId }) };
|
|
267
|
+
}
|
|
268
|
+
type AgentGroup = { label: string; entries: readonly { agent: AgentRecord; index: number; depth: number }[] };
|
|
269
|
+
function agentGroupKey(agent: AgentRecord): string { return JSON.stringify([agent.structuralPath ?? [], agent.parentBreadcrumb ?? null]); }
|
|
270
|
+
function agentGroupLabel(agents: readonly AgentRecord[]): string {
|
|
271
|
+
const structural = agents[0]?.structuralPath ?? [];
|
|
272
|
+
const breadcrumbs = [...new Set(agents.map((agent) => agent.parentBreadcrumb).filter((value): value is string => Boolean(value)))];
|
|
273
|
+
return [...(structural.length ? [structural.join(" > ")] : []), ...(breadcrumbs.length === 1 ? breadcrumbs : breadcrumbs.length ? [breadcrumbs.join(" | ")] : [])].join(" > ") || "Agents";
|
|
274
|
+
}
|
|
275
|
+
function agentGroups(agents: readonly AgentRecord[], allAgents: readonly AgentRecord[] = agents): AgentGroup[] {
|
|
276
|
+
const byId = new Map(allAgents.map((agent) => [agent.id, agent]));
|
|
277
|
+
const groups = new Map<string, { agents: Array<{ agent: AgentRecord; index: number; depth: number }> }>();
|
|
278
|
+
for (const [index, agent] of agents.entries()) {
|
|
279
|
+
let depth = 0;
|
|
280
|
+
const seen = new Set<string>([agent.id]);
|
|
281
|
+
for (let parent = agent.parentId; parent && byId.has(parent); parent = byId.get(parent)?.parentId) { if (seen.has(parent)) break; seen.add(parent); depth += 1; }
|
|
282
|
+
const key = agentGroupKey(agent);
|
|
283
|
+
const group = groups.get(key) ?? { agents: [] };
|
|
284
|
+
group.agents.push({ agent, index, depth });
|
|
285
|
+
groups.set(key, group);
|
|
286
|
+
}
|
|
287
|
+
return [...groups].map(([, group]) => ({ label: agentGroupLabel(group.agents.map(({ agent }) => agent)), entries: group.agents }));
|
|
288
|
+
}
|
|
289
|
+
function renderGroupedAgents(agents: readonly AgentRecord[], render: (entry: { agent: AgentRecord; index: number; depth: number }, grouped: boolean) => string, allAgents: readonly AgentRecord[] = agents, groupLabel: (label: string) => string = (label) => label): string[] {
|
|
290
|
+
const groups = agentGroups(agents, allAgents);
|
|
291
|
+
const grouped = groups.length > 1 || groups.some(({ label }) => label !== "Agents");
|
|
292
|
+
return groups.flatMap((group) => [
|
|
293
|
+
...(grouped ? [` ${groupLabel(group.label)}`] : []),
|
|
294
|
+
...group.entries.map((entry) => render(entry, grouped)),
|
|
295
|
+
]);
|
|
296
|
+
}
|
|
297
|
+
function progressStyleForState(state: string, styles: WorkflowProgressStyles): (text: string) => string {
|
|
298
|
+
if (state === "completed") return (text) => styles.success(text);
|
|
299
|
+
if (state === "failed" || state === "cancelled") return (text) => styles.error(text);
|
|
300
|
+
if (state === "running") return (text) => styles.accent(text);
|
|
301
|
+
return (text) => styles.muted(text);
|
|
302
|
+
}
|
|
303
|
+
export function formatWorkflowProgress(run: PersistedRun, spinner = "◇", styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES): string {
|
|
304
|
+
const done = run.agents.filter((agent) => SETTLED_AGENT_STATES.has(agent.state)).length;
|
|
305
|
+
const workflowIcon = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "budget_exhausted" ? "!" : run.state === "running" ? spinner : "◆";
|
|
306
|
+
const workflowIconStyle = run.state === "completed" ? (text: string) => styles.success(text) : run.state === "failed" || run.state === "stopped" ? (text: string) => styles.error(text) : run.state === "budget_exhausted" ? (text: string) => styles.warning(text) : run.state === "running" ? (text: string) => styles.accent(text) : (text: string) => styles.muted(text);
|
|
307
|
+
const header = styles.bold(styles.accent(`Workflow: ${run.workflowName} (${String(done)}/${String(run.agents.length)} done)`));
|
|
308
|
+
const lines = [`${workflowIconStyle(workflowIcon)} ${header}`];
|
|
309
|
+
const budgetWarning = run.state === "budget_exhausted" || (run.budgetEvents ?? []).some((event) => event.type === "hard_exhausted");
|
|
310
|
+
lines.push(...formatCompactBudgetStatus(run).map((line) => ` ${budgetWarning ? styles.warning(line) : line}`));
|
|
311
|
+
const byId = new Map(run.agents.map((agent) => [agent.id, agent]));
|
|
312
|
+
const renderAgents = (agents: readonly AgentRecord[], offset: number, nested: boolean) => renderGroupedAgents(agents, ({ agent, index, depth }, grouped) => {
|
|
313
|
+
const icon = agent.state === "completed" ? "✓" : agent.state === "failed" || agent.state === "cancelled" ? "✗" : agent.state === "running" ? spinner : "○";
|
|
314
|
+
const indent = " ".repeat((grouped ? 2 : 1) + depth);
|
|
315
|
+
const activity = SETTLED_AGENT_STATES.has(agent.state) ? "" : formatAgentActivity(agent, spinner, styles);
|
|
316
|
+
const name = grouped ? agent.label ?? agent.name : styledAgentBreadcrumb(agent, byId, styles);
|
|
317
|
+
const state = progressStyleForState(agent.state, styles);
|
|
318
|
+
return `${indent}#${String(offset + index + 1)} ${state(icon)} ${name} ${state(`[${agent.state}]`)}${activity ? ` ${activity}` : ""}`;
|
|
319
|
+
}, run.agents, (label) => styles.muted(label)).map((line) => nested ? ` ${line}` : line);
|
|
320
|
+
const phases = run.phaseHistory?.length ? run.phaseHistory : run.phase ? [{ phase: run.phase, afterAgent: 0 }] : [];
|
|
321
|
+
let renderedAgents = 0;
|
|
322
|
+
let nested = false;
|
|
323
|
+
for (const phase of phases) {
|
|
324
|
+
const boundary = Math.max(renderedAgents, Math.min(run.agents.length, phase.afterAgent));
|
|
325
|
+
lines.push(...renderAgents(run.agents.slice(renderedAgents, boundary), renderedAgents, nested));
|
|
326
|
+
lines.push(` ${styles.muted(`[Phase: ${phase.phase}]`)}`);
|
|
327
|
+
renderedAgents = boundary;
|
|
328
|
+
nested = true;
|
|
329
|
+
}
|
|
330
|
+
lines.push(...renderAgents(run.agents.slice(renderedAgents), renderedAgents, nested));
|
|
331
|
+
return lines.join("\n");
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function workflowToolUpdate(run: PersistedRun): WorkflowToolUpdate {
|
|
335
|
+
return { content: [{ type: "text", text: formatWorkflowProgress(run) }], details: { runId: run.id, run } };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const workflowSpinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
339
|
+
|
|
340
|
+
function textBlock(text: string) {
|
|
341
|
+
return {
|
|
342
|
+
render(width: number) {
|
|
343
|
+
return text.split("\n").map((line) => line.length <= width ? line : `${line.slice(0, Math.max(0, width - 1))}…`);
|
|
344
|
+
},
|
|
345
|
+
invalidate() {},
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
function styledTextBlock(text: string) {
|
|
349
|
+
return {
|
|
350
|
+
render(width: number) {
|
|
351
|
+
return truncateWorkflowProgress(text, width);
|
|
352
|
+
},
|
|
353
|
+
invalidate() {},
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
function workflowCatalogBlock(text: string, expanded: boolean) {
|
|
357
|
+
return {
|
|
358
|
+
render(width: number) {
|
|
359
|
+
const safeWidth = Math.max(1, width);
|
|
360
|
+
if (!expanded) return truncateWorkflowProgress(text, safeWidth);
|
|
361
|
+
return truncateToVisualLines(text, Number.MAX_SAFE_INTEGER, safeWidth, 0).visualLines.map((line) => line.trimEnd());
|
|
362
|
+
},
|
|
363
|
+
invalidate() {},
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function catalogText(value: string): string { return value.replace(/\s+/g, " ").trim(); }
|
|
368
|
+
|
|
369
|
+
type CatalogToolResult = { details?: unknown; content?: readonly { type: string; text?: string }[] };
|
|
370
|
+
|
|
371
|
+
function catalogResultValue(result: CatalogToolResult): unknown {
|
|
372
|
+
if (result.details !== undefined) return result.details;
|
|
373
|
+
const text = result.content?.find((entry) => entry.type === "text")?.text;
|
|
374
|
+
if (!text) return undefined;
|
|
375
|
+
try { return JSON.parse(text) as unknown; } catch { return text; }
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function isCatalogIndex(value: unknown): value is WorkflowCatalogIndex {
|
|
379
|
+
return object(value) && Array.isArray(value.functions) && Array.isArray(value.variables);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function isCatalogFunction(value: unknown): value is WorkflowCatalogFunction {
|
|
383
|
+
return object(value) && typeof value.name === "string" && typeof value.description === "string" && object(value.input) && object(value.output);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function isCatalogVariable(value: unknown): value is WorkflowCatalogVariable {
|
|
387
|
+
return object(value) && typeof value.name === "string" && typeof value.description === "string" && object(value.schema);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function isCatalogError(value: unknown): value is { error: { message: string } } {
|
|
391
|
+
return object(value) && object(value.error) && typeof value.error.message === "string";
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function catalogSectionTitle(label: string, count: number, theme: Theme): string {
|
|
395
|
+
return theme.fg("accent", theme.bold(`${label} (${String(count)})`));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function catalogIndexEntries(entries: readonly { name: string; description: string }[], theme: Theme): string[] {
|
|
399
|
+
const width = Math.max(0, ...entries.map((entry) => entry.name.length));
|
|
400
|
+
return entries.map((entry) => ` ${theme.fg("accent", entry.name.padEnd(width))} ${theme.fg("toolOutput", catalogText(entry.description))}`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function formatCatalogIndex(catalog: WorkflowCatalogIndex, theme: Theme): string {
|
|
404
|
+
const aliases = Object.prototype.propertyIsEnumerable.call(catalog, "modelAliases") ? Object.keys(catalog.modelAliases ?? {}).sort().map((name) => ({ name, kind: "static" as const, provenance: "settings" })) : catalog.modelAliasEntries ?? Object.keys(catalog.modelAliases ?? {}).sort().map((name) => ({ name, kind: "static" as const, provenance: "settings" }));
|
|
405
|
+
const aliasWidth = Math.max(0, ...aliases.map(({ name }) => name.length));
|
|
406
|
+
const aliasLines = aliases.map(({ name, kind, provenance }) => ` ${theme.fg("accent", name.padEnd(aliasWidth))} ${theme.fg("toolOutput", `${kind} · ${provenance}`)}`);
|
|
407
|
+
return [
|
|
408
|
+
catalogSectionTitle("Functions", catalog.functions.length, theme),
|
|
409
|
+
...catalogIndexEntries(catalog.functions, theme),
|
|
410
|
+
"",
|
|
411
|
+
catalogSectionTitle("Variables", catalog.variables.length, theme),
|
|
412
|
+
...catalogIndexEntries(catalog.variables, theme),
|
|
413
|
+
"",
|
|
414
|
+
catalogSectionTitle("Model aliases", aliases.length, theme),
|
|
415
|
+
...aliasLines,
|
|
416
|
+
].join("\n");
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function catalogSchemaLines(schema: unknown, theme: Theme): string[] {
|
|
420
|
+
const json = JSON.stringify(schema, null, 2);
|
|
421
|
+
return json.split("\n").map((line) => ` ${theme.fg("toolOutput", line)}`);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function formatCatalogDetail(value: WorkflowCatalogFunction | WorkflowCatalogVariable | import("./types.js").WorkflowCatalogModelAlias, expanded: boolean, theme: Theme): string {
|
|
425
|
+
if ("kind" in value) return [theme.fg("accent", theme.bold("Model alias")), ` ${theme.fg("accent", value.name)} ${theme.fg("toolOutput", `${value.kind} · ${value.provenance}`)}`].join("\n");
|
|
426
|
+
const kind = "input" in value ? "Function" : "Variable";
|
|
427
|
+
if (!expanded) return [theme.fg("accent", theme.bold(kind)), ` ${theme.fg("accent", value.name)} ${theme.fg("toolOutput", catalogText(value.description))}`, ` ${theme.fg("muted", "version")}: ${theme.fg("toolOutput", value.version)} ${theme.fg("muted", "headline")}: ${theme.fg("toolOutput", catalogText(value.headline))}`].join("\n");
|
|
428
|
+
const lines = [theme.fg("accent", theme.bold(`${kind}: ${value.name}`)), `${theme.fg("muted", "description")}: ${theme.fg("toolOutput", value.description)}`, "", theme.fg("accent", theme.bold("Extension")), ` ${theme.fg("muted", "version")}: ${theme.fg("toolOutput", value.version)}`, ` ${theme.fg("muted", "headline")}: ${theme.fg("toolOutput", value.headline)}`, ` ${theme.fg("muted", "description")}: ${theme.fg("toolOutput", value.extensionDescription)}`, "", theme.fg("accent", theme.bold("Schema"))];
|
|
429
|
+
if ("input" in value) lines.push(theme.fg("muted", "Input schema"), ...catalogSchemaLines(value.input, theme), "", theme.fg("muted", "Output schema"), ...catalogSchemaLines(value.output, theme));
|
|
430
|
+
else lines.push(theme.fg("muted", "Variable schema"), ...catalogSchemaLines(value.schema, theme));
|
|
431
|
+
return lines.join("\n");
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function formatWorkflowCatalog(value: unknown, expanded: boolean, theme: Theme): string {
|
|
435
|
+
if (isCatalogIndex(value)) return formatCatalogIndex(value, theme);
|
|
436
|
+
if (isCatalogFunction(value) || isCatalogVariable(value)) return formatCatalogDetail(value, expanded, theme);
|
|
437
|
+
if (object(value) && typeof value.name === "string" && (value.kind === "static" || value.kind === "dynamic")) return formatCatalogDetail(value as unknown as import("./types.js").WorkflowCatalogModelAlias, expanded, theme);
|
|
438
|
+
if (isCatalogError(value)) return theme.fg("error", value.error.message);
|
|
439
|
+
return theme.fg("error", "The workflow catalog returned an invalid result.");
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const ANSI_SGR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`);
|
|
443
|
+
export function truncateWorkflowProgress(text: string, width: number): string[] {
|
|
444
|
+
const safeWidth = Math.max(1, width);
|
|
445
|
+
return text.split("\n").flatMap((line) => {
|
|
446
|
+
if (!line) return [""];
|
|
447
|
+
const visualLines = truncateToVisualLines(line, Number.MAX_SAFE_INTEGER, safeWidth, 0).visualLines;
|
|
448
|
+
if (visualLines.length <= 1) return [visualLines[0]?.trimEnd() ?? ""];
|
|
449
|
+
if (safeWidth === 1) return [ANSI_SGR.test(line) ? "…\u001b[0m" : "…"];
|
|
450
|
+
const prefix = (truncateToVisualLines(line, Number.MAX_SAFE_INTEGER, safeWidth - 1, 0).visualLines[0] ?? "").trimEnd();
|
|
451
|
+
const truncated = `${prefix}…`;
|
|
452
|
+
return [ANSI_SGR.test(line) ? `${truncated}\u001b[0m` : truncated];
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
function themeWorkflowProgressStyles(theme: Theme): WorkflowProgressStyles {
|
|
456
|
+
return {
|
|
457
|
+
accent: (text) => theme.fg("accent", text),
|
|
458
|
+
success: (text) => theme.fg("success", text),
|
|
459
|
+
error: (text) => theme.fg("error", text),
|
|
460
|
+
warning: (text) => theme.fg("warning", text),
|
|
461
|
+
muted: (text) => theme.fg("muted", text),
|
|
462
|
+
dim: (text) => theme.fg("dim", text),
|
|
463
|
+
bold: (text) => typeof theme.bold === "function" ? theme.bold(text) : text,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
function workflowProgressBlock(run: PersistedRun, theme: Theme) {
|
|
467
|
+
const styles = themeWorkflowProgressStyles(theme);
|
|
468
|
+
return {
|
|
469
|
+
render(width: number) {
|
|
470
|
+
const frame = workflowSpinner[Math.floor(Date.now() / 80) % workflowSpinner.length] ?? "◇";
|
|
471
|
+
return truncateWorkflowProgress(formatWorkflowProgress(run, frame, styles), width);
|
|
472
|
+
},
|
|
473
|
+
invalidate() {},
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
export function formatBudgetStatus(run: Pick<PersistedRun, "budget" | "budgetVersion" | "usage" | "budgetEvents">): string[] {
|
|
477
|
+
const usage = budgetUsage(run.usage);
|
|
478
|
+
if (!run.budget || !Object.keys(run.budget).length) return ["Budget: unlimited"];
|
|
479
|
+
const lines = [`Budget version ${String(run.budgetVersion ?? 1)}`];
|
|
480
|
+
for (const dimension of ["tokens", "costUsd", "durationMs", "agentLaunches"] as const) {
|
|
481
|
+
const limits = run.budget[dimension];
|
|
482
|
+
if (!limits || (limits.soft === undefined && limits.hard === undefined)) continue;
|
|
483
|
+
const limit = limits.hard ?? limits.soft;
|
|
484
|
+
const percent = limit === undefined ? "" : ` ${limit === 0 ? "100.0" : ((usage[dimension] / limit) * 100).toFixed(1)}%`;
|
|
485
|
+
const state = (run.budgetEvents ?? []).filter((event) => event.dimensions.includes(dimension)).at(-1)?.type;
|
|
486
|
+
lines.push(` ${dimension}: ${String(usage[dimension])}${limits.soft !== undefined ? ` soft=${String(limits.soft)}` : ""}${limits.hard !== undefined ? ` hard=${String(limits.hard)}` : ""}${percent}${state ? ` state=${state}` : ""}`);
|
|
487
|
+
}
|
|
488
|
+
const events = run.budgetEvents ?? [];
|
|
489
|
+
if (events.length) lines.push(` events: ${events.map((event) => `${event.type}@v${String(event.budgetVersion)}`).join(", ")}`);
|
|
490
|
+
return lines;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function formatCompactBudgetStatus(run: Pick<PersistedRun, "budget" | "budgetVersion" | "usage" | "budgetEvents">): string[] {
|
|
494
|
+
if (!Object.values(run.budget ?? {}).some((limits) => limits.soft !== undefined || limits.hard !== undefined)) return [];
|
|
495
|
+
return formatBudgetStatus(run);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const ATTENTION_ORDER: Record<string, number> = { awaiting_input: 0, budget_exhausted: 1, running: 2, pausing: 3, paused: 4, interrupted: 5, failed: 6, queued: 7, stopped: 8, completed: 9 };
|
|
499
|
+
|
|
500
|
+
function navigatorAttentionSort<T extends { loaded: { run: PersistedRun } }>(entries: readonly T[]): T[] {
|
|
501
|
+
return [...entries].sort((a, b) => (ATTENTION_ORDER[a.loaded.run.state] ?? 9) - (ATTENTION_ORDER[b.loaded.run.state] ?? 9));
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function navigatorRunLabels(entries: readonly { store: RunStore; loaded: { run: PersistedRun } }[]): string[] {
|
|
505
|
+
const nameCount = new Map<string, number>();
|
|
506
|
+
for (const { loaded: { run } } of entries) nameCount.set(run.workflowName, (nameCount.get(run.workflowName) ?? 0) + 1);
|
|
507
|
+
return entries.map(({ store, loaded: { run } }) => {
|
|
508
|
+
const done = run.agents.filter((a) => SETTLED_AGENT_STATES.has(a.state)).length;
|
|
509
|
+
const glyph = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "budget_exhausted" ? "!" : run.state === "running" ? "⠦" : run.state === "awaiting_input" ? "●" : "◆";
|
|
510
|
+
const suffix = (nameCount.get(run.workflowName) ?? 0) > 1 ? ` ${store.runId.slice(0, 8)}` : "";
|
|
511
|
+
const cost = run.agents.reduce((sum, a) => sum + (a.accounting?.cost ?? 0), 0);
|
|
512
|
+
const costStr = cost > 0 ? ` $${cost.toFixed(2)}` : "";
|
|
513
|
+
return `${glyph} ${run.workflowName}${suffix} ${run.state} ${run.phase ?? ""} ${String(done)}/${String(run.agents.length)} agents${costStr}`;
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export function agentBreadcrumbParts(agent: AgentRecord, byId: Map<string, AgentRecord>, includeStructuralPath = false): string[] {
|
|
518
|
+
const leaf = agent.label ?? agent.name;
|
|
519
|
+
const parts: string[] = includeStructuralPath && agent.structuralPath?.length ? [agent.structuralPath.join(" > ")] : [];
|
|
520
|
+
if (agent.parentBreadcrumb) parts.push(agent.parentBreadcrumb);
|
|
521
|
+
const ancestors: string[] = [];
|
|
522
|
+
const seen = new Set<string>([agent.id]);
|
|
523
|
+
for (let parentId = agent.parentId; parentId; parentId = byId.get(parentId)?.parentId) {
|
|
524
|
+
if (seen.has(parentId)) break;
|
|
525
|
+
seen.add(parentId);
|
|
526
|
+
const parent = byId.get(parentId);
|
|
527
|
+
if (!parent) break;
|
|
528
|
+
ancestors.push(parent.label ?? parent.name);
|
|
529
|
+
}
|
|
530
|
+
parts.push(...ancestors.reverse(), leaf);
|
|
531
|
+
return parts;
|
|
532
|
+
}
|
|
533
|
+
export function agentBreadcrumb(agent: AgentRecord, byId: Map<string, AgentRecord>, includeStructuralPath = false): string {
|
|
534
|
+
return agentBreadcrumbParts(agent, byId, includeStructuralPath).join(" > ");
|
|
535
|
+
}
|
|
536
|
+
function styledAgentBreadcrumb(agent: AgentRecord, byId: Map<string, AgentRecord>, styles: WorkflowProgressStyles): string {
|
|
537
|
+
const parts = agentBreadcrumbParts(agent, byId);
|
|
538
|
+
if (parts.length <= 1) return parts[0] ?? "";
|
|
539
|
+
return `${styles.muted(parts.slice(0, -1).join(" > "))} > ${styles.bold(parts[parts.length - 1] ?? "")}`;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function formatAgentActivity(agent: AgentRecord, spinner: string, styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES): string {
|
|
543
|
+
const label = agent.activity?.kind === "reasoning" ? "reasoning" : agent.activity?.kind === "text" ? "responding" : agent.activity?.kind === "tool" ? agent.activity.text : [...(agent.toolCalls ?? [])].reverse().find(({ state }) => state === "running")?.name ?? "";
|
|
544
|
+
return label ? `${styles.accent(spinner)} ${styles.dim(label)}` : "";
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function formatAccounting(accounting: NonNullable<AgentRecord["accounting"]>): string {
|
|
548
|
+
const total = accounting.input + accounting.output + accounting.cacheRead + accounting.cacheWrite;
|
|
549
|
+
return `${new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(total).toLowerCase()} tok`;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
export function formatNavigatorDashboard(run: PersistedRun, checkpoints: readonly AwaitingCheckpoint[], worktrees: readonly WorktreeReference[]): string {
|
|
554
|
+
void worktrees;
|
|
555
|
+
const done = run.agents.filter((a) => SETTLED_AGENT_STATES.has(a.state)).length;
|
|
556
|
+
const totalAccounting = run.agents.reduce((sum, a) => ({ input: sum.input + (a.accounting?.input ?? 0), output: sum.output + (a.accounting?.output ?? 0), cacheRead: sum.cacheRead + (a.accounting?.cacheRead ?? 0), cacheWrite: sum.cacheWrite + (a.accounting?.cacheWrite ?? 0), cost: sum.cost + (a.accounting?.cost ?? 0) }), { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 });
|
|
557
|
+
const hasAccounting = run.agents.some((a) => a.accounting);
|
|
558
|
+
const glyph = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "budget_exhausted" ? "!" : run.state === "running" ? "⠦" : run.state === "awaiting_input" ? "●" : "◆";
|
|
559
|
+
const header = `${glyph} ${run.workflowName}`;
|
|
560
|
+
const meta = [run.state, run.phase ? `phase: ${run.phase}` : "", `${String(done)}/${String(run.agents.length)} agents`, hasAccounting ? formatAccounting(totalAccounting) : "", totalAccounting.cost > 0 ? `$${totalAccounting.cost.toFixed(2)}` : ""].filter(Boolean).join(" · ");
|
|
561
|
+
const lines = [header, meta, ...formatCompactBudgetStatus(run)];
|
|
562
|
+
if (run.error) lines.push(`Error: ${run.error.code}: ${run.error.message}`);
|
|
563
|
+
if (run.events?.length) lines.push(...run.events.map((event) => `Warning: ${event.message}`));
|
|
564
|
+
lines.push("");
|
|
565
|
+
const byId = new Map(run.agents.map((a) => [a.id, a]));
|
|
566
|
+
const render = ({ agent, depth }: { agent: AgentRecord; index: number; depth: number }, grouped: boolean) => {
|
|
567
|
+
const icon = agent.state === "completed" ? "✓" : agent.state === "failed" || agent.state === "cancelled" ? "✗" : agent.state === "running" ? "⠦" : "○";
|
|
568
|
+
const breadcrumb = grouped ? agent.label ?? agent.name : agentBreadcrumb(agent, byId);
|
|
569
|
+
const tokens = agent.accounting ? formatAccounting(agent.accounting) : "";
|
|
570
|
+
const indent = " ".repeat((grouped ? 2 : 1) + depth);
|
|
571
|
+
const result = [`${indent}${icon} ${breadcrumb} · ${agent.state}${tokens ? ` · ${tokens}` : ""}`];
|
|
572
|
+
if (agent.state === "failed" && agent.attemptDetails?.length) {
|
|
573
|
+
const last = agent.attemptDetails[agent.attemptDetails.length - 1];
|
|
574
|
+
if (last?.error) result.push(`${indent} error: ${last.error.code}: ${last.error.message}`);
|
|
575
|
+
}
|
|
576
|
+
const activity = !SETTLED_AGENT_STATES.has(agent.state) ? formatAgentActivity(agent, "⠦") : "";
|
|
577
|
+
if (activity) result.push(`${indent} ${activity}`);
|
|
578
|
+
return result.join("\n");
|
|
579
|
+
};
|
|
580
|
+
lines.push(...renderGroupedAgents(run.agents, render));
|
|
581
|
+
if (checkpoints.length) { lines.push(""); for (const cp of checkpoints) lines.push(`● checkpoint ${cp.name}: ${cp.prompt}`); }
|
|
582
|
+
return lines.join("\n");
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> }, checkpoints: readonly AwaitingCheckpoint[], _worktrees: readonly WorktreeReference[]): string {
|
|
586
|
+
const { run, snapshot } = loaded;
|
|
587
|
+
const lines = [
|
|
588
|
+
`Workflow: ${run.workflowName}`,
|
|
589
|
+
`Run: ${run.id}`,
|
|
590
|
+
`Status: ${run.state}`,
|
|
591
|
+
`Phase: ${run.phase ?? "(none)"}`,
|
|
592
|
+
`Launch cwd: ${run.cwd}`,
|
|
593
|
+
...formatCompactBudgetStatus(run),
|
|
594
|
+
`Launch models: ${snapshot.models.join(", ") || "(none)"}`,
|
|
595
|
+
`Settings: concurrency=${String(snapshot.settings.concurrency)}`,
|
|
596
|
+
];
|
|
597
|
+
if (run.error) lines.push(`Run error: ${run.error.code}: ${run.error.message}`);
|
|
598
|
+
if (run.events?.length) lines.push(...run.events.map((event) => `Warning: ${event.message}`));
|
|
599
|
+
const aliases = snapshot.modelAliases ?? snapshot.settings.modelAliases;
|
|
600
|
+
if (aliases && Object.keys(aliases).length) lines.push(`Model aliases: ${Object.entries(aliases).map(([name, target]) => `${name}=${target}`).join(", ")}`);
|
|
601
|
+
if (snapshot.settingsSources) lines.push(`Settings sources: concurrency=${snapshot.settingsSources.concurrency}, modelAliases=${snapshot.settingsSources.modelAliases}, disabledAgentResources=${snapshot.settingsSources.disabledAgentResources}`);
|
|
602
|
+
lines.push("Agents / ownership:");
|
|
603
|
+
if (!run.agents.length) lines.push(" (none)");
|
|
604
|
+
const byId = new Map(run.agents.map((agent) => [agent.id, agent]));
|
|
605
|
+
lines.push(...renderGroupedAgents(run.agents, ({ agent, index, depth }, grouped) => {
|
|
606
|
+
const model = `${agent.model.provider}/${agent.model.model}${agent.model.thinking ? `:${agent.model.thinking}` : ""}`;
|
|
607
|
+
const role = agent.role ? ` role=${agent.role}` : "";
|
|
608
|
+
const tools = ` tools=${agent.tools.join(",") || "(none)"}`;
|
|
609
|
+
const accounting = agent.accounting ? ` input=${String(agent.accounting.input)} output=${String(agent.accounting.output)} cache-read=${String(agent.accounting.cacheRead)} cache-write=${String(agent.accounting.cacheWrite)} cost=${String(agent.accounting.cost)}` : "";
|
|
610
|
+
const indent = " ".repeat((grouped ? 2 : 1) + depth);
|
|
611
|
+
const result = [`${indent}#${String(index + 1)} ${grouped ? agent.label ?? agent.name : agentBreadcrumb(agent, byId)} state=${agent.state} model=${model}${agent.requestedModel ? ` requested=${agent.requestedModel}` : ""}${role}${tools} attempts=${String(agent.attempts)} retries=${String(Math.max(0, agent.attempts - 1))}${accounting}`];
|
|
612
|
+
for (const attempt of agent.attemptDetails ?? []) result.push(`${indent} attempt ${String(attempt.attempt)}${attempt.error ? ` error=${attempt.error.code}: ${attempt.error.message}` : ""}`);
|
|
613
|
+
for (const call of agent.toolCalls ?? []) result.push(`${indent} tool ${call.name} state=${call.state}`);
|
|
614
|
+
return result.join("\n");
|
|
615
|
+
}));
|
|
616
|
+
lines.push("Checkpoints:");
|
|
617
|
+
if (!checkpoints.length) lines.push(" (none)");
|
|
618
|
+
for (const checkpoint of checkpoints) lines.push(` ${checkpoint.name}: ${checkpoint.prompt} context=${JSON.stringify(checkpoint.context)}`);
|
|
619
|
+
lines.push(`Worktrees: ${String(_worktrees.length)}`);
|
|
620
|
+
lines.push(`Native Pi transcripts: ${String(run.nativeSessions.length)}`);
|
|
621
|
+
return lines.join("\n");
|
|
622
|
+
}
|
|
623
|
+
export function formatWorkflowPhaseDashboard(run: PersistedRun, snapshot: Readonly<LaunchSnapshot>, width: number, selection: WorkflowPhaseSelection = {}, styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES): string[] {
|
|
624
|
+
const safeWidth = Math.max(1, width);
|
|
625
|
+
const model = buildWorkflowPhaseModel(run, snapshot);
|
|
626
|
+
const wrap = (text: string, limit = safeWidth): string[] => truncateToVisualLines(text, Number.MAX_SAFE_INTEGER, Math.max(1, limit), 0).visualLines.map((line) => line.trimEnd());
|
|
627
|
+
const phaseStyle = (state: WorkflowPhaseState): ((text: string) => string) => state === "completed" ? (text) => styles.success(text) : state === "failed" || state === "cancelled" ? (text) => styles.error(text) : state === "running" ? (text) => styles.accent(text) : state === "interrupted" || state === "budget_exhausted" ? (text) => styles.warning(text) : (text) => styles.muted(text);
|
|
628
|
+
const phaseName = (phase: WorkflowPhaseView): string => `${phase.name}${phase.occurrence > 1 ? ` #${String(phase.occurrence)}` : ""}`;
|
|
629
|
+
const phaseCounts = (phase: WorkflowPhaseView): string => `agents completed=${String(phase.counts.completed)} running=${String(phase.counts.running)} failed=${String(phase.counts.failed)} cancelled=${String(phase.counts.cancelled)} pending=${String(phase.counts.pending)}`;
|
|
630
|
+
const phaseLine = (phase: WorkflowPhaseView, selected: boolean): string => `${selected ? "→" : " "} ${phaseName(phase)} · ${phaseStyle(phase.state)(phase.state)} · ${phaseCounts(phase)}`;
|
|
631
|
+
const stateNames: readonly WorkflowPhaseState[] = ["not started", "running", "completed", "failed", "cancelled", "interrupted", "budget_exhausted"];
|
|
632
|
+
const statusSummary = stateNames.filter((state) => (model.counts[state] ?? 0) > 0).map((state) => `${String(model.counts[state])} ${state}`).join(" · ") || "0 phases";
|
|
633
|
+
const selectedIndex = selection.phaseId ? model.phases.findIndex((phase) => phase.id === selection.phaseId) : -1;
|
|
634
|
+
const activeIndex = selectedIndex >= 0 ? selectedIndex : model.currentPhaseIndex ?? (model.phases.length ? 0 : -1);
|
|
635
|
+
const selectedPhase = activeIndex >= 0 ? model.phases[activeIndex] : undefined;
|
|
636
|
+
const lines: string[] = [styles.bold(styles.accent(`Workflow: ${run.workflowName}`))];
|
|
637
|
+
if (run.error) lines.push(styles.error(`ERROR ${run.error.code}: ${run.error.message}`));
|
|
638
|
+
lines.push(`phase: ${run.phase ?? selectedPhase?.name ?? "none"}`, `Run state: ${run.state}`, `Phases: ${statusSummary}`);
|
|
639
|
+
for (const event of run.events ?? []) lines.push(styles.warning(`Warning: ${event.message}`));
|
|
640
|
+
lines.push(...formatCompactBudgetStatus(run));
|
|
641
|
+
const byId = new Map(run.agents.map((agent) => [agent.id, agent]));
|
|
642
|
+
const renderAgentLines = (agents: readonly AgentRecord[], selectedAgentId: string | undefined): string[] => {
|
|
643
|
+
if (!agents.length) return [styles.muted("Agents: none")];
|
|
644
|
+
const rendered: string[] = [];
|
|
645
|
+
for (const agent of agents) {
|
|
646
|
+
const selected = agent.id === selectedAgentId;
|
|
647
|
+
const stateStyle = progressStyleForState(agent.state, styles);
|
|
648
|
+
const icon = agent.state === "completed" ? "✓" : agent.state === "failed" || agent.state === "cancelled" ? "✗" : agent.state === "running" ? "⠦" : "○";
|
|
649
|
+
rendered.push(`${selected ? "→" : " "} ${stateStyle(icon)} ${styledAgentBreadcrumb(agent, byId, styles)} · ${stateStyle(agent.state)}`);
|
|
650
|
+
if (agent.state === "failed") {
|
|
651
|
+
const error = agent.attemptDetails?.at(-1)?.error;
|
|
652
|
+
if (error) rendered.push(styles.error(` error: ${error.code}: ${error.message}`));
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
return rendered;
|
|
656
|
+
};
|
|
657
|
+
if (!model.phases.length) {
|
|
658
|
+
lines.push(styles.muted("Phases: no declared or observed phases (legacy snapshot)"), styles.bold("Agents"), ...renderAgentLines(run.agents, selection.agentId));
|
|
659
|
+
return lines.flatMap((line) => wrap(line));
|
|
660
|
+
}
|
|
661
|
+
const selectedAgent = selectedPhase?.agents.find((agent) => agent.id === selection.agentId) ?? selectedPhase?.agents[0];
|
|
662
|
+
if (safeWidth >= 80) {
|
|
663
|
+
const sidebarWidth = Math.min(38, Math.max(24, Math.floor(safeWidth * 0.36)));
|
|
664
|
+
const detailWidth = Math.max(1, safeWidth - sidebarWidth - 3);
|
|
665
|
+
const sidebar = [styles.bold("Phases"), ...model.phases.flatMap((phase, index) => wrap(phaseLine(phase, index === activeIndex), sidebarWidth))];
|
|
666
|
+
const detail = selectedPhase ? [styles.bold(`Phase: ${phaseName(selectedPhase)}`), ...wrap(`Status: ${phaseStyle(selectedPhase.state)(selectedPhase.state)}`, detailWidth), ...wrap(phaseCounts(selectedPhase), detailWidth), "Agents", ...renderAgentLines(selectedPhase.agents, selectedAgent?.id).flatMap((line) => wrap(line, detailWidth))] : [styles.muted("No phase is selected")];
|
|
667
|
+
const rows = Math.max(sidebar.length, detail.length);
|
|
668
|
+
for (let index = 0; index < rows; index += 1) lines.push(`${sidebar[index] ?? ""} | ${detail[index] ?? ""}`);
|
|
669
|
+
} else {
|
|
670
|
+
lines.push(styles.bold("Phases"));
|
|
671
|
+
for (const [index, phase] of model.phases.entries()) lines.push(...wrap(phaseLine(phase, index === activeIndex)));
|
|
672
|
+
if (selectedPhase) lines.push("", styles.bold(`Selected phase: ${phaseName(selectedPhase)}`), ...wrap(`Status: ${phaseStyle(selectedPhase.state)(selectedPhase.state)}`), ...wrap(phaseCounts(selectedPhase)), styles.bold("Agents"), ...renderAgentLines(selectedPhase.agents, selectedAgent?.id).flatMap((line) => wrap(line)));
|
|
673
|
+
}
|
|
674
|
+
if (model.unassignedAgents?.length) lines.push(...wrap(styles.muted(`Unassigned agents: ${String(model.unassignedAgents.length)}`)));
|
|
675
|
+
return lines.flatMap((line) => wrap(line));
|
|
676
|
+
}
|
|
677
|
+
function formatCheckpointReview(checkpoint: AwaitingCheckpoint): string {
|
|
678
|
+
const context = JSON.stringify(checkpoint.context, null, 2);
|
|
679
|
+
return [`Name: ${checkpoint.name}`, "Prompt:", checkpoint.prompt, context === "null" ? "Context: null" : "Context:", ...(context === "null" ? [] : [context])].join("\n");
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const DELIVERY_LIMIT_BYTES = 4 * 1024;
|
|
683
|
+
const WORKFLOW_LOG_ENTRY = "workflow-log";
|
|
684
|
+
interface WorkflowLogEntry { workflowName: string; message: string }
|
|
685
|
+
|
|
686
|
+
function completionDelivery(name: string, value: JsonValue, resultPath: string, worktrees: readonly { branch: string; path: string }[]): string {
|
|
687
|
+
const locations = worktrees.length ? ` Changes: ${worktrees.map(({ branch, path }) => `${branch} (${path})`).join(", ")}.` : "";
|
|
688
|
+
const message = `Workflow ${name} completed: ${JSON.stringify(value)}${locations}`;
|
|
689
|
+
if (Buffer.byteLength(message) <= DELIVERY_LIMIT_BYTES) return message;
|
|
690
|
+
const suffix = `... Full result: ${resultPath}${locations}`;
|
|
691
|
+
const suffixBytes = Buffer.byteLength(suffix);
|
|
692
|
+
if (suffixBytes >= DELIVERY_LIMIT_BYTES) return utf8Prefix(suffix, DELIVERY_LIMIT_BYTES);
|
|
693
|
+
return utf8Prefix(message, DELIVERY_LIMIT_BYTES - suffixBytes) + suffix;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function utf8Prefix(value: string, maxBytes: number): string {
|
|
697
|
+
const bytes = Buffer.from(value);
|
|
698
|
+
let end = Math.min(bytes.length, maxBytes);
|
|
699
|
+
while (end < bytes.length && end > 0 && ((bytes[end] ?? 0) & 0xc0) === 0x80) end -= 1;
|
|
700
|
+
return bytes.subarray(0, end).toString("utf8");
|
|
701
|
+
}
|
|
702
|
+
const DIAGNOSTIC_LIMIT_BYTES = DELIVERY_LIMIT_BYTES - 512;
|
|
703
|
+
function failureDiagnosticsFrom(error: unknown): WorkflowFailureDiagnostics | undefined {
|
|
704
|
+
if (!error || typeof error !== "object") return undefined;
|
|
705
|
+
return (error as { [WORKFLOW_FAILURE_DIAGNOSTICS]?: WorkflowFailureDiagnostics })[WORKFLOW_FAILURE_DIAGNOSTICS];
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function boundedWorkflowFailureDiagnostics(value: WorkflowFailureDiagnostics): WorkflowFailureDiagnostics {
|
|
709
|
+
let bounded: WorkflowFailureDiagnostics = {
|
|
710
|
+
runId: utf8Prefix(value.runId, 128),
|
|
711
|
+
workflowName: utf8Prefix(value.workflowName, 256),
|
|
712
|
+
state: value.state,
|
|
713
|
+
failedAt: value.failedAt === null ? null : utf8Prefix(value.failedAt, 1024),
|
|
714
|
+
error: { code: value.error.code, message: utf8Prefix(value.error.message, 1024) },
|
|
715
|
+
...(value.failedAgent ? { failedAgent: {
|
|
716
|
+
id: utf8Prefix(value.failedAgent.id, 128),
|
|
717
|
+
...(value.failedAgent.label ? { label: utf8Prefix(value.failedAgent.label, 128) } : {}),
|
|
718
|
+
...(value.failedAgent.role ? { role: utf8Prefix(value.failedAgent.role, 128) } : {}),
|
|
719
|
+
structuralPath: value.failedAgent.structuralPath.slice(0, 8).map((part) => utf8Prefix(part, 128)),
|
|
720
|
+
attempt: value.failedAgent.attempt,
|
|
721
|
+
...(value.failedAgent.sessionId ? { sessionId: utf8Prefix(value.failedAgent.sessionId, 256) } : {}),
|
|
722
|
+
...(value.failedAgent.sessionFile ? { sessionFile: utf8Prefix(value.failedAgent.sessionFile, 1024) } : {}),
|
|
723
|
+
} } : {}),
|
|
724
|
+
completedSiblingAgents: (value.completedSiblingAgents ?? []).slice(0, 16).map((agent) => ({
|
|
725
|
+
id: utf8Prefix(agent.id, 128),
|
|
726
|
+
...(agent.label ? { label: utf8Prefix(agent.label, 128) } : {}),
|
|
727
|
+
...(agent.role ? { role: utf8Prefix(agent.role, 128) } : {}),
|
|
728
|
+
structuralPath: agent.structuralPath.slice(0, 8).map((part) => utf8Prefix(part, 128)),
|
|
729
|
+
})),
|
|
730
|
+
completedSiblingPaths: value.completedSiblingPaths.slice(0, 16).map((path) => path.slice(0, 8).map((part) => utf8Prefix(part, 128))),
|
|
731
|
+
...(value.retry ? { retry: { sourceRunId: utf8Prefix(value.retry.sourceRunId, 128), action: utf8Prefix(value.retry.action, 256), completedPaths: value.retry.completedPaths.slice(0, 16).map((path) => utf8Prefix(path, 256)), incompletePaths: value.retry.incompletePaths.slice(0, 16).map((path) => utf8Prefix(path, 256)), namedWorktrees: value.retry.namedWorktrees.slice(0, 16).map((name) => utf8Prefix(name, 128)), warning: utf8Prefix(value.retry.warning, 512) } } : {}),
|
|
732
|
+
artifacts: { runDirectory: utf8Prefix(value.artifacts.runDirectory, 1024), statePath: utf8Prefix(value.artifacts.statePath, 1024), journalPath: utf8Prefix(value.artifacts.journalPath, 1024) },
|
|
733
|
+
};
|
|
734
|
+
const size = () => Buffer.byteLength(JSON.stringify(bounded));
|
|
735
|
+
while (size() > DIAGNOSTIC_LIMIT_BYTES) {
|
|
736
|
+
if (bounded.completedSiblingAgents?.length || bounded.completedSiblingPaths.length) {
|
|
737
|
+
bounded = { ...bounded, completedSiblingAgents: bounded.completedSiblingAgents?.slice(0, -1) ?? [], completedSiblingPaths: bounded.completedSiblingPaths.slice(0, -1) };
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
if (bounded.retry && (bounded.retry.completedPaths.length || bounded.retry.incompletePaths.length || bounded.retry.namedWorktrees.length)) {
|
|
741
|
+
const retry = { ...bounded.retry };
|
|
742
|
+
if (retry.completedPaths.length) retry.completedPaths = retry.completedPaths.slice(0, -1);
|
|
743
|
+
else if (retry.incompletePaths.length) retry.incompletePaths = retry.incompletePaths.slice(0, -1);
|
|
744
|
+
else retry.namedWorktrees = retry.namedWorktrees.slice(0, -1);
|
|
745
|
+
bounded = { ...bounded, retry };
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
if (bounded.failedAgent?.sessionFile) { const failedAgent = { ...bounded.failedAgent }; delete failedAgent.sessionFile; bounded = { ...bounded, failedAgent }; continue; }
|
|
749
|
+
if (bounded.failedAgent?.sessionId) { const failedAgent = { ...bounded.failedAgent }; delete failedAgent.sessionId; bounded = { ...bounded, failedAgent }; continue; }
|
|
750
|
+
if (Buffer.byteLength(bounded.artifacts.runDirectory) > 256) { bounded = { ...bounded, artifacts: { ...bounded.artifacts, runDirectory: utf8Prefix(bounded.artifacts.runDirectory, 256) } }; continue; }
|
|
751
|
+
if (Buffer.byteLength(bounded.error.message) > 256) { bounded = { ...bounded, error: { ...bounded.error, message: utf8Prefix(bounded.error.message, 256) } }; continue; }
|
|
752
|
+
if (bounded.failedAt !== null && Buffer.byteLength(bounded.failedAt) > 256) { bounded = { ...bounded, failedAt: utf8Prefix(bounded.failedAt, 256) }; continue; }
|
|
753
|
+
if (bounded.failedAgent && bounded.failedAgent.structuralPath.length > 4) { bounded = { ...bounded, failedAgent: { ...bounded.failedAgent, structuralPath: bounded.failedAgent.structuralPath.slice(0, 4) } }; continue; }
|
|
754
|
+
if (bounded.failedAgent?.structuralPath.some((part) => Buffer.byteLength(part) > 64)) { bounded = { ...bounded, failedAgent: { ...bounded.failedAgent, structuralPath: bounded.failedAgent.structuralPath.map((part) => utf8Prefix(part, 64)) } }; continue; }
|
|
755
|
+
if (Buffer.byteLength(bounded.artifacts.statePath) > 512 || Buffer.byteLength(bounded.artifacts.journalPath) > 512) { bounded = { ...bounded, artifacts: { ...bounded.artifacts, statePath: utf8Prefix(bounded.artifacts.statePath, 512), journalPath: utf8Prefix(bounded.artifacts.journalPath, 512) } }; continue; }
|
|
756
|
+
if (Buffer.byteLength(bounded.workflowName) > 128) { bounded = { ...bounded, workflowName: utf8Prefix(bounded.workflowName, 128) }; continue; }
|
|
757
|
+
break;
|
|
758
|
+
}
|
|
759
|
+
return bounded;
|
|
760
|
+
}
|
|
761
|
+
async function diagnosticNamedWorktrees(store: RunStore, run: PersistedRun): Promise<readonly string[]> {
|
|
762
|
+
const names = new Set<string>();
|
|
763
|
+
try {
|
|
764
|
+
for (const name of await store.validNamedWorktrees()) names.add(name);
|
|
765
|
+
} catch { /* Do not block failure delivery on an invalid worktree record. */ }
|
|
766
|
+
for (const name of run.retry?.namedWorktrees ?? []) {
|
|
767
|
+
try { await store.resolveNamedWorktree(name); names.add(name); } catch { /* Do not advertise stale inherited worktrees. */ }
|
|
768
|
+
}
|
|
769
|
+
return [...names];
|
|
770
|
+
}
|
|
771
|
+
function incompleteRetryPaths(paths: readonly string[], completedPaths: readonly string[]): string[] {
|
|
772
|
+
return [...new Set(paths)].filter((path) => !completedPaths.some((completedPath) => completedPath === path || completedPath.startsWith(`${path}/`)));
|
|
773
|
+
}
|
|
774
|
+
async function createWorkflowFailureDiagnostics(store: RunStore, metadata: WorkflowMetadata, error: unknown, run: PersistedRun): Promise<WorkflowFailureDiagnostics> {
|
|
775
|
+
const rawFailedAt = error && typeof error === "object" ? (error as { failedAt?: unknown }).failedAt : undefined;
|
|
776
|
+
const failedAt = typeof rawFailedAt === "string" && rawFailedAt ? rawFailedAt : null;
|
|
777
|
+
const failedAgents = run.agents.filter((agent) => agent.state === "failed");
|
|
778
|
+
const failedAgentRecord = failedAgents.find((agent) => {
|
|
779
|
+
if (failedAt === null) return false;
|
|
780
|
+
try { return failedAt.includes(`${operationPath("agent", ...(agent.structuralPath ?? []))}/`); } catch { return false; }
|
|
781
|
+
}) ?? failedAgents.at(-1);
|
|
782
|
+
const failedAttempt = failedAgentRecord ? [...(failedAgentRecord.attemptDetails ?? [])].reverse().find((attempt) => attempt.error) ?? failedAgentRecord.attemptDetails?.at(-1) : undefined;
|
|
783
|
+
const failedAgent = failedAgentRecord ? {
|
|
784
|
+
id: failedAgentRecord.id,
|
|
785
|
+
...(failedAgentRecord.label ?? failedAgentRecord.name ? { label: failedAgentRecord.label ?? failedAgentRecord.name } : {}),
|
|
786
|
+
...(failedAgentRecord.role ? { role: failedAgentRecord.role } : {}),
|
|
787
|
+
structuralPath: [...(failedAgentRecord.structuralPath ?? [])],
|
|
788
|
+
attempt: Math.max(1, failedAttempt?.attempt ?? failedAgentRecord.attempts),
|
|
789
|
+
...(failedAttempt?.sessionId ? { sessionId: failedAttempt.sessionId } : {}),
|
|
790
|
+
...(failedAttempt?.sessionFile ? { sessionFile: failedAttempt.sessionFile } : {}),
|
|
791
|
+
} satisfies WorkflowFailureAgent : undefined;
|
|
792
|
+
const completedSiblingAgents = run.agents.filter((agent) => {
|
|
793
|
+
if (agent.state !== "completed" || agent.id === failedAgentRecord?.id) return false;
|
|
794
|
+
return failedAgentRecord?.parentId === undefined ? agent.parentId === undefined : agent.parentId === failedAgentRecord.parentId;
|
|
795
|
+
}).map((agent) => ({
|
|
796
|
+
id: agent.id,
|
|
797
|
+
...(agent.label ?? agent.name ? { label: agent.label ?? agent.name } : {}),
|
|
798
|
+
...(agent.role ? { role: agent.role } : {}),
|
|
799
|
+
structuralPath: [...(agent.structuralPath ?? [])],
|
|
800
|
+
} satisfies WorkflowSiblingAgent));
|
|
801
|
+
const completedSiblingPaths = completedSiblingAgents.map((agent) => [...agent.structuralPath]);
|
|
802
|
+
let journalCompletedPaths: readonly string[] = [];
|
|
803
|
+
try { journalCompletedPaths = (await store.replayableOperations()).map(({ path }) => path); } catch { /* Preserve failure diagnostics when retry history is unavailable. */ }
|
|
804
|
+
const completedPaths = run.retry ? [...new Set([...run.retry.completedPaths, ...journalCompletedPaths])] : journalCompletedPaths.length ? journalCompletedPaths : run.agents.filter((agent) => agent.state === "completed").map((agent) => operationPath("agent", ...(agent.structuralPath ?? [])));
|
|
805
|
+
const namedWorktrees = await diagnosticNamedWorktrees(store, run);
|
|
806
|
+
const retry = run.state === "failed" ? {
|
|
807
|
+
sourceRunId: run.id,
|
|
808
|
+
action: `workflow_retry({ runId: ${JSON.stringify(run.id)} })`,
|
|
809
|
+
completedPaths,
|
|
810
|
+
incompletePaths: incompleteRetryPaths([...(run.retry?.incompletePaths ?? []), ...(failedAt ? [failedAt] : [])], completedPaths),
|
|
811
|
+
namedWorktrees,
|
|
812
|
+
warning: "Retry re-executes incomplete operations; external side effects before failure are not guaranteed exactly once.",
|
|
813
|
+
} : undefined;
|
|
814
|
+
return boundedWorkflowFailureDiagnostics({
|
|
815
|
+
runId: run.id, workflowName: metadata.name, state: run.state, failedAt,
|
|
816
|
+
error: { code: errorCode(error) ?? "INTERNAL_ERROR", message: errorText(error) || "The workflow failed without an error message." },
|
|
817
|
+
...(failedAgent ? { failedAgent } : {}), completedSiblingAgents, completedSiblingPaths,
|
|
818
|
+
...(retry ? { retry } : {}),
|
|
819
|
+
artifacts: { runDirectory: store.directory, statePath: join(store.directory, "state.json"), journalPath: join(store.directory, "journal.json") },
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
export function formatWorkflowFailureDiagnostics(diagnostic: WorkflowFailureDiagnostics): string {
|
|
824
|
+
const failedAgent = diagnostic.failedAgent ? `${diagnostic.failedAgent.label ?? diagnostic.failedAgent.id}${diagnostic.failedAgent.role ? ` role=${diagnostic.failedAgent.role}` : ""} attempt=${String(diagnostic.failedAgent.attempt)} path=${diagnostic.failedAgent.structuralPath.join(" > ") || "(root)"}${diagnostic.failedAgent.sessionFile ? ` session=${diagnostic.failedAgent.sessionFile}` : ""}` : "(not persisted)";
|
|
825
|
+
const siblingAgents = diagnostic.completedSiblingAgents;
|
|
826
|
+
const siblings = siblingAgents ? siblingAgents.map((agent) => `${agent.label ?? agent.id}${agent.role ? ` role=${agent.role}` : ""} path=${agent.structuralPath.join(" > ") || "(root)"}`).join(", ") || "(none)" : diagnostic.completedSiblingPaths.map((path) => path.join(" > ") || "(root)").join(", ") || "(none)";
|
|
827
|
+
const retry = diagnostic.retry ? [` Retry: ${diagnostic.retry.action}`, ` Replayable completed paths: ${diagnostic.retry.completedPaths.join(", ") || "(none)"}`, ` Incomplete paths: ${diagnostic.retry.incompletePaths.join(", ") || "(unknown)"}`, ` Named worktrees: ${diagnostic.retry.namedWorktrees.join(", ") || "(none)"}`, ` Warning: ${diagnostic.retry.warning}`] : [];
|
|
828
|
+
return [`✗ Workflow: ${diagnostic.workflowName}`, ` Run: ${diagnostic.runId}`, ` State: ${diagnostic.state}`, ` Error: ${diagnostic.error.code}: ${diagnostic.error.message}`, ` Failed at: ${diagnostic.failedAt ?? "(unknown)"}`, ` Failed agent: ${failedAgent}`, ` Completed sibling ${siblingAgents ? "agents" : "paths"}: ${siblings}`, ...retry, ` Artifacts: state=${diagnostic.artifacts.statePath} journal=${diagnostic.artifacts.journalPath}`].join("\n");
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function serializeWorkflowFailureDiagnostics(diagnostic: WorkflowFailureDiagnostics): string { return JSON.stringify(diagnostic); }
|
|
832
|
+
function isWorkflowFailureDiagnostics(value: unknown): value is WorkflowFailureDiagnostics {
|
|
833
|
+
return object(value) && typeof value.runId === "string" && typeof value.workflowName === "string" && typeof value.state === "string" && "failedAt" in value && object(value.error) && object(value.artifacts);
|
|
834
|
+
}
|
|
835
|
+
function deliver(pi: ExtensionAPI, content: string): void {
|
|
836
|
+
pi.sendMessage({ customType: "workflow", content, display: true }, { deliverAs: "followUp", triggerTurn: true });
|
|
837
|
+
}
|
|
838
|
+
function deliverFailure(pi: ExtensionAPI, diagnostic: WorkflowFailureDiagnostics): void {
|
|
839
|
+
deliver(pi, `Workflow ${utf8Prefix(diagnostic.workflowName, 128)} failure diagnostics: ${serializeWorkflowFailureDiagnostics(diagnostic)}`);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
type WorkflowEventSink = { emit: (name: string, payload: unknown) => unknown };
|
|
843
|
+
|
|
844
|
+
function safeEventError(error: unknown): WorkflowErrorShape {
|
|
845
|
+
const code = errorCode(error) ?? "INTERNAL_ERROR";
|
|
846
|
+
return { code, message: `Workflow execution failed (${code})` };
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
class WorkflowEventPublisher {
|
|
850
|
+
#queues = new Map<string, Promise<void>>();
|
|
851
|
+
#budgetEvents = new Map<string, Set<string>>();
|
|
852
|
+
#worktrees = new Map<string, Set<string>>();
|
|
853
|
+
|
|
854
|
+
constructor(private readonly sink: WorkflowEventSink | undefined) {}
|
|
855
|
+
|
|
856
|
+
removeRun(runId: string): void {
|
|
857
|
+
this.#queues.delete(runId);
|
|
858
|
+
this.#budgetEvents.delete(runId);
|
|
859
|
+
this.#worktrees.delete(runId);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
seedBudget(runId: string, events: readonly BudgetEvent[] | undefined): void {
|
|
863
|
+
const seen = this.#budgetEvents.get(runId) ?? new Set<string>();
|
|
864
|
+
for (const event of events ?? []) seen.add(this.budgetKey(event));
|
|
865
|
+
this.#budgetEvents.set(runId, seen);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
async runStarted(store: RunStore, metadata: WorkflowMetadata): Promise<void> { await this.#publish(store, metadata, WORKFLOW_RUN_STARTED_EVENT, {}); }
|
|
869
|
+
async runResumed(store: RunStore, metadata: WorkflowMetadata): Promise<void> { await this.#publish(store, metadata, WORKFLOW_RUN_RESUMED_EVENT, {}); }
|
|
870
|
+
|
|
871
|
+
async runState(store: RunStore, metadata: WorkflowMetadata, previousState: RunState, state: RunState, reason?: string): Promise<void> {
|
|
872
|
+
await this.#publish(store, metadata, WORKFLOW_RUN_STATE_CHANGED_EVENT, { previousState, state, ...(reason ? { reason } : {}), ...(ERROR_CODES.includes(reason as WorkflowErrorCode) ? { errorCode: reason } : {}) });
|
|
873
|
+
if ((previousState === "paused" || previousState === "interrupted" || previousState === "budget_exhausted") && state === "running") await this.runResumed(store, metadata);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
async runCompleted(store: RunStore, metadata: WorkflowMetadata, resultPath: string): Promise<void> { await this.#publish(store, metadata, WORKFLOW_RUN_COMPLETED_EVENT, { resultPath }); }
|
|
877
|
+
async runFailed(store: RunStore, metadata: WorkflowMetadata, error: unknown, state: "failed" | "stopped" | "interrupted" | "budget_exhausted"): Promise<void> {
|
|
878
|
+
if (state === "failed") await this.#publish(store, metadata, WORKFLOW_RUN_FAILED_EVENT, { error: safeEventError(error) });
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
async agentState(store: RunStore, metadata: WorkflowMetadata, previous: AgentRecord | undefined, agent: AgentRecord): Promise<void> {
|
|
882
|
+
await this.#publish(store, metadata, WORKFLOW_AGENT_STATE_CHANGED_EVENT, { agentId: agent.id, displayLabel: agent.label ?? agent.name, ...(agent.role ? { role: agent.role } : {}), structuralPath: [...(agent.structuralPath ?? [])], ...(agent.parentId ? { parentId: agent.parentId } : {}), ...(agent.parentBreadcrumb ? { parentBreadcrumb: agent.parentBreadcrumb } : {}), ...(agent.worktreeOwner ? { worktreeOwner: agent.worktreeOwner } : {}), ...(previous ? { previousState: previous.state } : {}), state: agent.state, attempt: agent.attempts });
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
async agentStates(store: RunStore, metadata: WorkflowMetadata, previous: readonly AgentRecord[], current: readonly AgentRecord[]): Promise<void> {
|
|
886
|
+
const previousById = new Map(previous.map((agent) => [agent.id, agent]));
|
|
887
|
+
for (const agent of current) {
|
|
888
|
+
const old = previousById.get(agent.id);
|
|
889
|
+
if (!old || old.state !== agent.state || old.attempts !== agent.attempts) await this.agentState(store, metadata, old, agent);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
async phase(store: RunStore, metadata: WorkflowMetadata, previousPhase: string | undefined, phase: string): Promise<void> {
|
|
894
|
+
if (previousPhase !== phase) await this.#publish(store, metadata, WORKFLOW_PHASE_CHANGED_EVENT, { ...(previousPhase !== undefined ? { previousPhase } : {}), phase });
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
async checkpoint(store: RunStore, metadata: WorkflowMetadata, name: string, state: WorkflowCheckpointState): Promise<void> { await this.#publish(store, metadata, WORKFLOW_CHECKPOINT_STATE_CHANGED_EVENT, { name, state }); }
|
|
898
|
+
|
|
899
|
+
async budget(store: RunStore, metadata: WorkflowMetadata, run: Pick<PersistedRun, "budgetEvents">): Promise<void> {
|
|
900
|
+
const seen = this.#budgetEvents.get(store.runId) ?? new Set<string>();
|
|
901
|
+
this.#budgetEvents.set(store.runId, seen);
|
|
902
|
+
for (const event of run.budgetEvents ?? []) {
|
|
903
|
+
const key = this.budgetKey(event);
|
|
904
|
+
if (seen.has(key)) continue;
|
|
905
|
+
seen.add(key);
|
|
906
|
+
await this.#publish(store, metadata, WORKFLOW_BUDGET_EVENT, { ...event, timestamp: event.at });
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
async worktree(store: RunStore, metadata: WorkflowMetadata, worktree: WorktreeReference): Promise<void> {
|
|
911
|
+
const seen = this.#worktrees.get(store.runId) ?? new Set<string>();
|
|
912
|
+
this.#worktrees.set(store.runId, seen);
|
|
913
|
+
if (seen.has(worktree.owner)) return;
|
|
914
|
+
seen.add(worktree.owner);
|
|
915
|
+
await this.#publish(store, metadata, WORKFLOW_WORKTREE_CREATED_EVENT, { owner: worktree.owner, branch: worktree.branch, path: worktree.path, base: worktree.base });
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
async #publish(store: RunStore, metadata: WorkflowMetadata, name: string, payload: Record<string, unknown>): Promise<void> {
|
|
919
|
+
const base: WorkflowEventBase = { runId: store.runId, sessionId: store.sessionId, workflowName: metadata.name, cwd: store.cwd, runDirectory: store.directory, timestamp: Date.now() };
|
|
920
|
+
const previous = this.#queues.get(store.runId) ?? Promise.resolve();
|
|
921
|
+
const next = previous.then(() => {
|
|
922
|
+
try { void Promise.resolve(this.sink?.emit(name, { ...base, ...payload })).catch(() => undefined); } catch { /* Best effort: listeners cannot affect a run. */ }
|
|
923
|
+
});
|
|
924
|
+
this.#queues.set(store.runId, next.catch(() => undefined));
|
|
925
|
+
await next;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
private budgetKey(event: BudgetEvent): string { return `${String(event.budgetVersion)}:${event.type}:${event.proposalId ?? ""}`; }
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const inheritedHostAgentPath = new AsyncLocalStorage<readonly string[]>();
|
|
932
|
+
const inheritedHostWorktreeOwner = new AsyncLocalStorage<string>();
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
function namedRecord(value: unknown, kind: string): Array<[string, unknown]> {
|
|
936
|
+
if (!object(value)) fail("INVALID_METADATA", `${kind} must be a record`);
|
|
937
|
+
return Object.entries(value);
|
|
938
|
+
}
|
|
939
|
+
function publicWorktreeReference(reference: WorkflowWorktreeReference): Readonly<WorkflowWorktreeReference> {
|
|
940
|
+
if (!object(reference) || typeof reference.path !== "string" || typeof reference.branch !== "string") fail("WORKTREE_FAILED", "Worktree reference is invalid");
|
|
941
|
+
return Object.freeze({ path: reference.path, branch: reference.branch });
|
|
942
|
+
}
|
|
943
|
+
async function hostWithWorktree(args: readonly unknown[], resolveWorktree: ((owner: string, signal: AbortSignal) => Promise<Readonly<WorkflowWorktreeReference>>) | undefined, signal: AbortSignal): Promise<JsonValue> {
|
|
944
|
+
if (args.length !== 2) fail("INVALID_METADATA", "withWorktree requires a name and callback");
|
|
945
|
+
const name = args[0];
|
|
946
|
+
const callback = args[1];
|
|
947
|
+
if (typeof name !== "string" || !name.trim()) fail("INVALID_METADATA", "withWorktree name must be a non-empty string");
|
|
948
|
+
if (typeof callback !== "function") fail("INVALID_METADATA", "withWorktree callback must be a function");
|
|
949
|
+
if (!resolveWorktree) fail("WORKTREE_FAILED", "No worktree bridge is available");
|
|
950
|
+
const owner = operationPath("worktree", "named", name.trim());
|
|
951
|
+
const reference = publicWorktreeReference(await resolveWorktree(owner, signal));
|
|
952
|
+
return inheritedHostWorktreeOwner.run(owner, async () => await (callback as (reference: Readonly<WorkflowWorktreeReference>) => unknown)(reference)) as Promise<JsonValue>;
|
|
953
|
+
}
|
|
954
|
+
function workflowRunContext(cwd: string, sessionId: string, runId: string, workflow: WorkflowMetadata, args: JsonValue, signal: AbortSignal): Readonly<WorkflowRunContext> {
|
|
955
|
+
return Object.freeze({ cwd, sessionId, runId, workflow: deepFreeze(structuredClone(workflow)), args: deepFreeze(structuredClone(args)), signal });
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
async function resolveWorkflowVariables(run: Readonly<WorkflowRunContext>, controller: AbortController, registry: WorkflowRegistryApi): Promise<Readonly<Record<string, JsonValue>>> {
|
|
959
|
+
let first: WorkflowError | undefined;
|
|
960
|
+
const tasks = registry.variables().map(async ({ name, variable }) => {
|
|
961
|
+
try {
|
|
962
|
+
const result: unknown = await variable.resolve(run);
|
|
963
|
+
if (!jsonValue(result) || !Value.Check(variable.schema, result)) fail("RESULT_INVALID", `Invalid output from ${name}`);
|
|
964
|
+
return [name, deepFreeze(structuredClone(result))] as const;
|
|
965
|
+
} catch (error) {
|
|
966
|
+
const typed = errorCode(error) ? new WorkflowError(errorCode(error) as WorkflowErrorCode, `${name}: ${errorText(error)}`) : new WorkflowError("INTERNAL_ERROR", `${name}: ${errorText(error)}`);
|
|
967
|
+
if (!first) { first = typed; controller.abort(); }
|
|
968
|
+
throw typed;
|
|
969
|
+
}
|
|
970
|
+
});
|
|
971
|
+
await Promise.allSettled(tasks);
|
|
972
|
+
if (first) throw first;
|
|
973
|
+
return Object.freeze(Object.fromEntries((await Promise.all(tasks)).map(([name, value]) => [name, value])));
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
async function hostParallel(rawOperation: unknown, rawTasks: unknown): Promise<JsonValue> {
|
|
977
|
+
if (typeof rawOperation !== "string" || !rawOperation.trim()) fail("INVALID_METADATA", "parallel requires a stable explicit name");
|
|
978
|
+
const tasks = namedRecord(rawTasks, "parallel tasks");
|
|
979
|
+
for (const [name, run] of tasks) {
|
|
980
|
+
if (!name.trim()) fail("INVALID_METADATA", "parallel task requires a stable explicit name");
|
|
981
|
+
if (typeof run !== "function") fail("INVALID_METADATA", "parallel task values must be run functions");
|
|
982
|
+
}
|
|
983
|
+
const results = await Promise.all(tasks.map(async ([name, run]) => {
|
|
984
|
+
try {
|
|
985
|
+
const parent = inheritedHostAgentPath.getStore() ?? [];
|
|
986
|
+
return { name, value: await inheritedHostAgentPath.run([...parent, rawOperation, name], run as () => unknown) as JsonValue };
|
|
987
|
+
} catch (error) {
|
|
988
|
+
const typed = error instanceof WorkflowError ? error : new WorkflowError("INTERNAL_ERROR", error instanceof Error ? error.message : String(error));
|
|
989
|
+
if (typed.code === "CANCELLED") throw typed;
|
|
990
|
+
return { name, error: typed };
|
|
991
|
+
}
|
|
992
|
+
}));
|
|
993
|
+
const failure = results.find((result) => result.error);
|
|
994
|
+
if (failure?.error) throw failure.error;
|
|
995
|
+
return Object.fromEntries(results.map((result) => [result.name, result.value as JsonValue]));
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
async function hostPipeline(rawOperation: unknown, rawItems: unknown, rawStages: unknown): Promise<JsonValue> {
|
|
999
|
+
if (typeof rawOperation !== "string" || !rawOperation.trim()) fail("INVALID_METADATA", "pipeline requires a stable explicit name");
|
|
1000
|
+
const items = namedRecord(rawItems, "pipeline items");
|
|
1001
|
+
const stages = namedRecord(rawStages, "pipeline stages");
|
|
1002
|
+
if (!stages.length) fail("INVALID_METADATA", "pipeline requires at least one stage");
|
|
1003
|
+
for (const [name] of items) if (!name.trim()) fail("INVALID_METADATA", "pipeline item requires a stable explicit name");
|
|
1004
|
+
for (const [stageName, run] of stages) {
|
|
1005
|
+
if (!stageName.trim()) fail("INVALID_METADATA", "pipeline stage requires a stable explicit name");
|
|
1006
|
+
if (typeof run !== "function") fail("INVALID_METADATA", "pipeline stage values must be run functions");
|
|
1007
|
+
}
|
|
1008
|
+
const results = await Promise.all(items.map(async ([name, initial]) => {
|
|
1009
|
+
let current = initial;
|
|
1010
|
+
try {
|
|
1011
|
+
for (const [stageName, run] of stages) {
|
|
1012
|
+
const parent = inheritedHostAgentPath.getStore() ?? [];
|
|
1013
|
+
current = await inheritedHostAgentPath.run([...parent, rawOperation, name, stageName], () => (run as (value: unknown) => unknown)(current));
|
|
1014
|
+
}
|
|
1015
|
+
return { name, value: current as JsonValue };
|
|
1016
|
+
} catch (error) {
|
|
1017
|
+
const typed = error instanceof WorkflowError ? error : new WorkflowError("INTERNAL_ERROR", error instanceof Error ? error.message : String(error));
|
|
1018
|
+
if (typed.code === "CANCELLED") throw typed;
|
|
1019
|
+
return { name, error: typed };
|
|
1020
|
+
}
|
|
1021
|
+
}));
|
|
1022
|
+
const failure = results.find((result) => result.error);
|
|
1023
|
+
if (failure?.error) throw failure.error;
|
|
1024
|
+
return Object.fromEntries(results.map((result) => [result.name, result.value as JsonValue]));
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function nextNamedOccurrence(counters: Map<string, number>, label: string): string {
|
|
1028
|
+
const count = (counters.get(label) ?? 0) + 1;
|
|
1029
|
+
counters.set(label, count);
|
|
1030
|
+
return count === 1 ? label : `${label}#${String(count)}`;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function withWorkflowFunctions(bridge: WorkflowBridge, store: RunStore, runContext: Readonly<WorkflowRunContext>, variables: Readonly<Record<string, JsonValue>>, registry: WorkflowRegistryApi): WorkflowBridge {
|
|
1034
|
+
const functionAgentOccurrences = new Map<string, number>();
|
|
1035
|
+
const functionShellOccurrences = new Map<string, number>();
|
|
1036
|
+
const functionInvokeOccurrences = new Map<string, number>();
|
|
1037
|
+
const invokeFunction = async (name: string, input: Readonly<Record<string, JsonValue>>, path: string, signal: AbortSignal, worktreeOwner?: string, structuralPath: readonly string[] = [], breadcrumb?: string): Promise<JsonValue> => {
|
|
1038
|
+
const replayed = await store.replay(path);
|
|
1039
|
+
let stored: JsonValue | undefined;
|
|
1040
|
+
const sideEffects: Promise<void>[] = [];
|
|
1041
|
+
const functionBreadcrumb = breadcrumb ?? name;
|
|
1042
|
+
const context: WorkflowFunctionContext = {
|
|
1043
|
+
run: runContext,
|
|
1044
|
+
invoke: async (targetName, targetInput) => {
|
|
1045
|
+
const inherited = inheritedHostAgentPath.getStore() ?? structuralPath;
|
|
1046
|
+
const scopedWorktreeOwner = inheritedHostWorktreeOwner.getStore() ?? worktreeOwner;
|
|
1047
|
+
const key = JSON.stringify([path, inherited, targetName]);
|
|
1048
|
+
const occurrence = (functionInvokeOccurrences.get(key) ?? 0) + 1;
|
|
1049
|
+
functionInvokeOccurrences.set(key, occurrence);
|
|
1050
|
+
const nestedPath = operationPath("function", "nested", path, ...inherited, targetName, `occurrence:${String(occurrence)}`);
|
|
1051
|
+
return invokeFunction(targetName, targetInput, nestedPath, signal, scopedWorktreeOwner, inherited, `${functionBreadcrumb} > ${targetName}`);
|
|
1052
|
+
},
|
|
1053
|
+
agent: async (prompt: string, options: Readonly<AgentOptions> = {}) => {
|
|
1054
|
+
if (!bridge.agent || typeof prompt !== "string") fail("AGENT_FAILED", "No agent bridge is available");
|
|
1055
|
+
const validatedOptions = validateAgentOptions(options);
|
|
1056
|
+
const scopedWorktreeOwner = inheritedHostWorktreeOwner.getStore() ?? worktreeOwner;
|
|
1057
|
+
const inherited = inheritedHostAgentPath.getStore() ?? [];
|
|
1058
|
+
const key = `${path}\0${JSON.stringify(inherited)}`;
|
|
1059
|
+
const occurrence = (functionAgentOccurrences.get(key) ?? 0) + 1;
|
|
1060
|
+
functionAgentOccurrences.set(key, occurrence);
|
|
1061
|
+
return bridge.agent(prompt, validatedOptions, signal, { structuralPath: [...inherited], callSite: `function:${path}`, occurrence, parentBreadcrumb: functionBreadcrumb, ...(scopedWorktreeOwner ? { worktreeOwner: scopedWorktreeOwner } : {}) });
|
|
1062
|
+
},
|
|
1063
|
+
shell: async (...args: readonly unknown[]) => {
|
|
1064
|
+
if (!bridge.shell) fail("SHELL_FAILED", "No shell bridge is available");
|
|
1065
|
+
if (typeof args[0] !== "string") fail("INVALID_METADATA", "shell command must be a string");
|
|
1066
|
+
const options = validateShellOptions(args[1] === undefined ? {} : args[1]);
|
|
1067
|
+
const scopedWorktreeOwner = inheritedHostWorktreeOwner.getStore() ?? worktreeOwner;
|
|
1068
|
+
const inherited = inheritedHostAgentPath.getStore() ?? [];
|
|
1069
|
+
const key = `${path}\0${JSON.stringify([inherited, scopedWorktreeOwner ?? null])}`;
|
|
1070
|
+
const occurrence = (functionShellOccurrences.get(key) ?? 0) + 1;
|
|
1071
|
+
functionShellOccurrences.set(key, occurrence);
|
|
1072
|
+
return bridge.shell(args[0], options, signal, { structuralPath: [...inherited], callSite: `function:${path}`, occurrence, ...(scopedWorktreeOwner ? { worktreeOwner: scopedWorktreeOwner } : {}) });
|
|
1073
|
+
},
|
|
1074
|
+
prompt: workflowPrompt,
|
|
1075
|
+
parallel: (...args: readonly unknown[]) => hostParallel(args[0], args[1]),
|
|
1076
|
+
pipeline: (...args: readonly unknown[]) => hostPipeline(args[0], args[1], args[2]),
|
|
1077
|
+
withWorktree: (...args: readonly unknown[]) => hostWithWorktree(args, bridge.worktree, signal),
|
|
1078
|
+
checkpoint: async (...args: readonly unknown[]) => {
|
|
1079
|
+
if (!bridge.checkpoint || !object(args[0]) || !jsonValue(args[0])) fail("INTERNAL_ERROR", "No checkpoint bridge is available");
|
|
1080
|
+
return bridge.checkpoint(args[0], signal);
|
|
1081
|
+
},
|
|
1082
|
+
phase: (name: string) => { sideEffects.push(Promise.resolve(bridge.phase?.(name))); },
|
|
1083
|
+
log: (message: string) => { sideEffects.push(Promise.resolve(bridge.log?.(message))); },
|
|
1084
|
+
};
|
|
1085
|
+
const result = await inheritedHostAgentPath.run([...structuralPath], async () => registry.invokeFunction(name, input, context, path, { get: () => replayed?.value, put: (_path, value) => { stored = value; } }));
|
|
1086
|
+
await Promise.all(sideEffects);
|
|
1087
|
+
if (!replayed) await store.complete(path, stored ?? result);
|
|
1088
|
+
return result;
|
|
1089
|
+
};
|
|
1090
|
+
return { ...bridge, functions: registry.globals(), variables, function: invokeFunction };
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
function projectTrusted(ctx: unknown): boolean {
|
|
1094
|
+
const check = object(ctx) ? ctx.isProjectTrusted : undefined;
|
|
1095
|
+
return typeof check === "function" ? Boolean(Reflect.apply(check, ctx, [])) : true;
|
|
1096
|
+
}
|
|
1097
|
+
type PiHostCapabilities = { registerEntryRenderer?: ExtensionAPI["registerEntryRenderer"]; events?: WorkflowEventSink };
|
|
1098
|
+
function isEntryRenderer(value: unknown): value is NonNullable<PiHostCapabilities["registerEntryRenderer"]> { return typeof value === "function"; }
|
|
1099
|
+
function isWorkflowEventSink(value: unknown): value is WorkflowEventSink { return object(value) && typeof value.emit === "function"; }
|
|
1100
|
+
function piHostCapabilities(pi: unknown): PiHostCapabilities {
|
|
1101
|
+
if (!object(pi)) return {};
|
|
1102
|
+
const registerEntryRenderer = pi.registerEntryRenderer;
|
|
1103
|
+
const events = pi.events;
|
|
1104
|
+
return { ...(isEntryRenderer(registerEntryRenderer) ? { registerEntryRenderer } : {}), ...(isWorkflowEventSink(events) ? { events } : {}) };
|
|
1105
|
+
}
|
|
1106
|
+
type ContextHostCapabilities = { modelRegistry?: ModelRegistryCapability };
|
|
1107
|
+
type ModelSummary = { provider: string; id: string };
|
|
1108
|
+
type ModelRegistryGetter = () => readonly ModelSummary[];
|
|
1109
|
+
type ModelRegistryCapability = { getAll?: ModelRegistryGetter; getAvailable?: ModelRegistryGetter };
|
|
1110
|
+
function isModelRegistryGetter(value: unknown): value is ModelRegistryGetter { return typeof value === "function"; }
|
|
1111
|
+
function contextHostCapabilities(ctx: unknown): ContextHostCapabilities {
|
|
1112
|
+
if (!object(ctx) || !object(ctx.modelRegistry)) return {};
|
|
1113
|
+
const registry = ctx.modelRegistry;
|
|
1114
|
+
const getAll = registry.getAll;
|
|
1115
|
+
const getAvailable = registry.getAvailable;
|
|
1116
|
+
return { modelRegistry: { ...(isModelRegistryGetter(getAll) ? { getAll: () => getAll.call(registry) } : {}), ...(isModelRegistryGetter(getAvailable) ? { getAvailable: () => getAvailable.call(registry) } : {}) } };
|
|
1117
|
+
}
|
|
1118
|
+
function modelInventory(root: ModelSpec | undefined, registry: ModelRegistryCapability | undefined): { knownModels: ReadonlySet<string>; availableModels: ReadonlySet<string> } {
|
|
1119
|
+
const all = registry?.getAll?.() ?? registry?.getAvailable?.() ?? [];
|
|
1120
|
+
const available = registry?.getAvailable?.() ?? registry?.getAll?.() ?? [];
|
|
1121
|
+
const knownModels = new Set(all.map((model) => `${model.provider}/${model.id}`));
|
|
1122
|
+
const availableModels = new Set(available.map((model) => `${model.provider}/${model.id}`));
|
|
1123
|
+
const rootName = root?.provider && root.model ? `${root.provider}/${root.model}` : undefined;
|
|
1124
|
+
if (rootName) { knownModels.add(rootName); availableModels.add(rootName); }
|
|
1125
|
+
return { knownModels, availableModels };
|
|
1126
|
+
}
|
|
1127
|
+
function resumeHostContext(ctx: unknown): { model: { provider: string; id: string } | undefined; modelRegistry: ModelRegistryCapability | undefined } {
|
|
1128
|
+
const model = object(ctx) && object(ctx.model) && typeof ctx.model.provider === "string" && typeof ctx.model.id === "string" ? { provider: ctx.model.provider, id: ctx.model.id } : undefined;
|
|
1129
|
+
return { model, modelRegistry: contextHostCapabilities(ctx).modelRegistry };
|
|
1130
|
+
}
|
|
1131
|
+
async function resolveLaunchAliases(registry: WorkflowRegistryApi, staticAliases: Readonly<Record<string, string>>, context: Readonly<WorkflowModelAliasResolverContext>, availableModels: ReadonlySet<string>, knownModels: ReadonlySet<string>, settingsPath: string): Promise<{ aliases: Readonly<Record<string, string>>; dynamicNames: readonly string[] }> {
|
|
1132
|
+
const dynamic = typeof registry.resolveModelAliases === "function" ? await registry.resolveModelAliases(context, new Set(Object.keys(staticAliases))) : {};
|
|
1133
|
+
const dynamicNames = Object.keys(dynamic);
|
|
1134
|
+
try {
|
|
1135
|
+
const aliases = validateModelAliases({ ...dynamic, ...staticAliases }, settingsPath);
|
|
1136
|
+
validateModelAliasAvailability(aliases, dynamicNames, availableModels, knownModels, settingsPath);
|
|
1137
|
+
return { aliases, dynamicNames };
|
|
1138
|
+
} catch (error) {
|
|
1139
|
+
const name = modelAliasErrorName(error);
|
|
1140
|
+
const descriptor = name && typeof registry.modelAliases === "function" ? registry.modelAliases().find((candidate) => candidate.name === name) : undefined;
|
|
1141
|
+
if (descriptor && errorCode(error) !== "CANCELLED") throw new WorkflowError(errorCode(error) ?? "CONFIG_ERROR", `${errorText(error)} (extension: ${descriptor.headline})`);
|
|
1142
|
+
throw error;
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
type UiSelect = (title: string, options: string[]) => Promise<string | undefined>;
|
|
1146
|
+
type UiInput = (title: string, placeholder?: string) => Promise<string | undefined>;
|
|
1147
|
+
type UiSetStatus = (key: string, text?: string) => void;
|
|
1148
|
+
type UiHostCapabilities = { select?: UiSelect; input?: UiInput; setStatus?: UiSetStatus };
|
|
1149
|
+
function isUiSelect(value: unknown): value is UiSelect { return typeof value === "function"; }
|
|
1150
|
+
function isUiInput(value: unknown): value is UiInput { return typeof value === "function"; }
|
|
1151
|
+
function isUiSetStatus(value: unknown): value is UiSetStatus { return typeof value === "function"; }
|
|
1152
|
+
function uiHostCapabilities(ui: unknown): UiHostCapabilities | undefined {
|
|
1153
|
+
if (!object(ui)) return undefined;
|
|
1154
|
+
const select = ui.select;
|
|
1155
|
+
const input = ui.input;
|
|
1156
|
+
const setStatus = ui.setStatus;
|
|
1157
|
+
return { ...(isUiSelect(select) ? { select } : {}), ...(isUiInput(input) ? { input } : {}), ...(isUiSetStatus(setStatus) ? { setStatus } : {}) };
|
|
1158
|
+
}
|
|
1159
|
+
type TuiHostCapabilities = { terminal?: { rows?: unknown } };
|
|
1160
|
+
function tuiHostCapabilities(tui: unknown): TuiHostCapabilities {
|
|
1161
|
+
if (!object(tui) || !object(tui.terminal)) return {};
|
|
1162
|
+
return { terminal: { ...(tui.terminal.rows === undefined ? {} : { rows: tui.terminal.rows }) } };
|
|
1163
|
+
}
|
|
1164
|
+
function tuiRows(tui: unknown): number { const rows = tuiHostCapabilities(tui).terminal?.rows; return typeof rows === "number" && Number.isFinite(rows) ? rows : 24; }
|
|
1165
|
+
const WORKFLOW_OVERLAY_BORDER_ROWS = 2;
|
|
1166
|
+
type WorkflowOverlayComponent = { render(width: number): string[]; invalidate(): void; handleInput?(data: string): void; dispose?(): void };
|
|
1167
|
+
function borderWorkflowOverlay(component: WorkflowOverlayComponent, theme: { fg(color: "border", text: string): string }): WorkflowOverlayComponent {
|
|
1168
|
+
return {
|
|
1169
|
+
...component,
|
|
1170
|
+
render(width: number) {
|
|
1171
|
+
const border = theme.fg("border", "─".repeat(Math.max(1, width)));
|
|
1172
|
+
return [border, ...component.render(width), border];
|
|
1173
|
+
},
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
type KeybindingsHostCapabilities = { getKeys?: (name: string) => readonly string[] };
|
|
1177
|
+
function isKeybindingGetter(value: unknown): value is NonNullable<KeybindingsHostCapabilities["getKeys"]> { return typeof value === "function"; }
|
|
1178
|
+
function keybindingsHostCapabilities(keybindings: unknown): KeybindingsHostCapabilities {
|
|
1179
|
+
if (!object(keybindings) || !isKeybindingGetter(keybindings.getKeys)) return {};
|
|
1180
|
+
return { getKeys: keybindings.getKeys };
|
|
1181
|
+
}
|
|
1182
|
+
function keybindingKeys(keybindings: unknown, name: string): readonly string[] | undefined { const getKeys = keybindingsHostCapabilities(keybindings).getKeys; return typeof getKeys === "function" ? getKeys.call(keybindings, name) : undefined; }
|
|
1183
|
+
|
|
1184
|
+
export default function workflowExtension(pi: ExtensionAPI, home?: string, clipboard = copyToClipboard, createSession: SessionFactory = createNativeAgentSession, agentDir?: string) {
|
|
1185
|
+
beginWorkflowExtensionLoading();
|
|
1186
|
+
const registry = loadingRegistry();
|
|
1187
|
+
const extensionAgentDir = agentDir ?? getAgentDir();
|
|
1188
|
+
const registerEntryRenderer = piHostCapabilities(pi).registerEntryRenderer;
|
|
1189
|
+
registerEntryRenderer?.<WorkflowLogEntry>(WORKFLOW_LOG_ENTRY, (entry) => {
|
|
1190
|
+
const data = entry.data;
|
|
1191
|
+
return textBlock(data ? `Workflow ${data.workflowName}: ${data.message}` : "");
|
|
1192
|
+
});
|
|
1193
|
+
const logBridge = (lifecycle: RunLifecycle, workflowName: string) => async (message: string) => {
|
|
1194
|
+
await lifecycle.enter();
|
|
1195
|
+
try { pi.appendEntry<WorkflowLogEntry>(WORKFLOW_LOG_ENTRY, { workflowName, message: utf8Prefix(message, DELIVERY_LIMIT_BYTES) }); }
|
|
1196
|
+
finally { await lifecycle.leave(); }
|
|
1197
|
+
};
|
|
1198
|
+
const eventPublisher = new WorkflowEventPublisher(piHostCapabilities(pi).events);
|
|
1199
|
+
pi.on("resources_discover", () => {
|
|
1200
|
+
if (!pi.getActiveTools().includes("workflow")) return;
|
|
1201
|
+
const extensionDir = dirname(fileURLToPath(import.meta.url));
|
|
1202
|
+
const skillPath = [join(extensionDir, "../skills"), join(extensionDir, "../../skills")].find((path) => existsSync(path));
|
|
1203
|
+
return skillPath ? { skillPaths: [skillPath] } : undefined;
|
|
1204
|
+
});
|
|
1205
|
+
type BudgetDecisionResult = { state: "running" | "budget_exhausted"; approved: boolean };
|
|
1206
|
+
const runs = new Map<string, { executor: WorkflowAgentExecutor; store: RunStore; metadata: WorkflowMetadata; model: ModelSpec; lifecycle: RunLifecycle; budget: WorkflowBudgetRuntime; abortController: AbortController; projectTrusted: () => boolean; providerErrorRecovery?: (failure: AgentProviderFailure) => Promise<AgentProviderRecovery>; execution?: WorkflowExecution; completion?: Promise<unknown>; checkpointResolvers: Map<string, (value: boolean) => void>; update?: (result: WorkflowToolUpdate) => void }>();
|
|
1207
|
+
let providerRecoveryQueue = Promise.resolve();
|
|
1208
|
+
const enqueueProviderRecovery = <T>(task: () => Promise<T>): Promise<T> => { const next = providerRecoveryQueue.then(task, task); providerRecoveryQueue = next.then(() => undefined, () => undefined); return next; };
|
|
1209
|
+
const createProviderErrorRecovery = (host: unknown, fallbackModels: ReadonlySet<string>, abort: () => void) => {
|
|
1210
|
+
if (!object(host) || host.mode !== "tui" || host.hasUI !== true) return undefined;
|
|
1211
|
+
const ui = object(host.ui) ? host.ui : undefined;
|
|
1212
|
+
const select = uiHostCapabilities(ui)?.select;
|
|
1213
|
+
if (!select) return undefined;
|
|
1214
|
+
const hostModels = contextHostCapabilities(host).modelRegistry;
|
|
1215
|
+
const choose = (title: string, options: string[]) => select.call(ui, title, options);
|
|
1216
|
+
return (failure: AgentProviderFailure): Promise<AgentProviderRecovery> => enqueueProviderRecovery(async () => {
|
|
1217
|
+
const action = await choose(`Subagent "${failure.label}" failed\nCurrent provider/model: ${failure.provider}/${failure.model}\nProvider error: ${failure.error}\nChoose what to do`, ["Retry", "Change model", "Abort workflow"]);
|
|
1218
|
+
if (action === "Retry") return "retry";
|
|
1219
|
+
if (action === "Change model") {
|
|
1220
|
+
const available = hostModels?.getAvailable?.().map((model) => `${model.provider}/${model.id}`) ?? [...fallbackModels];
|
|
1221
|
+
const selected = await choose(`Available models for subagent "${failure.label}"`, [...new Set(available)].sort());
|
|
1222
|
+
if (selected) return { model: selected };
|
|
1223
|
+
}
|
|
1224
|
+
abort();
|
|
1225
|
+
return "abort";
|
|
1226
|
+
});
|
|
1227
|
+
};
|
|
1228
|
+
const pendingFailureDiagnostics = new Map<string, WorkflowFailureDiagnostics>();
|
|
1229
|
+
pi.on("tool_result", (event) => {
|
|
1230
|
+
if (event.toolName !== "workflow" || !event.isError) return;
|
|
1231
|
+
const diagnostic = pendingFailureDiagnostics.get(event.toolCallId);
|
|
1232
|
+
if (!diagnostic) return;
|
|
1233
|
+
pendingFailureDiagnostics.delete(event.toolCallId);
|
|
1234
|
+
return { content: [{ type: "text" as const, text: serializeWorkflowFailureDiagnostics(diagnostic) }], details: diagnostic, isError: true };
|
|
1235
|
+
});
|
|
1236
|
+
const liveActivities = new Map<string, Map<string, AgentActivity>>();
|
|
1237
|
+
const setLiveActivity = (runId: string, agentId: string, activity?: AgentActivity) => {
|
|
1238
|
+
const activities = liveActivities.get(runId);
|
|
1239
|
+
if (activity) {
|
|
1240
|
+
if (activities) activities.set(agentId, activity);
|
|
1241
|
+
else liveActivities.set(runId, new Map([[agentId, activity]]));
|
|
1242
|
+
} else {
|
|
1243
|
+
activities?.delete(agentId);
|
|
1244
|
+
if (activities?.size === 0) liveActivities.delete(runId);
|
|
1245
|
+
}
|
|
1246
|
+
};
|
|
1247
|
+
const withLiveActivities = (run: PersistedRun): PersistedRun => {
|
|
1248
|
+
const activities = liveActivities.get(run.id);
|
|
1249
|
+
return activities?.size ? { ...run, agents: run.agents.map((agent) => {
|
|
1250
|
+
const activity = activities.get(agent.id);
|
|
1251
|
+
return activity ? { ...agent, activity } : agent;
|
|
1252
|
+
}) } : run;
|
|
1253
|
+
};
|
|
1254
|
+
const terminalRunStates = new Map<string, "completed" | "failed" | "stopped">();
|
|
1255
|
+
let sessionLease: SessionLease | undefined;
|
|
1256
|
+
let sessionLeasePromise: Promise<SessionLease> | undefined;
|
|
1257
|
+
const ensureSessionLease = async (cwd: string, sessionId: string) => {
|
|
1258
|
+
if (sessionLease?.active) return;
|
|
1259
|
+
const pending = sessionLeasePromise ?? (sessionLeasePromise = acquireSessionLease(cwd, sessionId, home));
|
|
1260
|
+
try { sessionLease = await pending; }
|
|
1261
|
+
finally { if (sessionLeasePromise === pending) sessionLeasePromise = undefined; }
|
|
1262
|
+
};
|
|
1263
|
+
const releaseSessionLease = async () => {
|
|
1264
|
+
const lease = sessionLease ?? await sessionLeasePromise?.catch(() => undefined);
|
|
1265
|
+
sessionLease = undefined;
|
|
1266
|
+
sessionLeasePromise = undefined;
|
|
1267
|
+
await lease?.release();
|
|
1268
|
+
};
|
|
1269
|
+
const persistRunState = async (store: RunStore, metadata: WorkflowMetadata, update: (run: PersistedRun) => PersistedRun | Promise<PersistedRun>): Promise<PersistedRun> => {
|
|
1270
|
+
const persisted = await store.updateState(update);
|
|
1271
|
+
await eventPublisher.budget(store, metadata, persisted);
|
|
1272
|
+
return persisted;
|
|
1273
|
+
};
|
|
1274
|
+
const phaseBridge = (store: RunStore, metadata: WorkflowMetadata, lifecycle: RunLifecycle) => {
|
|
1275
|
+
let cursor = 0;
|
|
1276
|
+
return async (phase: string): Promise<void> => {
|
|
1277
|
+
await scheduler.flush();
|
|
1278
|
+
await lifecycle.enter();
|
|
1279
|
+
try {
|
|
1280
|
+
let previousPhase: string | undefined;
|
|
1281
|
+
const persisted = await persistRunState(store, metadata, (current) => {
|
|
1282
|
+
previousPhase = current.phase;
|
|
1283
|
+
const history = current.phaseHistory ?? [];
|
|
1284
|
+
if (history[cursor]?.phase === phase) { cursor += 1; return { ...current, phase }; }
|
|
1285
|
+
cursor = history.length + 1;
|
|
1286
|
+
return { ...current, phase, phaseHistory: [...history, { phase, afterAgent: current.agents.length }] };
|
|
1287
|
+
});
|
|
1288
|
+
await eventPublisher.phase(store, metadata, previousPhase, phase);
|
|
1289
|
+
runs.get(store.runId)?.update?.(workflowToolUpdate(persisted));
|
|
1290
|
+
} finally { await lifecycle.leave(); }
|
|
1291
|
+
};
|
|
1292
|
+
};
|
|
1293
|
+
const persistWorktree = async (store: RunStore, metadata: WorkflowMetadata, owner: string): Promise<WorktreeReference> => {
|
|
1294
|
+
const existing = (await store.worktrees()).some((worktree) => worktree.owner === owner);
|
|
1295
|
+
const worktree = await store.worktree(owner);
|
|
1296
|
+
if (!existing && await store.ownsWorktree(owner)) await eventPublisher.worktree(store, metadata, worktree);
|
|
1297
|
+
return worktree;
|
|
1298
|
+
};
|
|
1299
|
+
const resolveWorktree = async (store: RunStore, metadata: WorkflowMetadata, owner: string): Promise<Readonly<WorkflowWorktreeReference>> => {
|
|
1300
|
+
const run = runs.get(store.runId);
|
|
1301
|
+
if (!run) fail("INTERNAL_ERROR", `Unknown production run: ${store.runId}`);
|
|
1302
|
+
await run.lifecycle.enter();
|
|
1303
|
+
try {
|
|
1304
|
+
const worktree = await persistWorktree(store, metadata, owner);
|
|
1305
|
+
return { path: worktree.path, branch: worktree.branch };
|
|
1306
|
+
} finally { await run.lifecycle.leave(); }
|
|
1307
|
+
};
|
|
1308
|
+
const shellForRun = async (store: RunStore, metadata: WorkflowMetadata, lifecycle: RunLifecycle, command: string, options: ShellOptions, signal: AbortSignal, identity: ShellIdentity): Promise<ShellResult> => {
|
|
1309
|
+
await lifecycle.enter();
|
|
1310
|
+
try {
|
|
1311
|
+
const path = shellIdentityPath(identity);
|
|
1312
|
+
const replayed = await store.replay(path);
|
|
1313
|
+
if (replayed) return readShellResult(replayed.value);
|
|
1314
|
+
const cwd = identity.worktreeOwner ? (await persistWorktree(store, metadata, identity.worktreeOwner)).cwd : store.cwd;
|
|
1315
|
+
const result = await executeShellCommand(command, options, signal, cwd);
|
|
1316
|
+
await store.complete(path, result as unknown as JsonValue);
|
|
1317
|
+
return result;
|
|
1318
|
+
} finally { await lifecycle.leave(); }
|
|
1319
|
+
};
|
|
1320
|
+
const lifecycleFor = (store: RunStore, state: RunState, budget: WorkflowBudgetRuntime, metadata: WorkflowMetadata) => new RunLifecycle(state, async (next, previous, reason) => {
|
|
1321
|
+
if (next !== "pausing") budget.transition(next);
|
|
1322
|
+
const persisted = await persistRunState(store, metadata, (current) => {
|
|
1323
|
+
const nextRun = { ...current, state: next, ...budget.snapshot() };
|
|
1324
|
+
if (next === "running" || next === "completed") delete nextRun.error;
|
|
1325
|
+
return nextRun;
|
|
1326
|
+
});
|
|
1327
|
+
await eventPublisher.runState(store, metadata, previous, next, reason);
|
|
1328
|
+
runs.get(store.runId)?.update?.(workflowToolUpdate(withLiveActivities(persisted)));
|
|
1329
|
+
});
|
|
1330
|
+
const scheduler = new FairAgentScheduler(async ({ id, runId, parentId, prompt, options, signal, setSteer }) => {
|
|
1331
|
+
const run = runs.get(runId);
|
|
1332
|
+
if (!run) throw new WorkflowError("INTERNAL_ERROR", `Unknown production run: ${runId}`);
|
|
1333
|
+
try {
|
|
1334
|
+
const budget = run.budget.forAgent(id);
|
|
1335
|
+
const onProgress = async (progress: AgentProgress) => {
|
|
1336
|
+
let runState: PersistedRun;
|
|
1337
|
+
if (progress.persist) {
|
|
1338
|
+
runState = await persistRunState(run.store, run.metadata, (current) => current.agents.some((agent) => agent.id === id) ? { ...current, ...run.budget.snapshot(), agents: current.agents.map((agent) => agent.id === id ? { ...agent, accounting: progress.accounting, toolCalls: progress.toolCalls, activity: progress.activity } : agent) } : current);
|
|
1339
|
+
} else {
|
|
1340
|
+
const loaded = await run.store.load();
|
|
1341
|
+
if (!loaded.run.agents.some((agent) => agent.id === id)) return;
|
|
1342
|
+
runState = { ...loaded.run, ...run.budget.snapshot(), agents: loaded.run.agents.map((agent) => agent.id === id ? { ...agent, accounting: progress.accounting, toolCalls: progress.toolCalls, activity: progress.activity } : agent) };
|
|
1343
|
+
}
|
|
1344
|
+
if (!runState.agents.some((agent) => agent.id === id)) return;
|
|
1345
|
+
setLiveActivity(runId, id, progress.activity);
|
|
1346
|
+
run.update?.(workflowToolUpdate(withLiveActivities(runState)));
|
|
1347
|
+
};
|
|
1348
|
+
const onAttempt = async (attempt: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile" | "setup">) => {
|
|
1349
|
+
await scheduler.flush();
|
|
1350
|
+
scheduler.attemptStarted(id);
|
|
1351
|
+
await scheduler.flush();
|
|
1352
|
+
const before = (await run.store.load()).run;
|
|
1353
|
+
await persistActiveAgentAttempt(run.store, id, attempt);
|
|
1354
|
+
const active = (await run.store.load()).run;
|
|
1355
|
+
await eventPublisher.agentStates(run.store, run.metadata, before.agents, active.agents);
|
|
1356
|
+
const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
|
|
1357
|
+
run.update?.(workflowToolUpdate(withLiveActivities(persisted)));
|
|
1358
|
+
};
|
|
1359
|
+
const result = await run.executor.execute(prompt, { label: options.label, workflowName: run.metadata.name, onProgress, onAttempt, budget, ...(run.providerErrorRecovery ? { providerErrorRecovery: run.providerErrorRecovery } : {}), ...(parentId ? { parent: parentId, cwd: options.cwd, ...(options.worktreeOwner ? { worktreeOwner: options.worktreeOwner } : {}) } : options.worktreeOwner ? { worktreeOwner: options.worktreeOwner } : {}), ...(options.model ? { model: options.model } : {}), ...(options.thinking ? { thinking: options.thinking } : {}), ...(options.role ? { role: options.role } : {}), ...(options.role ? {} : { tools: options.tools }), effectiveTools: options.tools, ...(options.schema ? { schema: options.schema } : {}), ...(options.retries === undefined ? {} : { retries: options.retries }), ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }), ...(options.agentOptions ? { agentOptions: options.agentOptions } : {}), ...(options.agentIdentity ? { agentIdentity: options.agentIdentity } : {}) }, signal, scheduler.toolsFor(id, (role, tools, model, inheritedTools, thinking) => run.executor.resolve({ label: "child", workflowName: run.metadata.name, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(tools !== undefined ? { tools } : {}) }, inheritedTools).tools), setSteer, () => { scheduler.cancelChildren(id); scheduler.retry(id); });
|
|
1360
|
+
const before = (await run.store.load()).run;
|
|
1361
|
+
await persistAgentAttempts(run.store, id, result.attempts);
|
|
1362
|
+
const completed = (await run.store.load()).run;
|
|
1363
|
+
await eventPublisher.agentStates(run.store, run.metadata, before.agents, completed.agents);
|
|
1364
|
+
const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
|
|
1365
|
+
setLiveActivity(runId, id);
|
|
1366
|
+
run.update?.(workflowToolUpdate(withLiveActivities(persisted)));
|
|
1367
|
+
return result.value;
|
|
1368
|
+
} catch (error) {
|
|
1369
|
+
const attempts = (error as WorkflowError & { attempts?: readonly AgentAttempt[] }).attempts;
|
|
1370
|
+
if (attempts?.length) {
|
|
1371
|
+
const before = (await run.store.load()).run;
|
|
1372
|
+
await persistAgentAttempts(run.store, id, attempts);
|
|
1373
|
+
const failed = (await run.store.load()).run;
|
|
1374
|
+
await eventPublisher.agentStates(run.store, run.metadata, before.agents, failed.agents);
|
|
1375
|
+
}
|
|
1376
|
+
const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
|
|
1377
|
+
setLiveActivity(runId, id);
|
|
1378
|
+
run.update?.(workflowToolUpdate(withLiveActivities(persisted)));
|
|
1379
|
+
throw error;
|
|
1380
|
+
}
|
|
1381
|
+
}, 16, async (runId, ownership) => {
|
|
1382
|
+
const run = runs.get(runId);
|
|
1383
|
+
if (!run) return;
|
|
1384
|
+
await run.store.saveOwnership(ownership);
|
|
1385
|
+
let previousAgents: readonly AgentRecord[] = [];
|
|
1386
|
+
const runState = await persistRunState(run.store, run.metadata, (current) => {
|
|
1387
|
+
previousAgents = current.agents;
|
|
1388
|
+
const existing = new Map(current.agents.map((agent) => [agent.id, agent]));
|
|
1389
|
+
const agents = ownership.map((node) => {
|
|
1390
|
+
const previous = existing.get(node.id);
|
|
1391
|
+
const requested = { label: node.options.label, workflowName: run.metadata.name, ...(node.options.model ? { model: node.options.model } : {}), ...(node.options.thinking ? { thinking: node.options.thinking } : {}), ...(node.options.role ? { role: node.options.role } : {}), effectiveTools: node.options.tools };
|
|
1392
|
+
let effective: { model: ModelSpec; requestedModel?: string; tools: readonly string[] };
|
|
1393
|
+
try { effective = run.executor.resolve(requested); }
|
|
1394
|
+
catch { effective = previous ? { model: previous.model, ...(previous.requestedModel ? { requestedModel: previous.requestedModel } : {}), tools: previous.tools } : { model: node.options.model ? modelSpec(node.options.model, run.model) : { ...run.model, ...(node.options.thinking ? { thinking: node.options.thinking } : {}) }, ...(node.options.model ? { requestedModel: node.options.model } : {}), tools: node.options.tools }; }
|
|
1395
|
+
return { id: node.id, name: node.label, ...(node.options.requestedLabel ? { label: node.options.requestedLabel } : {}), path: node.id, state: node.state, ...(node.parentId ? { parentId: node.parentId } : {}), structuralPath: [...(node.options.agentIdentity?.structuralPath ?? [])], ...(node.options.parentBreadcrumb ? { parentBreadcrumb: node.options.parentBreadcrumb } : {}), ...(node.options.worktreeOwner ? { worktreeOwner: node.options.worktreeOwner } : {}), ...(node.options.role ? { role: node.options.role } : {}), ...(effective.requestedModel ? { requestedModel: effective.requestedModel } : {}), model: effective.model, tools: effective.tools, attempts: previous?.attempts ?? 0, ...(previous?.attemptDetails ? { attemptDetails: previous.attemptDetails } : {}), ...(previous?.accounting ? { accounting: previous.accounting } : {}), ...(previous?.toolCalls ? { toolCalls: previous.toolCalls } : {}), ...(previous?.activity ? { activity: previous.activity } : {}) };
|
|
1396
|
+
});
|
|
1397
|
+
return { ...current, agents };
|
|
1398
|
+
});
|
|
1399
|
+
await eventPublisher.agentStates(run.store, run.metadata, previousAgents, runState.agents);
|
|
1400
|
+
run.update?.(workflowToolUpdate(withLiveActivities(runState)));
|
|
1401
|
+
});
|
|
1402
|
+
const cleanupTerminalRun = async (runId: string): Promise<void> => {
|
|
1403
|
+
const run = runs.get(runId);
|
|
1404
|
+
if (!run || !["completed", "failed", "stopped"].includes(run.lifecycle.state)) return;
|
|
1405
|
+
await scheduler.cancelRun(runId);
|
|
1406
|
+
await scheduler.flush();
|
|
1407
|
+
if (runs.get(runId) !== run) return;
|
|
1408
|
+
scheduler.removeRun(runId);
|
|
1409
|
+
terminalRunStates.set(runId, run.lifecycle.state as "completed" | "failed" | "stopped");
|
|
1410
|
+
run.checkpointResolvers.clear();
|
|
1411
|
+
liveActivities.delete(runId);
|
|
1412
|
+
eventPublisher.removeRun(runId);
|
|
1413
|
+
runs.delete(runId);
|
|
1414
|
+
};
|
|
1415
|
+
type WorkflowStopResult = { runId: string; state: RunState | "unknown"; stopped: boolean; reason?: "unknown_run" | "already_terminal" };
|
|
1416
|
+
const stopWorkflowRun = async (runId: string): Promise<WorkflowStopResult> => {
|
|
1417
|
+
const run = runs.get(runId);
|
|
1418
|
+
const terminalState = terminalRunStates.get(runId);
|
|
1419
|
+
if (!run) return terminalState ? { runId, state: terminalState, stopped: false, reason: "already_terminal" } : { runId, state: "unknown", stopped: false, reason: "unknown_run" };
|
|
1420
|
+
const state = run.lifecycle.state;
|
|
1421
|
+
if (state === "completed" || state === "failed" || state === "stopped") return { runId, state, stopped: false, reason: "already_terminal" };
|
|
1422
|
+
await run.lifecycle.terminal("stopped");
|
|
1423
|
+
run.abortController.abort();
|
|
1424
|
+
run.execution?.cancel();
|
|
1425
|
+
await scheduler.cancelRun(run.store.runId);
|
|
1426
|
+
await scheduler.flush();
|
|
1427
|
+
await cleanupTerminalRun(runId);
|
|
1428
|
+
return { runId, state: "stopped", stopped: true };
|
|
1429
|
+
};
|
|
1430
|
+
const answerCheckpoint = async (runId: string, name: string, approved: boolean, silent = false) => {
|
|
1431
|
+
const run = runs.get(runId);
|
|
1432
|
+
if (!run) return false;
|
|
1433
|
+
const checkpoint = await run.store.answerCheckpoint(name, approved);
|
|
1434
|
+
if (!checkpoint) return false;
|
|
1435
|
+
await eventPublisher.checkpoint(run.store, run.metadata, checkpoint.name, approved ? "approved" : "rejected");
|
|
1436
|
+
if ((await run.store.awaitingCheckpoints()).length === 0) await run.lifecycle.resolveAwaitingInput();
|
|
1437
|
+
run.checkpointResolvers.get(checkpoint.path)?.(approved);
|
|
1438
|
+
run.checkpointResolvers.delete(checkpoint.path);
|
|
1439
|
+
if (!silent) deliver(pi, `Workflow ${run.metadata.name} checkpoint ${name}: ${approved ? "Approved" : "Rejected"}.`);
|
|
1440
|
+
return true;
|
|
1441
|
+
};
|
|
1442
|
+
const budgetDecisionDelivery = (metadata: WorkflowMetadata, request: BudgetApprovalRequest) => `Workflow ${metadata.name} budget adjustment ${request.proposalId} for run ${request.runId} requires approval. Consumed usage: ${JSON.stringify(request.consumed)}. Previous limits: ${JSON.stringify(request.previous)}. Proposed limits: ${JSON.stringify(request.proposed)}. Respond with workflow_respond using proposalId ${request.proposalId}.`;
|
|
1443
|
+
const appendBudgetDecisionEvent = async (run: NonNullable<ReturnType<typeof runs.get>>, request: BudgetApprovalRequest, type: "adjustment_requested" | "adjustment_approved" | "adjustment_rejected") => {
|
|
1444
|
+
run.budget.recordEvent({ type, budgetVersion: request.budgetVersion, dimensions: [], usage: structuredClone(request.consumed), limits: structuredClone(request.proposed), at: Date.now(), proposalId: request.proposalId, previous: structuredClone(request.previous), proposed: structuredClone(request.proposed) });
|
|
1445
|
+
await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
|
|
1446
|
+
};
|
|
1447
|
+
const answerBudgetDecision = async (runId: string, proposalId: string, approved: boolean, silent = false, context?: unknown, signal?: AbortSignal): Promise<BudgetDecisionResult | undefined> => {
|
|
1448
|
+
const run = runs.get(runId);
|
|
1449
|
+
if (!run) return undefined;
|
|
1450
|
+
const request = await run.store.answerWorkflowDecision(proposalId, approved);
|
|
1451
|
+
if (!request) return undefined;
|
|
1452
|
+
await appendBudgetDecisionEvent(run, request, approved ? "adjustment_approved" : "adjustment_rejected");
|
|
1453
|
+
const result = await applyBudgetDecision(request, approved, context, signal);
|
|
1454
|
+
if (!silent) deliver(pi, `Workflow ${run.metadata.name} budget adjustment ${proposalId}: ${approved ? "Approved" : "Rejected"}.`);
|
|
1455
|
+
return result;
|
|
1456
|
+
};
|
|
1457
|
+
const checkpointBridge = (runId: string, store: RunStore, metadata: WorkflowMetadata, foreground: boolean, ui?: { select?: (prompt: string, options: string[]) => Promise<string | undefined> }, headless = false) => {
|
|
1458
|
+
const checkpointCounters = new Map<string, number>();
|
|
1459
|
+
return async (raw: Readonly<Record<string, JsonValue>>, signal: AbortSignal): Promise<boolean> => {
|
|
1460
|
+
const input = validateCheckpoint(raw);
|
|
1461
|
+
const label = nextNamedOccurrence(checkpointCounters, input.name);
|
|
1462
|
+
const path = operationPath("checkpoint", label);
|
|
1463
|
+
if (headless) fail("RESUME_INCOMPATIBLE", "Headless CLI checkpoints are unsupported");
|
|
1464
|
+
if (foreground && !ui?.select) fail("RESUME_INCOMPATIBLE", "Foreground checkpoints require UI");
|
|
1465
|
+
const alreadyAwaiting = (await store.awaitingCheckpoints()).some((checkpoint) => checkpoint.path === path);
|
|
1466
|
+
const replayed = await store.awaitCheckpoint({ ...input, name: label, path });
|
|
1467
|
+
if (replayed !== undefined) return replayed;
|
|
1468
|
+
if (!alreadyAwaiting) await eventPublisher.checkpoint(store, metadata, label, "awaiting");
|
|
1469
|
+
const run = runs.get(runId);
|
|
1470
|
+
await run?.lifecycle.enterAwaitingInput();
|
|
1471
|
+
if (!alreadyAwaiting && !ui?.select) deliver(pi, `Workflow ${metadata.name} checkpoint ${label}: ${input.prompt}\nContext: ${JSON.stringify(input.context)}\nRespond with workflow_respond.`);
|
|
1472
|
+
const decision = new Promise<boolean>((resolve, reject) => {
|
|
1473
|
+
run?.checkpointResolvers.set(path, resolve);
|
|
1474
|
+
if (signal.aborted) reject(new WorkflowError("CANCELLED", "Workflow cancelled"));
|
|
1475
|
+
else signal.addEventListener("abort", () => { run?.checkpointResolvers.delete(path); reject(new WorkflowError("CANCELLED", "Workflow cancelled")); }, { once: true });
|
|
1476
|
+
});
|
|
1477
|
+
const answered = await store.awaitCheckpoint({ ...input, name: label, path });
|
|
1478
|
+
if (answered !== undefined) {
|
|
1479
|
+
if ((await store.awaitingCheckpoints()).length === 0) await run?.lifecycle.resolveAwaitingInput();
|
|
1480
|
+
run?.checkpointResolvers.get(path)?.(answered);
|
|
1481
|
+
run?.checkpointResolvers.delete(path);
|
|
1482
|
+
}
|
|
1483
|
+
if (ui?.select) void (async () => {
|
|
1484
|
+
while (!signal.aborted && run?.checkpointResolvers.has(path)) {
|
|
1485
|
+
const choice = await ui.select?.(input.prompt, ["Approve", "Reject"]);
|
|
1486
|
+
if (!choice) {
|
|
1487
|
+
if (foreground) continue; // foreground: retry until answered
|
|
1488
|
+
deliver(pi, `Workflow ${metadata.name} checkpoint ${label}: ${input.prompt}\nContext: ${JSON.stringify(input.context)}\nRespond with workflow_respond.`);
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
if (await answerCheckpoint(runId, label, choice === "Approve", true)) return;
|
|
1492
|
+
}
|
|
1493
|
+
})().catch(() => undefined);
|
|
1494
|
+
return decision;
|
|
1495
|
+
};
|
|
1496
|
+
};
|
|
1497
|
+
|
|
1498
|
+
pi.registerTool({
|
|
1499
|
+
name: "workflow_respond",
|
|
1500
|
+
label: "Workflow Respond",
|
|
1501
|
+
description: "Approve or reject one pending workflow checkpoint or budget decision",
|
|
1502
|
+
parameters: Type.Object({ runId: Type.String(), name: Type.Optional(Type.String()), proposalId: Type.Optional(Type.String()), approved: Type.Boolean() }, { additionalProperties: false }),
|
|
1503
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
1504
|
+
try {
|
|
1505
|
+
if (params.proposalId) {
|
|
1506
|
+
const result = await answerBudgetDecision(params.runId, params.proposalId, params.approved, false, ctx, signal);
|
|
1507
|
+
if (!result) { const denied = { state: "budget_exhausted" as const, approved: false, reason: "proposal_not_pending" }; return { content: [{ type: "text" as const, text: JSON.stringify(denied) }], details: denied }; }
|
|
1508
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: { ...result, reason: params.approved ? "approved" : "rejected" } };
|
|
1509
|
+
}
|
|
1510
|
+
if (!params.name) throw new WorkflowError("INVALID_METADATA", "workflow_respond requires name or proposalId");
|
|
1511
|
+
const accepted = await answerCheckpoint(params.runId, params.name, params.approved);
|
|
1512
|
+
return { content: [{ type: "text" as const, text: accepted ? "Checkpoint response accepted." : "Checkpoint is not awaiting a response." }], details: { accepted, state: accepted ? "checkpoint_answered" : "not_pending", approved: params.approved, reason: "checkpoint" } };
|
|
1513
|
+
} catch (error) {
|
|
1514
|
+
throw mainAgentError(error);
|
|
1515
|
+
}
|
|
1516
|
+
},
|
|
1517
|
+
});
|
|
1518
|
+
pi.registerTool({
|
|
1519
|
+
name: "workflow_stop",
|
|
1520
|
+
label: "Workflow Stop",
|
|
1521
|
+
description: "Stop an active workflow run by ID",
|
|
1522
|
+
parameters: Type.Object({ runId: Type.String() }, { additionalProperties: false }),
|
|
1523
|
+
async execute(_id, params) {
|
|
1524
|
+
try {
|
|
1525
|
+
const result = await stopWorkflowRun(params.runId);
|
|
1526
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result };
|
|
1527
|
+
} catch (error) {
|
|
1528
|
+
throw mainAgentError(error);
|
|
1529
|
+
}
|
|
1530
|
+
},
|
|
1531
|
+
});
|
|
1532
|
+
let catalogRegistered = false;
|
|
1533
|
+
let sessionStarted = false;
|
|
1534
|
+
const registerCatalog = (cwd: string, trustedProject: boolean) => {
|
|
1535
|
+
if (catalogRegistered || !pi.getActiveTools().includes("workflow")) return;
|
|
1536
|
+
const catalog = registry.catalog({ cwd, projectTrusted: trustedProject, globalSettingsPath: workflowSettingsPath(extensionAgentDir) });
|
|
1537
|
+
const hasAliases = Object.keys(catalog.modelAliases ?? {}).length > 0 || Boolean(catalog.modelAliasEntries?.length);
|
|
1538
|
+
const hasSettings = catalog.settings !== undefined && [catalog.settings.globalSettingsPath, catalog.settings.projectSettingsPath].some((path) => existsSync(path));
|
|
1539
|
+
if (!catalog.functions.length && !catalog.variables.length && !hasAliases && !hasSettings) return;
|
|
1540
|
+
pi.registerTool({
|
|
1541
|
+
name: "workflow_catalog",
|
|
1542
|
+
label: "Workflow Catalog",
|
|
1543
|
+
description: "List reusable workflow functions, variables, and model aliases, or load one entry in full",
|
|
1544
|
+
parameters: Type.Object({ name: Type.Optional(Type.String({ description: "Registered function, variable, or model alias name for full detail" })) }, { additionalProperties: false }),
|
|
1545
|
+
async execute(_id, params = {}) {
|
|
1546
|
+
const context = { cwd, projectTrusted: trustedProject, globalSettingsPath: workflowSettingsPath(extensionAgentDir) };
|
|
1547
|
+
const result = params.name === undefined ? registry.catalogIndex(context) : registry.catalogDetail(params.name, context);
|
|
1548
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result };
|
|
1549
|
+
},
|
|
1550
|
+
renderCall(args, theme) {
|
|
1551
|
+
const title = theme.fg("toolTitle", theme.bold("workflow_catalog"));
|
|
1552
|
+
return styledTextBlock(args.name === undefined ? title : `${title} ${theme.fg("accent", args.name)}`);
|
|
1553
|
+
},
|
|
1554
|
+
renderResult(result, options, theme) {
|
|
1555
|
+
return workflowCatalogBlock(formatWorkflowCatalog(catalogResultValue(result), options.expanded, theme), options.expanded);
|
|
1556
|
+
},
|
|
1557
|
+
});
|
|
1558
|
+
catalogRegistered = true;
|
|
1559
|
+
};
|
|
1560
|
+
const refreshPausedRunAliases = async (run: NonNullable<ReturnType<typeof runs.get>>, context?: { model: { provider: string; id: string } | undefined; modelRegistry: ModelRegistryCapability | undefined; projectTrusted?: boolean }) => {
|
|
1561
|
+
const loaded = await run.store.load();
|
|
1562
|
+
const active = new Set(pi.getActiveTools().filter((tool) => !["workflow", "workflow_respond", "workflow_stop", "workflow_resume", "workflow_retry", "workflow_catalog"].includes(tool)));
|
|
1563
|
+
const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
|
|
1564
|
+
if (missing) throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
|
|
1565
|
+
const settingsPath = workflowSettingsPath(extensionAgentDir);
|
|
1566
|
+
const trustedProject = context?.projectTrusted ?? run.projectTrusted();
|
|
1567
|
+
const resolution = resolveWorkflowSettings(run.store.cwd, trustedProject, settingsPath);
|
|
1568
|
+
const currentPolicy = resolveAgentResourcePolicy(run.store.cwd, trustedProject, settingsPath);
|
|
1569
|
+
const staticAliases = resolution.effective.modelAliases ?? {};
|
|
1570
|
+
const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
|
|
1571
|
+
const rootModel = context?.model ? { ...run.model, provider: context.model.provider, model: context.model.id } : run.model;
|
|
1572
|
+
const inventory = modelInventory(rootModel, context?.modelRegistry);
|
|
1573
|
+
const knownModels = context?.modelRegistry ? inventory.knownModels : new Set([...loaded.snapshot.models, ...inventory.knownModels]);
|
|
1574
|
+
const availableModels = context?.modelRegistry ? inventory.availableModels : new Set([...loaded.snapshot.models, ...inventory.availableModels]);
|
|
1575
|
+
const currentAliases = (await resolveLaunchAliases(registry, staticAliases, { cwd: run.store.cwd, projectTrusted: trustedProject, rootModel, knownModels, availableModels, signal: run.abortController.signal }, availableModels, knownModels, settingsPath)).aliases;
|
|
1576
|
+
const blockedAliases = new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
1577
|
+
const blockedAliasTargets = Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
1578
|
+
const refreshed = resumedSnapshotSettings(loaded.snapshot, resolution, currentAliases);
|
|
1579
|
+
const snapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, ...refreshed, modelAliases: currentAliases });
|
|
1580
|
+
await run.store.saveSnapshot(snapshot);
|
|
1581
|
+
scheduler.updateRunLimit(run.store.runId, snapshot.settings.concurrency);
|
|
1582
|
+
run.executor = new WorkflowAgentExecutor({ cwd: run.store.cwd, agentDir: extensionAgentDir, model: run.model, tools: new Set(snapshot.tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog")), availableModels, knownModels, modelAliases: currentAliases, blockedAliases, blockedAliasTargets, settingsPath, agentDefinitions: snapshot.roles ?? {}, runStore: run.store, providerPause: async () => { deliver(pi, `Workflow ${snapshot.metadata.name} paused: provider limit.`); await run.lifecycle.providerPause(); }, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: frozenResourcePolicy(currentPolicy) }, createSession);
|
|
1583
|
+
run.executor.setRunContext(workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, run.abortController.signal));
|
|
1584
|
+
const drift = aliasDrift(previousAliases, currentAliases);
|
|
1585
|
+
if (drift.length) await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
|
|
1586
|
+
};
|
|
1587
|
+
const coldResumeRun = async (run: NonNullable<ReturnType<typeof runs.get>>, hasUI: boolean, ui: { select?: (prompt: string, options: string[]) => Promise<string | undefined> }, trustedProject: boolean, context?: { model: { provider: string; id: string } | undefined; modelRegistry: ModelRegistryCapability | undefined; signal?: AbortSignal | undefined; resolvedAliases?: Readonly<Record<string, string>>; blockedAliases?: ReadonlySet<string>; blockedAliasTargets?: Readonly<Record<string, string>> }) => {
|
|
1588
|
+
const loaded = await run.store.load();
|
|
1589
|
+
await run.store.validateRetrySource();
|
|
1590
|
+
await run.store.validateBorrowedWorktrees();
|
|
1591
|
+
if (loaded.snapshot.identityVersion !== LAUNCH_SNAPSHOT_IDENTITY_VERSION) throw new WorkflowError("RESUME_INCOMPATIBLE", "Workflow launch snapshot identity version is incompatible");
|
|
1592
|
+
if (loaded.snapshot.roles === undefined) throw new WorkflowError("RESUME_INCOMPATIBLE", "Workflow role definitions are missing from the launch snapshot");
|
|
1593
|
+
if ((loaded.snapshot.projectRoles?.length ?? 0) > 0 && !trustedProject) throw new WorkflowError("RESUME_INCOMPATIBLE", "Cannot restore project roles in an untrusted project");
|
|
1594
|
+
const missingRole = loaded.snapshot.agentTypes.find((role) => !loaded.snapshot.roles?.[role]);
|
|
1595
|
+
if (missingRole) throw new WorkflowError("RESUME_INCOMPATIBLE", `Role definition is missing from the launch snapshot: ${missingRole}`);
|
|
1596
|
+
const active = new Set(pi.getActiveTools().filter((tool) => !["workflow", "workflow_respond", "workflow_stop", "workflow_resume", "workflow_retry", "workflow_catalog"].includes(tool)));
|
|
1597
|
+
const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
|
|
1598
|
+
if (missing) throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
|
|
1599
|
+
const settingsPath = workflowSettingsPath(extensionAgentDir);
|
|
1600
|
+
const resolution = resolveWorkflowSettings(run.store.cwd, trustedProject, settingsPath);
|
|
1601
|
+
const currentPolicy = resolveAgentResourcePolicy(run.store.cwd, trustedProject, settingsPath);
|
|
1602
|
+
const staticAliases = resolution.effective.modelAliases ?? {};
|
|
1603
|
+
const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
|
|
1604
|
+
const rootModel = context?.model ? { ...run.model, provider: context.model.provider, model: context.model.id } : run.model;
|
|
1605
|
+
const inventory = modelInventory(rootModel, context?.modelRegistry);
|
|
1606
|
+
const knownModels = context?.modelRegistry ? inventory.knownModels : new Set([...loaded.snapshot.models, ...inventory.knownModels]);
|
|
1607
|
+
const availableModels = context?.modelRegistry ? inventory.availableModels : new Set([...loaded.snapshot.models, ...inventory.availableModels]);
|
|
1608
|
+
const controller = new AbortController();
|
|
1609
|
+
if (context?.signal?.aborted) controller.abort(); else { context?.signal?.addEventListener("abort", () => { controller.abort(); }, { once: true }); }
|
|
1610
|
+
run.abortController = controller;
|
|
1611
|
+
const currentAliases = context?.resolvedAliases ?? (await resolveLaunchAliases(registry, staticAliases, { cwd: run.store.cwd, projectTrusted: trustedProject, rootModel, knownModels, availableModels, signal: controller.signal }, availableModels, knownModels, settingsPath)).aliases;
|
|
1612
|
+
const resumeAliases = { ...previousAliases, ...currentAliases };
|
|
1613
|
+
const blockedAliases = context?.blockedAliases ?? new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
1614
|
+
const blockedAliasTargets = context?.blockedAliasTargets ?? Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
1615
|
+
const script = launchScriptForSnapshot(loaded.snapshot, registry);
|
|
1616
|
+
preflight(script, { models: availableModels, tools: active, agentTypes: new Set(loaded.snapshot.agentTypes), modelAliases: resumeAliases, knownModels, settingsPath, skipModelAvailability: true }, loaded.snapshot.schemas, loaded.snapshot.metadata, true);
|
|
1617
|
+
const refreshed = resumedSnapshotSettings(loaded.snapshot, resolution, currentAliases);
|
|
1618
|
+
const snapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, ...refreshed, modelAliases: currentAliases });
|
|
1619
|
+
await run.store.saveSnapshot(snapshot);
|
|
1620
|
+
scheduler.updateRunLimit(run.store.runId, snapshot.settings.concurrency);
|
|
1621
|
+
run.executor = new WorkflowAgentExecutor({ cwd: run.store.cwd, agentDir: extensionAgentDir, model: rootModel, tools: new Set(snapshot.tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog")), availableModels, knownModels, modelAliases: currentAliases, blockedAliases, blockedAliasTargets, settingsPath, agentDefinitions: snapshot.roles ?? {}, runStore: run.store, providerPause: async () => { deliver(pi, `Workflow ${snapshot.metadata.name} paused: provider limit.`); await run.lifecycle.providerPause(); }, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: frozenResourcePolicy(currentPolicy) }, createSession);
|
|
1622
|
+
const drift = aliasDrift(previousAliases, currentAliases);
|
|
1623
|
+
if (drift.length) await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
|
|
1624
|
+
const runContext = workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, controller.signal);
|
|
1625
|
+
run.executor.setRunContext(runContext);
|
|
1626
|
+
let variables: Readonly<Record<string, JsonValue>>;
|
|
1627
|
+
try { variables = await resolveWorkflowVariables(runContext, controller, registry); }
|
|
1628
|
+
catch (error) {
|
|
1629
|
+
const typed = asWorkflowError(error);
|
|
1630
|
+
if (!["completed", "failed", "stopped"].includes(run.lifecycle.state)) { await run.lifecycle.terminal("failed", typed.code).catch(() => undefined); const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, error: { code: typed.code, message: typed.message } })); await eventPublisher.runFailed(run.store, run.metadata, typed, run.lifecycle.state === "interrupted" ? "interrupted" : "failed"); run.update?.(workflowToolUpdate(persisted)); if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state)) await createWorkflowFailureDiagnostics(run.store, run.metadata, typed, persisted).then((diagnostic) => { deliverFailure(pi, diagnostic); }).catch(() => undefined); }
|
|
1631
|
+
await cleanupTerminalRun(run.store.runId);
|
|
1632
|
+
throw typed;
|
|
1633
|
+
}
|
|
1634
|
+
await scheduler.cancelRun(run.store.runId);
|
|
1635
|
+
await run.lifecycle.resume();
|
|
1636
|
+
const execution = runWorkflow(script, loaded.snapshot.args, withWorkflowFunctions({ shell: (command, options, signal, identity) => shellForRun(run.store, run.metadata, run.lifecycle, command, options, signal, identity), agent: async (prompt, options, signal, identity) => {
|
|
1637
|
+
await run.lifecycle.enter();
|
|
1638
|
+
try {
|
|
1639
|
+
const path = agentIdentityPath(identity);
|
|
1640
|
+
const replayed = await run.store.replay(path);
|
|
1641
|
+
if (replayed) {
|
|
1642
|
+
return replayed.value;
|
|
1643
|
+
}
|
|
1644
|
+
const worktree = agentWorktree(identity);
|
|
1645
|
+
const cwd = worktree.worktreeOwner ? (await persistWorktree(run.store, run.metadata, worktree.worktreeOwner)).cwd : run.store.cwd;
|
|
1646
|
+
const role = typeof options.role === "string" ? options.role : undefined;
|
|
1647
|
+
const model = typeof options.model === "string" ? options.model : undefined;
|
|
1648
|
+
const thinking = parseThinking(options.thinking);
|
|
1649
|
+
const requestedLabel = typeof options.label === "string" ? options.label : undefined;
|
|
1650
|
+
const resolved = run.executor.resolve({ label: requestedLabel ?? role ?? "agent", workflowName: run.metadata.name, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(Array.isArray(options.tools) ? { tools: options.tools as string[] } : {}) });
|
|
1651
|
+
const label = displayAgentName(requestedLabel, role, resolved.model);
|
|
1652
|
+
const tools = resolved.tools;
|
|
1653
|
+
const schema = object(options.outputSchema) ? options.outputSchema : undefined;
|
|
1654
|
+
const spawned = scheduler.spawn(run.store.runId, prompt, { label, ...(requestedLabel ? { requestedLabel } : {}), ...(identity.parentBreadcrumb ? { parentBreadcrumb: identity.parentBreadcrumb } : {}), cwd, tools, ...worktree, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(schema ? { schema } : {}), ...(typeof options.retries === "number" ? { retries: options.retries } : {}), ...(positiveInteger(options.timeoutMs) || options.timeoutMs === null ? { timeoutMs: options.timeoutMs } : {}), agentOptions: options, agentIdentity: identity });
|
|
1655
|
+
const cancel = () => { scheduler.cancel(spawned.id); };
|
|
1656
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
1657
|
+
const outcome = await spawned.result.finally(() => { signal.removeEventListener("abort", cancel); });
|
|
1658
|
+
if (!outcome.ok) throw new WorkflowError(outcome.error.code as WorkflowErrorCode, outcome.error.message);
|
|
1659
|
+
await run.store.complete(path, outcome.value);
|
|
1660
|
+
return outcome.value;
|
|
1661
|
+
} finally { await run.lifecycle.leave(); }
|
|
1662
|
+
}, worktree: async (owner) => resolveWorktree(run.store, run.metadata, owner), checkpoint: checkpointBridge(run.store.runId, run.store, run.metadata, false, hasUI ? ui : undefined), phase: phaseBridge(run.store, run.metadata, run.lifecycle), log: logBridge(run.lifecycle, run.metadata.name) }, run.store, runContext, variables, registry), controller.signal);
|
|
1663
|
+
run.execution = execution;
|
|
1664
|
+
const completion = execution.result.then(async (value) => {
|
|
1665
|
+
await scheduler.flush();
|
|
1666
|
+
if (run.budget.hardExhausted) throw new WorkflowError("BUDGET_EXHAUSTED", "Budgeted work was attempted after hard exhaustion");
|
|
1667
|
+
const resultPath = await run.store.saveResult(value);
|
|
1668
|
+
await run.lifecycle.terminal("completed", "completed");
|
|
1669
|
+
await eventPublisher.runCompleted(run.store, run.metadata, resultPath);
|
|
1670
|
+
return { value, resultPath };
|
|
1671
|
+
}).catch(async (error: unknown) => {
|
|
1672
|
+
await scheduler.flush();
|
|
1673
|
+
const typed = error instanceof WorkflowError ? error : new WorkflowError(errorCode(error) ?? "INTERNAL_ERROR", errorText(error));
|
|
1674
|
+
if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state)) await run.lifecycle.terminal(typed.code === "BUDGET_EXHAUSTED" ? "budget_exhausted" : "failed", typed.code);
|
|
1675
|
+
const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot(), error: { code: typed.code, message: typed.message } }));
|
|
1676
|
+
const state = run.lifecycle.state === "stopped" || run.lifecycle.state === "interrupted" || run.lifecycle.state === "budget_exhausted" ? run.lifecycle.state : "failed";
|
|
1677
|
+
if (state === "failed") retryReservations.delete(persisted.retry?.lineageRootRunId ?? run.store.runId);
|
|
1678
|
+
await eventPublisher.runFailed(run.store, run.metadata, typed, state);
|
|
1679
|
+
run.update?.(workflowToolUpdate(persisted));
|
|
1680
|
+
if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state)) await createWorkflowFailureDiagnostics(run.store, run.metadata, typed, persisted).then((diagnostic) => { deliverFailure(pi, diagnostic); }).catch(() => undefined);
|
|
1681
|
+
}).finally(() => cleanupTerminalRun(run.store.runId));
|
|
1682
|
+
run.completion = completion;
|
|
1683
|
+
void completion;
|
|
1684
|
+
};
|
|
1685
|
+
const applyBudgetDecision = async (request: BudgetApprovalRequest, approved: boolean, context?: unknown, signal?: AbortSignal): Promise<BudgetDecisionResult> => {
|
|
1686
|
+
const run = runs.get(request.runId);
|
|
1687
|
+
if (!run) throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run: ${request.runId}`);
|
|
1688
|
+
if (!approved) return { state: "budget_exhausted", approved: false };
|
|
1689
|
+
const nextBudget = validateBudget(request.proposed);
|
|
1690
|
+
const nextVersion = request.budgetVersion + 1;
|
|
1691
|
+
const runtime = new WorkflowBudgetRuntime(nextBudget, nextVersion, request.consumed, run.budget.events, { active: false });
|
|
1692
|
+
run.budget = runtime;
|
|
1693
|
+
await persistRunState(run.store, run.metadata, (current) => { const next = { ...current, ...runtime.snapshot(), budgetVersion: nextVersion }; if (nextBudget) next.budget = nextBudget; else delete next.budget; return next; });
|
|
1694
|
+
await coldResumeRun(run, false, {}, projectTrusted(context), { ...resumeHostContext(context), ...(signal ? { signal } : {}) });
|
|
1695
|
+
return { state: "running", approved: true };
|
|
1696
|
+
};
|
|
1697
|
+
const resumeWorkflowRun = async (runId: string, rawPatch?: unknown, context?: unknown, signal?: AbortSignal): Promise<Record<string, JsonValue>> => {
|
|
1698
|
+
const run = runs.get(runId);
|
|
1699
|
+
if (!run) throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run: ${runId}`);
|
|
1700
|
+
const loaded = await run.store.load();
|
|
1701
|
+
if (loaded.run.state !== "budget_exhausted") throw new WorkflowError("RESUME_INCOMPATIBLE", "Only budget-exhausted runs can be resumed with workflow_resume");
|
|
1702
|
+
const currentBudget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
|
|
1703
|
+
const patch = rawPatch === undefined ? {} : validateBudgetPatch(rawPatch);
|
|
1704
|
+
const nextBudget = mergeBudget(currentBudget, patch);
|
|
1705
|
+
const usage = budgetUsage(loaded.run.usage);
|
|
1706
|
+
if (!resumeBudgetAllowed(nextBudget, usage)) throw new WorkflowError("RESUME_INCOMPATIBLE", "Every exhausted hard budget must be raised above retained usage or removed");
|
|
1707
|
+
if (budgetRelaxed(currentBudget, nextBudget)) {
|
|
1708
|
+
const proposalId = randomUUID();
|
|
1709
|
+
const request: BudgetApprovalRequest = { kind: "budget", proposalId, runId, consumed: usage, previous: currentBudget ?? {}, proposed: nextBudget ?? {}, budgetVersion: loaded.run.budgetVersion ?? 1 };
|
|
1710
|
+
await run.store.requestWorkflowDecision(request);
|
|
1711
|
+
await appendBudgetDecisionEvent(run, request, "adjustment_requested");
|
|
1712
|
+
deliver(pi, budgetDecisionDelivery(run.metadata, request));
|
|
1713
|
+
return { state: "awaiting_approval", proposalId };
|
|
1714
|
+
}
|
|
1715
|
+
const changed = JSON.stringify(currentBudget ?? {}) !== JSON.stringify(nextBudget ?? {});
|
|
1716
|
+
if (changed) {
|
|
1717
|
+
const nextVersion = (loaded.run.budgetVersion ?? 1) + 1;
|
|
1718
|
+
const runtime = new WorkflowBudgetRuntime(nextBudget, nextVersion, usage, loaded.run.budgetEvents, { active: false });
|
|
1719
|
+
run.budget = runtime;
|
|
1720
|
+
await persistRunState(run.store, run.metadata, (current) => { const next = { ...current, ...runtime.snapshot(), budgetVersion: nextVersion }; if (nextBudget) next.budget = nextBudget; else delete next.budget; return next; });
|
|
1721
|
+
}
|
|
1722
|
+
await coldResumeRun(run, false, {}, projectTrusted(context), { ...resumeHostContext(context), ...(signal ? { signal } : {}) });
|
|
1723
|
+
return { state: "running" };
|
|
1724
|
+
};
|
|
1725
|
+
const retryReservations = new Set<string>();
|
|
1726
|
+
const retryWorkflowRun = async (runId: string, context: unknown, signal?: AbortSignal): Promise<{ runId: string; parentRunId: string; state: "running" }> => {
|
|
1727
|
+
if (typeof runId !== "string" || !runId.trim()) throw new WorkflowError("RESUME_INCOMPATIBLE", "workflow_retry requires an explicit run ID");
|
|
1728
|
+
const host = object(context) ? context : {};
|
|
1729
|
+
const cwd = typeof host.cwd === "string" ? host.cwd : undefined;
|
|
1730
|
+
const sessionManager = object(host.sessionManager) ? host.sessionManager : undefined;
|
|
1731
|
+
const sessionId = typeof sessionManager?.getSessionId === "function" ? String(Reflect.apply(sessionManager.getSessionId, sessionManager, [])) : undefined;
|
|
1732
|
+
if (!cwd || !sessionId) throw new WorkflowError("RESUME_INCOMPATIBLE", "workflow_retry requires the current project and Pi session");
|
|
1733
|
+
await ensureSessionLease(cwd, sessionId);
|
|
1734
|
+
const sourceStore = new RunStore(cwd, sessionId, runId, home);
|
|
1735
|
+
let loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> };
|
|
1736
|
+
try { loaded = await sourceStore.load(); } catch (error) { throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot load failed source run ${runId}: ${errorText(error)}`); }
|
|
1737
|
+
if (loaded.run.state !== "failed") throw new WorkflowError("RESUME_INCOMPATIBLE", `Only failed workflow runs can be retried; source is ${loaded.run.state}`);
|
|
1738
|
+
if (loaded.run.retry && (typeof loaded.run.retry.sourceRunId !== "string" || !loaded.run.retry.sourceRunId || typeof loaded.run.retry.lineageRootRunId !== "string" || !loaded.run.retry.lineageRootRunId || !Array.isArray(loaded.run.retry.completedPaths) || loaded.run.retry.completedPaths.some((path) => typeof path !== "string") || !Array.isArray(loaded.run.retry.incompletePaths) || loaded.run.retry.incompletePaths.some((path) => typeof path !== "string") || !Array.isArray(loaded.run.retry.namedWorktrees) || loaded.run.retry.namedWorktrees.some((name) => typeof name !== "string"))) throw new WorkflowError("RESUME_INCOMPATIBLE", "The source retry provenance is incomplete");
|
|
1739
|
+
const lineageRootRunId = loaded.run.retry?.lineageRootRunId ?? loaded.run.id;
|
|
1740
|
+
if (retryReservations.has(lineageRootRunId)) throw new WorkflowError("RESUME_INCOMPATIBLE", `An active retry already owns lineage ${lineageRootRunId}`);
|
|
1741
|
+
const activeStates = new Set<RunState>(["queued", "running", "pausing", "paused", "awaiting_input", "interrupted", "budget_exhausted"]);
|
|
1742
|
+
for (const candidateId of await listRunIds(cwd, sessionId, home)) {
|
|
1743
|
+
if (candidateId === runId) continue;
|
|
1744
|
+
const candidate = new RunStore(cwd, sessionId, candidateId, home);
|
|
1745
|
+
try {
|
|
1746
|
+
const candidateRun = (await candidate.load()).run;
|
|
1747
|
+
if (activeStates.has(candidateRun.state) && candidateRun.retry?.lineageRootRunId === lineageRootRunId) throw new WorkflowError("RESUME_INCOMPATIBLE", `An active retry child already exists for source lineage ${lineageRootRunId}`);
|
|
1748
|
+
} catch (error) {
|
|
1749
|
+
if (error instanceof WorkflowError && error.code === "RESUME_INCOMPATIBLE") throw error;
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
retryReservations.add(lineageRootRunId);
|
|
1753
|
+
let childStarted = false;
|
|
1754
|
+
try {
|
|
1755
|
+
const trustedProject = projectTrusted(context);
|
|
1756
|
+
await sourceStore.validateRetrySource();
|
|
1757
|
+
await sourceStore.validateBorrowedWorktrees();
|
|
1758
|
+
if (loaded.snapshot.identityVersion !== LAUNCH_SNAPSHOT_IDENTITY_VERSION) throw new WorkflowError("RESUME_INCOMPATIBLE", "Workflow launch snapshot identity version is incompatible");
|
|
1759
|
+
if (loaded.snapshot.roles === undefined) throw new WorkflowError("RESUME_INCOMPATIBLE", "Workflow role definitions are missing from the launch snapshot");
|
|
1760
|
+
if ((loaded.snapshot.projectRoles?.length ?? 0) > 0 && !trustedProject) throw new WorkflowError("RESUME_INCOMPATIBLE", "Cannot restore project roles in an untrusted project");
|
|
1761
|
+
const missingRole = loaded.snapshot.agentTypes.find((role) => !loaded.snapshot.roles?.[role]);
|
|
1762
|
+
if (missingRole) throw new WorkflowError("RESUME_INCOMPATIBLE", `Role definition is missing from the launch snapshot: ${missingRole}`);
|
|
1763
|
+
const active = new Set<string>(pi.getActiveTools().filter((tool) => !["workflow", "workflow_respond", "workflow_stop", "workflow_resume", "workflow_retry", "workflow_catalog"].includes(tool)));
|
|
1764
|
+
const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
|
|
1765
|
+
if (missing) throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
|
|
1766
|
+
const settingsPath = workflowSettingsPath(extensionAgentDir);
|
|
1767
|
+
const resolution = resolveWorkflowSettings(cwd, trustedProject, settingsPath);
|
|
1768
|
+
const currentPolicy = resolveAgentResourcePolicy(cwd, trustedProject, settingsPath);
|
|
1769
|
+
const staticAliases = resolution.effective.modelAliases ?? {};
|
|
1770
|
+
const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
|
|
1771
|
+
const modelRegistry = contextHostCapabilities(context).modelRegistry;
|
|
1772
|
+
const hostModel = object(host.model) && typeof host.model.provider === "string" && typeof host.model.id === "string" ? { provider: host.model.provider, id: host.model.id } : { provider: "", id: "" };
|
|
1773
|
+
const rootModel: ModelSpec = { provider: hostModel.provider, model: hostModel.id, thinking: pi.getThinkingLevel() };
|
|
1774
|
+
const inventory = modelInventory(rootModel, modelRegistry);
|
|
1775
|
+
const knownModels = modelRegistry ? inventory.knownModels : new Set([...loaded.snapshot.models, ...inventory.knownModels]);
|
|
1776
|
+
const availableModels = modelRegistry ? inventory.availableModels : new Set([...loaded.snapshot.models, ...inventory.availableModels]);
|
|
1777
|
+
const resolvedAliases = await resolveLaunchAliases(registry, staticAliases, { cwd, projectTrusted: trustedProject, rootModel, knownModels, availableModels, signal: signal ?? new AbortController().signal }, availableModels, knownModels, settingsPath);
|
|
1778
|
+
const currentAliases = resolvedAliases.aliases;
|
|
1779
|
+
const resumeAliases = { ...previousAliases, ...currentAliases };
|
|
1780
|
+
const blockedAliases = new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
1781
|
+
const blockedAliasTargets = Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
1782
|
+
const script = launchScriptForSnapshot(loaded.snapshot, registry);
|
|
1783
|
+
preflight(script, { models: availableModels, tools: active, agentTypes: new Set(loaded.snapshot.agentTypes), modelAliases: resumeAliases, knownModels, settingsPath, skipModelAvailability: true }, loaded.snapshot.schemas, loaded.snapshot.metadata, true);
|
|
1784
|
+
await sourceStore.validateNamedWorktrees();
|
|
1785
|
+
for (const name of loaded.run.retry?.namedWorktrees ?? []) await sourceStore.resolveNamedWorktree(name);
|
|
1786
|
+
const completedPaths = (await sourceStore.replayableOperations()).map(({ path }) => path);
|
|
1787
|
+
const incompletePaths = incompleteRetryPaths([...(loaded.run.retry?.incompletePaths ?? []), ...loaded.run.agents.filter((agent) => agent.state !== "completed").map((agent) => operationPath("agent", ...(agent.structuralPath ?? [])))], completedPaths);
|
|
1788
|
+
const namedWorktrees = [...new Set([...(loaded.run.retry?.namedWorktrees ?? []), ...(await sourceStore.worktrees()).filter(({ owner }) => owner.startsWith(`${operationPath("worktree", "named")}/`)).map(({ owner }) => decodeURIComponent(owner.split("/").at(-1) ?? owner))])];
|
|
1789
|
+
const budget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
|
|
1790
|
+
const childRunId = randomUUID();
|
|
1791
|
+
const childStore = new RunStore(cwd, sessionId, childRunId, home);
|
|
1792
|
+
const refreshed = resumedSnapshotSettings(loaded.snapshot, resolution, currentAliases);
|
|
1793
|
+
const childSnapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, ...refreshed, modelAliases: currentAliases });
|
|
1794
|
+
const childBudget = new WorkflowBudgetRuntime(budget, loaded.run.budgetVersion ?? 1, loaded.run.usage, loaded.run.budgetEvents);
|
|
1795
|
+
const childInitialBudget = childBudget.snapshot();
|
|
1796
|
+
const retry: WorkflowRetryProvenance = { sourceRunId: loaded.run.id, lineageRootRunId, completedPaths, incompletePaths, namedWorktrees };
|
|
1797
|
+
await childStore.create({ id: childRunId, workflowName: loaded.snapshot.metadata.name, cwd, sessionId, state: "interrupted", parentRunId: loaded.run.id, retry, agents: [], nativeSessions: [], ...(budget ? { budget } : {}), budgetVersion: loaded.run.budgetVersion ?? 1, ...childInitialBudget }, childSnapshot);
|
|
1798
|
+
const fallbackModel: ModelSpec = { provider: hostModel.provider, model: hostModel.id, thinking: pi.getThinkingLevel() };
|
|
1799
|
+
const model = modelSpec(loaded.snapshot.models[0] ?? "", fallbackModel);
|
|
1800
|
+
const lifecycle = lifecycleFor(childStore, "interrupted", childBudget, loaded.snapshot.metadata);
|
|
1801
|
+
const abortController = new AbortController();
|
|
1802
|
+
const providerErrorRecovery = createProviderErrorRecovery(context, availableModels, () => { abortController.abort(); });
|
|
1803
|
+
const providerPause = async () => { deliver(pi, `Workflow ${loaded.snapshot.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
|
|
1804
|
+
const childRun = { executor: new WorkflowAgentExecutor({ cwd, agentDir: extensionAgentDir, model, tools: new Set(loaded.snapshot.tools.filter((tool) => active.has(tool) || tool === "workflow_catalog")), availableModels, knownModels, modelAliases: currentAliases, blockedAliases, blockedAliasTargets, settingsPath, agentDefinitions: loaded.snapshot.roles ?? {}, runStore: childStore, providerPause, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: frozenResourcePolicy(currentPolicy) }, createSession), store: childStore, metadata: loaded.snapshot.metadata, model, lifecycle, budget: childBudget, abortController, projectTrusted: () => projectTrusted(context), checkpointResolvers: new Map(), ...(providerErrorRecovery ? { providerErrorRecovery } : {}) };
|
|
1805
|
+
runs.set(childRunId, childRun);
|
|
1806
|
+
scheduler.addRun(childRunId, loaded.snapshot.settings.concurrency, () => { childBudget.checkAgentLaunch(); });
|
|
1807
|
+
await eventPublisher.runStarted(childStore, loaded.snapshot.metadata);
|
|
1808
|
+
await coldResumeRun(childRun, false, {}, trustedProject, { model: hostModel, modelRegistry, resolvedAliases: currentAliases, blockedAliases, blockedAliasTargets, ...(signal ? { signal } : {}) });
|
|
1809
|
+
const completion = runs.get(childRunId)?.completion;
|
|
1810
|
+
if (completion) {
|
|
1811
|
+
childStarted = true;
|
|
1812
|
+
void completion.then(() => { retryReservations.delete(lineageRootRunId); }, () => { retryReservations.delete(lineageRootRunId); });
|
|
1813
|
+
}
|
|
1814
|
+
return { runId: childRunId, parentRunId: loaded.run.id, state: "running" };
|
|
1815
|
+
} finally {
|
|
1816
|
+
if (!childStarted) retryReservations.delete(lineageRootRunId);
|
|
1817
|
+
}
|
|
1818
|
+
};
|
|
1819
|
+
pi.registerTool({
|
|
1820
|
+
name: "workflow_retry",
|
|
1821
|
+
label: "Workflow Retry",
|
|
1822
|
+
description: "Retry a failed workflow run by replaying its completed structural operations",
|
|
1823
|
+
parameters: WORKFLOW_RETRY_PARAMETERS,
|
|
1824
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
1825
|
+
try { const result = await retryWorkflowRun(params.runId, ctx, signal); return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result }; }
|
|
1826
|
+
catch (error) { throw mainAgentError(error); }
|
|
1827
|
+
},
|
|
1828
|
+
});
|
|
1829
|
+
pi.registerTool({
|
|
1830
|
+
name: "workflow_resume",
|
|
1831
|
+
label: "Workflow Resume",
|
|
1832
|
+
description: "Resume an exhausted workflow with unchanged or patched aggregate budgets",
|
|
1833
|
+
parameters: Type.Object({ runId: Type.String(), budget: Type.Optional(Type.Unknown()) }, { additionalProperties: false }),
|
|
1834
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
1835
|
+
try { const result = await resumeWorkflowRun(params.runId, params.budget, ctx, signal); return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result }; }
|
|
1836
|
+
catch (error) { throw mainAgentError(error); }
|
|
1837
|
+
},
|
|
1838
|
+
});
|
|
1839
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
1840
|
+
if (sessionStarted) return;
|
|
1841
|
+
sessionStarted = true;
|
|
1842
|
+
registry.freeze();
|
|
1843
|
+
registerCatalog(ctx.cwd, projectTrusted(ctx));
|
|
1844
|
+
await ensureSessionLease(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
1845
|
+
try {
|
|
1846
|
+
for (const runId of await listRunIds(ctx.cwd, ctx.sessionManager.getSessionId(), home)) {
|
|
1847
|
+
if (runs.has(runId)) continue;
|
|
1848
|
+
const store = new RunStore(ctx.cwd, ctx.sessionManager.getSessionId(), runId, home);
|
|
1849
|
+
let loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> };
|
|
1850
|
+
try { loaded = await store.load(); } catch { if (!await store.isComplete()) await store.delete(true).catch(() => undefined); continue; }
|
|
1851
|
+
if (loaded.run.state === "completed" || loaded.run.state === "failed" || loaded.run.state === "stopped") { terminalRunStates.set(runId, loaded.run.state); continue; }
|
|
1852
|
+
if (loaded.run.state !== "interrupted" && loaded.run.state !== "budget_exhausted") {
|
|
1853
|
+
const previousState = loaded.run.state;
|
|
1854
|
+
await store.updateState((current) => ["completed", "failed", "stopped", "interrupted", "budget_exhausted"].includes(current.state) ? current : { ...current, state: "interrupted" });
|
|
1855
|
+
loaded = { ...loaded, run: (await store.load()).run };
|
|
1856
|
+
await eventPublisher.runState(store, loaded.snapshot.metadata, previousState, "interrupted", "session_shutdown");
|
|
1857
|
+
loaded = { ...loaded, run: (await store.load()).run };
|
|
1858
|
+
}
|
|
1859
|
+
const model = modelSpec(loaded.snapshot.models[0] ?? "", { provider: ctx.model?.provider ?? "", model: ctx.model?.id ?? "", thinking: pi.getThinkingLevel() });
|
|
1860
|
+
const budget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
|
|
1861
|
+
eventPublisher.seedBudget(runId, loaded.run.budgetEvents);
|
|
1862
|
+
const budgetRuntime = new WorkflowBudgetRuntime(budget, loaded.run.budgetVersion ?? 1, loaded.run.usage, loaded.run.budgetEvents, { active: loaded.run.state === "running" });
|
|
1863
|
+
const lifecycle = lifecycleFor(store, loaded.run.state, budgetRuntime, loaded.snapshot.metadata);
|
|
1864
|
+
const providerPause = async () => { deliver(pi, `Workflow ${loaded.snapshot.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
|
|
1865
|
+
const roleDefinitions = loaded.snapshot.roles ?? {};
|
|
1866
|
+
const abortController = new AbortController();
|
|
1867
|
+
const providerErrorRecovery = createProviderErrorRecovery(ctx, new Set(loaded.snapshot.models), () => { abortController.abort(); });
|
|
1868
|
+
runs.set(runId, { executor: new WorkflowAgentExecutor({ cwd: ctx.cwd, agentDir: extensionAgentDir, model, tools: new Set(loaded.snapshot.tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog")), availableModels: new Set(loaded.snapshot.models), knownModels: new Set(loaded.snapshot.models), ...(loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ? { modelAliases: loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases } : {}), ...(loaded.snapshot.settingsSources?.modelAliases ? { settingsPath: loaded.snapshot.settingsSources.modelAliases } : loaded.snapshot.settingsPath ? { settingsPath: loaded.snapshot.settingsPath } : {}), agentDefinitions: roleDefinitions, runStore: store, providerPause, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: frozenResourcePolicy(snapshotResourcePolicy(loaded.snapshot, store.cwd, projectTrusted(ctx), workflowSettingsPath(extensionAgentDir))) }, createSession), store, metadata: loaded.snapshot.metadata, model, lifecycle, budget: budgetRuntime, abortController, projectTrusted: () => projectTrusted(ctx), checkpointResolvers: new Map(), ...(providerErrorRecovery ? { providerErrorRecovery } : {}) });
|
|
1869
|
+
for (const checkpoint of await store.awaitingCheckpoints()) deliver(pi, `Workflow ${loaded.snapshot.metadata.name} checkpoint ${checkpoint.name}: ${checkpoint.prompt}\nContext: ${JSON.stringify(checkpoint.context)}\nRespond with workflow_respond.`);
|
|
1870
|
+
for (const decision of await store.pendingWorkflowDecisions()) deliver(pi, budgetDecisionDelivery(loaded.snapshot.metadata, decision));
|
|
1871
|
+
scheduler.restoreRun(runId, loaded.snapshot.settings.concurrency, loaded.snapshot.identityVersion === LAUNCH_SNAPSHOT_IDENTITY_VERSION ? await store.loadOwnership() : [], () => runs.get(runId)?.budget.checkAgentLaunch());
|
|
1872
|
+
}
|
|
1873
|
+
const resumeSelect = uiHostCapabilities(ctx.ui)?.select;
|
|
1874
|
+
if (ctx.hasUI && resumeSelect) {
|
|
1875
|
+
const interrupted = [...runs.values()].filter((r) => r.lifecycle.state === "interrupted");
|
|
1876
|
+
if (interrupted.length > 0) {
|
|
1877
|
+
const labels = interrupted.map((r) => `Resume: ${r.metadata.name} (${r.store.runId.slice(0, 8)})`);
|
|
1878
|
+
const options = [...labels, ...(interrupted.length > 1 ? ["Resume all"] : []), "Skip"];
|
|
1879
|
+
const choice = await resumeSelect(`${String(interrupted.length)} interrupted workflow${interrupted.length > 1 ? "s" : ""} found`, options);
|
|
1880
|
+
if (choice && choice !== "Skip") {
|
|
1881
|
+
const toResume = choice === "Resume all" ? interrupted : interrupted.filter((_, i) => labels[i] === choice);
|
|
1882
|
+
for (const run of toResume) {
|
|
1883
|
+
try { await coldResumeRun(run, true, ctx.ui, projectTrusted(ctx), ctx); ctx.ui.notify(`Resumed workflow ${run.metadata.name}.`, "info"); }
|
|
1884
|
+
catch (err) { ctx.ui.notify(`Cannot resume ${run.metadata.name}: ${err instanceof Error ? err.message : String(err)}`, "warning"); }
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
} catch (error) { await releaseSessionLease(); throw error; }
|
|
1890
|
+
});
|
|
1891
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
1892
|
+
if (!pi.getActiveTools().includes("workflow")) return;
|
|
1893
|
+
const roles = Object.entries(loadAgentDefinitions(ctx.cwd, extensionAgentDir, projectTrusted(ctx), typeof registry.roleDirectoryRegistrations === "function" ? registry.roleDirectoryRegistrations() : undefined)).filter(([, definition]) => definition.description);
|
|
1894
|
+
if (!roles.length) return;
|
|
1895
|
+
const content = `Workflow role descriptions:\n${roles.map(([name, definition]) => `- \`${name}\`: ${String(definition.description)}`).join("\n")}`;
|
|
1896
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${content}` };
|
|
1897
|
+
});
|
|
1898
|
+
pi.registerTool({
|
|
1899
|
+
name: "workflow",
|
|
1900
|
+
label: WORKFLOW_TOOL_LABEL,
|
|
1901
|
+
description: WORKFLOW_TOOL_DESCRIPTION,
|
|
1902
|
+
promptSnippet: WORKFLOW_TOOL_PROMPT_SNIPPET,
|
|
1903
|
+
parameters: WORKFLOW_TOOL_PARAMETERS,
|
|
1904
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
1905
|
+
try {
|
|
1906
|
+
const headless = object(ctx) && ctx.headless === true;
|
|
1907
|
+
const settingsPath = workflowSettingsPath(extensionAgentDir);
|
|
1908
|
+
if (!ctx.model) throw new WorkflowError("UNKNOWN_MODEL", "A launching model is required");
|
|
1909
|
+
const budget = validateBudget(params.budget);
|
|
1910
|
+
const rootModel: ModelSpec = { provider: ctx.model.provider, model: ctx.model.id, thinking: pi.getThinkingLevel() };
|
|
1911
|
+
const rootModelName = `${rootModel.provider}/${rootModel.model}`;
|
|
1912
|
+
const modelRegistry = contextHostCapabilities(ctx).modelRegistry;
|
|
1913
|
+
const inventory = modelInventory(rootModel, modelRegistry);
|
|
1914
|
+
const knownModels = inventory.knownModels;
|
|
1915
|
+
const availableModels = inventory.availableModels;
|
|
1916
|
+
const rootTools = pi.getActiveTools().filter((name) => !["workflow", "workflow_respond", "workflow_stop", "workflow_resume", "workflow_retry", "workflow_catalog"].includes(name));
|
|
1917
|
+
const trustedProject = projectTrusted(ctx);
|
|
1918
|
+
const launchCwd = typeof ctx.cwd === "string" ? ctx.cwd : process.cwd();
|
|
1919
|
+
const launch = workflowLaunchSettings(launchCwd, trustedProject, settingsPath, params.concurrency);
|
|
1920
|
+
const runController = new AbortController();
|
|
1921
|
+
if (signal?.aborted) runController.abort(); else signal?.addEventListener("abort", () => { runController.abort(); }, { once: true });
|
|
1922
|
+
const resolvedAliases = await resolveLaunchAliases(registry, launch.settings.modelAliases ?? {}, { cwd: launchCwd, projectTrusted: trustedProject, rootModel, knownModels, availableModels, signal: runController.signal }, availableModels, knownModels, settingsPath);
|
|
1923
|
+
const modelAliases = resolvedAliases.aliases;
|
|
1924
|
+
const settings = Object.freeze({ ...launch.settings, ...(Object.keys(modelAliases).length ? { modelAliases } : {}) });
|
|
1925
|
+
const validated = validateWorkflowLaunchWithRegistry({ ...params, args: params.args }, { cwd: ctx.cwd, agentDir: extensionAgentDir, projectTrusted: trustedProject, availableModels, rootTools: new Set(rootTools), modelAliases, knownModels, settingsPath }, registry);
|
|
1926
|
+
const { script, checked, agentDefinitions, projectAgentDefinitions, roleNames, functionName } = validated;
|
|
1927
|
+
await ensureSessionLease(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
1928
|
+
const runId = randomUUID();
|
|
1929
|
+
const args = (params.args ?? null) as JsonValue;
|
|
1930
|
+
encoded(args);
|
|
1931
|
+
const runContext = workflowRunContext(ctx.cwd, ctx.sessionManager.getSessionId(), runId, checked.metadata, args, runController.signal);
|
|
1932
|
+
const variables = await resolveWorkflowVariables(runContext, runController, registry);
|
|
1933
|
+
const store = new RunStore(ctx.cwd, ctx.sessionManager.getSessionId(), runId, home);
|
|
1934
|
+
const parentRunId = params.parentRunId;
|
|
1935
|
+
if (parentRunId !== undefined) await store.validateParentRun(parentRunId);
|
|
1936
|
+
const roles = Object.fromEntries(roleNames.map((role) => [role, agentDefinitions[role]])) as Record<string, AgentDefinition>;
|
|
1937
|
+
const projectRoles = roleNames.filter((role) => projectAgentDefinitions[role] !== undefined);
|
|
1938
|
+
const roleModels = roleNames.flatMap((role) => { const model = agentDefinitions[role]?.model; return model ? [modelCapability(model, modelAliases, knownModels, settingsPath)] : []; });
|
|
1939
|
+
const snapshotModels = [...new Set([rootModelName, ...checked.referenced.models, ...roleModels])];
|
|
1940
|
+
const snapshot = createLaunchSnapshot({ script, args, metadata: checked.metadata, settings, settingsPath, settingsSources: { ...launch.resolution.sources, concurrency: params.concurrency === undefined ? launch.resolution.sources.concurrency : "per-run options" }, ...(functionName ? { launchKind: "function" as const, functionName } : {}), ...(Object.keys(modelAliases).length ? { modelAliases } : {}), ...(budget ? { budget } : {}), ...(checked.referenced.phases.length ? { phases: checked.referenced.phases } : {}), models: snapshotModels, tools: rootTools, agentTypes: checked.referenced.agentTypes, roles, projectRoles, schemas: checked.schemas });
|
|
1941
|
+
const budgetRuntime = new WorkflowBudgetRuntime(budget);
|
|
1942
|
+
const initialBudget = budgetRuntime.snapshot();
|
|
1943
|
+
await store.create({ id: runId, workflowName: checked.metadata.name, cwd: ctx.cwd, sessionId: ctx.sessionManager.getSessionId(), state: "running", ...(parentRunId !== undefined ? { parentRunId } : {}), agents: [], nativeSessions: [], ...(budget ? { budget } : {}), budgetVersion: 1, ...initialBudget }, snapshot);
|
|
1944
|
+
const lifecycle = lifecycleFor(store, "running", budgetRuntime, checked.metadata);
|
|
1945
|
+
const background = !params.foreground;
|
|
1946
|
+
const providerPause = async () => { if (background) deliver(pi, `Workflow ${checked.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
|
|
1947
|
+
const providerErrorRecovery = createProviderErrorRecovery(ctx, availableModels, () => { runController.abort(); });
|
|
1948
|
+
const executor = new WorkflowAgentExecutor({ cwd: ctx.cwd, agentDir: extensionAgentDir, model: rootModel, tools: new Set(rootTools), availableModels, knownModels, modelAliases, settingsPath, agentDefinitions, runStore: store, providerPause, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: frozenResourcePolicy(launch.resourcePolicy), runContext }, createSession);
|
|
1949
|
+
runs.set(runId, { executor, store, metadata: checked.metadata, model: rootModel, lifecycle, budget: budgetRuntime, abortController: runController, projectTrusted: () => projectTrusted(ctx), checkpointResolvers: new Map(), ...(providerErrorRecovery ? { providerErrorRecovery } : {}), ...(params.foreground && onUpdate ? { update: onUpdate } : {}) });
|
|
1950
|
+
if (params.foreground && onUpdate) onUpdate(workflowToolUpdate((await store.load()).run));
|
|
1951
|
+
scheduler.addRun(runId, settings.concurrency, () => runs.get(runId)?.budget.checkAgentLaunch());
|
|
1952
|
+
const execution = runWorkflow(script, args, withWorkflowFunctions({ shell: (command, options, signal, identity) => shellForRun(store, checked.metadata, lifecycle, command, options, signal, identity), agent: async (prompt, options, agentSignal, identity) => {
|
|
1953
|
+
await lifecycle.enter();
|
|
1954
|
+
try {
|
|
1955
|
+
const path = agentIdentityPath(identity);
|
|
1956
|
+
const replayed = await store.replay(path);
|
|
1957
|
+
if (replayed) {
|
|
1958
|
+
return replayed.value;
|
|
1959
|
+
}
|
|
1960
|
+
const worktree = agentWorktree(identity);
|
|
1961
|
+
const cwd = worktree.worktreeOwner ? (await persistWorktree(store, checked.metadata, worktree.worktreeOwner)).cwd : ctx.cwd;
|
|
1962
|
+
const role = typeof options.role === "string" ? options.role : undefined;
|
|
1963
|
+
const model = typeof options.model === "string" ? options.model : undefined;
|
|
1964
|
+
const thinking = parseThinking(options.thinking);
|
|
1965
|
+
const requestedLabel = typeof options.label === "string" ? options.label : undefined;
|
|
1966
|
+
const resolved = executor.resolve({ label: requestedLabel ?? role ?? "agent", workflowName: checked.metadata.name, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(Array.isArray(options.tools) ? { tools: options.tools as string[] } : {}) });
|
|
1967
|
+
const label = displayAgentName(requestedLabel, role, resolved.model);
|
|
1968
|
+
const tools = resolved.tools;
|
|
1969
|
+
const schema = object(options.outputSchema) ? options.outputSchema : undefined;
|
|
1970
|
+
const spawned = scheduler.spawn(runId, prompt, { label, ...(requestedLabel ? { requestedLabel } : {}), ...(identity.parentBreadcrumb ? { parentBreadcrumb: identity.parentBreadcrumb } : {}), cwd, tools, ...worktree, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(schema ? { schema } : {}), ...(typeof options.retries === "number" ? { retries: options.retries } : {}), ...(positiveInteger(options.timeoutMs) || options.timeoutMs === null ? { timeoutMs: options.timeoutMs } : {}), agentOptions: options, agentIdentity: identity });
|
|
1971
|
+
const cancel = () => { scheduler.cancel(spawned.id); };
|
|
1972
|
+
if (agentSignal.aborted) cancel(); else agentSignal.addEventListener("abort", cancel, { once: true });
|
|
1973
|
+
const outcome = await spawned.result.finally(() => { agentSignal.removeEventListener("abort", cancel); });
|
|
1974
|
+
if (!outcome.ok) throw new WorkflowError(outcome.error.code as WorkflowErrorCode, outcome.error.message);
|
|
1975
|
+
await store.complete(path, outcome.value);
|
|
1976
|
+
return outcome.value;
|
|
1977
|
+
} finally { await lifecycle.leave(); }
|
|
1978
|
+
}, worktree: async (owner) => resolveWorktree(store, checked.metadata, owner), checkpoint: checkpointBridge(runId, store, checked.metadata, Boolean(params.foreground), params.foreground && ctx.hasUI ? ctx.ui : undefined, headless), phase: phaseBridge(store, checked.metadata, lifecycle), log: logBridge(lifecycle, checked.metadata.name) }, store, runContext, variables, registry), runController.signal);
|
|
1979
|
+
(runs.get(runId) as NonNullable<ReturnType<typeof runs.get>>).execution = execution;
|
|
1980
|
+
await eventPublisher.runStarted(store, checked.metadata);
|
|
1981
|
+
const finish = execution.result.then(async (value) => {
|
|
1982
|
+
await scheduler.flush();
|
|
1983
|
+
if (budgetRuntime.hardExhausted) throw new WorkflowError("BUDGET_EXHAUSTED", "Budgeted work was attempted after hard exhaustion");
|
|
1984
|
+
const resultPath = await store.saveResult(value);
|
|
1985
|
+
await lifecycle.terminal("completed", "completed");
|
|
1986
|
+
await eventPublisher.runCompleted(store, checked.metadata, resultPath);
|
|
1987
|
+
return { value, resultPath };
|
|
1988
|
+
}).catch(async (error: unknown) => {
|
|
1989
|
+
await scheduler.flush();
|
|
1990
|
+
const typed = error instanceof WorkflowError ? error : new WorkflowError("INTERNAL_ERROR", String(error));
|
|
1991
|
+
if (!["stopped", "interrupted", "budget_exhausted"].includes(lifecycle.state)) await lifecycle.terminal(typed.code === "CANCELLED" ? "stopped" : typed.code === "BUDGET_EXHAUSTED" ? "budget_exhausted" : "failed", typed.code);
|
|
1992
|
+
const persisted = await persistRunState(store, checked.metadata, (current) => ({ ...current, ...budgetRuntime.snapshot(), error: { code: typed.code, message: typed.message } }));
|
|
1993
|
+
const state = lifecycle.state === "stopped" || lifecycle.state === "interrupted" || lifecycle.state === "budget_exhausted" ? lifecycle.state : "failed";
|
|
1994
|
+
await eventPublisher.runFailed(store, checked.metadata, typed, state);
|
|
1995
|
+
const diagnostic = await createWorkflowFailureDiagnostics(store, checked.metadata, typed, persisted);
|
|
1996
|
+
Object.defineProperty(typed, WORKFLOW_FAILURE_DIAGNOSTICS, { value: diagnostic });
|
|
1997
|
+
if (params.foreground) pendingFailureDiagnostics.set(toolCallId, diagnostic);
|
|
1998
|
+
throw typed;
|
|
1999
|
+
});
|
|
2000
|
+
const completion = finish.finally(() => cleanupTerminalRun(runId));
|
|
2001
|
+
(runs.get(runId) as NonNullable<ReturnType<typeof runs.get>>).completion = completion;
|
|
2002
|
+
if (background) {
|
|
2003
|
+
void completion.then(async ({ value, resultPath }) => {
|
|
2004
|
+
deliver(pi, completionDelivery(checked.metadata.name, value, resultPath, await store.changedWorktrees()));
|
|
2005
|
+
}, (error: unknown) => {
|
|
2006
|
+
const diagnostic = failureDiagnosticsFrom(error);
|
|
2007
|
+
if (diagnostic) deliverFailure(pi, diagnostic);
|
|
2008
|
+
else deliver(pi, `Workflow ${checked.metadata.name} failed: ${formatWorkflowFailure(error)}`);
|
|
2009
|
+
});
|
|
2010
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ runId, state: "running" }) }], details: { runId, preview: `Started workflow ${runId}.` } };
|
|
2011
|
+
}
|
|
2012
|
+
const { value } = await completion;
|
|
2013
|
+
const run = (await store.load()).run;
|
|
2014
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(value) }, { type: "text" as const, text: `Workflow run ID: ${runId}` }], details: { runId, value, run } };
|
|
2015
|
+
} catch (error) {
|
|
2016
|
+
throw mainAgentError(error);
|
|
2017
|
+
}
|
|
2018
|
+
},
|
|
2019
|
+
renderCall(args) {
|
|
2020
|
+
return textBlock(formatWorkflowPreview(args));
|
|
2021
|
+
},
|
|
2022
|
+
renderResult(result, { isPartial }, theme, context) {
|
|
2023
|
+
const details = result.details;
|
|
2024
|
+
if (isWorkflowFailureDiagnostics(details)) return textBlock(formatWorkflowFailureDiagnostics(details));
|
|
2025
|
+
const runDetails = details as { run?: PersistedRun; value?: JsonValue; preview?: string } | undefined;
|
|
2026
|
+
const state = context.state as { workflowSpinner?: ReturnType<typeof setInterval> };
|
|
2027
|
+
if (runDetails?.run && isPartial && runDetails.run.state === "running" && !state.workflowSpinner) {
|
|
2028
|
+
state.workflowSpinner = setInterval(context.invalidate, 80);
|
|
2029
|
+
state.workflowSpinner.unref();
|
|
2030
|
+
} else if ((!isPartial || runDetails?.run?.state !== "running") && state.workflowSpinner) {
|
|
2031
|
+
clearInterval(state.workflowSpinner);
|
|
2032
|
+
delete state.workflowSpinner;
|
|
2033
|
+
}
|
|
2034
|
+
if (runDetails?.run) return workflowProgressBlock(runDetails.run, theme);
|
|
2035
|
+
const content = result.content[0];
|
|
2036
|
+
return textBlock(isPartial ? "Workflow starting..." : runDetails?.preview ?? (content?.type === "text" ? content.text : "Workflow finished"));
|
|
2037
|
+
},
|
|
2038
|
+
});
|
|
2039
|
+
pi.registerCommand("workflow", {
|
|
2040
|
+
description: "Inspect and control workflows for this Pi session",
|
|
2041
|
+
handler: async (args, ctx) => {
|
|
2042
|
+
const command = args.trim();
|
|
2043
|
+
if (command === "doctor") {
|
|
2044
|
+
const { doctor, doctorExitCode, formatDoctorReport } = await import("./doctor.js");
|
|
2045
|
+
const report = await doctor({ cwd: ctx.cwd, activeTools: pi.getActiveTools().filter((tool) => tool !== "workflow" && tool !== "workflow_respond") });
|
|
2046
|
+
ctx.ui.notify(formatDoctorReport(report), doctorExitCode(report) ? "warning" : "info");
|
|
2047
|
+
return;
|
|
2048
|
+
}
|
|
2049
|
+
await ensureSessionLease(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
2050
|
+
const loadStores = async () => {
|
|
2051
|
+
const entries = await Promise.all((await listRunIds(ctx.cwd, ctx.sessionManager.getSessionId(), home)).map(async (runId) => {
|
|
2052
|
+
const store = new RunStore(ctx.cwd, ctx.sessionManager.getSessionId(), runId, home);
|
|
2053
|
+
try { return { store, loaded: await store.load() }; }
|
|
2054
|
+
catch { if (!await store.isComplete()) await store.delete(true).catch(() => undefined); return undefined; }
|
|
2055
|
+
}));
|
|
2056
|
+
return entries.filter((entry): entry is { store: RunStore; loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> } } => entry !== undefined);
|
|
2057
|
+
};
|
|
2058
|
+
let stores = await loadStores();
|
|
2059
|
+
const usage = "Usage: /workflow [doctor|model-aliases], or /workflow pause|resume|stop|approve|reject|delete <run-id> [checkpoint-name]. Approve/reject are for checkpoints only; use workflow_respond with a proposalId or the navigator's budget controls for budget decisions. Use workflow_resume for budget patches."
|
|
2060
|
+
const setWorkflowStatus = (text: string | undefined) => {
|
|
2061
|
+
const setStatus = uiHostCapabilities(ctx.ui)?.setStatus;
|
|
2062
|
+
setStatus?.call(ctx.ui, "workflow-stop", text);
|
|
2063
|
+
};
|
|
2064
|
+
const runAction = async (actionCommand: string, keepContext: boolean, status: (text: string | undefined) => void = setWorkflowStatus): Promise<"dashboard" | "picker" | "done"> => {
|
|
2065
|
+
const [action, runId, ...rest] = actionCommand.split(/\s+/);
|
|
2066
|
+
try {
|
|
2067
|
+
const run = runId ? runs.get(runId) : undefined;
|
|
2068
|
+
const storedEntry = runId ? stores.find(({ store }) => store.runId === runId) : undefined;
|
|
2069
|
+
const stored = storedEntry ? { store: storedEntry.store, loaded: await storedEntry.store.load() } : undefined;
|
|
2070
|
+
if ((action === "approve" || action === "reject") && runId && rest.length) {
|
|
2071
|
+
const accepted = await answerCheckpoint(runId, rest.join(" "), action === "approve", true);
|
|
2072
|
+
ctx.ui.notify(accepted ? `${action === "approve" ? "Approved" : "Rejected"} checkpoint ${rest.join(" ")}.` : "Checkpoint is not awaiting a response.", accepted ? "info" : "warning");
|
|
2073
|
+
return keepContext ? "dashboard" : "done";
|
|
2074
|
+
}
|
|
2075
|
+
if ((action === "budget-approve" || action === "budget-reject") && runId && rest[0]) {
|
|
2076
|
+
const result = await answerBudgetDecision(runId, rest[0], action === "budget-approve", true, ctx);
|
|
2077
|
+
ctx.ui.notify(result ? `Budget adjustment ${rest[0]} ${result.approved ? "approved" : "rejected"}.` : "Budget proposal is not pending.", result ? "info" : "warning");
|
|
2078
|
+
return keepContext ? "dashboard" : "done";
|
|
2079
|
+
}
|
|
2080
|
+
if (action === "delete" && stored) {
|
|
2081
|
+
if (!["completed", "failed", "stopped"].includes(stored.loaded.run.state)) { ctx.ui.notify("Stop the workflow before deleting it.", "warning"); return keepContext ? "dashboard" : "done"; }
|
|
2082
|
+
if (!await ctx.ui.confirm("Delete workflow?", `Delete ${stored.loaded.run.workflowName} (${stored.store.runId}) and all owned artifacts? This cannot be undone.`)) return keepContext ? "dashboard" : "done";
|
|
2083
|
+
await stored.store.delete(true); runs.delete(stored.store.runId); terminalRunStates.delete(stored.store.runId); ctx.ui.notify(`Deleted workflow ${stored.store.runId}.`, "info"); return keepContext ? "picker" : "done";
|
|
2084
|
+
}
|
|
2085
|
+
if (action === "pause" && run) { await run.lifecycle.pause(); ctx.ui.notify(`Paused workflow ${run.store.runId}.`, "info"); return keepContext ? "dashboard" : "done"; }
|
|
2086
|
+
if (action === "resume" && run) {
|
|
2087
|
+
if (run.lifecycle.state === "budget_exhausted") {
|
|
2088
|
+
const patch: unknown = rest.length ? JSON.parse(rest.join(" ")) as unknown : undefined;
|
|
2089
|
+
const result = await resumeWorkflowRun(run.store.runId, patch, ctx);
|
|
2090
|
+
ctx.ui.notify(result.state === "running" ? `Resumed workflow ${run.store.runId}.` : `Budget adjustment for ${run.store.runId} is awaiting approval.`, result.state === "running" ? "info" : "warning");
|
|
2091
|
+
} else {
|
|
2092
|
+
if (run.lifecycle.state === "interrupted") await coldResumeRun(run, ctx.hasUI, ctx.ui, projectTrusted(ctx), ctx);
|
|
2093
|
+
else {
|
|
2094
|
+
if (run.lifecycle.state === "paused") await refreshPausedRunAliases(run, { ...resumeHostContext(ctx), projectTrusted: projectTrusted(ctx) });
|
|
2095
|
+
await run.lifecycle.resume();
|
|
2096
|
+
}
|
|
2097
|
+
ctx.ui.notify(`Resumed workflow ${run.store.runId}.`, "info");
|
|
2098
|
+
}
|
|
2099
|
+
return keepContext ? "dashboard" : "done";
|
|
2100
|
+
}
|
|
2101
|
+
if (action === "adjust" && run?.lifecycle.state === "budget_exhausted") {
|
|
2102
|
+
const input = await uiHostCapabilities(ctx.ui)?.input?.call(ctx.ui, "Budget patch (JSON)", "{\"tokens\":{\"hard\":null}}" );
|
|
2103
|
+
if (input === undefined) return keepContext ? "dashboard" : "done";
|
|
2104
|
+
const result = await resumeWorkflowRun(run.store.runId, JSON.parse(input), ctx);
|
|
2105
|
+
ctx.ui.notify(result.state === "running" ? `Resumed workflow ${run.store.runId}.` : `Budget adjustment for ${run.store.runId} is awaiting approval.`, result.state === "running" ? "info" : "warning");
|
|
2106
|
+
return keepContext ? "dashboard" : "done";
|
|
2107
|
+
}
|
|
2108
|
+
if (action === "stop" && run) {
|
|
2109
|
+
const workflowName = stored?.loaded.run.workflowName ?? run.metadata.name;
|
|
2110
|
+
if (keepContext && !await ctx.ui.confirm("Stop workflow?", `Stop workflow ${workflowName} (${run.store.runId})? This cannot be undone.`)) return "dashboard";
|
|
2111
|
+
if (keepContext) status(`Stopping workflow ${workflowName}...`);
|
|
2112
|
+
await stopWorkflowRun(run.store.runId);
|
|
2113
|
+
if (keepContext) status(`Workflow ${run.store.runId} stopped.`);
|
|
2114
|
+
ctx.ui.notify(`Stopped workflow ${run.store.runId}.`, "info"); return keepContext ? "dashboard" : "done";
|
|
2115
|
+
}
|
|
2116
|
+
if (keepContext && action && runId) { ctx.ui.notify(`Cannot ${action} workflow ${runId}: the run is no longer available.`, "warning"); return "dashboard"; }
|
|
2117
|
+
ctx.ui.notify(usage, "warning");
|
|
2118
|
+
return "done";
|
|
2119
|
+
} catch (error) {
|
|
2120
|
+
if (!keepContext) throw error;
|
|
2121
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2122
|
+
if (action === "stop") status(`Could not stop workflow ${runId ?? ""}: ${message}`);
|
|
2123
|
+
ctx.ui.notify(`Cannot ${action ?? "workflow action"}${runId ? ` for ${runId}` : ""}: ${message}`, "warning");
|
|
2124
|
+
return "dashboard";
|
|
2125
|
+
}
|
|
2126
|
+
};
|
|
2127
|
+
const manageAliases = async (): Promise<void> => {
|
|
2128
|
+
const settingsPath = workflowSettingsPath(extensionAgentDir);
|
|
2129
|
+
let aliasSettingsPath = settingsPath;
|
|
2130
|
+
const trustedProject = projectTrusted(ctx);
|
|
2131
|
+
const modelRegistry = contextHostCapabilities(ctx).modelRegistry;
|
|
2132
|
+
const available = () => [...new Set((modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`))].sort();
|
|
2133
|
+
const selectTarget = async (aliases: Readonly<Record<string, string>>): Promise<string | undefined> => {
|
|
2134
|
+
const models = available();
|
|
2135
|
+
const choice = await ctx.ui.select("Model alias target", [...models, ...Object.keys(aliases).sort(), "Manual model ID", "Back"]);
|
|
2136
|
+
if (!choice || choice === "Back") return undefined;
|
|
2137
|
+
if (choice !== "Manual model ID") return choice;
|
|
2138
|
+
return (await ctx.ui.input("Manual model ID", "provider/model[:thinking] or alias[:thinking]"))?.trim() || undefined;
|
|
2139
|
+
};
|
|
2140
|
+
const save = (aliases: Readonly<Record<string, string>>): boolean => {
|
|
2141
|
+
try { saveModelAliases(aliasSettingsPath, aliases); ctx.ui.notify(`Saved model aliases to ${aliasSettingsPath}.`, "info"); return true; }
|
|
2142
|
+
catch (error) { ctx.ui.notify(`${aliasSettingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error"); return false; }
|
|
2143
|
+
};
|
|
2144
|
+
for (;;) {
|
|
2145
|
+
let aliases: Readonly<Record<string, string>>;
|
|
2146
|
+
try { const resolution = resolveWorkflowSettings(ctx.cwd, trustedProject, settingsPath); aliases = resolution.effective.modelAliases ?? {}; aliasSettingsPath = resolution.sources.modelAliases; }
|
|
2147
|
+
catch (error) { ctx.ui.notify(`${trustedProject ? workflowProjectSettingsPath(ctx.cwd) : settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error"); return; }
|
|
2148
|
+
const names = Object.keys(aliases).sort();
|
|
2149
|
+
const listing = names.length ? names.map((name) => `${name} = ${aliases[name] ?? ""}`).join("\n") : "(none)";
|
|
2150
|
+
const options = ["Add alias", ...names.map((name) => `Edit ${name}`), ...names.map((name) => `Delete ${name}`), "Back"];
|
|
2151
|
+
const choice = await ctx.ui.select(`Model aliases\n${listing}`, options);
|
|
2152
|
+
if (!choice || choice === "Back") return;
|
|
2153
|
+
if (choice === "Add alias") {
|
|
2154
|
+
const name = (await ctx.ui.input("Alias name", "reviewer-model"))?.trim();
|
|
2155
|
+
if (!name) continue;
|
|
2156
|
+
if (Object.prototype.hasOwnProperty.call(aliases, name)) { ctx.ui.notify(`Alias ${name} already exists; choose Edit ${name}.`, "warning"); continue; }
|
|
2157
|
+
const target = await selectTarget(aliases);
|
|
2158
|
+
if (!target) continue;
|
|
2159
|
+
const next = { ...aliases, [name]: target };
|
|
2160
|
+
try { validateModelAliases(next, aliasSettingsPath); } catch (error) { ctx.ui.notify(`${aliasSettingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error"); continue; }
|
|
2161
|
+
const parsed = resolveModelReference(target, next, new Set(available()), aliasSettingsPath);
|
|
2162
|
+
if (!available().includes(`${parsed.provider}/${parsed.model}`)) {
|
|
2163
|
+
ctx.ui.notify(`Warning: ${target} is not currently available in Pi.`, "warning");
|
|
2164
|
+
if (!await ctx.ui.confirm("Save unknown model?", "Save this target for cross-machine portability?")) continue;
|
|
2165
|
+
}
|
|
2166
|
+
save(next);
|
|
2167
|
+
continue;
|
|
2168
|
+
}
|
|
2169
|
+
const edit = /^Edit (.+)$/.exec(choice);
|
|
2170
|
+
if (edit?.[1]) {
|
|
2171
|
+
const target = await selectTarget(aliases);
|
|
2172
|
+
if (!target) continue;
|
|
2173
|
+
const next = { ...aliases, [edit[1]]: target };
|
|
2174
|
+
try { validateModelAliases(next, aliasSettingsPath); } catch (error) { ctx.ui.notify(`${aliasSettingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error"); continue; }
|
|
2175
|
+
const parsed = resolveModelReference(target, next, new Set(available()), aliasSettingsPath);
|
|
2176
|
+
if (!available().includes(`${parsed.provider}/${parsed.model}`)) {
|
|
2177
|
+
ctx.ui.notify(`Warning: ${target} is not currently available in Pi.`, "warning");
|
|
2178
|
+
if (!await ctx.ui.confirm("Save unknown model?", "Save this target for cross-machine portability?")) continue;
|
|
2179
|
+
}
|
|
2180
|
+
save(next);
|
|
2181
|
+
continue;
|
|
2182
|
+
}
|
|
2183
|
+
const deletion = /^Delete (.+)$/.exec(choice);
|
|
2184
|
+
if (deletion?.[1] && await ctx.ui.confirm("Delete model alias?", `Delete ${deletion[1]}? Future workflow resumes using this alias may fail.`)) {
|
|
2185
|
+
const next = Object.fromEntries(Object.entries(aliases).filter(([name]) => name !== deletion[1]));
|
|
2186
|
+
save(next);
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
};
|
|
2190
|
+
if (command === "model-aliases") {
|
|
2191
|
+
if (!ctx.hasUI) { ctx.ui.notify("Model alias management requires UI.", "warning"); return; }
|
|
2192
|
+
await manageAliases();
|
|
2193
|
+
return;
|
|
2194
|
+
}
|
|
2195
|
+
if (!command) {
|
|
2196
|
+
for (;;) {
|
|
2197
|
+
if (!ctx.hasUI) {
|
|
2198
|
+
if (!stores.length) { ctx.ui.notify("No workflow runs in this session.", "info"); return; }
|
|
2199
|
+
const details = await Promise.all(stores.map(async ({ store, loaded }) => formatNavigatorRun(loaded, await store.awaitingCheckpoints(), await store.worktrees())));
|
|
2200
|
+
ctx.ui.notify(details.join("\n\n"), "info"); return;
|
|
2201
|
+
}
|
|
2202
|
+
const sorted = navigatorAttentionSort(stores);
|
|
2203
|
+
const labels = navigatorRunLabels(sorted);
|
|
2204
|
+
const terminalStates = new Set(["completed", "failed", "stopped"]);
|
|
2205
|
+
const hasCompleted = sorted.some(({ loaded: { run } }) => run.state === "completed");
|
|
2206
|
+
const hasFailed = sorted.some(({ loaded: { run } }) => run.state === "failed");
|
|
2207
|
+
const pickerOptions = [...labels, ...(herdrPaneId() ? ["Inspect session in pane"] : []), "Model aliases", "Close", ...(hasCompleted ? ["Delete all completed"] : []), ...(hasFailed ? ["Delete all failed"] : [])];
|
|
2208
|
+
const runChoice = await ctx.ui.select("Workflows\n", pickerOptions);
|
|
2209
|
+
if (!runChoice || runChoice === "Close") return;
|
|
2210
|
+
if (runChoice === "Inspect session in pane") {
|
|
2211
|
+
try {
|
|
2212
|
+
await openHerdrPane({ action: "inspect", cwd: ctx.cwd, sessionId: ctx.sessionManager.getSessionId() });
|
|
2213
|
+
ctx.ui.notify("Opened session inspector in pane.", "info");
|
|
2214
|
+
} catch (error) {
|
|
2215
|
+
ctx.ui.notify(`Cannot open session inspector in pane: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
2216
|
+
}
|
|
2217
|
+
continue;
|
|
2218
|
+
}
|
|
2219
|
+
if (runChoice === "Model aliases") { await manageAliases(); stores = await loadStores(); continue; }
|
|
2220
|
+
if (runChoice === "Delete all completed") {
|
|
2221
|
+
if (!await ctx.ui.confirm("Delete completed runs?", "Delete all completed workflow runs and their artifacts? This cannot be undone.")) continue;
|
|
2222
|
+
for (const entry of sorted) {
|
|
2223
|
+
if (entry.loaded.run.state === "completed") { await entry.store.delete(true); runs.delete(entry.store.runId); terminalRunStates.delete(entry.store.runId); }
|
|
2224
|
+
}
|
|
2225
|
+
ctx.ui.notify("Deleted all completed workflow runs.", "info"); stores = await loadStores(); continue;
|
|
2226
|
+
}
|
|
2227
|
+
if (runChoice === "Delete all failed") {
|
|
2228
|
+
if (!await ctx.ui.confirm("Delete failed runs?", "Delete all failed workflow runs and their artifacts? This cannot be undone.")) continue;
|
|
2229
|
+
for (const entry of sorted) {
|
|
2230
|
+
if (entry.loaded.run.state === "failed") { await entry.store.delete(true); runs.delete(entry.store.runId); terminalRunStates.delete(entry.store.runId); }
|
|
2231
|
+
}
|
|
2232
|
+
ctx.ui.notify("Deleted all failed workflow runs.", "info"); stores = await loadStores(); continue;
|
|
2233
|
+
}
|
|
2234
|
+
const runIndex = labels.indexOf(runChoice);
|
|
2235
|
+
if (runIndex < 0) return;
|
|
2236
|
+
const selected = sorted[runIndex];
|
|
2237
|
+
if (!selected) return;
|
|
2238
|
+
const { store } = selected;
|
|
2239
|
+
const copyArtifact = async (value: string, artifact: string) => {
|
|
2240
|
+
try {
|
|
2241
|
+
await clipboard(value);
|
|
2242
|
+
ctx.ui.notify(`Copied ${artifact}.`, "info");
|
|
2243
|
+
} catch (error) {
|
|
2244
|
+
ctx.ui.notify(`Failed to copy ${artifact}: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
2245
|
+
}
|
|
2246
|
+
};
|
|
2247
|
+
const loadDashboard = async () => {
|
|
2248
|
+
const loaded = await store.load();
|
|
2249
|
+
const checkpoints = await store.awaitingCheckpoints();
|
|
2250
|
+
const worktrees = await store.worktrees();
|
|
2251
|
+
const actions = new Map<string, string>();
|
|
2252
|
+
const copies = new Map<string, { value: string; artifact: string }>();
|
|
2253
|
+
const reviews = new Map<string, AwaitingCheckpoint>();
|
|
2254
|
+
const add = (label: string, value: string) => { actions.set(label, `${value} ${store.runId}`); };
|
|
2255
|
+
const addCopy = (label: string, value: string, artifact: string) => { actions.set(label, "copy"); copies.set(label, { value, artifact }); };
|
|
2256
|
+
if (loaded.run.state === "running") add("Pause", "pause");
|
|
2257
|
+
if (["paused", "interrupted"].includes(loaded.run.state)) add("Resume", "resume");
|
|
2258
|
+
if (loaded.run.state === "budget_exhausted") { actions.set("Resume unchanged", `resume ${store.runId}`); actions.set("Adjust budget", `adjust ${store.runId}`); }
|
|
2259
|
+
for (const decision of await store.pendingWorkflowDecisions()) {
|
|
2260
|
+
const id = decision.proposalId.slice(0, 8);
|
|
2261
|
+
actions.set(`Approve budget ${id}`, `budget-approve ${store.runId} ${decision.proposalId}`);
|
|
2262
|
+
actions.set(`Reject budget ${id}`, `budget-reject ${store.runId} ${decision.proposalId}`);
|
|
2263
|
+
}
|
|
2264
|
+
if (!terminalStates.has(loaded.run.state)) add("Stop", "stop");
|
|
2265
|
+
for (const cp of checkpoints) {
|
|
2266
|
+
if (ctx.mode === "tui") {
|
|
2267
|
+
const label = `Review ${cp.name}`;
|
|
2268
|
+
actions.set(label, "review");
|
|
2269
|
+
reviews.set(label, cp);
|
|
2270
|
+
} else {
|
|
2271
|
+
actions.set(`Approve ${cp.name}`, `approve ${store.runId} ${cp.name}`);
|
|
2272
|
+
actions.set(`Reject ${cp.name}`, `reject ${store.runId} ${cp.name}`);
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
if (ctx.mode !== "tui") actions.set("Refresh", "refresh");
|
|
2276
|
+
else actions.set("View script", "view-script");
|
|
2277
|
+
if (loaded.run.agents.length) actions.set("Agents...", "agents");
|
|
2278
|
+
if (terminalStates.has(loaded.run.state)) add("Delete", "delete");
|
|
2279
|
+
if (ctx.mode === "tui") {
|
|
2280
|
+
addCopy("Copy run path", store.directory, "run path");
|
|
2281
|
+
addCopy("Copy run ID", store.runId, "run ID");
|
|
2282
|
+
}
|
|
2283
|
+
return { dashboard: formatWorkflowPhaseDashboard(loaded.run, loaded.snapshot, process.stdout.columns || 80).join("\n"), phaseModel: buildWorkflowPhaseModel(loaded.run, loaded.snapshot), run: loaded.run, snapshot: loaded.snapshot, actions, copies, reviews, script: loaded.snapshot.script, agents: loaded.run.agents, worktrees, cwd: loaded.run.cwd };
|
|
2284
|
+
};
|
|
2285
|
+
const selectAgent = async (dashboard: Awaited<ReturnType<typeof loadDashboard>>): Promise<void> => {
|
|
2286
|
+
const byId = new Map(dashboard.agents.map((agent) => [agent.id, agent]));
|
|
2287
|
+
const title = (agent: AgentRecord): string => agentBreadcrumb(agent, byId, true);
|
|
2288
|
+
const labels = dashboard.agents.map((agent, index) => `#${String(index + 1)} ${title(agent)} [${agent.state}]`);
|
|
2289
|
+
const selectedLabel = await ctx.ui.select("Agents", [...labels, "Back"]);
|
|
2290
|
+
const selectedIndex = selectedLabel ? labels.indexOf(selectedLabel) : -1;
|
|
2291
|
+
const selected = selectedIndex >= 0 ? dashboard.agents[selectedIndex] : undefined;
|
|
2292
|
+
if (!selected) return;
|
|
2293
|
+
const attempts = [...(selected.attemptDetails ?? [])].sort((left, right) => left.attempt - right.attempt);
|
|
2294
|
+
const worktree = selected.worktreeOwner ? dashboard.worktrees.find((candidate) => candidate.owner === selected.worktreeOwner) : undefined;
|
|
2295
|
+
const actions = [
|
|
2296
|
+
...(attempts.length && herdrPaneId() ? ["Fork as Pi session in pane"] : []),
|
|
2297
|
+
...(worktree ? ["Copy branch", "Copy worktree path"] : []),
|
|
2298
|
+
"Copy agent ID",
|
|
2299
|
+
"Back",
|
|
2300
|
+
];
|
|
2301
|
+
const chooseAttempt = async (): Promise<AgentAttemptSummary | undefined> => {
|
|
2302
|
+
const choices = attempts.map((attempt) => `Attempt ${String(attempt.attempt)}`);
|
|
2303
|
+
const choice = choices.length === 1 ? choices[0] : await ctx.ui.select("Fork attempts", [...choices, "Back"]);
|
|
2304
|
+
const index = choice ? choices.indexOf(choice) : -1;
|
|
2305
|
+
return index >= 0 ? attempts[index] : undefined;
|
|
2306
|
+
};
|
|
2307
|
+
for (;;) {
|
|
2308
|
+
const action = await ctx.ui.select(title(selected), actions);
|
|
2309
|
+
if (!action || action === "Back") return;
|
|
2310
|
+
if (action === "Copy agent ID") { await copyArtifact(selected.id, "agent ID"); continue; }
|
|
2311
|
+
if (action === "Copy branch" && worktree) { await copyArtifact(worktree.branch, "branch"); continue; }
|
|
2312
|
+
if (action === "Copy worktree path" && worktree) { await copyArtifact(worktree.path, "worktree path"); continue; }
|
|
2313
|
+
if (action === "Fork as Pi session in pane") {
|
|
2314
|
+
const attempt = await chooseAttempt();
|
|
2315
|
+
if (!attempt) continue;
|
|
2316
|
+
const running = !SETTLED_AGENT_STATES.has(selected.state) && attempt.attempt === attempts.at(-1)?.attempt && !attempt.error;
|
|
2317
|
+
if (running && !await ctx.ui.confirm("Fork running attempt?", "This attempt is still running. The snapshot may end mid-turn and will not receive later updates. It opens read-only to avoid concurrent changes to the workflow agent's working directory. Continue?")) continue;
|
|
2318
|
+
try {
|
|
2319
|
+
await openHerdrPane({ action: "fork", cwd: worktree?.cwd ?? attempt.setup?.cwd ?? dashboard.cwd, original: attempt.sessionFile, ...(running ? { readOnly: true } : {}) });
|
|
2320
|
+
ctx.ui.notify("Forked Pi session in pane.", "info");
|
|
2321
|
+
} catch (error) {
|
|
2322
|
+
ctx.ui.notify(`Cannot fork Pi session in pane: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
};
|
|
2327
|
+
for (;;) {
|
|
2328
|
+
let view = await loadDashboard();
|
|
2329
|
+
const actionChoice = ctx.mode === "tui"
|
|
2330
|
+
? await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
|
|
2331
|
+
let options = [...view.actions.keys(), "Close"];
|
|
2332
|
+
let selectedIndex = 0;
|
|
2333
|
+
let dashboardOffset = 0;
|
|
2334
|
+
let refreshing = false;
|
|
2335
|
+
let disposed = false;
|
|
2336
|
+
let stopRequested = false;
|
|
2337
|
+
let stopStatus: string | undefined;
|
|
2338
|
+
const initialSelection = preserveWorkflowPhaseSelection(view.phaseModel, {});
|
|
2339
|
+
let selectedPhaseId = initialSelection.phaseId;
|
|
2340
|
+
let selectedAgentId = initialSelection.agentId;
|
|
2341
|
+
const terminalRows = () => Math.max(1, tuiRows(tui) - WORKFLOW_OVERLAY_BORDER_ROWS);
|
|
2342
|
+
const keyLabels: Record<string, string> = { up: "↑", down: "↓", left: "←", right: "→", tab: "tab", pageUp: "pgup", pageDown: "pgdn", escape: "esc" };
|
|
2343
|
+
const keyLabel = (binding: string, fallback: string) => {
|
|
2344
|
+
const keys = keybindingKeys(keybindings, binding);
|
|
2345
|
+
return keys?.length ? keys.map((key) => keyLabels[key] ?? key).join("/") : fallback;
|
|
2346
|
+
};
|
|
2347
|
+
const dashboardLayout = () => {
|
|
2348
|
+
const rows = terminalRows();
|
|
2349
|
+
const hintRows = rows >= 4 ? 1 : 0;
|
|
2350
|
+
const separatorRows = rows >= 8 ? 1 : 0;
|
|
2351
|
+
const available = Math.max(1, rows - hintRows - separatorRows);
|
|
2352
|
+
const actionViewport = Math.min(options.length, Math.max(1, Math.ceil(available / 2)));
|
|
2353
|
+
return { rows, hintRows, separatorRows, actionViewport, dashboardViewport: available - actionViewport };
|
|
2354
|
+
};
|
|
2355
|
+
const updateDashboard = async (selectedOption: string | undefined) => {
|
|
2356
|
+
const phaseId = selectedPhaseId;
|
|
2357
|
+
const agentId = selectedAgentId;
|
|
2358
|
+
const next = await loadDashboard();
|
|
2359
|
+
if (disposed) return;
|
|
2360
|
+
view = next;
|
|
2361
|
+
options = [...view.actions.keys(), "Close"];
|
|
2362
|
+
selectedIndex = Math.max(0, options.indexOf(selectedOption ?? ""));
|
|
2363
|
+
const preserved = preserveWorkflowPhaseSelection(view.phaseModel, { phaseId, agentId });
|
|
2364
|
+
selectedPhaseId = preserved.phaseId;
|
|
2365
|
+
selectedAgentId = preserved.agentId;
|
|
2366
|
+
tui.requestRender();
|
|
2367
|
+
};
|
|
2368
|
+
const requestStop = () => {
|
|
2369
|
+
if (stopRequested) return;
|
|
2370
|
+
stopRequested = true;
|
|
2371
|
+
stopStatus = undefined;
|
|
2372
|
+
setWorkflowStatus(undefined);
|
|
2373
|
+
const selectedOption = options[selectedIndex];
|
|
2374
|
+
void runAction(`stop ${store.runId}`, true, (status) => {
|
|
2375
|
+
stopStatus = status;
|
|
2376
|
+
setWorkflowStatus(status);
|
|
2377
|
+
if (!disposed) tui.requestRender();
|
|
2378
|
+
}).then(() => updateDashboard(selectedOption)).catch((error: unknown) => {
|
|
2379
|
+
if (disposed) return;
|
|
2380
|
+
stopStatus = `Could not stop workflow ${store.runId}: ${error instanceof Error ? error.message : String(error)}`;
|
|
2381
|
+
tui.requestRender();
|
|
2382
|
+
}).finally(() => {
|
|
2383
|
+
stopRequested = false;
|
|
2384
|
+
if (!disposed) tui.requestRender();
|
|
2385
|
+
});
|
|
2386
|
+
};
|
|
2387
|
+
const timer = setInterval(() => {
|
|
2388
|
+
if (refreshing || stopRequested) return;
|
|
2389
|
+
refreshing = true;
|
|
2390
|
+
const selectedOption = options[selectedIndex];
|
|
2391
|
+
void updateDashboard(selectedOption).catch(() => undefined).finally(() => { refreshing = false; });
|
|
2392
|
+
}, 1000);
|
|
2393
|
+
timer.unref();
|
|
2394
|
+
return borderWorkflowOverlay({
|
|
2395
|
+
render(width: number) {
|
|
2396
|
+
const styles = themeWorkflowProgressStyles(theme);
|
|
2397
|
+
const phaseLines = formatWorkflowPhaseDashboard(view.run, view.snapshot, width, { phaseId: selectedPhaseId, agentId: selectedAgentId }, styles);
|
|
2398
|
+
const dashboardLines = stopStatus ? [styles.error(stopStatus), ...phaseLines] : phaseLines;
|
|
2399
|
+
const actionLines = options.map((option, index) => truncateToVisualLines(`${index === selectedIndex ? "→ " : " "}${option}`, Number.MAX_SAFE_INTEGER, width, 1).visualLines[0] ?? "");
|
|
2400
|
+
const layout = dashboardLayout();
|
|
2401
|
+
const hint = truncateToVisualLines(theme.fg("dim", `${keyLabel("tui.select.up", "↑")}/${keyLabel("tui.select.down", "↓")} actions · ${keyLabel("tui.editor.cursorLeft", "←")}/${keyLabel("tui.editor.cursorRight", "→")} phases · ${keyLabel("tui.input.tab", "tab")} agents${dashboardLines.length > layout.dashboardViewport ? ` · ${keyLabel("tui.select.pageUp", "pgup")}/${keyLabel("tui.select.pageDown", "pgdn")} scroll` : ""} · ${keyLabel("tui.select.confirm", "enter")} select · ${keyLabel("tui.select.cancel", "esc")} close · auto-refresh 1s`), Number.MAX_SAFE_INTEGER, width, 1).visualLines[0] ?? "";
|
|
2402
|
+
const compact = [...dashboardLines, "", ...actionLines, "", hint];
|
|
2403
|
+
if (compact.length <= layout.rows) { dashboardOffset = 0; return compact; }
|
|
2404
|
+
const maxOffset = Math.max(0, dashboardLines.length - layout.dashboardViewport);
|
|
2405
|
+
dashboardOffset = Math.max(0, Math.min(maxOffset, dashboardOffset));
|
|
2406
|
+
const actionStart = Math.min(Math.max(0, selectedIndex - layout.actionViewport + 1), Math.max(0, options.length - layout.actionViewport));
|
|
2407
|
+
return [
|
|
2408
|
+
...dashboardLines.slice(dashboardOffset, dashboardOffset + layout.dashboardViewport),
|
|
2409
|
+
...(layout.separatorRows && layout.dashboardViewport ? [""] : []),
|
|
2410
|
+
...actionLines.slice(actionStart, actionStart + layout.actionViewport),
|
|
2411
|
+
...(layout.hintRows ? [hint] : []),
|
|
2412
|
+
];
|
|
2413
|
+
},
|
|
2414
|
+
invalidate() {},
|
|
2415
|
+
handleInput(data: string) {
|
|
2416
|
+
if (stopRequested) return;
|
|
2417
|
+
const currentPhase = () => view.phaseModel.phases.find((phase) => phase.id === selectedPhaseId);
|
|
2418
|
+
const left = keybindings.matches(data, "tui.editor.cursorLeft");
|
|
2419
|
+
const right = keybindings.matches(data, "tui.editor.cursorRight");
|
|
2420
|
+
if (left || right) {
|
|
2421
|
+
const phases = view.phaseModel.phases;
|
|
2422
|
+
if (phases.length) {
|
|
2423
|
+
const currentIndex = Math.max(0, phases.findIndex((phase) => phase.id === selectedPhaseId));
|
|
2424
|
+
const delta = left ? -1 : 1;
|
|
2425
|
+
const nextPhase = phases[(currentIndex + delta + phases.length) % phases.length];
|
|
2426
|
+
selectedPhaseId = nextPhase?.id;
|
|
2427
|
+
selectedAgentId = nextPhase?.agents[0]?.id;
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
else if (keybindings.matches(data, "tui.input.tab")) {
|
|
2431
|
+
const agents = currentPhase()?.agents ?? [];
|
|
2432
|
+
if (agents.length) {
|
|
2433
|
+
const currentIndex = Math.max(0, agents.findIndex((agent) => agent.id === selectedAgentId));
|
|
2434
|
+
selectedAgentId = agents[(currentIndex + 1) % agents.length]?.id;
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
else if (keybindings.matches(data, "tui.select.up")) selectedIndex = (selectedIndex + options.length - 1) % options.length;
|
|
2438
|
+
else if (keybindings.matches(data, "tui.select.down")) selectedIndex = (selectedIndex + 1) % options.length;
|
|
2439
|
+
else if (keybindings.matches(data, "tui.select.pageUp")) {
|
|
2440
|
+
dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, dashboardLayout().dashboardViewport));
|
|
2441
|
+
}
|
|
2442
|
+
else if (keybindings.matches(data, "tui.select.pageDown")) {
|
|
2443
|
+
dashboardOffset += Math.max(1, dashboardLayout().dashboardViewport);
|
|
2444
|
+
}
|
|
2445
|
+
else if (keybindings.matches(data, "tui.select.confirm")) {
|
|
2446
|
+
if (options[selectedIndex] === "Stop") requestStop();
|
|
2447
|
+
else done(options[selectedIndex]);
|
|
2448
|
+
}
|
|
2449
|
+
else if (keybindings.matches(data, "tui.select.cancel")) done(undefined);
|
|
2450
|
+
tui.requestRender();
|
|
2451
|
+
},
|
|
2452
|
+
dispose() { disposed = true; clearInterval(timer); setWorkflowStatus(undefined); },
|
|
2453
|
+
}, theme);
|
|
2454
|
+
}, { overlay: true })
|
|
2455
|
+
: await ctx.ui.select(view.dashboard, [...view.actions.keys(), "Close"]);
|
|
2456
|
+
if (!actionChoice || actionChoice === "Close") return;
|
|
2457
|
+
if (actionChoice === "Agents...") { await selectAgent(view); continue; }
|
|
2458
|
+
if (actionChoice === "Refresh") continue;
|
|
2459
|
+
if (actionChoice === "View script") {
|
|
2460
|
+
await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
|
|
2461
|
+
const highlighted = highlightCode(view.script, "javascript");
|
|
2462
|
+
let offset = 0;
|
|
2463
|
+
let renderedLines: string[] = [];
|
|
2464
|
+
const viewport = () => Math.max(1, tuiRows(tui) - 3 - WORKFLOW_OVERLAY_BORDER_ROWS);
|
|
2465
|
+
const move = (delta: number) => {
|
|
2466
|
+
const maxOffset = Math.max(0, renderedLines.length - viewport());
|
|
2467
|
+
offset = Math.max(0, Math.min(maxOffset, offset + delta));
|
|
2468
|
+
};
|
|
2469
|
+
return borderWorkflowOverlay({
|
|
2470
|
+
render(width: number) {
|
|
2471
|
+
renderedLines = highlighted.flatMap((line) => line ? truncateToVisualLines(line, Number.MAX_SAFE_INTEGER, width, 0).visualLines : [""]);
|
|
2472
|
+
const maxOffset = Math.max(0, renderedLines.length - viewport());
|
|
2473
|
+
offset = Math.min(offset, maxOffset);
|
|
2474
|
+
return [
|
|
2475
|
+
theme.fg("accent", "Workflow script"),
|
|
2476
|
+
...renderedLines.slice(offset, offset + viewport()),
|
|
2477
|
+
"",
|
|
2478
|
+
theme.fg("dim", "↑↓/pgup/pgdn scroll · esc close"),
|
|
2479
|
+
];
|
|
2480
|
+
},
|
|
2481
|
+
invalidate() {},
|
|
2482
|
+
handleInput(data: string) {
|
|
2483
|
+
if (keybindings.matches(data, "tui.select.up")) move(-1);
|
|
2484
|
+
else if (keybindings.matches(data, "tui.select.down")) move(1);
|
|
2485
|
+
else if (keybindings.matches(data, "tui.select.pageUp")) move(-viewport());
|
|
2486
|
+
else if (keybindings.matches(data, "tui.select.pageDown")) move(viewport());
|
|
2487
|
+
else if (keybindings.matches(data, "tui.select.cancel")) done(undefined);
|
|
2488
|
+
tui.requestRender();
|
|
2489
|
+
},
|
|
2490
|
+
}, theme);
|
|
2491
|
+
}, { overlay: true, overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%" } });
|
|
2492
|
+
continue;
|
|
2493
|
+
}
|
|
2494
|
+
const copy = view.copies.get(actionChoice);
|
|
2495
|
+
if (copy) { await copyArtifact(copy.value, copy.artifact); continue; }
|
|
2496
|
+
if (actionChoice.startsWith("Review ")) {
|
|
2497
|
+
const checkpoint = view.reviews.get(actionChoice);
|
|
2498
|
+
if (!checkpoint) continue;
|
|
2499
|
+
const decision = await ctx.ui.custom<"Approve" | "Reject" | undefined>((tui, theme, keybindings, done) => {
|
|
2500
|
+
const options = ["Approve", "Reject", "Cancel"];
|
|
2501
|
+
let selectedIndex = 0;
|
|
2502
|
+
let offset = 0;
|
|
2503
|
+
let renderedLines: string[] = [];
|
|
2504
|
+
const layout = () => {
|
|
2505
|
+
const rows = Math.max(1, tuiRows(tui) - WORKFLOW_OVERLAY_BORDER_ROWS);
|
|
2506
|
+
const compactControls = rows < 4;
|
|
2507
|
+
const titleRows = rows >= 5 ? 1 : 0;
|
|
2508
|
+
const hintRows = rows >= 8 ? 1 : 0;
|
|
2509
|
+
const separatorRows = rows >= 8 ? 1 : 0;
|
|
2510
|
+
const controlRows = compactControls ? 1 : options.length;
|
|
2511
|
+
const contentViewport = Math.max(0, rows - titleRows - hintRows - separatorRows - controlRows);
|
|
2512
|
+
return { rows, compactControls, titleRows, hintRows, separatorRows, contentViewport };
|
|
2513
|
+
};
|
|
2514
|
+
const move = (delta: number) => {
|
|
2515
|
+
const maxOffset = Math.max(0, renderedLines.length - layout().contentViewport);
|
|
2516
|
+
offset = Math.max(0, Math.min(maxOffset, offset + delta));
|
|
2517
|
+
};
|
|
2518
|
+
return borderWorkflowOverlay({
|
|
2519
|
+
render(width: number) {
|
|
2520
|
+
renderedLines = truncateToVisualLines(formatCheckpointReview(checkpoint), Number.MAX_SAFE_INTEGER, width, 0).visualLines;
|
|
2521
|
+
const currentLayout = layout();
|
|
2522
|
+
const maxOffset = Math.max(0, renderedLines.length - currentLayout.contentViewport);
|
|
2523
|
+
offset = Math.min(offset, maxOffset);
|
|
2524
|
+
const hint = truncateToVisualLines(theme.fg("dim", "↑↓/pgup/pgdn scroll · enter select · esc cancel"), Number.MAX_SAFE_INTEGER, width, 1).visualLines[0] ?? "";
|
|
2525
|
+
const controls = currentLayout.compactControls
|
|
2526
|
+
? [options.map((option, index) => `${index === selectedIndex ? "[" : " "}${option}${index === selectedIndex ? "]" : " "}`).join(" ")]
|
|
2527
|
+
: options.map((option, index) => `${index === selectedIndex ? "→ " : " "}${option}`);
|
|
2528
|
+
return [
|
|
2529
|
+
...(currentLayout.titleRows ? [theme.fg("accent", "Checkpoint review")] : []),
|
|
2530
|
+
...renderedLines.slice(offset, offset + currentLayout.contentViewport),
|
|
2531
|
+
...(currentLayout.separatorRows ? [""] : []),
|
|
2532
|
+
...controls,
|
|
2533
|
+
...(currentLayout.hintRows ? [hint] : []),
|
|
2534
|
+
];
|
|
2535
|
+
},
|
|
2536
|
+
invalidate() {},
|
|
2537
|
+
handleInput(data: string) {
|
|
2538
|
+
if (keybindings.matches(data, "tui.select.up")) selectedIndex = (selectedIndex + options.length - 1) % options.length;
|
|
2539
|
+
else if (keybindings.matches(data, "tui.select.down")) selectedIndex = (selectedIndex + 1) % options.length;
|
|
2540
|
+
else if (keybindings.matches(data, "tui.select.pageUp")) move(-layout().contentViewport);
|
|
2541
|
+
else if (keybindings.matches(data, "tui.select.pageDown")) move(layout().contentViewport);
|
|
2542
|
+
else if (keybindings.matches(data, "tui.select.confirm")) done(options[selectedIndex] === "Cancel" ? undefined : options[selectedIndex] as "Approve" | "Reject");
|
|
2543
|
+
else if (keybindings.matches(data, "tui.select.cancel")) done(undefined);
|
|
2544
|
+
tui.requestRender();
|
|
2545
|
+
},
|
|
2546
|
+
}, theme);
|
|
2547
|
+
}, { overlay: true, overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%" } });
|
|
2548
|
+
if (decision) {
|
|
2549
|
+
const accepted = await answerCheckpoint(store.runId, checkpoint.name, decision === "Approve", true);
|
|
2550
|
+
if (!accepted) ctx.ui.notify("Checkpoint is not awaiting a response.", "warning");
|
|
2551
|
+
}
|
|
2552
|
+
continue;
|
|
2553
|
+
}
|
|
2554
|
+
const actionCommand = view.actions.get(actionChoice);
|
|
2555
|
+
if (!actionCommand) { ctx.ui.notify(`Cannot select workflow action: ${actionChoice}`, "warning"); continue; }
|
|
2556
|
+
const outcome = await runAction(actionCommand, true);
|
|
2557
|
+
if (outcome === "picker") { stores = await loadStores(); break; }
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
await runAction(command, false);
|
|
2562
|
+
},
|
|
2563
|
+
});
|
|
2564
|
+
pi.on("session_shutdown", async () => {
|
|
2565
|
+
try {
|
|
2566
|
+
await Promise.all([...runs.entries()].map(async ([runId, run]) => {
|
|
2567
|
+
if (["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state)) { await run.completion?.catch(() => undefined); return; }
|
|
2568
|
+
if (!["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state)) {
|
|
2569
|
+
try { await run.lifecycle.terminal("interrupted"); } catch (error) { if (!["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state)) throw error; }
|
|
2570
|
+
run.abortController.abort();
|
|
2571
|
+
run.execution?.cancel();
|
|
2572
|
+
await scheduler.cancelRun(runId);
|
|
2573
|
+
}
|
|
2574
|
+
await run.completion?.catch(() => undefined);
|
|
2575
|
+
}));
|
|
2576
|
+
await scheduler.flush();
|
|
2577
|
+
} finally {
|
|
2578
|
+
await releaseSessionLease();
|
|
2579
|
+
resetWorkflowRegistry();
|
|
2580
|
+
}
|
|
2581
|
+
});
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
function displayAgentName(label: string | undefined, role: string | undefined, model: ModelSpec): string {
|
|
2585
|
+
return label ?? role ?? model.model;
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
function modelSpec(value: string, fallback: ModelSpec): ModelSpec {
|
|
2589
|
+
try {
|
|
2590
|
+
const parsed = parseModelReference(value);
|
|
2591
|
+
return { ...parsed, ...(parsed.thinking || !fallback.thinking ? {} : { thinking: fallback.thinking }) };
|
|
2592
|
+
} catch {
|
|
2593
|
+
return fallback;
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
|
|
2598
|
+
|