pi-extensible-workflows 3.0.0 → 3.2.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 +15 -17
- package/dist/src/agent-execution.d.ts +4 -80
- package/dist/src/agent-execution.js +20 -10
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +48 -2
- 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.js +13 -4
- package/dist/src/host.d.ts +83 -4
- package/dist/src/host.js +1246 -410
- package/dist/src/index.d.ts +5 -2
- package/dist/src/index.js +3 -1
- package/dist/src/persistence.d.ts +3 -0
- package/dist/src/persistence.js +49 -1
- package/dist/src/registry.d.ts +21 -9
- package/dist/src/registry.js +131 -21
- package/dist/src/session-inspector.js +4 -2
- package/dist/src/types.d.ts +135 -7
- package/dist/src/utils.d.ts +6 -0
- package/dist/src/utils.js +45 -5
- package/dist/src/validation.d.ts +6 -2
- package/dist/src/validation.js +157 -31
- package/dist/src/workflow-artifacts.d.ts +13 -0
- package/dist/src/workflow-artifacts.js +39 -0
- package/examples/workflow-extension-template/README.md +37 -0
- package/examples/workflow-extension-template/extension.test.mjs +59 -0
- package/examples/workflow-extension-template/index.js +51 -0
- package/examples/workflow-extension-template/roles/reviewer.md +4 -0
- package/package.json +5 -3
- package/skills/pi-extensible-workflows/SKILL.md +45 -18
- package/src/agent-execution.ts +23 -37
- package/src/cli.ts +33 -2
- package/src/doctor-cleanup.ts +337 -0
- package/src/doctor.ts +117 -24
- package/src/execution.ts +13 -4
- package/src/host.ts +1039 -366
- package/src/index.ts +5 -2
- package/src/persistence.ts +35 -1
- package/src/registry.ts +108 -25
- package/src/session-inspector.ts +4 -2
- package/src/types.ts +53 -8
- package/src/utils.ts +39 -5
- package/src/validation.ts +130 -31
- package/src/workflow-artifacts.ts +34 -0
package/src/host.ts
CHANGED
|
@@ -1,22 +1,27 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
4
5
|
import { dirname, join } from "node:path";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
7
|
import { Type } from "@earendil-works/pi-ai";
|
|
7
8
|
import { Value } from "typebox/value";
|
|
8
|
-
import { copyToClipboard, getAgentDir,
|
|
9
|
-
import { createNativeAgentSession, FairAgentScheduler, WorkflowAgentExecutor, type AgentActivity, type AgentAttempt, type AgentDefinition, type AgentProgress, type AgentProviderFailure, type AgentProviderRecovery, type
|
|
9
|
+
import { copyToClipboard, getAgentDir, SettingsManager, truncateToVisualLines, type ExtensionAPI, type Theme } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { createNativeAgentSession, FairAgentScheduler, WorkflowAgentExecutor, type AgentActivity, type AgentAttempt, type AgentDefinition, type AgentProgress, type AgentProviderFailure, type AgentProviderRecovery, type SessionFactory } from "./agent-execution.js";
|
|
10
11
|
import { herdrPaneId, openHerdrPane } from "./herdr.js";
|
|
11
12
|
import { acquireSessionLease, listRunIds, RunStore, SessionLease, structuralPath as operationPath } from "./persistence.js";
|
|
12
13
|
import type { AwaitingCheckpoint, PersistedRun, WorktreeReference } from "./persistence.js";
|
|
13
14
|
import { budgetRelaxed, budgetUsage, mergeBudget, resumeBudgetAllowed, validateBudget, validateBudgetPatch, WorkflowBudgetRuntime } from "./budget.js";
|
|
14
|
-
import { asWorkflowError, aliasDrift, createLaunchSnapshot, deepFreeze, errorCode, errorText, fail, isWorkflowAuthored, jsonValue, modelCapability, object, parseModelReference, parseThinking, positiveInteger, resolveModelReference, validateModelAliases } from "./utils.js";
|
|
15
|
-
import { launchScriptForSnapshot, loadAgentDefinitions,
|
|
15
|
+
import { asWorkflowError, aliasDrift, createLaunchSnapshot, deepFreeze, errorCode, errorText, fail, isWorkflowAuthored, jsonValue, modelAliasErrorName, modelCapability, object, parseModelReference, parseThinking, positiveInteger, resolveModelReference, validateModelAliases } from "./utils.js";
|
|
16
|
+
import { launchScriptForSnapshot, loadAgentDefinitions, preflight, resolveAgentResourcePolicy, resolveWorkflowSettings, saveModelAliases, validateAgentOptions, validateCheckpoint, validateModelAliasAvailability, validateShellOptions, validateWorkflowLaunchWithRegistry, workflowProjectSettingsPath, workflowPrompt, workflowSettingsPath } from "./validation.js";
|
|
16
17
|
import { beginWorkflowExtensionLoading, loadingRegistry, resetWorkflowRegistry, type WorkflowRegistryApi } from "./registry.js";
|
|
17
18
|
import { agentIdentityPath, agentWorktree, encoded, executeShellCommand, persistActiveAgentAttempt, persistAgentAttempts, readShellResult, runWorkflow, shellIdentityPath } from "./execution.js";
|
|
18
|
-
import {
|
|
19
|
+
import { openWorkflowArtifact, workflowResultArtifact, workflowScriptArtifact, type WorkflowArtifact } from "./workflow-artifacts.js";
|
|
20
|
+
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 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
21
|
const SETTLED_AGENT_STATES: ReadonlySet<import("./types.js").AgentState> = new Set(["completed", "failed", "cancelled"]);
|
|
22
|
+
const INTERNAL_WORKFLOW_TOOLS: readonly string[] = ["workflow", "workflow_respond", "workflow_stop", "workflow_resume", "workflow_retry", "workflow_catalog"];
|
|
23
|
+
const HARD_TERMINAL_RUN_STATES: ReadonlySet<string> = new Set(["completed", "failed", "stopped"]);
|
|
24
|
+
const SHUTDOWN_TERMINAL_RUN_STATES: ReadonlySet<string> = new Set(["completed", "failed", "stopped", "budget_exhausted"]);
|
|
20
25
|
export interface WorkflowProgressStyles {
|
|
21
26
|
accent(text: string): string;
|
|
22
27
|
success(text: string): string;
|
|
@@ -26,7 +31,25 @@ export interface WorkflowProgressStyles {
|
|
|
26
31
|
dim(text: string): string;
|
|
27
32
|
bold(text: string): string;
|
|
28
33
|
}
|
|
34
|
+
function snapshotResourcePolicy(snapshot: Readonly<LaunchSnapshot>, cwd: string, projectTrusted: boolean, globalSettingsPath: string): AgentResourcePolicy {
|
|
35
|
+
const empty = { skills: [], extensions: [] };
|
|
36
|
+
return { globalSettingsPath, projectSettingsPath: workflowProjectSettingsPath(cwd), projectTrusted, global: empty, project: empty, effective: snapshot.settings.disabledAgentResources ?? empty, unmatchedSkills: [], unmatchedExtensions: [] };
|
|
37
|
+
}
|
|
29
38
|
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 };
|
|
39
|
+
type WorkflowLaunchSettings = { settings: Readonly<WorkflowSettings>; resolution: WorkflowSettingsResolution; resourcePolicy: AgentResourcePolicy };
|
|
40
|
+
function workflowLaunchSettings(cwd: string, projectTrusted: boolean, globalSettingsPath: string, concurrency?: number): WorkflowLaunchSettings {
|
|
41
|
+
const resolution = resolveWorkflowSettings(cwd, projectTrusted, globalSettingsPath);
|
|
42
|
+
const settings = Object.freeze({ ...resolution.effective, ...(concurrency === undefined ? {} : { concurrency }) });
|
|
43
|
+
return { settings, resolution, resourcePolicy: resolveAgentResourcePolicy(cwd, projectTrusted, globalSettingsPath) };
|
|
44
|
+
}
|
|
45
|
+
function frozenResourcePolicy(policy: AgentResourcePolicy): () => AgentResourcePolicy { return () => structuredClone(policy); }
|
|
46
|
+
function resumedSnapshotSettings(snapshot: Readonly<LaunchSnapshot>, resolution: WorkflowSettingsResolution, modelAliases: Readonly<Record<string, string>>): { settings: WorkflowSettings; settingsSources?: NonNullable<LaunchSnapshot["settingsSources"]> } {
|
|
47
|
+
const settings: WorkflowSettings = { ...snapshot.settings, concurrency: snapshot.settingsSources === undefined || snapshot.settingsSources.concurrency === "per-run options" ? snapshot.settings.concurrency : resolution.effective.concurrency, modelAliases };
|
|
48
|
+
if (resolution.effective.disabledAgentResources === undefined) delete settings.disabledAgentResources;
|
|
49
|
+
else settings.disabledAgentResources = resolution.effective.disabledAgentResources;
|
|
50
|
+
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 };
|
|
51
|
+
return { settings, ...(settingsSources === undefined ? {} : { settingsSources }) };
|
|
52
|
+
}
|
|
30
53
|
const WORKFLOW_FAILURE_DIAGNOSTICS = Symbol("workflowFailureDiagnostics");
|
|
31
54
|
|
|
32
55
|
function workflowDetail(message: string): string {
|
|
@@ -71,7 +94,6 @@ function mainAgentError(error: unknown): WorkflowError {
|
|
|
71
94
|
const typed = asWorkflowError(error);
|
|
72
95
|
const presented = new WorkflowError(typed.code, formatWorkflowFailure(typed));
|
|
73
96
|
Object.assign(presented, typed);
|
|
74
|
-
presented.message = formatWorkflowFailure(typed);
|
|
75
97
|
return presented;
|
|
76
98
|
}
|
|
77
99
|
|
|
@@ -126,7 +148,7 @@ export class RunLifecycle {
|
|
|
126
148
|
}
|
|
127
149
|
|
|
128
150
|
async terminal(state: "completed" | "failed" | "stopped" | "interrupted" | "budget_exhausted", reason?: string): Promise<void> {
|
|
129
|
-
if (
|
|
151
|
+
if (HARD_TERMINAL_RUN_STATES.has(this.#state)) throw new WorkflowError("RESUME_INCOMPATIBLE", `${this.#state} runs are terminal`);
|
|
130
152
|
await this.#set(state, reason ?? state);
|
|
131
153
|
for (const resolve of this.#waiters.splice(0)) resolve();
|
|
132
154
|
}
|
|
@@ -146,23 +168,276 @@ export function formatWorkflowPreview(args: { script?: unknown; workflow?: unkno
|
|
|
146
168
|
return [`workflow ${name}`, typeof args.description === "string" && args.description.trim() ? args.description.trim() : ""].filter(Boolean).join("\n");
|
|
147
169
|
}
|
|
148
170
|
export const WORKFLOW_TOOL_LABEL = "Workflow";
|
|
149
|
-
export const WORKFLOW_TOOL_DESCRIPTION = "Run a deterministic JavaScript workflow";
|
|
150
|
-
export const WORKFLOW_TOOL_PROMPT_SNIPPET = "Run a deterministic, resumable JavaScript workflow that
|
|
171
|
+
export const WORKFLOW_TOOL_DESCRIPTION = "Run a deterministic JavaScript workflow with a named inline parallel-to-summary path by default";
|
|
172
|
+
export const WORKFLOW_TOOL_PROMPT_SNIPPET = "Run a deterministic, resumable JavaScript workflow. Prefer a named inline script that fans out independent work with parallel(...), awaits the keyed results before interpolating them into one summarizing agent(...), and returns. Inline launches require an explicit non-empty name; registered function launches reject name and use workflow as the run name. Advanced controls include registered functions, outputSchema, budgets, checkpoints, worktrees, retry/resume, CLI export, and pipelines. Use workflow_retry with an explicit failed run ID; parentRunId only reuses named worktrees. Runs are in the background by default; completion arrives as a follow-up message. Set foreground: true when the caller must wait for the final value. Foreground results include the completed run ID. Recovery map: agent(..., { retries }) reruns one agent call in the same run for transient failures; workflow_retry({ runId }) replays a failed run into a child; workflow_resume({ runId, budget? }) continues a budget_exhausted run; parentRunId on a new launch only borrows named worktrees and never replays or resumes.";
|
|
173
|
+
function workflowRecoveryGuidance(action: "resume" | "retry", state: RunState): string {
|
|
174
|
+
if (action === "resume") {
|
|
175
|
+
if (state === "failed") return "Failed workflow runs must use workflow_retry({ runId })";
|
|
176
|
+
if (state === "completed") return "Completed workflow runs have no recovery action";
|
|
177
|
+
if (state === "stopped") return "Stopped workflow runs have no recovery action; launch a new workflow";
|
|
178
|
+
if (state === "interrupted") return "Interrupted workflow runs use /workflow resume, not workflow_resume";
|
|
179
|
+
return `Only budget-exhausted runs can be resumed with workflow_resume; source is ${state}`;
|
|
180
|
+
}
|
|
181
|
+
if (state === "budget_exhausted") return "Budget-exhausted workflow runs must use workflow_resume({ runId, budget? })";
|
|
182
|
+
if (state === "completed") return "Completed workflow runs have no recovery action";
|
|
183
|
+
if (state === "stopped") return "Stopped workflow runs cannot be retried; launch a new workflow";
|
|
184
|
+
if (state === "interrupted") return "Interrupted workflow runs use /workflow resume, not workflow_retry";
|
|
185
|
+
return `Only failed workflow runs can be retried; source is ${state}`;
|
|
186
|
+
}
|
|
151
187
|
export const WORKFLOW_TOOL_PARAMETERS = Type.Object({
|
|
152
|
-
name: Type.Optional(Type.String({ description: "Required non-empty name for inline workflow
|
|
188
|
+
name: Type.Optional(Type.String({ description: "Required non-empty name for the default inline workflow path; invalid for registered function launches" })),
|
|
153
189
|
description: Type.Optional(Type.String({ description: "Optional human-readable workflow description" })),
|
|
154
|
-
script: Type.Optional(Type.String({ description: "Immutable workflow source
|
|
155
|
-
workflow: Type.Optional(Type.String({ description: "
|
|
190
|
+
script: Type.Optional(Type.String({ description: "Immutable inline workflow source; default to a named script that fans out with parallel(...) and awaits results before passing them to a summarizing agent(...)" })),
|
|
191
|
+
workflow: Type.Optional(Type.String({ description: "Advanced: registered reusable function as an unqualified name" })),
|
|
156
192
|
args: Type.Optional(Type.Unknown({ description: "JSON-compatible workflow arguments" })),
|
|
157
|
-
foreground: Type.Optional(Type.Boolean({ description: "Wait for completion instead of
|
|
158
|
-
concurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: 16 })),
|
|
159
|
-
budget: Type.Optional(Type.Unknown({ description: "
|
|
160
|
-
parentRunId: Type.Optional(Type.String({ description: "
|
|
193
|
+
foreground: Type.Optional(Type.Boolean({ description: "Wait for completion instead of the default background launch" })),
|
|
194
|
+
concurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: 16, description: "Advanced: optional per-run active-agent limit" })),
|
|
195
|
+
budget: Type.Optional(Type.Unknown({ description: "Advanced: optional aggregate soft and hard run budgets" })),
|
|
196
|
+
parentRunId: Type.Optional(Type.String({ description: "Advanced: terminal run whose named worktrees may be reused" })),
|
|
161
197
|
});
|
|
162
198
|
export const WORKFLOW_RETRY_PARAMETERS = Type.Object({ runId: Type.String({ description: "Explicit failed workflow run ID" }) });
|
|
163
199
|
|
|
164
200
|
type WorkflowToolUpdate = { content: [{ type: "text"; text: string }]; details: { runId: string; run: PersistedRun } };
|
|
165
|
-
|
|
201
|
+
export type WorkflowPhaseState = "not started" | "running" | "completed" | "failed" | "cancelled" | "interrupted" | "budget_exhausted";
|
|
202
|
+
export interface WorkflowPhaseAgentCounts { total: number; completed: number; running: number; failed: number; cancelled: number; pending: number }
|
|
203
|
+
export interface WorkflowPhaseView { id: string; name: string; occurrence: number; state: WorkflowPhaseState; observed: boolean; afterAgent?: number; agents: readonly AgentRecord[]; counts: WorkflowPhaseAgentCounts }
|
|
204
|
+
export interface WorkflowPhaseModel { declaredPhases: readonly string[]; phases: readonly WorkflowPhaseView[]; currentPhaseIndex?: number; currentPhaseId?: string; counts: Readonly<Partial<Record<WorkflowPhaseState, number>>>; unassignedAgents?: readonly AgentRecord[] }
|
|
205
|
+
type WorkflowPhaseSource = readonly string[] | Pick<LaunchSnapshot, "phases"> | undefined;
|
|
206
|
+
function phaseNames(source: WorkflowPhaseSource): string[] {
|
|
207
|
+
const phases: readonly unknown[] = source === undefined ? [] : Array.isArray(source) ? source : (source as Pick<LaunchSnapshot, "phases">).phases ?? [];
|
|
208
|
+
return phases.filter((phase): phase is string => typeof phase === "string" && phase.trim() !== "").map((phase) => phase.trim());
|
|
209
|
+
}
|
|
210
|
+
function phaseAgentCounts(agents: readonly AgentRecord[]): WorkflowPhaseAgentCounts {
|
|
211
|
+
const counts: WorkflowPhaseAgentCounts = { total: agents.length, completed: 0, running: 0, failed: 0, cancelled: 0, pending: 0 };
|
|
212
|
+
for (const agent of agents) {
|
|
213
|
+
if (agent.state === "completed") counts.completed += 1;
|
|
214
|
+
else if (agent.state === "running") counts.running += 1;
|
|
215
|
+
else if (agent.state === "failed") counts.failed += 1;
|
|
216
|
+
else if (agent.state === "cancelled") counts.cancelled += 1;
|
|
217
|
+
else counts.pending += 1;
|
|
218
|
+
}
|
|
219
|
+
return counts;
|
|
220
|
+
}
|
|
221
|
+
function phaseState(runState: RunState, counts: WorkflowPhaseAgentCounts, isLatest: boolean): WorkflowPhaseState {
|
|
222
|
+
if (!isLatest) return "completed";
|
|
223
|
+
if (runState === "failed") return "failed";
|
|
224
|
+
if (runState === "stopped") return "cancelled";
|
|
225
|
+
if (runState === "interrupted") return "interrupted";
|
|
226
|
+
if (runState === "budget_exhausted") return "budget_exhausted";
|
|
227
|
+
if (counts.failed > 0) return "failed";
|
|
228
|
+
if (counts.cancelled > 0) return "cancelled";
|
|
229
|
+
if (counts.running > 0 || counts.pending > 0) return "running";
|
|
230
|
+
return runState === "completed" ? "completed" : "running";
|
|
231
|
+
}
|
|
232
|
+
export function buildWorkflowPhaseModel(run: Pick<PersistedRun, "state" | "phase" | "phaseHistory" | "agents">, source?: WorkflowPhaseSource): WorkflowPhaseModel {
|
|
233
|
+
const declaredPhases = phaseNames(source);
|
|
234
|
+
const rawHistory: readonly unknown[] = Array.isArray(run.phaseHistory) ? run.phaseHistory : [];
|
|
235
|
+
const observed: Array<{ name: string; afterAgent: number }> = [];
|
|
236
|
+
let boundary = 0;
|
|
237
|
+
for (const record of rawHistory) {
|
|
238
|
+
if (!object(record) || typeof record.phase !== "string" || !record.phase.trim() || typeof record.afterAgent !== "number" || !Number.isSafeInteger(record.afterAgent)) continue;
|
|
239
|
+
boundary = Math.max(boundary, Math.min(run.agents.length, Math.max(0, record.afterAgent)));
|
|
240
|
+
observed.push({ name: record.phase.trim(), afterAgent: boundary });
|
|
241
|
+
}
|
|
242
|
+
if (!observed.length && typeof run.phase === "string" && run.phase.trim()) observed.push({ name: run.phase.trim(), afterAgent: 0 });
|
|
243
|
+
const observedEntries = observed.map((entry, index) => ({ ...entry, index, agents: run.agents.slice(entry.afterAgent, observed[index + 1]?.afterAgent ?? run.agents.length) }));
|
|
244
|
+
const matchedDeclarations = new Set<number>();
|
|
245
|
+
const declarationIndices = observedEntries.map((entry) => {
|
|
246
|
+
const index = declaredPhases.findIndex((name, candidate) => !matchedDeclarations.has(candidate) && name === entry.name);
|
|
247
|
+
if (index >= 0) matchedDeclarations.add(index);
|
|
248
|
+
return index >= 0 ? index : undefined;
|
|
249
|
+
});
|
|
250
|
+
const entries: Array<{ name: string; observedIndex?: number; declarationIndex?: number }> = observedEntries.map((entry, index) => ({ name: entry.name, observedIndex: index, ...(declarationIndices[index] === undefined ? {} : { declarationIndex: declarationIndices[index] }) }));
|
|
251
|
+
for (const [declarationIndex, name] of declaredPhases.entries()) {
|
|
252
|
+
if (matchedDeclarations.has(declarationIndex)) continue;
|
|
253
|
+
const insertion = entries.findIndex((entry) => entry.declarationIndex !== undefined && entry.declarationIndex > declarationIndex);
|
|
254
|
+
const pending = { name };
|
|
255
|
+
if (insertion < 0) entries.push(pending); else entries.splice(insertion, 0, pending);
|
|
256
|
+
}
|
|
257
|
+
const occurrences = new Map<string, number>();
|
|
258
|
+
const phases = entries.map((entry) => {
|
|
259
|
+
const occurrence = (occurrences.get(entry.name) ?? 0) + 1;
|
|
260
|
+
occurrences.set(entry.name, occurrence);
|
|
261
|
+
const observation = entry.observedIndex === undefined ? undefined : observedEntries[entry.observedIndex];
|
|
262
|
+
const agents = observation?.agents ?? [];
|
|
263
|
+
const counts = phaseAgentCounts(agents);
|
|
264
|
+
const state = observation ? phaseState(run.state, counts, entry.observedIndex === observedEntries.length - 1) : "not started";
|
|
265
|
+
return { id: `${entry.name}#${String(occurrence)}`, name: entry.name, occurrence, state, observed: observation !== undefined, ...(observation ? { afterAgent: observation.afterAgent } : {}), agents, counts };
|
|
266
|
+
});
|
|
267
|
+
let currentPhaseIndex: number | undefined;
|
|
268
|
+
for (let index = phases.length - 1; index >= 0; index -= 1) { if (phases[index]?.observed) { currentPhaseIndex = index; break; } }
|
|
269
|
+
const counts: Partial<Record<WorkflowPhaseState, number>> = {};
|
|
270
|
+
for (const phase of phases) counts[phase.state] = (counts[phase.state] ?? 0) + 1;
|
|
271
|
+
const current = currentPhaseIndex === undefined ? undefined : phases[currentPhaseIndex];
|
|
272
|
+
const assigned = new Set(observedEntries.flatMap(({ agents }) => agents.map((agent) => agent.id)));
|
|
273
|
+
const unassignedAgents = run.agents.filter((agent) => !assigned.has(agent.id));
|
|
274
|
+
const result: WorkflowPhaseModel = { declaredPhases, phases, counts };
|
|
275
|
+
if (current !== undefined && currentPhaseIndex !== undefined) { result.currentPhaseIndex = currentPhaseIndex; result.currentPhaseId = current.id; }
|
|
276
|
+
if (unassignedAgents.length) result.unassignedAgents = unassignedAgents;
|
|
277
|
+
return result;
|
|
278
|
+
}
|
|
279
|
+
export interface WorkflowPhaseSelection { phaseId?: string | undefined; agentId?: string | undefined; nodeId?: string | undefined; expandedNodeIds?: readonly string[] | undefined; treeOnly?: boolean | undefined; detailsOnly?: boolean | undefined; actions?: { title: string; options: readonly string[]; index: number } | undefined }
|
|
280
|
+
export type WorkflowPhaseTreeNodeKind = "phase" | "operation" | "agent";
|
|
281
|
+
export interface WorkflowPhaseTreeNode { id: string; kind: WorkflowPhaseTreeNodeKind; label: string; depth: number; phaseId: string; operationPath: readonly string[]; parentId?: string; children: readonly string[]; state: WorkflowPhaseState | AgentRecord["state"]; agentId?: string; agent?: AgentRecord; phase?: WorkflowPhaseView }
|
|
282
|
+
export interface WorkflowPhaseTree { roots: readonly string[]; nodes: readonly WorkflowPhaseTreeNode[]; byId: ReadonlyMap<string, WorkflowPhaseTreeNode> }
|
|
283
|
+
export interface WorkflowPhaseTreeSelection { nodeId?: string | undefined }
|
|
284
|
+
export type WorkflowPhaseTreeDirection = "up" | "down" | "left" | "right";
|
|
285
|
+
function workflowPhaseTreePath(kind: WorkflowPhaseTreeNodeKind, phaseId: string, operationPath: readonly string[], agentId?: string): string {
|
|
286
|
+
const root = `phase/${encodeURIComponent(phaseId)}`;
|
|
287
|
+
if (kind === "phase") return root;
|
|
288
|
+
const operation = operationPath.map((part) => encodeURIComponent(part)).join("/");
|
|
289
|
+
if (kind === "operation") return `${root}/operation/${operation}`;
|
|
290
|
+
return operation ? `${root}/operation/${operation}/agent/${encodeURIComponent(agentId ?? "")}` : `${root}/agent/${encodeURIComponent(agentId ?? "")}`;
|
|
291
|
+
}
|
|
292
|
+
function workflowPhaseTreeAggregateState(states: readonly AgentRecord["state"][]): AgentRecord["state"] {
|
|
293
|
+
if (!states.length || states.every((state) => state === "completed")) return "completed";
|
|
294
|
+
if (states.some((state) => state === "failed")) return "failed";
|
|
295
|
+
if (states.some((state) => state === "cancelled")) return "cancelled";
|
|
296
|
+
if (states.some((state) => state === "running")) return "running";
|
|
297
|
+
return "queued";
|
|
298
|
+
}
|
|
299
|
+
export function buildWorkflowPhaseTree(model: WorkflowPhaseModel): WorkflowPhaseTree {
|
|
300
|
+
type Draft = Omit<WorkflowPhaseTreeNode, "children"> & { children: string[] };
|
|
301
|
+
type AgentEntry = { agent: AgentRecord; node: Draft; path: readonly string[]; defaultParentId: string };
|
|
302
|
+
const drafts = new Map<string, Draft>();
|
|
303
|
+
const roots: string[] = [];
|
|
304
|
+
const add = (node: Omit<Draft, "children">, parentId?: string): Draft => {
|
|
305
|
+
const existing = drafts.get(node.id);
|
|
306
|
+
if (existing) return existing;
|
|
307
|
+
const draft: Draft = { ...node, ...(parentId === undefined ? {} : { parentId }), children: [] };
|
|
308
|
+
drafts.set(draft.id, draft);
|
|
309
|
+
if (parentId === undefined) roots.push(draft.id); else drafts.get(parentId)?.children.push(draft.id);
|
|
310
|
+
return draft;
|
|
311
|
+
};
|
|
312
|
+
const samePath = (left: readonly string[], right: readonly string[]): boolean => left.length === right.length && left.every((part, index) => part === right[index]);
|
|
313
|
+
const addPhase = (phaseId: string, label: string, agents: readonly AgentRecord[], phase?: WorkflowPhaseView): void => {
|
|
314
|
+
const phaseNode = add({ id: workflowPhaseTreePath("phase", phaseId, []), kind: "phase", label, depth: 0, phaseId, operationPath: [], state: phase?.state ?? workflowPhaseTreeAggregateState(agents.map((agent) => agent.state)), ...(phase ? { phase } : {}) });
|
|
315
|
+
const operationNodes = new Map<string, Draft>();
|
|
316
|
+
const entries: AgentEntry[] = agents.map((agent) => ({ agent, path: [...(agent.structuralPath ?? [])], node: undefined as unknown as Draft, defaultParentId: phaseNode.id }));
|
|
317
|
+
const agentEntries = new Map(entries.map((entry) => [entry.agent.id, entry]));
|
|
318
|
+
const acceptedParents = new Map<string, string>();
|
|
319
|
+
const wouldCycle = (childId: string, parentId: string): boolean => {
|
|
320
|
+
const seen = new Set<string>([childId]);
|
|
321
|
+
let current: string | undefined = parentId;
|
|
322
|
+
while (current) {
|
|
323
|
+
if (seen.has(current)) return true;
|
|
324
|
+
seen.add(current);
|
|
325
|
+
current = acceptedParents.get(current);
|
|
326
|
+
}
|
|
327
|
+
return false;
|
|
328
|
+
};
|
|
329
|
+
for (const entry of entries) {
|
|
330
|
+
const parent = entry.agent.parentId ? agentEntries.get(entry.agent.parentId) : undefined;
|
|
331
|
+
if (parent && !wouldCycle(entry.agent.id, parent.agent.id)) acceptedParents.set(entry.agent.id, parent.agent.id);
|
|
332
|
+
}
|
|
333
|
+
const operationChain = (path: readonly string[], owner: Draft, startIndex = 0): Draft => {
|
|
334
|
+
let parent = owner;
|
|
335
|
+
for (let index = startIndex; index < path.length; index += 1) {
|
|
336
|
+
const prefix = path.slice(0, index + 1);
|
|
337
|
+
const key = `${owner.id}:${JSON.stringify(prefix)}`;
|
|
338
|
+
const existing = operationNodes.get(key);
|
|
339
|
+
if (existing) { parent = existing; continue; }
|
|
340
|
+
const suffix = path.slice(startIndex, index + 1).map((part) => encodeURIComponent(part)).join("/");
|
|
341
|
+
const id = owner.id === phaseNode.id ? workflowPhaseTreePath("operation", phaseId, prefix) : `${owner.id}/operation/${suffix}`;
|
|
342
|
+
const operation = add({ id, kind: "operation", label: prefix.at(-1) ?? "", depth: 0, phaseId, operationPath: prefix, state: "queued", ...(phase ? { phase } : {}) }, parent.id);
|
|
343
|
+
operationNodes.set(key, operation);
|
|
344
|
+
parent = operation;
|
|
345
|
+
}
|
|
346
|
+
return parent;
|
|
347
|
+
};
|
|
348
|
+
for (const entry of entries) {
|
|
349
|
+
if (!acceptedParents.has(entry.agent.id)) entry.defaultParentId = operationChain(entry.path, phaseNode).id;
|
|
350
|
+
}
|
|
351
|
+
for (const entry of entries) {
|
|
352
|
+
entry.node = add({ id: workflowPhaseTreePath("agent", phaseId, entry.path, entry.agent.id), kind: "agent", label: entry.agent.label ?? entry.agent.name, depth: 0, phaseId, operationPath: entry.path, state: entry.agent.state, agentId: entry.agent.id, agent: entry.agent }, phaseNode.id);
|
|
353
|
+
}
|
|
354
|
+
const attach = (entry: AgentEntry, parentId: string): void => {
|
|
355
|
+
const previous = entry.node.parentId ? drafts.get(entry.node.parentId) : undefined;
|
|
356
|
+
if (previous) previous.children = previous.children.filter((childId) => childId !== entry.node.id);
|
|
357
|
+
entry.node.parentId = parentId;
|
|
358
|
+
const parent = drafts.get(parentId);
|
|
359
|
+
if (parent && !parent.children.includes(entry.node.id)) parent.children.push(entry.node.id);
|
|
360
|
+
};
|
|
361
|
+
for (const entry of entries) {
|
|
362
|
+
const parentId = acceptedParents.get(entry.agent.id);
|
|
363
|
+
const parent = parentId ? agentEntries.get(parentId) : undefined;
|
|
364
|
+
if (parent) {
|
|
365
|
+
if (samePath(entry.path, parent.path)) entry.defaultParentId = parent.node.id;
|
|
366
|
+
else {
|
|
367
|
+
const commonLength = entry.path.findIndex((part, index) => parent.path[index] !== part);
|
|
368
|
+
const startIndex = commonLength < 0 ? Math.min(entry.path.length, parent.path.length) : commonLength;
|
|
369
|
+
entry.defaultParentId = operationChain(entry.path, parent.node, startIndex).id;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
attach(entry, entry.defaultParentId);
|
|
373
|
+
}
|
|
374
|
+
const setDepth = (node: Draft, depth: number, seen = new Set<string>()): void => {
|
|
375
|
+
if (seen.has(node.id)) return;
|
|
376
|
+
seen.add(node.id);
|
|
377
|
+
node.depth = depth;
|
|
378
|
+
for (const childId of node.children) { const child = drafts.get(childId); if (child) setDepth(child, depth + 1, seen); }
|
|
379
|
+
};
|
|
380
|
+
setDepth(phaseNode, 0);
|
|
381
|
+
const statesFor = (node: Draft, seen = new Set<string>()): AgentRecord["state"][] => {
|
|
382
|
+
if (seen.has(node.id)) return [];
|
|
383
|
+
const nextSeen = new Set(seen).add(node.id);
|
|
384
|
+
return node.children.flatMap((childId) => {
|
|
385
|
+
const child = drafts.get(childId);
|
|
386
|
+
return child?.kind === "agent" ? [child.agent?.state ?? "queued", ...statesFor(child, nextSeen)] : child ? statesFor(child, nextSeen) : [];
|
|
387
|
+
});
|
|
388
|
+
};
|
|
389
|
+
for (const operation of operationNodes.values()) operation.state = workflowPhaseTreeAggregateState(statesFor(operation));
|
|
390
|
+
};
|
|
391
|
+
for (const phase of model.phases) addPhase(phase.id, `${phase.name}${phase.occurrence > 1 ? ` #${String(phase.occurrence)}` : ""}`, phase.agents, phase);
|
|
392
|
+
if (model.unassignedAgents?.length) addPhase("unassigned", "Unassigned", model.unassignedAgents);
|
|
393
|
+
const nodes = [...drafts.values()].map((node) => ({ ...node, children: [...node.children] }));
|
|
394
|
+
return { roots, nodes, byId: new Map(nodes.map((node) => [node.id, node])) };
|
|
395
|
+
}
|
|
396
|
+
export function workflowPhaseTreeVisibleNodes(tree: WorkflowPhaseTree, expanded: ReadonlySet<string> = new Set()): readonly WorkflowPhaseTreeNode[] {
|
|
397
|
+
const visible: WorkflowPhaseTreeNode[] = [];
|
|
398
|
+
const visit = (id: string): void => {
|
|
399
|
+
const node = tree.byId.get(id);
|
|
400
|
+
if (!node) return;
|
|
401
|
+
visible.push(node);
|
|
402
|
+
if (expanded.has(node.id)) for (const childId of node.children) visit(childId);
|
|
403
|
+
};
|
|
404
|
+
for (const root of tree.roots) visit(root);
|
|
405
|
+
return visible;
|
|
406
|
+
}
|
|
407
|
+
export function workflowPhaseTreeInitialExpanded(tree: WorkflowPhaseTree): ReadonlySet<string> {
|
|
408
|
+
return new Set(tree.nodes.filter((node) => node.children.length > 0).map((node) => node.id));
|
|
409
|
+
}
|
|
410
|
+
export function preserveWorkflowPhaseTreeSelection(tree: WorkflowPhaseTree, selection: WorkflowPhaseTreeSelection): WorkflowPhaseTreeSelection {
|
|
411
|
+
const node = (selection.nodeId ? tree.byId.get(selection.nodeId) : undefined) ?? tree.nodes[0];
|
|
412
|
+
return node ? { nodeId: node.id } : {};
|
|
413
|
+
}
|
|
414
|
+
export function navigateWorkflowPhaseTree(tree: WorkflowPhaseTree, selectedNodeId: string | undefined, expandedNodeIds: ReadonlySet<string>, direction: WorkflowPhaseTreeDirection): { nodeId?: string; expandedNodeIds: ReadonlySet<string> } {
|
|
415
|
+
const expanded = new Set(expandedNodeIds);
|
|
416
|
+
const current = (selectedNodeId ? tree.byId.get(selectedNodeId) : undefined) ?? tree.nodes[0];
|
|
417
|
+
if (!current) return { expandedNodeIds: expanded };
|
|
418
|
+
if (direction === "left") {
|
|
419
|
+
if (current.children.length && expanded.delete(current.id)) return { nodeId: current.id, expandedNodeIds: expanded };
|
|
420
|
+
return { nodeId: current.parentId ?? current.id, expandedNodeIds: expanded };
|
|
421
|
+
}
|
|
422
|
+
if (direction === "right") {
|
|
423
|
+
if (current.children.length && !expanded.has(current.id)) { expanded.add(current.id); return { nodeId: current.id, expandedNodeIds: expanded }; }
|
|
424
|
+
return { nodeId: current.children[0] ?? current.id, expandedNodeIds: expanded };
|
|
425
|
+
}
|
|
426
|
+
const visible = workflowPhaseTreeVisibleNodes(tree, expanded);
|
|
427
|
+
const index = Math.max(0, visible.findIndex((node) => node.id === current.id));
|
|
428
|
+
const next = visible[(index + (direction === "up" ? visible.length - 1 : 1)) % visible.length];
|
|
429
|
+
return { nodeId: next?.id ?? current.id, expandedNodeIds: expanded };
|
|
430
|
+
}
|
|
431
|
+
export function preserveWorkflowPhaseSelection(model: WorkflowPhaseModel, selection: WorkflowPhaseSelection): WorkflowPhaseSelection {
|
|
432
|
+
const phase = model.phases.find((candidate) => candidate.id === selection.phaseId) ?? (model.currentPhaseIndex === undefined ? undefined : model.phases[model.currentPhaseIndex]) ?? model.phases[0];
|
|
433
|
+
if (!phase) return model.unassignedAgents?.length ? { nodeId: workflowPhaseTreePath("phase", "unassigned", []) } : {};
|
|
434
|
+
const tree = buildWorkflowPhaseTree(model);
|
|
435
|
+
const selectedAgent = selection.agentId ? phase.agents.find((candidate) => candidate.id === selection.agentId) : undefined;
|
|
436
|
+
const selectedCandidate = selection.nodeId ? tree.byId.get(selection.nodeId) : undefined;
|
|
437
|
+
const selected = selectedCandidate?.phaseId === phase.id ? selectedCandidate : selectedAgent ? tree.byId.get(workflowPhaseTreePath("agent", phase.id, selectedAgent.structuralPath ?? [], selectedAgent.id)) : undefined;
|
|
438
|
+
const nodeId = selected?.id ?? tree.byId.get(workflowPhaseTreePath("phase", phase.id, []))?.id;
|
|
439
|
+
return { phaseId: phase.id, ...(selection.agentId && phase.agents.some((agent) => agent.id === selection.agentId) ? { agentId: selection.agentId } : phase.agents[0] ? { agentId: phase.agents[0].id } : {}), ...(nodeId ? { nodeId } : {}), ...(selection.expandedNodeIds ? { expandedNodeIds: selection.expandedNodeIds } : {}) };
|
|
440
|
+
}
|
|
166
441
|
type AgentGroup = { label: string; entries: readonly { agent: AgentRecord; index: number; depth: number }[] };
|
|
167
442
|
function agentGroupKey(agent: AgentRecord): string { return JSON.stringify([agent.structuralPath ?? [], agent.parentBreadcrumb ?? null]); }
|
|
168
443
|
function agentGroupLabel(agents: readonly AgentRecord[]): string {
|
|
@@ -175,7 +450,8 @@ function agentGroups(agents: readonly AgentRecord[], allAgents: readonly AgentRe
|
|
|
175
450
|
const groups = new Map<string, { agents: Array<{ agent: AgentRecord; index: number; depth: number }> }>();
|
|
176
451
|
for (const [index, agent] of agents.entries()) {
|
|
177
452
|
let depth = 0;
|
|
178
|
-
|
|
453
|
+
const seen = new Set<string>([agent.id]);
|
|
454
|
+
for (let parent = agent.parentId; parent && byId.has(parent); parent = byId.get(parent)?.parentId) { if (seen.has(parent)) break; seen.add(parent); depth += 1; }
|
|
179
455
|
const key = agentGroupKey(agent);
|
|
180
456
|
const group = groups.get(key) ?? { agents: [] };
|
|
181
457
|
group.agents.push({ agent, index, depth });
|
|
@@ -191,23 +467,32 @@ function renderGroupedAgents(agents: readonly AgentRecord[], render: (entry: { a
|
|
|
191
467
|
...group.entries.map((entry) => render(entry, grouped)),
|
|
192
468
|
]);
|
|
193
469
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
470
|
+
const RUN_STATE_GLYPH: Record<string, string> = { completed: "✓", failed: "✗", stopped: "✗", budget_exhausted: "!", awaiting_input: "●" };
|
|
471
|
+
const AGENT_STATE_GLYPH: Record<string, string> = { completed: "✓", failed: "✗", cancelled: "✗" };
|
|
472
|
+
function runStateGlyph(state: string, running: string): string { return state === "running" ? running : RUN_STATE_GLYPH[state] ?? "◆"; }
|
|
473
|
+
function agentStateGlyph(state: string, running: string): string { return state === "running" ? running : AGENT_STATE_GLYPH[state] ?? "○"; }
|
|
474
|
+
type ProgressStyleKey = "success" | "error" | "warning" | "accent" | "muted";
|
|
475
|
+
const PROGRESS_STATE_STYLE: Record<string, ProgressStyleKey> = { completed: "success", failed: "error", cancelled: "error", running: "accent" };
|
|
476
|
+
const WORKFLOW_ICON_STYLE: Record<string, ProgressStyleKey> = { completed: "success", failed: "error", stopped: "error", budget_exhausted: "warning", running: "accent" };
|
|
477
|
+
const PHASE_STATE_STYLE: Record<string, ProgressStyleKey> = { completed: "success", failed: "error", cancelled: "error", running: "accent", interrupted: "warning", budget_exhausted: "warning" };
|
|
478
|
+
function styleForState(map: Record<string, ProgressStyleKey>, state: string, styles: WorkflowProgressStyles): (text: string) => string {
|
|
479
|
+
const key = map[state] ?? "muted";
|
|
480
|
+
return (text) => styles[key](text);
|
|
199
481
|
}
|
|
482
|
+
function progressStyleForState(state: string, styles: WorkflowProgressStyles): (text: string) => string { return styleForState(PROGRESS_STATE_STYLE, state, styles); }
|
|
483
|
+
function workflowIconStyle(state: string, styles: WorkflowProgressStyles): (text: string) => string { return styleForState(WORKFLOW_ICON_STYLE, state, styles); }
|
|
484
|
+
function phaseStyleForState(state: string, styles: WorkflowProgressStyles): (text: string) => string { return styleForState(PHASE_STATE_STYLE, state, styles); }
|
|
200
485
|
export function formatWorkflowProgress(run: PersistedRun, spinner = "◇", styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES): string {
|
|
201
486
|
const done = run.agents.filter((agent) => SETTLED_AGENT_STATES.has(agent.state)).length;
|
|
202
|
-
const workflowIcon = run.state
|
|
203
|
-
const
|
|
487
|
+
const workflowIcon = runStateGlyph(run.state, spinner);
|
|
488
|
+
const iconStyle = workflowIconStyle(run.state, styles);
|
|
204
489
|
const header = styles.bold(styles.accent(`Workflow: ${run.workflowName} (${String(done)}/${String(run.agents.length)} done)`));
|
|
205
|
-
const lines = [`${
|
|
490
|
+
const lines = [`${iconStyle(workflowIcon)} ${header}`];
|
|
206
491
|
const budgetWarning = run.state === "budget_exhausted" || (run.budgetEvents ?? []).some((event) => event.type === "hard_exhausted");
|
|
207
492
|
lines.push(...formatCompactBudgetStatus(run).map((line) => ` ${budgetWarning ? styles.warning(line) : line}`));
|
|
208
493
|
const byId = new Map(run.agents.map((agent) => [agent.id, agent]));
|
|
209
494
|
const renderAgents = (agents: readonly AgentRecord[], offset: number, nested: boolean) => renderGroupedAgents(agents, ({ agent, index, depth }, grouped) => {
|
|
210
|
-
const icon = agent.state
|
|
495
|
+
const icon = agentStateGlyph(agent.state, spinner);
|
|
211
496
|
const indent = " ".repeat((grouped ? 2 : 1) + depth);
|
|
212
497
|
const activity = SETTLED_AGENT_STATES.has(agent.state) ? "" : formatAgentActivity(agent, spinner, styles);
|
|
213
498
|
const name = grouped ? agent.label ?? agent.name : styledAgentBreadcrumb(agent, byId, styles);
|
|
@@ -242,8 +527,189 @@ function textBlock(text: string) {
|
|
|
242
527
|
invalidate() {},
|
|
243
528
|
};
|
|
244
529
|
}
|
|
530
|
+
function styledTextBlock(text: string) {
|
|
531
|
+
return {
|
|
532
|
+
render(width: number) {
|
|
533
|
+
return truncateWorkflowProgress(text, width);
|
|
534
|
+
},
|
|
535
|
+
invalidate() {},
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
function workflowCatalogBlock(text: string, expanded: boolean) {
|
|
539
|
+
return {
|
|
540
|
+
render(width: number) {
|
|
541
|
+
const safeWidth = Math.max(1, width);
|
|
542
|
+
if (!expanded) return truncateWorkflowProgress(text, safeWidth);
|
|
543
|
+
return truncateToVisualLines(text, Number.MAX_SAFE_INTEGER, safeWidth, 0).visualLines.map((line) => line.trimEnd());
|
|
544
|
+
},
|
|
545
|
+
invalidate() {},
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
type WorkflowControlResult = { details?: unknown; content?: readonly { type: string; text?: string }[] };
|
|
550
|
+
function controlString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; }
|
|
551
|
+
function controlValue(value: unknown): string {
|
|
552
|
+
if (value === null) return "removed";
|
|
553
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
|
|
554
|
+
const json = JSON.stringify(value);
|
|
555
|
+
return typeof json === "string" ? json : "unknown";
|
|
556
|
+
}
|
|
557
|
+
function controlTitle(name: string, theme: Theme): string { return theme.fg("toolTitle", theme.bold(name)); }
|
|
558
|
+
function controlState(state: string, theme: Theme): string {
|
|
559
|
+
const color = state === "completed" || state === "running" || state === "stopped" ? "success" : state === "failed" || state === "unknown" ? "error" : state === "budget_exhausted" || state === "awaiting_approval" ? "warning" : "accent";
|
|
560
|
+
return theme.fg(color, state);
|
|
561
|
+
}
|
|
562
|
+
function controlAction(action: string, theme: Theme): string {
|
|
563
|
+
const color = /approved|stopped|started|resumed/.test(action) ? "success" : /rejected|failed/.test(action) ? "error" : "warning";
|
|
564
|
+
return theme.fg(color, action);
|
|
565
|
+
}
|
|
566
|
+
function budgetPatchEntries(value: unknown): string[] {
|
|
567
|
+
if (!object(value)) return value === undefined ? [] : [controlValue(value)];
|
|
568
|
+
return Object.entries(value).map(([dimension, limits]) => {
|
|
569
|
+
if (limits === null) return `${dimension}=removed`;
|
|
570
|
+
if (!object(limits)) return `${dimension}=${controlValue(limits)}`;
|
|
571
|
+
const parts = ["soft", "hard"].filter((key) => Object.prototype.hasOwnProperty.call(limits, key)).map((key) => `${key}=${controlValue(limits[key])}`);
|
|
572
|
+
return `${dimension} ${parts.join(" ")}`;
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
function budgetPatchSummary(value: unknown): string {
|
|
576
|
+
const entries = budgetPatchEntries(value);
|
|
577
|
+
return entries.length ? entries.join(", ") : "unchanged";
|
|
578
|
+
}
|
|
579
|
+
function budgetPatchDetails(value: unknown, theme: Theme): string[] {
|
|
580
|
+
const entries = budgetPatchEntries(value);
|
|
581
|
+
return entries.length ? [theme.fg("accent", theme.bold("Budget patch")), ...entries.map((entry) => ` ${theme.fg("toolOutput", entry)}`)] : [];
|
|
582
|
+
}
|
|
583
|
+
function workflowControlValue(result: WorkflowControlResult): unknown { return catalogResultValue(result); }
|
|
584
|
+
function workflowControlCall(name: string, args: Record<string, unknown>, theme: Theme): string {
|
|
585
|
+
const runId = controlString(args.runId) ?? "(missing run ID)";
|
|
586
|
+
if (name === "workflow_respond") {
|
|
587
|
+
const proposalId = controlString(args.proposalId);
|
|
588
|
+
const target = proposalId ? `budget proposal ${proposalId}` : `checkpoint ${controlString(args.name) ?? "(missing name)"}`;
|
|
589
|
+
const decision = args.approved === true ? "approve" : "reject";
|
|
590
|
+
return [`${controlTitle(name, theme)} ${theme.fg("accent", runId)}`, `${theme.fg("muted", target)} · ${controlAction(decision, theme)}`].join("\n");
|
|
591
|
+
}
|
|
592
|
+
if (name === "workflow_resume") return args.budget === undefined ? `${controlTitle(name, theme)} ${theme.fg("accent", runId)}` : [`${controlTitle(name, theme)} ${theme.fg("accent", runId)}`, `${theme.fg("muted", "Budget")} ${theme.fg("toolOutput", budgetPatchSummary(args.budget))}`].join("\n");
|
|
593
|
+
if (name === "workflow_retry") return `${controlTitle(name, theme)} ${theme.fg("accent", runId)} ${theme.fg("muted", "failed run")}`;
|
|
594
|
+
return `${controlTitle(name, theme)} ${theme.fg("accent", runId)}`;
|
|
595
|
+
}
|
|
596
|
+
function workflowControlResult(name: string, args: Record<string, unknown>, result: WorkflowControlResult, expanded: boolean, theme: Theme, isError: boolean): string {
|
|
597
|
+
if (isError) {
|
|
598
|
+
const text = result.content?.filter(({ type }) => type === "text").map(({ text }) => text ?? "").join("\n").trim();
|
|
599
|
+
return theme.fg("error", text || `The ${name} tool failed.`);
|
|
600
|
+
}
|
|
601
|
+
const value = workflowControlValue(result);
|
|
602
|
+
if (!object(value)) return theme.fg("error", `The ${name} tool returned an invalid result.`);
|
|
603
|
+
const runId = controlString(args.runId) ?? controlString(value.runId) ?? "(unknown)";
|
|
604
|
+
const title = controlTitle(name, theme);
|
|
605
|
+
if (name === "workflow_stop") {
|
|
606
|
+
const state = controlString(value.state) ?? "unknown";
|
|
607
|
+
const action = value.stopped === true ? "stopped" : value.reason === "already_terminal" ? "already terminal" : value.reason === "unknown_run" ? "run not found" : "no change";
|
|
608
|
+
if (!expanded) return `${title}\nRun ${theme.fg("accent", runId)} · ${controlState(state, theme)} · ${controlAction(action, theme)}`;
|
|
609
|
+
return [title, `Run: ${theme.fg("accent", runId)}`, `State: ${controlState(state, theme)}`, `Action: ${controlAction(action, theme)}`, ...(controlString(value.reason) ? [`Reason: ${theme.fg("toolOutput", controlValue(value.reason))}`] : [])].join("\n");
|
|
610
|
+
}
|
|
611
|
+
if (name === "workflow_retry") {
|
|
612
|
+
const childRunId = controlString(value.runId) ?? "(unknown)";
|
|
613
|
+
const state = controlString(value.state) ?? "unknown";
|
|
614
|
+
if (!expanded) return [title, `Source ${theme.fg("accent", runId)}`, `Child ${theme.fg("accent", childRunId)} · ${controlState(state, theme)} · ${controlAction("started", theme)}`].join("\n");
|
|
615
|
+
return [title, `Source run: ${theme.fg("accent", runId)}`, `Retry run: ${theme.fg("accent", childRunId)}`, `State: ${controlState(state, theme)}`, `Action: ${controlAction("started; completed work will be replayed", theme)}`].join("\n");
|
|
616
|
+
}
|
|
617
|
+
if (name === "workflow_resume") {
|
|
618
|
+
const state = controlString(value.state) ?? "unknown";
|
|
619
|
+
const proposalId = controlString(value.proposalId);
|
|
620
|
+
const action = state === "awaiting_approval" ? "approval required" : state === "running" ? "resumed" : "no change";
|
|
621
|
+
if (!expanded) return [title, `Run ${theme.fg("accent", runId)} · ${controlState(state, theme)} · ${controlAction(action, theme)}`, ...(proposalId ? [`Proposal ${theme.fg("accent", proposalId)}`] : [])].join("\n");
|
|
622
|
+
return [title, `Run: ${theme.fg("accent", runId)}`, `State: ${controlState(state, theme)}`, `Action: ${controlAction(action, theme)}`, ...(proposalId ? [`Proposal: ${theme.fg("accent", proposalId)}`] : []), ...budgetPatchDetails(args.budget, theme)].join("\n");
|
|
623
|
+
}
|
|
624
|
+
const proposalId = controlString(args.proposalId);
|
|
625
|
+
const checkpointName = controlString(args.name);
|
|
626
|
+
const target = proposalId ? `Budget proposal ${theme.fg("accent", proposalId)}` : `Checkpoint ${theme.fg("accent", checkpointName ?? "(missing)")}`;
|
|
627
|
+
const accepted = value.accepted === true;
|
|
628
|
+
const approved = value.approved === true;
|
|
629
|
+
const reason = controlString(value.reason);
|
|
630
|
+
const action = reason === "proposal_not_pending" ? "not pending" : reason === "checkpoint" && !accepted ? "not pending" : approved ? "approved" : "rejected";
|
|
631
|
+
const state = controlString(value.state);
|
|
632
|
+
if (!expanded) return [title, target, `Run ${theme.fg("accent", runId)} · ${controlAction(action, theme)}${state ? ` · ${controlState(state, theme)}` : ""}`].join("\n");
|
|
633
|
+
return [title, `Run: ${theme.fg("accent", runId)}`, `Target: ${target}`, `Action: ${controlAction(action, theme)}`, ...(state ? [`State: ${controlState(state, theme)}`] : []), ...(reason ? [`Reason: ${theme.fg("toolOutput", reason)}`] : [])].join("\n");
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function catalogText(value: string): string { return value.replace(/\s+/g, " ").trim(); }
|
|
637
|
+
|
|
638
|
+
type CatalogToolResult = { details?: unknown; content?: readonly { type: string; text?: string }[] };
|
|
639
|
+
|
|
640
|
+
function catalogResultValue(result: CatalogToolResult): unknown {
|
|
641
|
+
if (result.details !== undefined) return result.details;
|
|
642
|
+
const text = result.content?.find((entry) => entry.type === "text")?.text;
|
|
643
|
+
if (!text) return undefined;
|
|
644
|
+
try { return JSON.parse(text) as unknown; } catch { return text; }
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function isCatalogIndex(value: unknown): value is WorkflowCatalogIndex {
|
|
648
|
+
return object(value) && Array.isArray(value.functions) && Array.isArray(value.variables);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function isCatalogFunction(value: unknown): value is WorkflowCatalogFunction {
|
|
652
|
+
return object(value) && typeof value.name === "string" && typeof value.description === "string" && object(value.input) && object(value.output);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function isCatalogVariable(value: unknown): value is WorkflowCatalogVariable {
|
|
656
|
+
return object(value) && typeof value.name === "string" && typeof value.description === "string" && object(value.schema);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function isCatalogError(value: unknown): value is { error: { message: string } } {
|
|
660
|
+
return object(value) && object(value.error) && typeof value.error.message === "string";
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function catalogSectionTitle(label: string, count: number, theme: Theme): string {
|
|
664
|
+
return theme.fg("accent", theme.bold(`${label} (${String(count)})`));
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function catalogIndexEntries(entries: readonly { name: string; description: string }[], theme: Theme): string[] {
|
|
668
|
+
const width = Math.max(0, ...entries.map((entry) => entry.name.length));
|
|
669
|
+
return entries.map((entry) => ` ${theme.fg("accent", entry.name.padEnd(width))} ${theme.fg("toolOutput", catalogText(entry.description))}`);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function formatCatalogIndex(catalog: WorkflowCatalogIndex, theme: Theme): string {
|
|
673
|
+
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" }));
|
|
674
|
+
const aliasWidth = Math.max(0, ...aliases.map(({ name }) => name.length));
|
|
675
|
+
const aliasLines = aliases.map(({ name, kind, provenance }) => ` ${theme.fg("accent", name.padEnd(aliasWidth))} ${theme.fg("toolOutput", `${kind} · ${provenance}`)}`);
|
|
676
|
+
return [
|
|
677
|
+
catalogSectionTitle("Functions", catalog.functions.length, theme),
|
|
678
|
+
...catalogIndexEntries(catalog.functions, theme),
|
|
679
|
+
"",
|
|
680
|
+
catalogSectionTitle("Variables", catalog.variables.length, theme),
|
|
681
|
+
...catalogIndexEntries(catalog.variables, theme),
|
|
682
|
+
"",
|
|
683
|
+
catalogSectionTitle("Model aliases", aliases.length, theme),
|
|
684
|
+
...aliasLines,
|
|
685
|
+
].join("\n");
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function catalogSchemaLines(schema: unknown, theme: Theme): string[] {
|
|
689
|
+
const json = JSON.stringify(schema, null, 2);
|
|
690
|
+
return json.split("\n").map((line) => ` ${theme.fg("toolOutput", line)}`);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function formatCatalogDetail(value: WorkflowCatalogFunction | WorkflowCatalogVariable | import("./types.js").WorkflowCatalogModelAlias, expanded: boolean, theme: Theme): string {
|
|
694
|
+
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");
|
|
695
|
+
const kind = "input" in value ? "Function" : "Variable";
|
|
696
|
+
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");
|
|
697
|
+
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"))];
|
|
698
|
+
if ("input" in value) lines.push(theme.fg("muted", "Input schema"), ...catalogSchemaLines(value.input, theme), "", theme.fg("muted", "Output schema"), ...catalogSchemaLines(value.output, theme));
|
|
699
|
+
else lines.push(theme.fg("muted", "Variable schema"), ...catalogSchemaLines(value.schema, theme));
|
|
700
|
+
return lines.join("\n");
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function formatWorkflowCatalog(value: unknown, expanded: boolean, theme: Theme): string {
|
|
704
|
+
if (isCatalogIndex(value)) return formatCatalogIndex(value, theme);
|
|
705
|
+
if (isCatalogFunction(value) || isCatalogVariable(value)) return formatCatalogDetail(value, expanded, theme);
|
|
706
|
+
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);
|
|
707
|
+
if (isCatalogError(value)) return theme.fg("error", value.error.message);
|
|
708
|
+
return theme.fg("error", "The workflow catalog returned an invalid result.");
|
|
709
|
+
}
|
|
245
710
|
|
|
246
|
-
const
|
|
711
|
+
const ANSI_SGR_SOURCE = `${String.fromCharCode(27)}\\[[0-9;]*m`;
|
|
712
|
+
const ANSI_SGR = new RegExp(ANSI_SGR_SOURCE);
|
|
247
713
|
export function truncateWorkflowProgress(text: string, width: number): string[] {
|
|
248
714
|
const safeWidth = Math.max(1, width);
|
|
249
715
|
return text.split("\n").flatMap((line) => {
|
|
@@ -264,7 +730,7 @@ function themeWorkflowProgressStyles(theme: Theme): WorkflowProgressStyles {
|
|
|
264
730
|
warning: (text) => theme.fg("warning", text),
|
|
265
731
|
muted: (text) => theme.fg("muted", text),
|
|
266
732
|
dim: (text) => theme.fg("dim", text),
|
|
267
|
-
bold: (text) => theme.bold(text),
|
|
733
|
+
bold: (text) => typeof theme.bold === "function" ? theme.bold(text) : text,
|
|
268
734
|
};
|
|
269
735
|
}
|
|
270
736
|
function workflowProgressBlock(run: PersistedRun, theme: Theme) {
|
|
@@ -310,7 +776,7 @@ function navigatorRunLabels(entries: readonly { store: RunStore; loaded: { run:
|
|
|
310
776
|
for (const { loaded: { run } } of entries) nameCount.set(run.workflowName, (nameCount.get(run.workflowName) ?? 0) + 1);
|
|
311
777
|
return entries.map(({ store, loaded: { run } }) => {
|
|
312
778
|
const done = run.agents.filter((a) => SETTLED_AGENT_STATES.has(a.state)).length;
|
|
313
|
-
const glyph = run.state
|
|
779
|
+
const glyph = runStateGlyph(run.state, "⠦");
|
|
314
780
|
const suffix = (nameCount.get(run.workflowName) ?? 0) > 1 ? ` ${store.runId.slice(0, 8)}` : "";
|
|
315
781
|
const cost = run.agents.reduce((sum, a) => sum + (a.accounting?.cost ?? 0), 0);
|
|
316
782
|
const costStr = cost > 0 ? ` $${cost.toFixed(2)}` : "";
|
|
@@ -318,28 +784,29 @@ function navigatorRunLabels(entries: readonly { store: RunStore; loaded: { run:
|
|
|
318
784
|
});
|
|
319
785
|
}
|
|
320
786
|
|
|
321
|
-
function agentBreadcrumbParts(agent: AgentRecord, byId: Map<string, AgentRecord
|
|
322
|
-
const
|
|
323
|
-
const parts: string[] = agent.
|
|
787
|
+
export function agentBreadcrumbParts(agent: AgentRecord, byId: Map<string, AgentRecord>, includeStructuralPath = false): string[] {
|
|
788
|
+
const leaf = agent.label ?? agent.name;
|
|
789
|
+
const parts: string[] = includeStructuralPath && agent.structuralPath?.length ? [agent.structuralPath.join(" > ")] : [];
|
|
790
|
+
if (agent.parentBreadcrumb) parts.push(agent.parentBreadcrumb);
|
|
791
|
+
const ancestors: string[] = [];
|
|
324
792
|
const seen = new Set<string>([agent.id]);
|
|
325
793
|
for (let parentId = agent.parentId; parentId; parentId = byId.get(parentId)?.parentId) {
|
|
326
|
-
if (seen.has(parentId)) break;
|
|
794
|
+
if (seen.has(parentId)) break;
|
|
327
795
|
seen.add(parentId);
|
|
328
796
|
const parent = byId.get(parentId);
|
|
329
|
-
if (parent)
|
|
330
|
-
|
|
797
|
+
if (!parent) break;
|
|
798
|
+
ancestors.push(parent.label ?? parent.name);
|
|
331
799
|
}
|
|
332
|
-
parts.push(
|
|
800
|
+
parts.push(...ancestors.reverse(), leaf);
|
|
333
801
|
return parts;
|
|
334
802
|
}
|
|
335
|
-
function agentBreadcrumb(agent: AgentRecord, byId: Map<string, AgentRecord
|
|
336
|
-
|
|
337
|
-
return parts.length > 1 ? parts.join(" > ") : parts[0] ?? "";
|
|
803
|
+
export function agentBreadcrumb(agent: AgentRecord, byId: Map<string, AgentRecord>, includeStructuralPath = false): string {
|
|
804
|
+
return agentBreadcrumbParts(agent, byId, includeStructuralPath).join(" > ");
|
|
338
805
|
}
|
|
339
806
|
function styledAgentBreadcrumb(agent: AgentRecord, byId: Map<string, AgentRecord>, styles: WorkflowProgressStyles): string {
|
|
340
807
|
const parts = agentBreadcrumbParts(agent, byId);
|
|
341
808
|
if (parts.length <= 1) return parts[0] ?? "";
|
|
342
|
-
return `${styles.muted(parts.slice(0, -1).join(" > "))} > ${parts[parts.length - 1] ?? ""}`;
|
|
809
|
+
return `${styles.muted(parts.slice(0, -1).join(" > "))} > ${styles.bold(parts[parts.length - 1] ?? "")}`;
|
|
343
810
|
}
|
|
344
811
|
|
|
345
812
|
function formatAgentActivity(agent: AgentRecord, spinner: string, styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES): string {
|
|
@@ -358,7 +825,7 @@ export function formatNavigatorDashboard(run: PersistedRun, checkpoints: readonl
|
|
|
358
825
|
const done = run.agents.filter((a) => SETTLED_AGENT_STATES.has(a.state)).length;
|
|
359
826
|
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 });
|
|
360
827
|
const hasAccounting = run.agents.some((a) => a.accounting);
|
|
361
|
-
const glyph = run.state
|
|
828
|
+
const glyph = runStateGlyph(run.state, "⠦");
|
|
362
829
|
const header = `${glyph} ${run.workflowName}`;
|
|
363
830
|
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(" · ");
|
|
364
831
|
const lines = [header, meta, ...formatCompactBudgetStatus(run)];
|
|
@@ -367,7 +834,7 @@ export function formatNavigatorDashboard(run: PersistedRun, checkpoints: readonl
|
|
|
367
834
|
lines.push("");
|
|
368
835
|
const byId = new Map(run.agents.map((a) => [a.id, a]));
|
|
369
836
|
const render = ({ agent, depth }: { agent: AgentRecord; index: number; depth: number }, grouped: boolean) => {
|
|
370
|
-
const icon = agent.state
|
|
837
|
+
const icon = agentStateGlyph(agent.state, "⠦");
|
|
371
838
|
const breadcrumb = grouped ? agent.label ?? agent.name : agentBreadcrumb(agent, byId);
|
|
372
839
|
const tokens = agent.accounting ? formatAccounting(agent.accounting) : "";
|
|
373
840
|
const indent = " ".repeat((grouped ? 2 : 1) + depth);
|
|
@@ -385,7 +852,7 @@ export function formatNavigatorDashboard(run: PersistedRun, checkpoints: readonl
|
|
|
385
852
|
return lines.join("\n");
|
|
386
853
|
}
|
|
387
854
|
|
|
388
|
-
export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> }, checkpoints: readonly AwaitingCheckpoint[],
|
|
855
|
+
export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> }, checkpoints: readonly AwaitingCheckpoint[], worktrees: readonly WorktreeReference[]): string {
|
|
389
856
|
const { run, snapshot } = loaded;
|
|
390
857
|
const lines = [
|
|
391
858
|
`Workflow: ${run.workflowName}`,
|
|
@@ -395,11 +862,13 @@ export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readon
|
|
|
395
862
|
`Launch cwd: ${run.cwd}`,
|
|
396
863
|
...formatCompactBudgetStatus(run),
|
|
397
864
|
`Launch models: ${snapshot.models.join(", ") || "(none)"}`,
|
|
865
|
+
`Settings: concurrency=${String(snapshot.settings.concurrency)}`,
|
|
398
866
|
];
|
|
399
867
|
if (run.error) lines.push(`Run error: ${run.error.code}: ${run.error.message}`);
|
|
400
868
|
if (run.events?.length) lines.push(...run.events.map((event) => `Warning: ${event.message}`));
|
|
401
869
|
const aliases = snapshot.modelAliases ?? snapshot.settings.modelAliases;
|
|
402
870
|
if (aliases && Object.keys(aliases).length) lines.push(`Model aliases: ${Object.entries(aliases).map(([name, target]) => `${name}=${target}`).join(", ")}`);
|
|
871
|
+
if (snapshot.settingsSources) lines.push(`Settings sources: concurrency=${snapshot.settingsSources.concurrency}, modelAliases=${snapshot.settingsSources.modelAliases}, disabledAgentResources=${snapshot.settingsSources.disabledAgentResources}`);
|
|
403
872
|
lines.push("Agents / ownership:");
|
|
404
873
|
if (!run.agents.length) lines.push(" (none)");
|
|
405
874
|
const byId = new Map(run.agents.map((agent) => [agent.id, agent]));
|
|
@@ -417,10 +886,94 @@ export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readon
|
|
|
417
886
|
lines.push("Checkpoints:");
|
|
418
887
|
if (!checkpoints.length) lines.push(" (none)");
|
|
419
888
|
for (const checkpoint of checkpoints) lines.push(` ${checkpoint.name}: ${checkpoint.prompt} context=${JSON.stringify(checkpoint.context)}`);
|
|
420
|
-
lines.push(`Worktrees: ${String(
|
|
889
|
+
lines.push(`Worktrees: ${String(worktrees.length)}`);
|
|
421
890
|
lines.push(`Native Pi transcripts: ${String(run.nativeSessions.length)}`);
|
|
422
891
|
return lines.join("\n");
|
|
423
892
|
}
|
|
893
|
+
export function formatWorkflowPhaseDashboard(run: PersistedRun, snapshot: Readonly<LaunchSnapshot>, width: number, selection: WorkflowPhaseSelection = {}, styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES): string[] {
|
|
894
|
+
const safeWidth = Math.max(1, width);
|
|
895
|
+
const model = buildWorkflowPhaseModel(run, snapshot);
|
|
896
|
+
const tree = buildWorkflowPhaseTree(model);
|
|
897
|
+
const expanded = selection.expandedNodeIds === undefined ? workflowPhaseTreeInitialExpanded(tree) : new Set(selection.expandedNodeIds);
|
|
898
|
+
const wrap = (text: string, limit = safeWidth): string[] => truncateToVisualLines(text, Number.MAX_SAFE_INTEGER, Math.max(1, limit), 0).visualLines.map((line) => line.trimEnd());
|
|
899
|
+
// ponytail: ANSI-only width, good enough for the ASCII labels the tree renders
|
|
900
|
+
const ansiPattern = new RegExp(ANSI_SGR_SOURCE, "g");
|
|
901
|
+
const visibleLength = (text: string): number => text.replace(ansiPattern, "").length;
|
|
902
|
+
const padTo = (text: string, limit: number): string => `${text}${" ".repeat(Math.max(0, limit - visibleLength(text)))}`;
|
|
903
|
+
const phaseStyle = (state: WorkflowPhaseState | AgentRecord["state"]): ((text: string) => string) => phaseStyleForState(state, styles);
|
|
904
|
+
const phase = selection.phaseId ? model.phases.find((candidate) => candidate.id === selection.phaseId) : undefined;
|
|
905
|
+
const selectedByAgent = selection.agentId ? tree.nodes.find((node) => node.kind === "agent" && node.agentId === selection.agentId && (!selection.phaseId || node.phaseId === selection.phaseId)) : undefined;
|
|
906
|
+
const selectedNode = (selection.nodeId ? tree.byId.get(selection.nodeId) : undefined) ?? selectedByAgent ?? (phase ? tree.byId.get(workflowPhaseTreePath("phase", phase.id, [])) : undefined) ?? (model.currentPhaseId ? tree.byId.get(workflowPhaseTreePath("phase", model.currentPhaseId, [])) : undefined) ?? tree.nodes[0];
|
|
907
|
+
const selectedPhase = selectedNode?.phase ?? (selectedNode ? model.phases.find((candidate) => candidate.id === selectedNode.phaseId) : undefined);
|
|
908
|
+
const visibleNodes = workflowPhaseTreeVisibleNodes(tree, expanded);
|
|
909
|
+
const nodeAgents = (node: WorkflowPhaseTreeNode): AgentRecord[] => {
|
|
910
|
+
const agents: AgentRecord[] = [];
|
|
911
|
+
const visit = (id: string): void => {
|
|
912
|
+
const child = tree.byId.get(id);
|
|
913
|
+
if (!child) return;
|
|
914
|
+
if (child.agent) agents.push(child.agent); else for (const childId of child.children) visit(childId);
|
|
915
|
+
};
|
|
916
|
+
if (node.agent) agents.push(node.agent); else for (const childId of node.children) visit(childId);
|
|
917
|
+
return agents;
|
|
918
|
+
};
|
|
919
|
+
const nodeStatus = (node: WorkflowPhaseTreeNode): string => phaseStyle(node.state)(node.state);
|
|
920
|
+
const nodeIcon = (node: WorkflowPhaseTreeNode): string => node.children.length ? expanded.has(node.id) ? "▾" : "▸" : node.kind === "agent" ? "•" : " ";
|
|
921
|
+
const treeLine = (node: WorkflowPhaseTreeNode): string => {
|
|
922
|
+
const selected = node.id === selectedNode?.id;
|
|
923
|
+
const state = progressStyleForState(node.state, styles);
|
|
924
|
+
return `${selected ? "→" : " "} ${" ".repeat(node.depth)}${nodeIcon(node)} ${node.label} · ${state(node.state)}`;
|
|
925
|
+
};
|
|
926
|
+
const details = (node: WorkflowPhaseTreeNode | undefined): string[] => {
|
|
927
|
+
if (!node) return [styles.muted("No workflow node is selected")];
|
|
928
|
+
const agents = nodeAgents(node);
|
|
929
|
+
if (node.kind === "phase") {
|
|
930
|
+
const selected = node.phase;
|
|
931
|
+
const counts = selected?.counts ?? phaseAgentCounts(agents);
|
|
932
|
+
return [styles.bold(`Selected phase: ${node.label}`), `Status: ${nodeStatus(node)}`, `agents completed=${String(counts.completed)} running=${String(counts.running)} failed=${String(counts.failed)} cancelled=${String(counts.cancelled)} pending=${String(counts.pending)}`, `Agents: ${String(agents.length)}`];
|
|
933
|
+
}
|
|
934
|
+
if (node.kind === "operation") {
|
|
935
|
+
const states = phaseAgentCounts(agents);
|
|
936
|
+
return [styles.bold(`Selected operation: ${node.operationPath.join(" > ")}`), `Phase: ${node.phase?.name ?? node.phaseId}`, `Status: ${nodeStatus(node)}`, `agents completed=${String(states.completed)} running=${String(states.running)} failed=${String(states.failed)} cancelled=${String(states.cancelled)} pending=${String(states.pending)}`, `Agents: ${String(agents.length)}`];
|
|
937
|
+
}
|
|
938
|
+
const agent = node.agent;
|
|
939
|
+
if (!agent) return [styles.muted("Agent details are unavailable")];
|
|
940
|
+
const byId = new Map(run.agents.map((candidate) => [candidate.id, candidate]));
|
|
941
|
+
const result = [styles.bold(`Selected agent: ${agentBreadcrumb(agent, byId, true)}`), `State: ${phaseStyle(agent.state)(agent.state)}`, `Structural path: ${agent.structuralPath?.join(" > ") || "(root)"}`, `Model: ${agent.model.provider}/${agent.model.model}${agent.model.thinking ? `:${agent.model.thinking}` : ""}`, `Role: ${agent.role ?? "(none)"}`, `Tools: ${agent.tools.join(", ") || "(none)"}`, `Attempts: ${String(agent.attempts)}`, ...(selection.actions ? [] : [styles.muted("enter for agent actions")])];
|
|
942
|
+
const error = agent.attemptDetails?.at(-1)?.error;
|
|
943
|
+
if (error) result.push(styles.error(`Error: ${error.code}: ${error.message}`));
|
|
944
|
+
if (agent.activity) result.push(`Activity: ${agent.activity.text}`);
|
|
945
|
+
return result;
|
|
946
|
+
};
|
|
947
|
+
const stateNames: readonly WorkflowPhaseState[] = ["not started", "running", "completed", "failed", "cancelled", "interrupted", "budget_exhausted"];
|
|
948
|
+
const statusSummary = stateNames.filter((state) => (model.counts[state] ?? 0) > 0).map((state) => `${String(model.counts[state])} ${state}`).join(" · ") || "0 phases";
|
|
949
|
+
const lines: string[] = [styles.bold(styles.accent(`Workflow: ${run.workflowName}`))];
|
|
950
|
+
if (run.error) lines.push(styles.error(`ERROR ${run.error.code}: ${run.error.message}`));
|
|
951
|
+
lines.push(`phase: ${run.phase ?? selectedPhase?.name ?? "none"}`, `Run state: ${run.state}`, `Phases: ${statusSummary}`);
|
|
952
|
+
for (const event of run.events ?? []) lines.push(styles.warning(`Warning: ${event.message}`));
|
|
953
|
+
lines.push(...formatCompactBudgetStatus(run));
|
|
954
|
+
const renderTree = (limit: number): string[] => [styles.bold("Tree"), ...(visibleNodes.length ? visibleNodes.flatMap((node) => wrap(treeLine(node), limit)) : [styles.muted("(empty)")])];
|
|
955
|
+
const actionRows = (): string[] => {
|
|
956
|
+
const actions = selection.actions;
|
|
957
|
+
if (!actions) return [];
|
|
958
|
+
return ["", styles.bold(actions.title), ...actions.options.map((option, index) => `${index === actions.index ? "→ " : " "}${index === actions.index ? styles.accent(option) : option}`)];
|
|
959
|
+
};
|
|
960
|
+
const detailRows = (): string[] => [...details(selectedNode), ...actionRows()];
|
|
961
|
+
if (safeWidth >= 80) {
|
|
962
|
+
const sidebarWidth = Math.min(42, Math.max(24, Math.floor((safeWidth - 3) * 0.38)));
|
|
963
|
+
const detailWidth = Math.max(1, safeWidth - sidebarWidth - 3);
|
|
964
|
+
const sidebar = renderTree(sidebarWidth).flatMap((line) => wrap(line, sidebarWidth));
|
|
965
|
+
const detail = detailRows().flatMap((line) => wrap(line, detailWidth));
|
|
966
|
+
const rows = Math.max(sidebar.length, detail.length);
|
|
967
|
+
for (let index = 0; index < rows; index += 1) lines.push(`${padTo(sidebar[index] ?? "", sidebarWidth)} | ${detail[index] ?? ""}`);
|
|
968
|
+
} else if (selection.detailsOnly) {
|
|
969
|
+
lines.push(...detailRows().flatMap((line) => wrap(line)));
|
|
970
|
+
} else {
|
|
971
|
+
lines.push(...renderTree(safeWidth));
|
|
972
|
+
if (!selection.treeOnly) lines.push("", ...detailRows().flatMap((line) => wrap(line)));
|
|
973
|
+
}
|
|
974
|
+
if (model.unassignedAgents?.length && !tree.nodes.some((node) => node.phaseId === "unassigned")) lines.push(...wrap(styles.muted(`Unassigned agents: ${String(model.unassignedAgents.length)}`)));
|
|
975
|
+
return lines.flatMap((line) => wrap(line));
|
|
976
|
+
}
|
|
424
977
|
function formatCheckpointReview(checkpoint: AwaitingCheckpoint): string {
|
|
425
978
|
const context = JSON.stringify(checkpoint.context, null, 2);
|
|
426
979
|
return [`Name: ${checkpoint.name}`, "Prompt:", checkpoint.prompt, context === "null" ? "Context: null" : "Context:", ...(context === "null" ? [] : [context])].join("\n");
|
|
@@ -841,48 +1394,71 @@ function projectTrusted(ctx: unknown): boolean {
|
|
|
841
1394
|
const check = object(ctx) ? ctx.isProjectTrusted : undefined;
|
|
842
1395
|
return typeof check === "function" ? Boolean(Reflect.apply(check, ctx, [])) : true;
|
|
843
1396
|
}
|
|
1397
|
+
function asFn(value: unknown): ((...args: never[]) => unknown) | undefined { return typeof value === "function" ? value as (...args: never[]) => unknown : undefined; }
|
|
844
1398
|
type PiHostCapabilities = { registerEntryRenderer?: ExtensionAPI["registerEntryRenderer"]; events?: WorkflowEventSink };
|
|
845
|
-
function isEntryRenderer(value: unknown): value is NonNullable<PiHostCapabilities["registerEntryRenderer"]> { return typeof value === "function"; }
|
|
846
1399
|
function isWorkflowEventSink(value: unknown): value is WorkflowEventSink { return object(value) && typeof value.emit === "function"; }
|
|
847
1400
|
function piHostCapabilities(pi: unknown): PiHostCapabilities {
|
|
848
1401
|
if (!object(pi)) return {};
|
|
849
|
-
const registerEntryRenderer = pi.registerEntryRenderer;
|
|
1402
|
+
const registerEntryRenderer = asFn(pi.registerEntryRenderer) as NonNullable<PiHostCapabilities["registerEntryRenderer"]> | undefined;
|
|
850
1403
|
const events = pi.events;
|
|
851
|
-
return { ...(
|
|
1404
|
+
return { ...(registerEntryRenderer ? { registerEntryRenderer } : {}), ...(isWorkflowEventSink(events) ? { events } : {}) };
|
|
852
1405
|
}
|
|
853
1406
|
type ContextHostCapabilities = { modelRegistry?: ModelRegistryCapability };
|
|
854
1407
|
type ModelSummary = { provider: string; id: string };
|
|
855
1408
|
type ModelRegistryGetter = () => readonly ModelSummary[];
|
|
856
1409
|
type ModelRegistryCapability = { getAll?: ModelRegistryGetter; getAvailable?: ModelRegistryGetter };
|
|
857
|
-
function isModelRegistryGetter(value: unknown): value is ModelRegistryGetter { return typeof value === "function"; }
|
|
858
1410
|
function contextHostCapabilities(ctx: unknown): ContextHostCapabilities {
|
|
859
1411
|
if (!object(ctx) || !object(ctx.modelRegistry)) return {};
|
|
860
1412
|
const registry = ctx.modelRegistry;
|
|
861
|
-
const getAll = registry.getAll;
|
|
862
|
-
const getAvailable = registry.getAvailable;
|
|
863
|
-
return { modelRegistry: { ...(
|
|
1413
|
+
const getAll = (asFn(registry.getAll) as ModelRegistryGetter | undefined);
|
|
1414
|
+
const getAvailable = (asFn(registry.getAvailable) as ModelRegistryGetter | undefined);
|
|
1415
|
+
return { modelRegistry: { ...(getAll ? { getAll: () => getAll.call(registry) } : {}), ...(getAvailable ? { getAvailable: () => getAvailable.call(registry) } : {}) } };
|
|
1416
|
+
}
|
|
1417
|
+
function modelInventory(root: ModelSpec | undefined, registry: ModelRegistryCapability | undefined): { knownModels: ReadonlySet<string>; availableModels: ReadonlySet<string> } {
|
|
1418
|
+
const all = registry?.getAll?.() ?? registry?.getAvailable?.() ?? [];
|
|
1419
|
+
const available = registry?.getAvailable?.() ?? registry?.getAll?.() ?? [];
|
|
1420
|
+
const knownModels = new Set(all.map((model) => `${model.provider}/${model.id}`));
|
|
1421
|
+
const availableModels = new Set(available.map((model) => `${model.provider}/${model.id}`));
|
|
1422
|
+
const rootName = root?.provider && root.model ? `${root.provider}/${root.model}` : undefined;
|
|
1423
|
+
if (rootName) { knownModels.add(rootName); availableModels.add(rootName); }
|
|
1424
|
+
return { knownModels, availableModels };
|
|
1425
|
+
}
|
|
1426
|
+
function resumeHostContext(ctx: unknown): { model: { provider: string; id: string } | undefined; modelRegistry: ModelRegistryCapability | undefined } {
|
|
1427
|
+
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;
|
|
1428
|
+
return { model, modelRegistry: contextHostCapabilities(ctx).modelRegistry };
|
|
1429
|
+
}
|
|
1430
|
+
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[] }> {
|
|
1431
|
+
const dynamic = typeof registry.resolveModelAliases === "function" ? await registry.resolveModelAliases(context, new Set(Object.keys(staticAliases))) : {};
|
|
1432
|
+
const dynamicNames = Object.keys(dynamic);
|
|
1433
|
+
try {
|
|
1434
|
+
const aliases = validateModelAliases({ ...dynamic, ...staticAliases }, settingsPath);
|
|
1435
|
+
validateModelAliasAvailability(aliases, dynamicNames, availableModels, knownModels, settingsPath);
|
|
1436
|
+
return { aliases, dynamicNames };
|
|
1437
|
+
} catch (error) {
|
|
1438
|
+
const name = modelAliasErrorName(error);
|
|
1439
|
+
const descriptor = name && typeof registry.modelAliases === "function" ? registry.modelAliases().find((candidate) => candidate.name === name) : undefined;
|
|
1440
|
+
if (descriptor && errorCode(error) !== "CANCELLED") throw new WorkflowError(errorCode(error) ?? "CONFIG_ERROR", `${errorText(error)} (extension: ${descriptor.headline})`);
|
|
1441
|
+
throw error;
|
|
1442
|
+
}
|
|
864
1443
|
}
|
|
865
1444
|
type UiSelect = (title: string, options: string[]) => Promise<string | undefined>;
|
|
866
1445
|
type UiInput = (title: string, placeholder?: string) => Promise<string | undefined>;
|
|
867
1446
|
type UiSetStatus = (key: string, text?: string) => void;
|
|
868
1447
|
type UiHostCapabilities = { select?: UiSelect; input?: UiInput; setStatus?: UiSetStatus };
|
|
869
|
-
function isUiSelect(value: unknown): value is UiSelect { return typeof value === "function"; }
|
|
870
|
-
function isUiInput(value: unknown): value is UiInput { return typeof value === "function"; }
|
|
871
|
-
function isUiSetStatus(value: unknown): value is UiSetStatus { return typeof value === "function"; }
|
|
872
1448
|
function uiHostCapabilities(ui: unknown): UiHostCapabilities | undefined {
|
|
873
1449
|
if (!object(ui)) return undefined;
|
|
874
|
-
const select = ui.select;
|
|
875
|
-
const input = ui.input;
|
|
876
|
-
const setStatus = ui.setStatus;
|
|
877
|
-
return { ...(
|
|
1450
|
+
const select = (asFn(ui.select) as UiSelect | undefined);
|
|
1451
|
+
const input = (asFn(ui.input) as UiInput | undefined);
|
|
1452
|
+
const setStatus = (asFn(ui.setStatus) as UiSetStatus | undefined);
|
|
1453
|
+
return { ...(select ? { select } : {}), ...(input ? { input } : {}), ...(setStatus ? { setStatus } : {}) };
|
|
878
1454
|
}
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
return { terminal: { ...(tui.terminal.rows === undefined ? {} : { rows: tui.terminal.rows }) } };
|
|
1455
|
+
function tuiRows(tui: unknown): number {
|
|
1456
|
+
const rows = object(tui) && object(tui.terminal) ? tui.terminal.rows : undefined;
|
|
1457
|
+
return typeof rows === "number" && Number.isFinite(rows) ? rows : 24;
|
|
883
1458
|
}
|
|
884
|
-
function tuiRows(tui: unknown): number { const rows = tuiHostCapabilities(tui).terminal?.rows; return typeof rows === "number" && Number.isFinite(rows) ? rows : 24; }
|
|
885
1459
|
const WORKFLOW_OVERLAY_BORDER_ROWS = 2;
|
|
1460
|
+
const WORKFLOW_OVERLAY_TOP_MARGIN = 1;
|
|
1461
|
+
const WORKFLOW_OVERLAY_OPTIONS = { anchor: "top-left", width: "100%", maxHeight: "100%", margin: { top: WORKFLOW_OVERLAY_TOP_MARGIN } } as const;
|
|
886
1462
|
type WorkflowOverlayComponent = { render(width: number): string[]; invalidate(): void; handleInput?(data: string): void; dispose?(): void };
|
|
887
1463
|
function borderWorkflowOverlay(component: WorkflowOverlayComponent, theme: { fg(color: "border", text: string): string }): WorkflowOverlayComponent {
|
|
888
1464
|
return {
|
|
@@ -894,12 +1470,10 @@ function borderWorkflowOverlay(component: WorkflowOverlayComponent, theme: { fg(
|
|
|
894
1470
|
};
|
|
895
1471
|
}
|
|
896
1472
|
type KeybindingsHostCapabilities = { getKeys?: (name: string) => readonly string[] };
|
|
897
|
-
function
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
return { getKeys: keybindings.getKeys };
|
|
1473
|
+
function keybindingKeys(keybindings: unknown, name: string): readonly string[] | undefined {
|
|
1474
|
+
const getKeys = object(keybindings) ? asFn(keybindings.getKeys) as NonNullable<KeybindingsHostCapabilities["getKeys"]> | undefined : undefined;
|
|
1475
|
+
return getKeys ? getKeys.call(keybindings, name) : undefined;
|
|
901
1476
|
}
|
|
902
|
-
function keybindingKeys(keybindings: unknown, name: string): readonly string[] | undefined { const getKeys = keybindingsHostCapabilities(keybindings).getKeys; return typeof getKeys === "function" ? getKeys.call(keybindings, name) : undefined; }
|
|
903
1477
|
|
|
904
1478
|
export default function workflowExtension(pi: ExtensionAPI, home?: string, clipboard = copyToClipboard, createSession: SessionFactory = createNativeAgentSession, agentDir?: string) {
|
|
905
1479
|
beginWorkflowExtensionLoading();
|
|
@@ -993,10 +1567,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
993
1567
|
};
|
|
994
1568
|
const phaseBridge = (store: RunStore, metadata: WorkflowMetadata, lifecycle: RunLifecycle) => {
|
|
995
1569
|
let cursor = 0;
|
|
996
|
-
let executionPhase: string | undefined;
|
|
997
1570
|
return async (phase: string): Promise<void> => {
|
|
998
|
-
if (phase === executionPhase) return;
|
|
999
|
-
executionPhase = phase;
|
|
1000
1571
|
await scheduler.flush();
|
|
1001
1572
|
await lifecycle.enter();
|
|
1002
1573
|
try {
|
|
@@ -1115,7 +1686,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1115
1686
|
let effective: { model: ModelSpec; requestedModel?: string; tools: readonly string[] };
|
|
1116
1687
|
try { effective = run.executor.resolve(requested); }
|
|
1117
1688
|
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 }; }
|
|
1118
|
-
|
|
1689
|
+
const resultPath = !node.parentId && node.options.agentIdentity ? agentIdentityPath(node.options.agentIdentity) : undefined;
|
|
1690
|
+
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 ?? [])], ...(resultPath ? { resultPath } : {}), ...(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 } : {}) };
|
|
1119
1691
|
});
|
|
1120
1692
|
return { ...current, agents };
|
|
1121
1693
|
});
|
|
@@ -1124,7 +1696,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1124
1696
|
});
|
|
1125
1697
|
const cleanupTerminalRun = async (runId: string): Promise<void> => {
|
|
1126
1698
|
const run = runs.get(runId);
|
|
1127
|
-
if (!run || !
|
|
1699
|
+
if (!run || !HARD_TERMINAL_RUN_STATES.has(run.lifecycle.state)) return;
|
|
1128
1700
|
await scheduler.cancelRun(runId);
|
|
1129
1701
|
await scheduler.flush();
|
|
1130
1702
|
if (runs.get(runId) !== run) return;
|
|
@@ -1167,13 +1739,13 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1167
1739
|
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) });
|
|
1168
1740
|
await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
|
|
1169
1741
|
};
|
|
1170
|
-
const answerBudgetDecision = async (runId: string, proposalId: string, approved: boolean, silent = false): Promise<BudgetDecisionResult | undefined> => {
|
|
1742
|
+
const answerBudgetDecision = async (runId: string, proposalId: string, approved: boolean, silent = false, context?: unknown, signal?: AbortSignal): Promise<BudgetDecisionResult | undefined> => {
|
|
1171
1743
|
const run = runs.get(runId);
|
|
1172
1744
|
if (!run) return undefined;
|
|
1173
1745
|
const request = await run.store.answerWorkflowDecision(proposalId, approved);
|
|
1174
1746
|
if (!request) return undefined;
|
|
1175
1747
|
await appendBudgetDecisionEvent(run, request, approved ? "adjustment_approved" : "adjustment_rejected");
|
|
1176
|
-
const result = await applyBudgetDecision(request, approved);
|
|
1748
|
+
const result = await applyBudgetDecision(request, approved, context, signal);
|
|
1177
1749
|
if (!silent) deliver(pi, `Workflow ${run.metadata.name} budget adjustment ${proposalId}: ${approved ? "Approved" : "Rejected"}.`);
|
|
1178
1750
|
return result;
|
|
1179
1751
|
};
|
|
@@ -1223,20 +1795,22 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1223
1795
|
label: "Workflow Respond",
|
|
1224
1796
|
description: "Approve or reject one pending workflow checkpoint or budget decision",
|
|
1225
1797
|
parameters: Type.Object({ runId: Type.String(), name: Type.Optional(Type.String()), proposalId: Type.Optional(Type.String()), approved: Type.Boolean() }, { additionalProperties: false }),
|
|
1226
|
-
async execute(_id, params) {
|
|
1798
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
1227
1799
|
try {
|
|
1228
1800
|
if (params.proposalId) {
|
|
1229
|
-
const result = await answerBudgetDecision(params.runId, params.proposalId, params.approved);
|
|
1801
|
+
const result = await answerBudgetDecision(params.runId, params.proposalId, params.approved, false, ctx, signal);
|
|
1230
1802
|
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 }; }
|
|
1231
1803
|
return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: { ...result, reason: params.approved ? "approved" : "rejected" } };
|
|
1232
1804
|
}
|
|
1233
1805
|
if (!params.name) throw new WorkflowError("INVALID_METADATA", "workflow_respond requires name or proposalId");
|
|
1234
1806
|
const accepted = await answerCheckpoint(params.runId, params.name, params.approved);
|
|
1235
|
-
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" }
|
|
1807
|
+
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" } };
|
|
1236
1808
|
} catch (error) {
|
|
1237
1809
|
throw mainAgentError(error);
|
|
1238
1810
|
}
|
|
1239
1811
|
},
|
|
1812
|
+
renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_respond", args, theme)); },
|
|
1813
|
+
renderResult(result, options, theme, context) { return workflowCatalogBlock(workflowControlResult("workflow_respond", context.args, result, options.expanded, theme, context.isError), options.expanded); },
|
|
1240
1814
|
});
|
|
1241
1815
|
pi.registerTool({
|
|
1242
1816
|
name: "workflow_stop",
|
|
@@ -1251,50 +1825,117 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1251
1825
|
throw mainAgentError(error);
|
|
1252
1826
|
}
|
|
1253
1827
|
},
|
|
1828
|
+
renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_stop", args, theme)); },
|
|
1829
|
+
renderResult(result, options, theme, context) { return workflowCatalogBlock(workflowControlResult("workflow_stop", context.args, result, options.expanded, theme, context.isError), options.expanded); },
|
|
1254
1830
|
});
|
|
1255
1831
|
let catalogRegistered = false;
|
|
1256
1832
|
let sessionStarted = false;
|
|
1257
|
-
const registerCatalog = () => {
|
|
1833
|
+
const registerCatalog = (cwd: string, trustedProject: boolean) => {
|
|
1258
1834
|
if (catalogRegistered || !pi.getActiveTools().includes("workflow")) return;
|
|
1259
|
-
const catalog = registry.catalog();
|
|
1260
|
-
const hasAliases = Object.keys(catalog.modelAliases ?? {}).length > 0;
|
|
1261
|
-
|
|
1835
|
+
const catalog = registry.catalog({ cwd, projectTrusted: trustedProject, globalSettingsPath: workflowSettingsPath(extensionAgentDir) });
|
|
1836
|
+
const hasAliases = Object.keys(catalog.modelAliases ?? {}).length > 0 || Boolean(catalog.modelAliasEntries?.length);
|
|
1837
|
+
const hasSettings = catalog.settings !== undefined && [catalog.settings.globalSettingsPath, catalog.settings.projectSettingsPath].some((path) => existsSync(path));
|
|
1838
|
+
if (!catalog.functions.length && !catalog.variables.length && !hasAliases && !hasSettings) return;
|
|
1262
1839
|
pi.registerTool({
|
|
1263
1840
|
name: "workflow_catalog",
|
|
1264
1841
|
label: "Workflow Catalog",
|
|
1265
1842
|
description: "List reusable workflow functions, variables, and model aliases, or load one entry in full",
|
|
1266
|
-
parameters: Type.Object({ name: Type.Optional(Type.String({ description: "Registered function or
|
|
1843
|
+
parameters: Type.Object({ name: Type.Optional(Type.String({ description: "Registered function, variable, or model alias name for full detail" })) }, { additionalProperties: false }),
|
|
1267
1844
|
async execute(_id, params = {}) {
|
|
1268
|
-
const
|
|
1845
|
+
const context = { cwd, projectTrusted: trustedProject, globalSettingsPath: workflowSettingsPath(extensionAgentDir) };
|
|
1846
|
+
const result = params.name === undefined ? registry.catalogIndex(context) : registry.catalogDetail(params.name, context);
|
|
1269
1847
|
return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result };
|
|
1270
|
-
}
|
|
1848
|
+
},
|
|
1849
|
+
renderCall(args, theme) {
|
|
1850
|
+
const title = theme.fg("toolTitle", theme.bold("workflow_catalog"));
|
|
1851
|
+
return styledTextBlock(args.name === undefined ? title : `${title} ${theme.fg("accent", args.name)}`);
|
|
1852
|
+
},
|
|
1853
|
+
renderResult(result, options, theme) {
|
|
1854
|
+
return workflowCatalogBlock(formatWorkflowCatalog(catalogResultValue(result), options.expanded, theme), options.expanded);
|
|
1855
|
+
},
|
|
1271
1856
|
});
|
|
1272
1857
|
catalogRegistered = true;
|
|
1273
1858
|
};
|
|
1274
|
-
const
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1859
|
+
const createAgentExecutor = (root: Omit<import("./agent-execution.js").AgentExecutionRoot, "agentDir" | "agentSetupHooks">) => new WorkflowAgentExecutor({ ...root, agentDir: extensionAgentDir, agentSetupHooks: registry.agentSetupHooks() }, createSession);
|
|
1860
|
+
const activeSnapshotTools = (tools: readonly string[], active: ReadonlySet<string> | "session") => active === "session"
|
|
1861
|
+
? new Set(tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog"))
|
|
1862
|
+
: new Set(tools.filter((tool) => active.has(tool) || tool === "workflow_catalog"));
|
|
1863
|
+
const resumeLaunchPrologue = async (input: {
|
|
1864
|
+
snapshot: Readonly<LaunchSnapshot>;
|
|
1865
|
+
cwd: string;
|
|
1866
|
+
trustedProject: boolean;
|
|
1867
|
+
rootModel: ModelSpec;
|
|
1868
|
+
modelRegistry?: ModelRegistryCapability | undefined;
|
|
1869
|
+
signal: AbortSignal;
|
|
1870
|
+
resolvedAliases?: Readonly<Record<string, string>>;
|
|
1871
|
+
blockedAliases?: ReadonlySet<string>;
|
|
1872
|
+
blockedAliasTargets?: Readonly<Record<string, string>>;
|
|
1873
|
+
withPreflight: boolean;
|
|
1874
|
+
}) => {
|
|
1875
|
+
const active = new Set(pi.getActiveTools().filter((tool) => !INTERNAL_WORKFLOW_TOOLS.includes(tool)));
|
|
1876
|
+
const missing = input.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
|
|
1278
1877
|
if (missing) throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
|
|
1279
1878
|
const settingsPath = workflowSettingsPath(extensionAgentDir);
|
|
1280
|
-
const
|
|
1281
|
-
resolveAgentResourcePolicy(
|
|
1282
|
-
const
|
|
1283
|
-
const previousAliases =
|
|
1284
|
-
const
|
|
1285
|
-
const knownModels =
|
|
1286
|
-
|
|
1287
|
-
const
|
|
1288
|
-
const blockedAliases = new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
1289
|
-
const blockedAliasTargets = Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
1290
|
-
|
|
1879
|
+
const resolution = resolveWorkflowSettings(input.cwd, input.trustedProject, settingsPath);
|
|
1880
|
+
const currentPolicy = resolveAgentResourcePolicy(input.cwd, input.trustedProject, settingsPath);
|
|
1881
|
+
const staticAliases = resolution.effective.modelAliases ?? {};
|
|
1882
|
+
const previousAliases = input.snapshot.modelAliases ?? input.snapshot.settings.modelAliases ?? {};
|
|
1883
|
+
const inventory = modelInventory(input.rootModel, input.modelRegistry);
|
|
1884
|
+
const knownModels = input.modelRegistry ? inventory.knownModels : new Set([...input.snapshot.models, ...inventory.knownModels]);
|
|
1885
|
+
const availableModels = input.modelRegistry ? inventory.availableModels : new Set([...input.snapshot.models, ...inventory.availableModels]);
|
|
1886
|
+
const currentAliases = input.resolvedAliases ?? (await resolveLaunchAliases(registry, staticAliases, { cwd: input.cwd, projectTrusted: input.trustedProject, rootModel: input.rootModel, knownModels, availableModels, signal: input.signal }, availableModels, knownModels, settingsPath)).aliases;
|
|
1887
|
+
const blockedAliases = input.blockedAliases ?? new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
1888
|
+
const blockedAliasTargets = input.blockedAliasTargets ?? Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
1889
|
+
let script: ReturnType<typeof launchScriptForSnapshot> | undefined;
|
|
1890
|
+
if (input.withPreflight) {
|
|
1891
|
+
const resumeAliases = { ...previousAliases, ...currentAliases };
|
|
1892
|
+
script = launchScriptForSnapshot(input.snapshot, registry);
|
|
1893
|
+
preflight(script, { models: availableModels, tools: active, agentTypes: new Set(input.snapshot.agentTypes), modelAliases: resumeAliases, knownModels, settingsPath, skipModelAvailability: true }, input.snapshot.schemas, input.snapshot.metadata, true);
|
|
1894
|
+
}
|
|
1895
|
+
const refreshed = resumedSnapshotSettings(input.snapshot, resolution, currentAliases);
|
|
1896
|
+
const snapshot = createLaunchSnapshot({ ...input.snapshot, settingsPath, ...refreshed, modelAliases: currentAliases });
|
|
1897
|
+
return { active, settingsPath, resolution, currentPolicy, previousAliases, knownModels, availableModels, currentAliases, blockedAliases, blockedAliasTargets, snapshot, script };
|
|
1898
|
+
};
|
|
1899
|
+
const workflowAgentHandler = (store: RunStore, metadata: WorkflowMetadata, lifecycle: RunLifecycle, executor: WorkflowAgentExecutor, cwd: string, runId: string) => async (prompt: string, options: Readonly<Record<string, JsonValue>>, agentSignal: AbortSignal, identity: import("./types.js").AgentIdentity) => {
|
|
1900
|
+
await lifecycle.enter();
|
|
1901
|
+
try {
|
|
1902
|
+
const path = agentIdentityPath(identity);
|
|
1903
|
+
const replayed = await store.replay(path);
|
|
1904
|
+
if (replayed) {
|
|
1905
|
+
return replayed.value;
|
|
1906
|
+
}
|
|
1907
|
+
const worktree = agentWorktree(identity);
|
|
1908
|
+
const agentCwd = worktree.worktreeOwner ? (await persistWorktree(store, metadata, worktree.worktreeOwner)).cwd : cwd;
|
|
1909
|
+
const role = typeof options.role === "string" ? options.role : undefined;
|
|
1910
|
+
const model = typeof options.model === "string" ? options.model : undefined;
|
|
1911
|
+
const thinking = parseThinking(options.thinking);
|
|
1912
|
+
const requestedLabel = typeof options.label === "string" ? options.label : undefined;
|
|
1913
|
+
const resolved = executor.resolve({ label: requestedLabel ?? role ?? "agent", workflowName: metadata.name, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(Array.isArray(options.tools) ? { tools: options.tools as string[] } : {}) });
|
|
1914
|
+
const label = displayAgentName(requestedLabel, role, resolved.model);
|
|
1915
|
+
const tools = resolved.tools;
|
|
1916
|
+
const schema = object(options.outputSchema) ? options.outputSchema : undefined;
|
|
1917
|
+
const spawned = scheduler.spawn(runId, prompt, { label, ...(requestedLabel ? { requestedLabel } : {}), ...(identity.parentBreadcrumb ? { parentBreadcrumb: identity.parentBreadcrumb } : {}), cwd: agentCwd, 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 });
|
|
1918
|
+
const cancel = () => { scheduler.cancel(spawned.id); };
|
|
1919
|
+
if (agentSignal.aborted) cancel(); else agentSignal.addEventListener("abort", cancel, { once: true });
|
|
1920
|
+
const outcome = await spawned.result.finally(() => { agentSignal.removeEventListener("abort", cancel); });
|
|
1921
|
+
if (!outcome.ok) throw new WorkflowError(outcome.error.code as WorkflowErrorCode, outcome.error.message);
|
|
1922
|
+
await store.complete(path, outcome.value);
|
|
1923
|
+
return outcome.value;
|
|
1924
|
+
} finally { await lifecycle.leave(); }
|
|
1925
|
+
};
|
|
1926
|
+
const refreshPausedRunAliases = async (run: NonNullable<ReturnType<typeof runs.get>>, context?: { model: { provider: string; id: string } | undefined; modelRegistry: ModelRegistryCapability | undefined; projectTrusted?: boolean }) => {
|
|
1927
|
+
const loaded = await run.store.load();
|
|
1928
|
+
const trustedProject = context?.projectTrusted ?? run.projectTrusted();
|
|
1929
|
+
const rootModel = context?.model ? { ...run.model, provider: context.model.provider, model: context.model.id } : run.model;
|
|
1930
|
+
const { settingsPath, currentPolicy, previousAliases, knownModels, availableModels, currentAliases, blockedAliases, blockedAliasTargets, snapshot } = await resumeLaunchPrologue({ snapshot: loaded.snapshot, cwd: run.store.cwd, trustedProject, rootModel, ...(context?.modelRegistry ? { modelRegistry: context.modelRegistry } : {}), signal: run.abortController.signal, withPreflight: false });
|
|
1291
1931
|
await run.store.saveSnapshot(snapshot);
|
|
1292
|
-
|
|
1932
|
+
scheduler.updateRunLimit(run.store.runId, snapshot.settings.concurrency);
|
|
1933
|
+
run.executor = createAgentExecutor({ cwd: run.store.cwd, model: run.model, tools: activeSnapshotTools(snapshot.tools, "session"), 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(); }, agentResourcePolicy: frozenResourcePolicy(currentPolicy) });
|
|
1293
1934
|
run.executor.setRunContext(workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, run.abortController.signal));
|
|
1294
1935
|
const drift = aliasDrift(previousAliases, currentAliases);
|
|
1295
1936
|
if (drift.length) await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
|
|
1296
1937
|
};
|
|
1297
|
-
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:
|
|
1938
|
+
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>> }) => {
|
|
1298
1939
|
const loaded = await run.store.load();
|
|
1299
1940
|
await run.store.validateRetrySource();
|
|
1300
1941
|
await run.store.validateBorrowedWorktrees();
|
|
@@ -1303,69 +1944,30 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1303
1944
|
if ((loaded.snapshot.projectRoles?.length ?? 0) > 0 && !trustedProject) throw new WorkflowError("RESUME_INCOMPATIBLE", "Cannot restore project roles in an untrusted project");
|
|
1304
1945
|
const missingRole = loaded.snapshot.agentTypes.find((role) => !loaded.snapshot.roles?.[role]);
|
|
1305
1946
|
if (missingRole) throw new WorkflowError("RESUME_INCOMPATIBLE", `Role definition is missing from the launch snapshot: ${missingRole}`);
|
|
1306
|
-
const
|
|
1307
|
-
const
|
|
1308
|
-
if (
|
|
1309
|
-
|
|
1310
|
-
const
|
|
1311
|
-
|
|
1312
|
-
const currentAliases = currentSettings.modelAliases ?? {};
|
|
1313
|
-
const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
|
|
1314
|
-
const modelRegistry = context?.modelRegistry;
|
|
1315
|
-
const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`));
|
|
1316
|
-
if (context?.model) knownModels.add(`${context.model.provider}/${context.model.id}`);
|
|
1317
|
-
const resumeModels = modelRegistry ? knownModels : new Set([...loaded.snapshot.models, ...knownModels]);
|
|
1318
|
-
const resumeAliases = { ...previousAliases, ...currentAliases };
|
|
1319
|
-
const blockedAliases = new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
1320
|
-
const blockedAliasTargets = Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
1321
|
-
const script = launchScriptForSnapshot(loaded.snapshot, registry);
|
|
1322
|
-
preflight(script, { models: resumeModels, tools: active, agentTypes: new Set(loaded.snapshot.agentTypes), modelAliases: resumeAliases, knownModels: resumeModels, settingsPath, skipModelAvailability: true }, loaded.snapshot.schemas, loaded.snapshot.metadata, true);
|
|
1323
|
-
const snapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, settings: { ...loaded.snapshot.settings, modelAliases: currentAliases }, modelAliases: currentAliases });
|
|
1947
|
+
const rootModel = context?.model ? { ...run.model, provider: context.model.provider, model: context.model.id } : run.model;
|
|
1948
|
+
const controller = new AbortController();
|
|
1949
|
+
if (context?.signal?.aborted) controller.abort(); else { context?.signal?.addEventListener("abort", () => { controller.abort(); }, { once: true }); }
|
|
1950
|
+
run.abortController = controller;
|
|
1951
|
+
const { settingsPath, currentPolicy, previousAliases, knownModels, availableModels, currentAliases, blockedAliases, blockedAliasTargets, snapshot, script } = await resumeLaunchPrologue({ snapshot: loaded.snapshot, cwd: run.store.cwd, trustedProject, rootModel, ...(context?.modelRegistry ? { modelRegistry: context.modelRegistry } : {}), signal: controller.signal, ...(context?.resolvedAliases ? { resolvedAliases: context.resolvedAliases } : {}), ...(context?.blockedAliases ? { blockedAliases: context.blockedAliases } : {}), ...(context?.blockedAliasTargets ? { blockedAliasTargets: context.blockedAliasTargets } : {}), withPreflight: true });
|
|
1952
|
+
if (!script) throw new WorkflowError("INTERNAL_ERROR", "Resume preflight did not produce a launch script");
|
|
1324
1953
|
await run.store.saveSnapshot(snapshot);
|
|
1325
|
-
|
|
1954
|
+
scheduler.updateRunLimit(run.store.runId, snapshot.settings.concurrency);
|
|
1955
|
+
run.executor = createAgentExecutor({ cwd: run.store.cwd, model: rootModel, tools: activeSnapshotTools(snapshot.tools, "session"), 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(); }, agentResourcePolicy: frozenResourcePolicy(currentPolicy) });
|
|
1326
1956
|
const drift = aliasDrift(previousAliases, currentAliases);
|
|
1327
1957
|
if (drift.length) await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
|
|
1328
|
-
const controller = new AbortController();
|
|
1329
|
-
run.abortController = controller;
|
|
1330
1958
|
const runContext = workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, controller.signal);
|
|
1331
1959
|
run.executor.setRunContext(runContext);
|
|
1332
1960
|
let variables: Readonly<Record<string, JsonValue>>;
|
|
1333
1961
|
try { variables = await resolveWorkflowVariables(runContext, controller, registry); }
|
|
1334
1962
|
catch (error) {
|
|
1335
1963
|
const typed = asWorkflowError(error);
|
|
1336
|
-
if (!
|
|
1964
|
+
if (!HARD_TERMINAL_RUN_STATES.has(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); }
|
|
1337
1965
|
await cleanupTerminalRun(run.store.runId);
|
|
1338
1966
|
throw typed;
|
|
1339
1967
|
}
|
|
1340
1968
|
await scheduler.cancelRun(run.store.runId);
|
|
1341
1969
|
await run.lifecycle.resume();
|
|
1342
|
-
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 (
|
|
1343
|
-
await run.lifecycle.enter();
|
|
1344
|
-
try {
|
|
1345
|
-
const path = agentIdentityPath(identity);
|
|
1346
|
-
const replayed = await run.store.replay(path);
|
|
1347
|
-
if (replayed) {
|
|
1348
|
-
return replayed.value;
|
|
1349
|
-
}
|
|
1350
|
-
const worktree = agentWorktree(identity);
|
|
1351
|
-
const cwd = worktree.worktreeOwner ? (await persistWorktree(run.store, run.metadata, worktree.worktreeOwner)).cwd : run.store.cwd;
|
|
1352
|
-
const role = typeof options.role === "string" ? options.role : undefined;
|
|
1353
|
-
const model = typeof options.model === "string" ? options.model : undefined;
|
|
1354
|
-
const thinking = parseThinking(options.thinking);
|
|
1355
|
-
const requestedLabel = typeof options.label === "string" ? options.label : undefined;
|
|
1356
|
-
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[] } : {}) });
|
|
1357
|
-
const label = displayAgentName(requestedLabel, role, resolved.model);
|
|
1358
|
-
const tools = resolved.tools;
|
|
1359
|
-
const schema = object(options.outputSchema) ? options.outputSchema : undefined;
|
|
1360
|
-
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 });
|
|
1361
|
-
const cancel = () => { scheduler.cancel(spawned.id); };
|
|
1362
|
-
signal.addEventListener("abort", cancel, { once: true });
|
|
1363
|
-
const outcome = await spawned.result.finally(() => { signal.removeEventListener("abort", cancel); });
|
|
1364
|
-
if (!outcome.ok) throw new WorkflowError(outcome.error.code as WorkflowErrorCode, outcome.error.message);
|
|
1365
|
-
await run.store.complete(path, outcome.value);
|
|
1366
|
-
return outcome.value;
|
|
1367
|
-
} finally { await run.lifecycle.leave(); }
|
|
1368
|
-
}, 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);
|
|
1970
|
+
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: workflowAgentHandler(run.store, run.metadata, run.lifecycle, run.executor, run.store.cwd, run.store.runId), 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);
|
|
1369
1971
|
run.execution = execution;
|
|
1370
1972
|
const completion = execution.result.then(async (value) => {
|
|
1371
1973
|
await scheduler.flush();
|
|
@@ -1388,7 +1990,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1388
1990
|
run.completion = completion;
|
|
1389
1991
|
void completion;
|
|
1390
1992
|
};
|
|
1391
|
-
const applyBudgetDecision = async (request: BudgetApprovalRequest, approved: boolean): Promise<BudgetDecisionResult> => {
|
|
1993
|
+
const applyBudgetDecision = async (request: BudgetApprovalRequest, approved: boolean, context?: unknown, signal?: AbortSignal): Promise<BudgetDecisionResult> => {
|
|
1392
1994
|
const run = runs.get(request.runId);
|
|
1393
1995
|
if (!run) throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run: ${request.runId}`);
|
|
1394
1996
|
if (!approved) return { state: "budget_exhausted", approved: false };
|
|
@@ -1397,14 +1999,28 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1397
1999
|
const runtime = new WorkflowBudgetRuntime(nextBudget, nextVersion, request.consumed, run.budget.events, { active: false });
|
|
1398
2000
|
run.budget = runtime;
|
|
1399
2001
|
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; });
|
|
1400
|
-
await coldResumeRun(run, false, {},
|
|
2002
|
+
await coldResumeRun(run, false, {}, projectTrusted(context), { ...resumeHostContext(context), ...(signal ? { signal } : {}) });
|
|
1401
2003
|
return { state: "running", approved: true };
|
|
1402
2004
|
};
|
|
1403
|
-
const resumeWorkflowRun = async (runId: string, rawPatch?: unknown): Promise<Record<string, JsonValue>> => {
|
|
2005
|
+
const resumeWorkflowRun = async (runId: string, rawPatch?: unknown, context?: unknown, signal?: AbortSignal): Promise<Record<string, JsonValue>> => {
|
|
1404
2006
|
const run = runs.get(runId);
|
|
1405
|
-
if (!run)
|
|
2007
|
+
if (!run) {
|
|
2008
|
+
const host = object(context) ? context : {};
|
|
2009
|
+
const cwd = typeof host.cwd === "string" ? host.cwd : undefined;
|
|
2010
|
+
const sessionManager = object(host.sessionManager) ? host.sessionManager : undefined;
|
|
2011
|
+
const sessionId = typeof sessionManager?.getSessionId === "function" ? String(Reflect.apply(sessionManager.getSessionId, sessionManager, [])) : undefined;
|
|
2012
|
+
if (cwd && sessionId) {
|
|
2013
|
+
try {
|
|
2014
|
+
const state = (await new RunStore(cwd, sessionId, runId, home).load()).run.state;
|
|
2015
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", workflowRecoveryGuidance("resume", state));
|
|
2016
|
+
} catch (error) {
|
|
2017
|
+
if (error instanceof WorkflowError) throw error;
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run ${runId} in the current project and Pi session`);
|
|
2021
|
+
}
|
|
1406
2022
|
const loaded = await run.store.load();
|
|
1407
|
-
if (loaded.run.state !== "budget_exhausted") throw new WorkflowError("RESUME_INCOMPATIBLE", "
|
|
2023
|
+
if (loaded.run.state !== "budget_exhausted") throw new WorkflowError("RESUME_INCOMPATIBLE", workflowRecoveryGuidance("resume", loaded.run.state));
|
|
1408
2024
|
const currentBudget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
|
|
1409
2025
|
const patch = rawPatch === undefined ? {} : validateBudgetPatch(rawPatch);
|
|
1410
2026
|
const nextBudget = mergeBudget(currentBudget, patch);
|
|
@@ -1425,11 +2041,11 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1425
2041
|
run.budget = runtime;
|
|
1426
2042
|
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; });
|
|
1427
2043
|
}
|
|
1428
|
-
await coldResumeRun(run, false, {},
|
|
2044
|
+
await coldResumeRun(run, false, {}, projectTrusted(context), { ...resumeHostContext(context), ...(signal ? { signal } : {}) });
|
|
1429
2045
|
return { state: "running" };
|
|
1430
2046
|
};
|
|
1431
2047
|
const retryReservations = new Set<string>();
|
|
1432
|
-
const retryWorkflowRun = async (runId: string, context: unknown): Promise<{ runId: string; parentRunId: string; state: "running" }> => {
|
|
2048
|
+
const retryWorkflowRun = async (runId: string, context: unknown, signal?: AbortSignal): Promise<{ runId: string; parentRunId: string; state: "running" }> => {
|
|
1433
2049
|
if (typeof runId !== "string" || !runId.trim()) throw new WorkflowError("RESUME_INCOMPATIBLE", "workflow_retry requires an explicit run ID");
|
|
1434
2050
|
const host = object(context) ? context : {};
|
|
1435
2051
|
const cwd = typeof host.cwd === "string" ? host.cwd : undefined;
|
|
@@ -1439,8 +2055,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1439
2055
|
await ensureSessionLease(cwd, sessionId);
|
|
1440
2056
|
const sourceStore = new RunStore(cwd, sessionId, runId, home);
|
|
1441
2057
|
let loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> };
|
|
1442
|
-
try { loaded = await sourceStore.load(); } catch (error) { throw new WorkflowError("RESUME_INCOMPATIBLE", `
|
|
1443
|
-
if (loaded.run.state !== "failed") throw new WorkflowError("RESUME_INCOMPATIBLE",
|
|
2058
|
+
try { loaded = await sourceStore.load(); } catch (error) { throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run ${runId} in the current project and Pi session: ${errorText(error)}`); }
|
|
2059
|
+
if (loaded.run.state !== "failed") throw new WorkflowError("RESUME_INCOMPATIBLE", workflowRecoveryGuidance("retry", loaded.run.state));
|
|
1444
2060
|
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");
|
|
1445
2061
|
const lineageRootRunId = loaded.run.retry?.lineageRootRunId ?? loaded.run.id;
|
|
1446
2062
|
if (retryReservations.has(lineageRootRunId)) throw new WorkflowError("RESUME_INCOMPATIBLE", `An active retry already owns lineage ${lineageRootRunId}`);
|
|
@@ -1466,22 +2082,10 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1466
2082
|
if ((loaded.snapshot.projectRoles?.length ?? 0) > 0 && !trustedProject) throw new WorkflowError("RESUME_INCOMPATIBLE", "Cannot restore project roles in an untrusted project");
|
|
1467
2083
|
const missingRole = loaded.snapshot.agentTypes.find((role) => !loaded.snapshot.roles?.[role]);
|
|
1468
2084
|
if (missingRole) throw new WorkflowError("RESUME_INCOMPATIBLE", `Role definition is missing from the launch snapshot: ${missingRole}`);
|
|
1469
|
-
const active = new Set<string>(pi.getActiveTools().filter((tool) => !["workflow", "workflow_respond", "workflow_stop", "workflow_resume", "workflow_retry", "workflow_catalog"].includes(tool)));
|
|
1470
|
-
const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
|
|
1471
|
-
if (missing) throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
|
|
1472
|
-
const settingsPath = workflowSettingsPath(extensionAgentDir);
|
|
1473
|
-
const currentSettings = loadSettings(settingsPath);
|
|
1474
|
-
resolveAgentResourcePolicy(cwd, trustedProject, settingsPath);
|
|
1475
|
-
const currentAliases = currentSettings.modelAliases ?? {};
|
|
1476
|
-
const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
|
|
1477
2085
|
const modelRegistry = contextHostCapabilities(context).modelRegistry;
|
|
1478
|
-
const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`));
|
|
1479
2086
|
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: "" };
|
|
1480
|
-
|
|
1481
|
-
const
|
|
1482
|
-
const resumeAliases = { ...previousAliases, ...currentAliases };
|
|
1483
|
-
const script = launchScriptForSnapshot(loaded.snapshot, registry);
|
|
1484
|
-
preflight(script, { models: resumeModels, tools: active, agentTypes: new Set(loaded.snapshot.agentTypes), modelAliases: resumeAliases, knownModels: resumeModels, settingsPath, skipModelAvailability: true }, loaded.snapshot.schemas, loaded.snapshot.metadata, true);
|
|
2087
|
+
const rootModel: ModelSpec = { provider: hostModel.provider, model: hostModel.id, thinking: pi.getThinkingLevel() };
|
|
2088
|
+
const { active, settingsPath, currentPolicy, knownModels, availableModels, currentAliases, blockedAliases, blockedAliasTargets, snapshot: childBaseSnapshot } = await resumeLaunchPrologue({ snapshot: loaded.snapshot, cwd, trustedProject, rootModel, ...(modelRegistry ? { modelRegistry } : {}), signal: signal ?? new AbortController().signal, withPreflight: true });
|
|
1485
2089
|
await sourceStore.validateNamedWorktrees();
|
|
1486
2090
|
for (const name of loaded.run.retry?.namedWorktrees ?? []) await sourceStore.resolveNamedWorktree(name);
|
|
1487
2091
|
const completedPaths = (await sourceStore.replayableOperations()).map(({ path }) => path);
|
|
@@ -1490,7 +2094,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1490
2094
|
const budget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
|
|
1491
2095
|
const childRunId = randomUUID();
|
|
1492
2096
|
const childStore = new RunStore(cwd, sessionId, childRunId, home);
|
|
1493
|
-
const childSnapshot =
|
|
2097
|
+
const childSnapshot = childBaseSnapshot;
|
|
1494
2098
|
const childBudget = new WorkflowBudgetRuntime(budget, loaded.run.budgetVersion ?? 1, loaded.run.usage, loaded.run.budgetEvents);
|
|
1495
2099
|
const childInitialBudget = childBudget.snapshot();
|
|
1496
2100
|
const retry: WorkflowRetryProvenance = { sourceRunId: loaded.run.id, lineageRootRunId, completedPaths, incompletePaths, namedWorktrees };
|
|
@@ -1499,13 +2103,13 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1499
2103
|
const model = modelSpec(loaded.snapshot.models[0] ?? "", fallbackModel);
|
|
1500
2104
|
const lifecycle = lifecycleFor(childStore, "interrupted", childBudget, loaded.snapshot.metadata);
|
|
1501
2105
|
const abortController = new AbortController();
|
|
1502
|
-
const providerErrorRecovery = createProviderErrorRecovery(context,
|
|
2106
|
+
const providerErrorRecovery = createProviderErrorRecovery(context, availableModels, () => { abortController.abort(); });
|
|
1503
2107
|
const providerPause = async () => { deliver(pi, `Workflow ${loaded.snapshot.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
|
|
1504
|
-
const childRun = { executor:
|
|
2108
|
+
const childRun = { executor: createAgentExecutor({ cwd, model, tools: activeSnapshotTools(loaded.snapshot.tools, active), availableModels, knownModels, modelAliases: currentAliases, blockedAliases, blockedAliasTargets, settingsPath, agentDefinitions: loaded.snapshot.roles ?? {}, runStore: childStore, providerPause, agentResourcePolicy: frozenResourcePolicy(currentPolicy) }), store: childStore, metadata: loaded.snapshot.metadata, model, lifecycle, budget: childBudget, abortController, projectTrusted: () => projectTrusted(context), checkpointResolvers: new Map(), ...(providerErrorRecovery ? { providerErrorRecovery } : {}) };
|
|
1505
2109
|
runs.set(childRunId, childRun);
|
|
1506
2110
|
scheduler.addRun(childRunId, loaded.snapshot.settings.concurrency, () => { childBudget.checkAgentLaunch(); });
|
|
1507
2111
|
await eventPublisher.runStarted(childStore, loaded.snapshot.metadata);
|
|
1508
|
-
await coldResumeRun(childRun, false, {}, trustedProject, { model: hostModel, modelRegistry
|
|
2112
|
+
await coldResumeRun(childRun, false, {}, trustedProject, { model: hostModel, modelRegistry, resolvedAliases: currentAliases, blockedAliases, blockedAliasTargets, ...(signal ? { signal } : {}) });
|
|
1509
2113
|
const completion = runs.get(childRunId)?.completion;
|
|
1510
2114
|
if (completion) {
|
|
1511
2115
|
childStarted = true;
|
|
@@ -1521,26 +2125,30 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1521
2125
|
label: "Workflow Retry",
|
|
1522
2126
|
description: "Retry a failed workflow run by replaying its completed structural operations",
|
|
1523
2127
|
parameters: WORKFLOW_RETRY_PARAMETERS,
|
|
1524
|
-
async execute(_id, params,
|
|
1525
|
-
try { const result = await retryWorkflowRun(params.runId, ctx); return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result }; }
|
|
2128
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
2129
|
+
try { const result = await retryWorkflowRun(params.runId, ctx, signal); return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result }; }
|
|
1526
2130
|
catch (error) { throw mainAgentError(error); }
|
|
1527
2131
|
},
|
|
2132
|
+
renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_retry", args, theme)); },
|
|
2133
|
+
renderResult(result, options, theme, context) { return workflowCatalogBlock(workflowControlResult("workflow_retry", context.args, result, options.expanded, theme, context.isError), options.expanded); },
|
|
1528
2134
|
});
|
|
1529
2135
|
pi.registerTool({
|
|
1530
2136
|
name: "workflow_resume",
|
|
1531
2137
|
label: "Workflow Resume",
|
|
1532
2138
|
description: "Resume an exhausted workflow with unchanged or patched aggregate budgets",
|
|
1533
2139
|
parameters: Type.Object({ runId: Type.String(), budget: Type.Optional(Type.Unknown()) }, { additionalProperties: false }),
|
|
1534
|
-
async execute(_id, params) {
|
|
1535
|
-
try { const result = await resumeWorkflowRun(params.runId, params.budget); return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result }; }
|
|
2140
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
2141
|
+
try { const result = await resumeWorkflowRun(params.runId, params.budget, ctx, signal); return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result }; }
|
|
1536
2142
|
catch (error) { throw mainAgentError(error); }
|
|
1537
2143
|
},
|
|
2144
|
+
renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_resume", args, theme)); },
|
|
2145
|
+
renderResult(result, options, theme, context) { return workflowCatalogBlock(workflowControlResult("workflow_resume", context.args, result, options.expanded, theme, context.isError), options.expanded); },
|
|
1538
2146
|
});
|
|
1539
2147
|
pi.on("session_start", async (_event, ctx) => {
|
|
1540
2148
|
if (sessionStarted) return;
|
|
1541
2149
|
sessionStarted = true;
|
|
1542
2150
|
registry.freeze();
|
|
1543
|
-
registerCatalog();
|
|
2151
|
+
registerCatalog(ctx.cwd, projectTrusted(ctx));
|
|
1544
2152
|
await ensureSessionLease(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
1545
2153
|
try {
|
|
1546
2154
|
for (const runId of await listRunIds(ctx.cwd, ctx.sessionManager.getSessionId(), home)) {
|
|
@@ -1565,7 +2173,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1565
2173
|
const roleDefinitions = loaded.snapshot.roles ?? {};
|
|
1566
2174
|
const abortController = new AbortController();
|
|
1567
2175
|
const providerErrorRecovery = createProviderErrorRecovery(ctx, new Set(loaded.snapshot.models), () => { abortController.abort(); });
|
|
1568
|
-
runs.set(runId, { executor:
|
|
2176
|
+
runs.set(runId, { executor: createAgentExecutor({ cwd: ctx.cwd, model, tools: activeSnapshotTools(loaded.snapshot.tools, "session"), 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, agentResourcePolicy: frozenResourcePolicy(snapshotResourcePolicy(loaded.snapshot, store.cwd, projectTrusted(ctx), workflowSettingsPath(extensionAgentDir))) }), store, metadata: loaded.snapshot.metadata, model, lifecycle, budget: budgetRuntime, abortController, projectTrusted: () => projectTrusted(ctx), checkpointResolvers: new Map(), ...(providerErrorRecovery ? { providerErrorRecovery } : {}) });
|
|
1569
2177
|
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.`);
|
|
1570
2178
|
for (const decision of await store.pendingWorkflowDecisions()) deliver(pi, budgetDecisionDelivery(loaded.snapshot.metadata, decision));
|
|
1571
2179
|
scheduler.restoreRun(runId, loaded.snapshot.settings.concurrency, loaded.snapshot.identityVersion === LAUNCH_SNAPSHOT_IDENTITY_VERSION ? await store.loadOwnership() : [], () => runs.get(runId)?.budget.checkAgentLaunch());
|
|
@@ -1590,7 +2198,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1590
2198
|
});
|
|
1591
2199
|
pi.on("before_agent_start", (event, ctx) => {
|
|
1592
2200
|
if (!pi.getActiveTools().includes("workflow")) return;
|
|
1593
|
-
const roles = Object.entries(loadAgentDefinitions(ctx.cwd, extensionAgentDir, projectTrusted(ctx))).filter(([, definition]) => definition.description);
|
|
2201
|
+
const roles = Object.entries(loadAgentDefinitions(ctx.cwd, extensionAgentDir, projectTrusted(ctx), typeof registry.roleDirectoryRegistrations === "function" ? registry.roleDirectoryRegistrations() : undefined)).filter(([, definition]) => definition.description);
|
|
1594
2202
|
if (!roles.length) return;
|
|
1595
2203
|
const content = `Workflow role descriptions:\n${roles.map(([name, definition]) => `- \`${name}\`: ${String(definition.description)}`).join("\n")}`;
|
|
1596
2204
|
return { systemPrompt: `${event.systemPrompt}\n\n${content}` };
|
|
@@ -1605,27 +2213,29 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1605
2213
|
try {
|
|
1606
2214
|
const headless = object(ctx) && ctx.headless === true;
|
|
1607
2215
|
const settingsPath = workflowSettingsPath(extensionAgentDir);
|
|
1608
|
-
const defaults = loadSettings(settingsPath);
|
|
1609
2216
|
if (!ctx.model) throw new WorkflowError("UNKNOWN_MODEL", "A launching model is required");
|
|
1610
|
-
const settings = Object.freeze({ concurrency: params.concurrency ?? defaults.concurrency, ...(defaults.modelAliases ? { modelAliases: defaults.modelAliases } : {}) });
|
|
1611
2217
|
const budget = validateBudget(params.budget);
|
|
1612
2218
|
const rootModel: ModelSpec = { provider: ctx.model.provider, model: ctx.model.id, thinking: pi.getThinkingLevel() };
|
|
1613
2219
|
const rootModelName = `${rootModel.provider}/${rootModel.model}`;
|
|
1614
2220
|
const modelRegistry = contextHostCapabilities(ctx).modelRegistry;
|
|
1615
|
-
const
|
|
1616
|
-
knownModels.
|
|
1617
|
-
const availableModels =
|
|
1618
|
-
const rootTools = pi.getActiveTools().filter((name) => !
|
|
2221
|
+
const inventory = modelInventory(rootModel, modelRegistry);
|
|
2222
|
+
const knownModels = inventory.knownModels;
|
|
2223
|
+
const availableModels = inventory.availableModels;
|
|
2224
|
+
const rootTools = pi.getActiveTools().filter((name) => !INTERNAL_WORKFLOW_TOOLS.includes(name));
|
|
1619
2225
|
const trustedProject = projectTrusted(ctx);
|
|
1620
|
-
|
|
1621
|
-
const
|
|
2226
|
+
const launchCwd = typeof ctx.cwd === "string" ? ctx.cwd : process.cwd();
|
|
2227
|
+
const launch = workflowLaunchSettings(launchCwd, trustedProject, settingsPath, params.concurrency);
|
|
2228
|
+
const runController = new AbortController();
|
|
2229
|
+
if (signal?.aborted) runController.abort(); else signal?.addEventListener("abort", () => { runController.abort(); }, { once: true });
|
|
2230
|
+
const resolvedAliases = await resolveLaunchAliases(registry, launch.settings.modelAliases ?? {}, { cwd: launchCwd, projectTrusted: trustedProject, rootModel, knownModels, availableModels, signal: runController.signal }, availableModels, knownModels, settingsPath);
|
|
2231
|
+
const modelAliases = resolvedAliases.aliases;
|
|
2232
|
+
const settings = Object.freeze({ ...launch.settings, ...(Object.keys(modelAliases).length ? { modelAliases } : {}) });
|
|
2233
|
+
const validated = validateWorkflowLaunchWithRegistry({ ...params, args: params.args }, { cwd: ctx.cwd, agentDir: extensionAgentDir, projectTrusted: trustedProject, availableModels, rootTools: new Set(rootTools), modelAliases, knownModels, settingsPath }, registry);
|
|
1622
2234
|
const { script, checked, agentDefinitions, projectAgentDefinitions, roleNames, functionName } = validated;
|
|
1623
2235
|
await ensureSessionLease(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
1624
2236
|
const runId = randomUUID();
|
|
1625
2237
|
const args = (params.args ?? null) as JsonValue;
|
|
1626
2238
|
encoded(args);
|
|
1627
|
-
const runController = new AbortController();
|
|
1628
|
-
if (signal?.aborted) runController.abort(); else signal?.addEventListener("abort", () => { runController.abort(); }, { once: true });
|
|
1629
2239
|
const runContext = workflowRunContext(ctx.cwd, ctx.sessionManager.getSessionId(), runId, checked.metadata, args, runController.signal);
|
|
1630
2240
|
const variables = await resolveWorkflowVariables(runContext, runController, registry);
|
|
1631
2241
|
const store = new RunStore(ctx.cwd, ctx.sessionManager.getSessionId(), runId, home);
|
|
@@ -1633,9 +2243,9 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1633
2243
|
if (parentRunId !== undefined) await store.validateParentRun(parentRunId);
|
|
1634
2244
|
const roles = Object.fromEntries(roleNames.map((role) => [role, agentDefinitions[role]])) as Record<string, AgentDefinition>;
|
|
1635
2245
|
const projectRoles = roleNames.filter((role) => projectAgentDefinitions[role] !== undefined);
|
|
1636
|
-
const roleModels = roleNames.flatMap((role) => { const model = agentDefinitions[role]?.model; return model ? [modelCapability(model,
|
|
2246
|
+
const roleModels = roleNames.flatMap((role) => { const model = agentDefinitions[role]?.model; return model ? [modelCapability(model, modelAliases, knownModels, settingsPath)] : []; });
|
|
1637
2247
|
const snapshotModels = [...new Set([rootModelName, ...checked.referenced.models, ...roleModels])];
|
|
1638
|
-
const snapshot = createLaunchSnapshot({ script, args, metadata: checked.metadata, settings, settingsPath, ...(functionName ? { launchKind: "function" as const, functionName } : {}), ...(
|
|
2248
|
+
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 });
|
|
1639
2249
|
const budgetRuntime = new WorkflowBudgetRuntime(budget);
|
|
1640
2250
|
const initialBudget = budgetRuntime.snapshot();
|
|
1641
2251
|
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);
|
|
@@ -1643,37 +2253,11 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1643
2253
|
const background = !params.foreground;
|
|
1644
2254
|
const providerPause = async () => { if (background) deliver(pi, `Workflow ${checked.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
|
|
1645
2255
|
const providerErrorRecovery = createProviderErrorRecovery(ctx, availableModels, () => { runController.abort(); });
|
|
1646
|
-
const executor =
|
|
2256
|
+
const executor = createAgentExecutor({ cwd: ctx.cwd, model: rootModel, tools: new Set(rootTools), availableModels, knownModels, modelAliases, settingsPath, agentDefinitions, runStore: store, providerPause, agentResourcePolicy: frozenResourcePolicy(launch.resourcePolicy), runContext });
|
|
1647
2257
|
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 } : {}) });
|
|
1648
2258
|
if (params.foreground && onUpdate) onUpdate(workflowToolUpdate((await store.load()).run));
|
|
1649
2259
|
scheduler.addRun(runId, settings.concurrency, () => runs.get(runId)?.budget.checkAgentLaunch());
|
|
1650
|
-
const execution = runWorkflow(script, args, withWorkflowFunctions({ shell: (command, options, signal, identity) => shellForRun(store, checked.metadata, lifecycle, command, options, signal, identity), agent: async (
|
|
1651
|
-
await lifecycle.enter();
|
|
1652
|
-
try {
|
|
1653
|
-
const path = agentIdentityPath(identity);
|
|
1654
|
-
const replayed = await store.replay(path);
|
|
1655
|
-
if (replayed) {
|
|
1656
|
-
return replayed.value;
|
|
1657
|
-
}
|
|
1658
|
-
const worktree = agentWorktree(identity);
|
|
1659
|
-
const cwd = worktree.worktreeOwner ? (await persistWorktree(store, checked.metadata, worktree.worktreeOwner)).cwd : ctx.cwd;
|
|
1660
|
-
const role = typeof options.role === "string" ? options.role : undefined;
|
|
1661
|
-
const model = typeof options.model === "string" ? options.model : undefined;
|
|
1662
|
-
const thinking = parseThinking(options.thinking);
|
|
1663
|
-
const requestedLabel = typeof options.label === "string" ? options.label : undefined;
|
|
1664
|
-
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[] } : {}) });
|
|
1665
|
-
const label = displayAgentName(requestedLabel, role, resolved.model);
|
|
1666
|
-
const tools = resolved.tools;
|
|
1667
|
-
const schema = object(options.outputSchema) ? options.outputSchema : undefined;
|
|
1668
|
-
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 });
|
|
1669
|
-
const cancel = () => { scheduler.cancel(spawned.id); };
|
|
1670
|
-
if (agentSignal.aborted) cancel(); else agentSignal.addEventListener("abort", cancel, { once: true });
|
|
1671
|
-
const outcome = await spawned.result.finally(() => { agentSignal.removeEventListener("abort", cancel); });
|
|
1672
|
-
if (!outcome.ok) throw new WorkflowError(outcome.error.code as WorkflowErrorCode, outcome.error.message);
|
|
1673
|
-
await store.complete(path, outcome.value);
|
|
1674
|
-
return outcome.value;
|
|
1675
|
-
} finally { await lifecycle.leave(); }
|
|
1676
|
-
}, 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);
|
|
2260
|
+
const execution = runWorkflow(script, args, withWorkflowFunctions({ shell: (command, options, signal, identity) => shellForRun(store, checked.metadata, lifecycle, command, options, signal, identity), agent: workflowAgentHandler(store, checked.metadata, lifecycle, executor, ctx.cwd, runId), 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);
|
|
1677
2261
|
(runs.get(runId) as NonNullable<ReturnType<typeof runs.get>>).execution = execution;
|
|
1678
2262
|
await eventPublisher.runStarted(store, checked.metadata);
|
|
1679
2263
|
const finish = execution.result.then(async (value) => {
|
|
@@ -1754,7 +2338,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1754
2338
|
return entries.filter((entry): entry is { store: RunStore; loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> } } => entry !== undefined);
|
|
1755
2339
|
};
|
|
1756
2340
|
let stores = await loadStores();
|
|
1757
|
-
const usage = "Usage: /workflow [doctor|model-aliases], or /workflow pause|resume|stop|approve|reject|delete <run-id> [checkpoint or
|
|
2341
|
+
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."
|
|
1758
2342
|
const setWorkflowStatus = (text: string | undefined) => {
|
|
1759
2343
|
const setStatus = uiHostCapabilities(ctx.ui)?.setStatus;
|
|
1760
2344
|
setStatus?.call(ctx.ui, "workflow-stop", text);
|
|
@@ -1771,12 +2355,12 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1771
2355
|
return keepContext ? "dashboard" : "done";
|
|
1772
2356
|
}
|
|
1773
2357
|
if ((action === "budget-approve" || action === "budget-reject") && runId && rest[0]) {
|
|
1774
|
-
const result = await answerBudgetDecision(runId, rest[0], action === "budget-approve", true);
|
|
2358
|
+
const result = await answerBudgetDecision(runId, rest[0], action === "budget-approve", true, ctx);
|
|
1775
2359
|
ctx.ui.notify(result ? `Budget adjustment ${rest[0]} ${result.approved ? "approved" : "rejected"}.` : "Budget proposal is not pending.", result ? "info" : "warning");
|
|
1776
2360
|
return keepContext ? "dashboard" : "done";
|
|
1777
2361
|
}
|
|
1778
2362
|
if (action === "delete" && stored) {
|
|
1779
|
-
if (!
|
|
2363
|
+
if (!HARD_TERMINAL_RUN_STATES.has(stored.loaded.run.state)) { ctx.ui.notify("Stop the workflow before deleting it.", "warning"); return keepContext ? "dashboard" : "done"; }
|
|
1780
2364
|
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";
|
|
1781
2365
|
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";
|
|
1782
2366
|
}
|
|
@@ -1784,12 +2368,12 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1784
2368
|
if (action === "resume" && run) {
|
|
1785
2369
|
if (run.lifecycle.state === "budget_exhausted") {
|
|
1786
2370
|
const patch: unknown = rest.length ? JSON.parse(rest.join(" ")) as unknown : undefined;
|
|
1787
|
-
const result = await resumeWorkflowRun(run.store.runId, patch);
|
|
2371
|
+
const result = await resumeWorkflowRun(run.store.runId, patch, ctx);
|
|
1788
2372
|
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");
|
|
1789
2373
|
} else {
|
|
1790
2374
|
if (run.lifecycle.state === "interrupted") await coldResumeRun(run, ctx.hasUI, ctx.ui, projectTrusted(ctx), ctx);
|
|
1791
2375
|
else {
|
|
1792
|
-
if (run.lifecycle.state === "paused") await refreshPausedRunAliases(run, ctx);
|
|
2376
|
+
if (run.lifecycle.state === "paused") await refreshPausedRunAliases(run, { ...resumeHostContext(ctx), projectTrusted: projectTrusted(ctx) });
|
|
1793
2377
|
await run.lifecycle.resume();
|
|
1794
2378
|
}
|
|
1795
2379
|
ctx.ui.notify(`Resumed workflow ${run.store.runId}.`, "info");
|
|
@@ -1799,7 +2383,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1799
2383
|
if (action === "adjust" && run?.lifecycle.state === "budget_exhausted") {
|
|
1800
2384
|
const input = await uiHostCapabilities(ctx.ui)?.input?.call(ctx.ui, "Budget patch (JSON)", "{\"tokens\":{\"hard\":null}}" );
|
|
1801
2385
|
if (input === undefined) return keepContext ? "dashboard" : "done";
|
|
1802
|
-
const result = await resumeWorkflowRun(run.store.runId, JSON.parse(input));
|
|
2386
|
+
const result = await resumeWorkflowRun(run.store.runId, JSON.parse(input), ctx);
|
|
1803
2387
|
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");
|
|
1804
2388
|
return keepContext ? "dashboard" : "done";
|
|
1805
2389
|
}
|
|
@@ -1824,6 +2408,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1824
2408
|
};
|
|
1825
2409
|
const manageAliases = async (): Promise<void> => {
|
|
1826
2410
|
const settingsPath = workflowSettingsPath(extensionAgentDir);
|
|
2411
|
+
let aliasSettingsPath = settingsPath;
|
|
2412
|
+
const trustedProject = projectTrusted(ctx);
|
|
1827
2413
|
const modelRegistry = contextHostCapabilities(ctx).modelRegistry;
|
|
1828
2414
|
const available = () => [...new Set((modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`))].sort();
|
|
1829
2415
|
const selectTarget = async (aliases: Readonly<Record<string, string>>): Promise<string | undefined> => {
|
|
@@ -1834,13 +2420,13 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1834
2420
|
return (await ctx.ui.input("Manual model ID", "provider/model[:thinking] or alias[:thinking]"))?.trim() || undefined;
|
|
1835
2421
|
};
|
|
1836
2422
|
const save = (aliases: Readonly<Record<string, string>>): boolean => {
|
|
1837
|
-
try { saveModelAliases(
|
|
1838
|
-
catch (error) { ctx.ui.notify(`${
|
|
2423
|
+
try { saveModelAliases(aliasSettingsPath, aliases); ctx.ui.notify(`Saved model aliases to ${aliasSettingsPath}.`, "info"); return true; }
|
|
2424
|
+
catch (error) { ctx.ui.notify(`${aliasSettingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error"); return false; }
|
|
1839
2425
|
};
|
|
1840
2426
|
for (;;) {
|
|
1841
2427
|
let aliases: Readonly<Record<string, string>>;
|
|
1842
|
-
try {
|
|
1843
|
-
catch (error) { ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error"); return; }
|
|
2428
|
+
try { const resolution = resolveWorkflowSettings(ctx.cwd, trustedProject, settingsPath); aliases = resolution.effective.modelAliases ?? {}; aliasSettingsPath = resolution.sources.modelAliases; }
|
|
2429
|
+
catch (error) { ctx.ui.notify(`${trustedProject ? workflowProjectSettingsPath(ctx.cwd) : settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error"); return; }
|
|
1844
2430
|
const names = Object.keys(aliases).sort();
|
|
1845
2431
|
const listing = names.length ? names.map((name) => `${name} = ${aliases[name] ?? ""}`).join("\n") : "(none)";
|
|
1846
2432
|
const options = ["Add alias", ...names.map((name) => `Edit ${name}`), ...names.map((name) => `Delete ${name}`), "Back"];
|
|
@@ -1853,8 +2439,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1853
2439
|
const target = await selectTarget(aliases);
|
|
1854
2440
|
if (!target) continue;
|
|
1855
2441
|
const next = { ...aliases, [name]: target };
|
|
1856
|
-
try { validateModelAliases(next,
|
|
1857
|
-
const parsed = resolveModelReference(target, next, new Set(available()),
|
|
2442
|
+
try { validateModelAliases(next, aliasSettingsPath); } catch (error) { ctx.ui.notify(`${aliasSettingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error"); continue; }
|
|
2443
|
+
const parsed = resolveModelReference(target, next, new Set(available()), aliasSettingsPath);
|
|
1858
2444
|
if (!available().includes(`${parsed.provider}/${parsed.model}`)) {
|
|
1859
2445
|
ctx.ui.notify(`Warning: ${target} is not currently available in Pi.`, "warning");
|
|
1860
2446
|
if (!await ctx.ui.confirm("Save unknown model?", "Save this target for cross-machine portability?")) continue;
|
|
@@ -1867,8 +2453,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1867
2453
|
const target = await selectTarget(aliases);
|
|
1868
2454
|
if (!target) continue;
|
|
1869
2455
|
const next = { ...aliases, [edit[1]]: target };
|
|
1870
|
-
try { validateModelAliases(next,
|
|
1871
|
-
const parsed = resolveModelReference(target, next, new Set(available()),
|
|
2456
|
+
try { validateModelAliases(next, aliasSettingsPath); } catch (error) { ctx.ui.notify(`${aliasSettingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error"); continue; }
|
|
2457
|
+
const parsed = resolveModelReference(target, next, new Set(available()), aliasSettingsPath);
|
|
1872
2458
|
if (!available().includes(`${parsed.provider}/${parsed.model}`)) {
|
|
1873
2459
|
ctx.ui.notify(`Warning: ${target} is not currently available in Pi.`, "warning");
|
|
1874
2460
|
if (!await ctx.ui.confirm("Save unknown model?", "Save this target for cross-machine portability?")) continue;
|
|
@@ -1897,9 +2483,10 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1897
2483
|
}
|
|
1898
2484
|
const sorted = navigatorAttentionSort(stores);
|
|
1899
2485
|
const labels = navigatorRunLabels(sorted);
|
|
1900
|
-
const terminalStates =
|
|
2486
|
+
const terminalStates = HARD_TERMINAL_RUN_STATES;
|
|
1901
2487
|
const hasCompleted = sorted.some(({ loaded: { run } }) => run.state === "completed");
|
|
1902
|
-
const
|
|
2488
|
+
const hasFailed = sorted.some(({ loaded: { run } }) => run.state === "failed");
|
|
2489
|
+
const pickerOptions = [...labels, ...(herdrPaneId() ? ["Inspect session in pane"] : []), "Model aliases", "Close", ...(hasCompleted ? ["Delete all completed"] : []), ...(hasFailed ? ["Delete all failed"] : [])];
|
|
1903
2490
|
const runChoice = await ctx.ui.select("Workflows\n", pickerOptions);
|
|
1904
2491
|
if (!runChoice || runChoice === "Close") return;
|
|
1905
2492
|
if (runChoice === "Inspect session in pane") {
|
|
@@ -1919,6 +2506,13 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1919
2506
|
}
|
|
1920
2507
|
ctx.ui.notify("Deleted all completed workflow runs.", "info"); stores = await loadStores(); continue;
|
|
1921
2508
|
}
|
|
2509
|
+
if (runChoice === "Delete all failed") {
|
|
2510
|
+
if (!await ctx.ui.confirm("Delete failed runs?", "Delete all failed workflow runs and their artifacts? This cannot be undone.")) continue;
|
|
2511
|
+
for (const entry of sorted) {
|
|
2512
|
+
if (entry.loaded.run.state === "failed") { await entry.store.delete(true); runs.delete(entry.store.runId); terminalRunStates.delete(entry.store.runId); }
|
|
2513
|
+
}
|
|
2514
|
+
ctx.ui.notify("Deleted all failed workflow runs.", "info"); stores = await loadStores(); continue;
|
|
2515
|
+
}
|
|
1922
2516
|
const runIndex = labels.indexOf(runChoice);
|
|
1923
2517
|
if (runIndex < 0) return;
|
|
1924
2518
|
const selected = sorted[runIndex];
|
|
@@ -1936,6 +2530,13 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1936
2530
|
const loaded = await store.load();
|
|
1937
2531
|
const checkpoints = await store.awaitingCheckpoints();
|
|
1938
2532
|
const worktrees = await store.worktrees();
|
|
2533
|
+
const completedOperations = ctx.mode === "tui" ? await store.replayableOperations().catch(() => []) : [];
|
|
2534
|
+
const agentResults = new Map<string, JsonValue>();
|
|
2535
|
+
for (const agent of loaded.run.agents) {
|
|
2536
|
+
if (agent.state !== "completed" || agent.parentId || !agent.resultPath) continue;
|
|
2537
|
+
const operation = completedOperations.find((candidate) => candidate.path === agent.resultPath);
|
|
2538
|
+
if (operation) agentResults.set(agent.id, operation.value);
|
|
2539
|
+
}
|
|
1939
2540
|
const actions = new Map<string, string>();
|
|
1940
2541
|
const copies = new Map<string, { value: string; artifact: string }>();
|
|
1941
2542
|
const reviews = new Map<string, AwaitingCheckpoint>();
|
|
@@ -1961,96 +2562,135 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1961
2562
|
}
|
|
1962
2563
|
}
|
|
1963
2564
|
if (ctx.mode !== "tui") actions.set("Refresh", "refresh");
|
|
1964
|
-
else actions.set("
|
|
1965
|
-
if (loaded.run.agents.length) actions.set("Agents...", "agents");
|
|
2565
|
+
else actions.set("Open script in editor", "open-script");
|
|
2566
|
+
if (ctx.mode !== "tui" && loaded.run.agents.length) actions.set("Agents...", "agents");
|
|
1966
2567
|
if (terminalStates.has(loaded.run.state)) add("Delete", "delete");
|
|
1967
2568
|
if (ctx.mode === "tui") {
|
|
1968
2569
|
addCopy("Copy run path", store.directory, "run path");
|
|
1969
2570
|
addCopy("Copy run ID", store.runId, "run ID");
|
|
1970
2571
|
}
|
|
1971
|
-
return { dashboard:
|
|
2572
|
+
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, agentResults, agents: loaded.run.agents, worktrees, cwd: loaded.run.cwd };
|
|
1972
2573
|
};
|
|
1973
|
-
const
|
|
1974
|
-
|
|
1975
|
-
const
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
const parent = byId.get(parentId);
|
|
1979
|
-
if (!parent) break;
|
|
1980
|
-
parents.unshift(parent.label ?? parent.name);
|
|
1981
|
-
}
|
|
1982
|
-
return [...((agent.structuralPath ?? []).length ? [(agent.structuralPath ?? []).join(" > ")] : []), ...(agent.parentBreadcrumb ? [agent.parentBreadcrumb] : []), ...parents, agent.label ?? agent.name].join(" > ");
|
|
1983
|
-
};
|
|
1984
|
-
const labels = dashboard.agents.map((agent, index) => `#${String(index + 1)} ${title(agent)} [${agent.state}]`);
|
|
1985
|
-
const selectedLabel = await ctx.ui.select("Agents", [...labels, "Back"]);
|
|
1986
|
-
const selectedIndex = selectedLabel ? labels.indexOf(selectedLabel) : -1;
|
|
1987
|
-
const selected = selectedIndex >= 0 ? dashboard.agents[selectedIndex] : undefined;
|
|
1988
|
-
if (!selected) return;
|
|
1989
|
-
const attempts = [...(selected.attemptDetails ?? [])].sort((left, right) => left.attempt - right.attempt);
|
|
1990
|
-
const worktree = selected.worktreeOwner ? dashboard.worktrees.find((candidate) => candidate.owner === selected.worktreeOwner) : undefined;
|
|
1991
|
-
const actions = [
|
|
1992
|
-
...(attempts.length && herdrPaneId() ? ["Fork as Pi session in pane"] : []),
|
|
2574
|
+
const agentWorktreeFor = (dashboard: Awaited<ReturnType<typeof loadDashboard>>, agent: AgentRecord): WorktreeReference | undefined => agent.worktreeOwner ? dashboard.worktrees.find((candidate) => candidate.owner === agent.worktreeOwner) : undefined;
|
|
2575
|
+
const agentActionLabels = (dashboard: Awaited<ReturnType<typeof loadDashboard>>, agent: AgentRecord): string[] => {
|
|
2576
|
+
const worktree = agentWorktreeFor(dashboard, agent);
|
|
2577
|
+
return [
|
|
2578
|
+
...(agent.attemptDetails?.length && herdrPaneId() ? ["Fork as Pi session in pane"] : []),
|
|
1993
2579
|
...(worktree ? ["Copy branch", "Copy worktree path"] : []),
|
|
2580
|
+
...(ctx.mode === "tui" && dashboard.agentResults.has(agent.id) ? ["Open result in editor"] : []),
|
|
1994
2581
|
"Copy agent ID",
|
|
1995
2582
|
"Back",
|
|
1996
2583
|
];
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2584
|
+
};
|
|
2585
|
+
const forkAgentSession = async (dashboard: Awaited<ReturnType<typeof loadDashboard>>, agent: AgentRecord): Promise<void> => {
|
|
2586
|
+
const attempts = [...(agent.attemptDetails ?? [])].sort((left, right) => left.attempt - right.attempt);
|
|
2587
|
+
const worktree = agentWorktreeFor(dashboard, agent);
|
|
2588
|
+
const choices = attempts.map((attempt) => `Attempt ${String(attempt.attempt)}`);
|
|
2589
|
+
const choice = choices.length === 1 ? choices[0] : await ctx.ui.select("Fork attempts", [...choices, "Back"]);
|
|
2590
|
+
const index = choice ? choices.indexOf(choice) : -1;
|
|
2591
|
+
const attempt = index >= 0 ? attempts[index] : undefined;
|
|
2592
|
+
if (!attempt) return;
|
|
2593
|
+
const running = !SETTLED_AGENT_STATES.has(agent.state) && attempt.attempt === attempts.at(-1)?.attempt && !attempt.error;
|
|
2594
|
+
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?")) return;
|
|
2595
|
+
try {
|
|
2596
|
+
await openHerdrPane({ action: "fork", cwd: worktree?.cwd ?? attempt.setup?.cwd ?? dashboard.cwd, original: attempt.sessionFile, ...(running ? { readOnly: true } : {}) });
|
|
2597
|
+
ctx.ui.notify("Forked Pi session in pane.", "info");
|
|
2598
|
+
} catch (error) {
|
|
2599
|
+
ctx.ui.notify(`Cannot fork Pi session in pane: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
2600
|
+
}
|
|
2601
|
+
};
|
|
2602
|
+
const selectAgent = async (dashboard: Awaited<ReturnType<typeof loadDashboard>>, requestedAgentId?: string): Promise<void> => {
|
|
2603
|
+
const byId = new Map(dashboard.agents.map((agent) => [agent.id, agent]));
|
|
2604
|
+
const title = (agent: AgentRecord): string => agentBreadcrumb(agent, byId, true);
|
|
2605
|
+
const labels = dashboard.agents.map((agent, index) => `#${String(index + 1)} ${title(agent)} [${agent.state}]`);
|
|
2606
|
+
let selected: AgentRecord | undefined;
|
|
2607
|
+
if (requestedAgentId) selected = dashboard.agents.find((agent) => agent.id === requestedAgentId);
|
|
2608
|
+
else {
|
|
2609
|
+
const selectedLabel = await ctx.ui.select("Agents", [...labels, "Back"]);
|
|
2610
|
+
const selectedIndex = selectedLabel ? labels.indexOf(selectedLabel) : -1;
|
|
2611
|
+
selected = selectedIndex >= 0 ? dashboard.agents[selectedIndex] : undefined;
|
|
2612
|
+
}
|
|
2613
|
+
if (!selected) return;
|
|
2614
|
+
const worktree = agentWorktreeFor(dashboard, selected);
|
|
2615
|
+
const actions = agentActionLabels(dashboard, selected);
|
|
2003
2616
|
for (;;) {
|
|
2004
2617
|
const action = await ctx.ui.select(title(selected), actions);
|
|
2005
2618
|
if (!action || action === "Back") return;
|
|
2006
2619
|
if (action === "Copy agent ID") { await copyArtifact(selected.id, "agent ID"); continue; }
|
|
2007
2620
|
if (action === "Copy branch" && worktree) { await copyArtifact(worktree.branch, "branch"); continue; }
|
|
2008
2621
|
if (action === "Copy worktree path" && worktree) { await copyArtifact(worktree.path, "worktree path"); continue; }
|
|
2009
|
-
if (action === "Fork as Pi session in pane")
|
|
2010
|
-
const attempt = await chooseAttempt();
|
|
2011
|
-
if (!attempt) continue;
|
|
2012
|
-
const running = !SETTLED_AGENT_STATES.has(selected.state) && attempt.attempt === attempts.at(-1)?.attempt && !attempt.error;
|
|
2013
|
-
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;
|
|
2014
|
-
try {
|
|
2015
|
-
await openHerdrPane({ action: "fork", cwd: worktree?.cwd ?? attempt.setup?.cwd ?? dashboard.cwd, original: attempt.sessionFile, ...(running ? { readOnly: true } : {}) });
|
|
2016
|
-
ctx.ui.notify("Forked Pi session in pane.", "info");
|
|
2017
|
-
} catch (error) {
|
|
2018
|
-
ctx.ui.notify(`Cannot fork Pi session in pane: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
2019
|
-
}
|
|
2020
|
-
}
|
|
2622
|
+
if (action === "Fork as Pi session in pane") await forkAgentSession(dashboard, selected);
|
|
2021
2623
|
}
|
|
2022
2624
|
};
|
|
2023
2625
|
for (;;) {
|
|
2024
2626
|
let view = await loadDashboard();
|
|
2025
2627
|
const actionChoice = ctx.mode === "tui"
|
|
2026
2628
|
? await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
|
|
2027
|
-
let options = [...view.actions.keys(), "Close"];
|
|
2028
|
-
let selectedIndex = 0;
|
|
2029
2629
|
let dashboardOffset = 0;
|
|
2030
2630
|
let refreshing = false;
|
|
2031
2631
|
let disposed = false;
|
|
2632
|
+
let detailsMode = false;
|
|
2633
|
+
let actionMode = false;
|
|
2634
|
+
let actionIndex = 0;
|
|
2032
2635
|
let stopRequested = false;
|
|
2033
2636
|
let stopStatus: string | undefined;
|
|
2637
|
+
let selectionNeedsScroll = true;
|
|
2638
|
+
let renderedWidth = 80;
|
|
2639
|
+
let refreshGeneration = 0;
|
|
2640
|
+
const initialSelection = preserveWorkflowPhaseSelection(view.phaseModel, {});
|
|
2641
|
+
let tree = buildWorkflowPhaseTree(view.phaseModel);
|
|
2642
|
+
let selectedNodeId = initialSelection.nodeId ?? tree.nodes[0]?.id;
|
|
2643
|
+
let expandedNodeIds = new Set(initialSelection.expandedNodeIds ?? workflowPhaseTreeInitialExpanded(tree));
|
|
2034
2644
|
const terminalRows = () => Math.max(1, tuiRows(tui) - WORKFLOW_OVERLAY_BORDER_ROWS);
|
|
2035
|
-
const keyLabels: Record<string, string> = { up: "↑", down: "↓",
|
|
2645
|
+
const keyLabels: Record<string, string> = { up: "↑", down: "↓", left: "←", right: "→", pageUp: "pgup", pageDown: "pgdn" };
|
|
2036
2646
|
const keyLabel = (binding: string, fallback: string) => {
|
|
2037
2647
|
const keys = keybindingKeys(keybindings, binding);
|
|
2038
2648
|
return keys?.length ? keys.map((key) => keyLabels[key] ?? key).join("/") : fallback;
|
|
2039
2649
|
};
|
|
2040
|
-
const
|
|
2041
|
-
const
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
const
|
|
2046
|
-
return
|
|
2650
|
+
const selectedAgentRecord = (): AgentRecord | undefined => {
|
|
2651
|
+
const node = selectedNodeId ? tree.byId.get(selectedNodeId) : tree.nodes[0];
|
|
2652
|
+
return node?.kind === "agent" && node.agentId ? view.agents.find((agent) => agent.id === node.agentId) : undefined;
|
|
2653
|
+
};
|
|
2654
|
+
const actionOptions = () => {
|
|
2655
|
+
const agent = selectedAgentRecord();
|
|
2656
|
+
return agent ? agentActionLabels(view, agent) : [...view.actions.keys(), "Back"];
|
|
2047
2657
|
};
|
|
2048
|
-
|
|
2658
|
+
let editorRunning = false;
|
|
2659
|
+
const openArtifact = async (artifact: Promise<WorkflowArtifact>, label: string): Promise<void> => {
|
|
2660
|
+
if (editorRunning) return;
|
|
2661
|
+
editorRunning = true;
|
|
2662
|
+
try {
|
|
2663
|
+
const command = SettingsManager.create(view.cwd, extensionAgentDir, { projectTrusted: projectTrusted(ctx) }).getExternalEditorCommand();
|
|
2664
|
+
if (!command) { ctx.ui.notify(`Cannot open ${label}: no external editor is configured.`, "warning"); return; }
|
|
2665
|
+
const exitCode = await openWorkflowArtifact(tui, command, await artifact);
|
|
2666
|
+
if (exitCode !== 0) {
|
|
2667
|
+
const detail = exitCode === null ? "could not be started" : `exited with code ${String(exitCode)}`;
|
|
2668
|
+
ctx.ui.notify(`Cannot open ${label}: external editor ${detail}.`, "warning");
|
|
2669
|
+
}
|
|
2670
|
+
} catch (error) {
|
|
2671
|
+
ctx.ui.notify(`Cannot open ${label}: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
2672
|
+
} finally {
|
|
2673
|
+
editorRunning = false;
|
|
2674
|
+
tui.requestRender(true);
|
|
2675
|
+
}
|
|
2676
|
+
};
|
|
2677
|
+
const updateDashboard = async () => {
|
|
2678
|
+
const generation = ++refreshGeneration;
|
|
2679
|
+
const hadExpandableNodes = tree.nodes.some((node) => node.children.length > 0);
|
|
2049
2680
|
const next = await loadDashboard();
|
|
2050
|
-
if (disposed) return;
|
|
2681
|
+
if (disposed || generation !== refreshGeneration) return;
|
|
2682
|
+
const previousNodeId = selectedNodeId;
|
|
2683
|
+
const previousExpanded = expandedNodeIds;
|
|
2684
|
+
const selectedAction = actionMode ? actionOptions()[actionIndex] : undefined;
|
|
2051
2685
|
view = next;
|
|
2052
|
-
|
|
2053
|
-
|
|
2686
|
+
tree = buildWorkflowPhaseTree(view.phaseModel);
|
|
2687
|
+
selectedNodeId = preserveWorkflowPhaseTreeSelection(tree, { nodeId: previousNodeId }).nodeId;
|
|
2688
|
+
expandedNodeIds = new Set([...previousExpanded].filter((id) => tree.byId.has(id)));
|
|
2689
|
+
if (!hadExpandableNodes && !expandedNodeIds.size && tree.nodes.some((node) => node.children.length > 0)) expandedNodeIds = new Set(workflowPhaseTreeInitialExpanded(tree));
|
|
2690
|
+
const nextActions = actionOptions();
|
|
2691
|
+
const preservedActionIndex = selectedAction ? nextActions.indexOf(selectedAction) : -1;
|
|
2692
|
+
actionIndex = preservedActionIndex >= 0 ? preservedActionIndex : selectedAction ? nextActions.length - 1 : Math.min(actionIndex, Math.max(0, nextActions.length - 1));
|
|
2693
|
+
selectionNeedsScroll = true;
|
|
2054
2694
|
tui.requestRender();
|
|
2055
2695
|
};
|
|
2056
2696
|
const requestStop = () => {
|
|
@@ -2058,14 +2698,14 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2058
2698
|
stopRequested = true;
|
|
2059
2699
|
stopStatus = undefined;
|
|
2060
2700
|
setWorkflowStatus(undefined);
|
|
2061
|
-
const selectedOption = options[selectedIndex];
|
|
2062
2701
|
void runAction(`stop ${store.runId}`, true, (status) => {
|
|
2063
2702
|
stopStatus = status;
|
|
2064
2703
|
setWorkflowStatus(status);
|
|
2065
2704
|
if (!disposed) tui.requestRender();
|
|
2066
|
-
}).then(() => updateDashboard(
|
|
2705
|
+
}).then(() => updateDashboard()).catch((error: unknown) => {
|
|
2067
2706
|
if (disposed) return;
|
|
2068
2707
|
stopStatus = `Could not stop workflow ${store.runId}: ${error instanceof Error ? error.message : String(error)}`;
|
|
2708
|
+
setWorkflowStatus(stopStatus);
|
|
2069
2709
|
tui.requestRender();
|
|
2070
2710
|
}).finally(() => {
|
|
2071
2711
|
stopRequested = false;
|
|
@@ -2075,89 +2715,122 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2075
2715
|
const timer = setInterval(() => {
|
|
2076
2716
|
if (refreshing || stopRequested) return;
|
|
2077
2717
|
refreshing = true;
|
|
2078
|
-
|
|
2079
|
-
void updateDashboard(selectedOption).catch(() => undefined).finally(() => { refreshing = false; });
|
|
2718
|
+
void updateDashboard().catch(() => undefined).finally(() => { refreshing = false; });
|
|
2080
2719
|
}, 1000);
|
|
2081
2720
|
timer.unref();
|
|
2082
2721
|
return borderWorkflowOverlay({
|
|
2083
2722
|
render(width: number) {
|
|
2084
|
-
|
|
2085
|
-
const
|
|
2086
|
-
const
|
|
2087
|
-
const
|
|
2088
|
-
const
|
|
2089
|
-
const
|
|
2090
|
-
|
|
2091
|
-
const
|
|
2723
|
+
renderedWidth = width;
|
|
2724
|
+
const narrow = width < 80;
|
|
2725
|
+
const styles = themeWorkflowProgressStyles(theme);
|
|
2726
|
+
const agent = selectedAgentRecord();
|
|
2727
|
+
const actions = actionMode ? { title: agent ? "Agent actions" : "Run actions", options: actionOptions(), index: actionIndex } : undefined;
|
|
2728
|
+
const phaseLines = formatWorkflowPhaseDashboard(view.run, view.snapshot, width, { nodeId: selectedNodeId, expandedNodeIds: [...expandedNodeIds], ...(narrow && !detailsMode ? { treeOnly: true } : {}), ...(narrow && detailsMode ? { detailsOnly: true } : {}), ...(actions ? { actions } : {}) }, styles);
|
|
2729
|
+
const statusLines = stopStatus ? truncateToVisualLines(styles.error(stopStatus), Number.MAX_SAFE_INTEGER, width, 0).visualLines.map((line) => line.trimEnd()) : [];
|
|
2730
|
+
const content = [...statusLines, ...phaseLines];
|
|
2731
|
+
const rows = terminalRows();
|
|
2732
|
+
const hintRows = rows >= 3 ? 1 : 0;
|
|
2733
|
+
const viewport = Math.max(1, rows - hintRows);
|
|
2734
|
+
const maxOffset = Math.max(0, content.length - viewport);
|
|
2735
|
+
dashboardOffset = Math.max(0, Math.min(maxOffset, dashboardOffset));
|
|
2736
|
+
if (actionMode) {
|
|
2737
|
+
const label = actions?.options[actionIndex];
|
|
2738
|
+
const actionRow = label ? content.findIndex((line) => line.includes(label)) : -1;
|
|
2739
|
+
if (actionRow >= 0) {
|
|
2740
|
+
if (actionRow < dashboardOffset) dashboardOffset = actionRow;
|
|
2741
|
+
else if (actionRow >= dashboardOffset + viewport) dashboardOffset = actionRow - viewport + 1;
|
|
2742
|
+
}
|
|
2743
|
+
} else if (!detailsMode && selectionNeedsScroll) {
|
|
2744
|
+
const selectedRow = content.findIndex((line) => line.startsWith("→"));
|
|
2745
|
+
if (selectedRow >= 0) {
|
|
2746
|
+
if (selectedRow < dashboardOffset) dashboardOffset = selectedRow;
|
|
2747
|
+
else if (selectedRow >= dashboardOffset + viewport) dashboardOffset = selectedRow - viewport + 1;
|
|
2748
|
+
}
|
|
2749
|
+
selectionNeedsScroll = false;
|
|
2750
|
+
}
|
|
2092
2751
|
dashboardOffset = Math.max(0, Math.min(maxOffset, dashboardOffset));
|
|
2093
|
-
const
|
|
2094
|
-
return [
|
|
2095
|
-
...dashboardLines.slice(dashboardOffset, dashboardOffset + layout.dashboardViewport),
|
|
2096
|
-
...(layout.separatorRows && layout.dashboardViewport ? [""] : []),
|
|
2097
|
-
...actionLines.slice(actionStart, actionStart + layout.actionViewport),
|
|
2098
|
-
...(layout.hintRows ? [hint] : []),
|
|
2099
|
-
];
|
|
2752
|
+
const hint = truncateToVisualLines(theme.fg("dim", actionMode ? `${keyLabel("tui.select.up", "↑")}/${keyLabel("tui.select.down", "↓")} actions · ${keyLabel("tui.select.confirm", "enter")} run · ${keyLabel("tui.select.cancel", "esc")} tree` : `${keyLabel("tui.select.up", "↑")}/${keyLabel("tui.select.down", "↓")} tree · ${keyLabel("tui.editor.cursorLeft", "←")}/${keyLabel("tui.editor.cursorRight", "→")} collapse/expand · ${keyLabel("tui.select.confirm", "enter")} inspect · a actions · ${keyLabel("tui.select.cancel", "esc")} ${narrow && detailsMode ? "tree" : "close"}${content.length > viewport ? ` · ${keyLabel("tui.select.pageUp", "pgup")}/${keyLabel("tui.select.pageDown", "pgdn")} scroll` : ""} · auto-refresh 1s`), Number.MAX_SAFE_INTEGER, width, 1).visualLines[0] ?? "";
|
|
2753
|
+
return [...content.slice(dashboardOffset, dashboardOffset + viewport), ...(hintRows ? [hint] : [])];
|
|
2100
2754
|
},
|
|
2101
2755
|
invalidate() {},
|
|
2102
2756
|
handleInput(data: string) {
|
|
2103
|
-
if (stopRequested) return;
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2757
|
+
if (stopRequested || editorRunning) return;
|
|
2758
|
+
const narrow = renderedWidth < 80;
|
|
2759
|
+
if (!actionMode && (data === "a" || data === "A")) { actionMode = true; actionIndex = 0; dashboardOffset = 0; tui.requestRender(); return; }
|
|
2760
|
+
if (actionMode) {
|
|
2761
|
+
const options = actionOptions();
|
|
2762
|
+
if (keybindings.matches(data, "tui.select.cancel")) { actionMode = false; dashboardOffset = 0; tui.requestRender(); return; }
|
|
2763
|
+
if (keybindings.matches(data, "tui.select.up")) actionIndex = (actionIndex + options.length - 1) % options.length;
|
|
2764
|
+
else if (keybindings.matches(data, "tui.select.down")) actionIndex = (actionIndex + 1) % options.length;
|
|
2765
|
+
else if (keybindings.matches(data, "tui.select.pageUp")) dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, terminalRows() - 1));
|
|
2766
|
+
else if (keybindings.matches(data, "tui.select.pageDown")) dashboardOffset += Math.max(1, terminalRows() - 1);
|
|
2767
|
+
else if (keybindings.matches(data, "tui.select.confirm")) {
|
|
2768
|
+
const action = options[actionIndex];
|
|
2769
|
+
const agent = selectedAgentRecord();
|
|
2770
|
+
if (!action || action === "Back") { actionMode = false; dashboardOffset = 0; }
|
|
2771
|
+
else if (agent) {
|
|
2772
|
+
const worktree = agentWorktreeFor(view, agent);
|
|
2773
|
+
if (action === "Open result in editor") {
|
|
2774
|
+
const result = view.agentResults.get(agent.id);
|
|
2775
|
+
if (result !== undefined) void openArtifact(Promise.resolve(workflowResultArtifact(result)), "agent result");
|
|
2776
|
+
}
|
|
2777
|
+
else if (action === "Copy agent ID") void copyArtifact(agent.id, "agent ID");
|
|
2778
|
+
else if (action === "Copy branch" && worktree) void copyArtifact(worktree.branch, "branch");
|
|
2779
|
+
else if (action === "Copy worktree path" && worktree) void copyArtifact(worktree.path, "worktree path");
|
|
2780
|
+
else done(`__workflow_fork__:${agent.id}`);
|
|
2781
|
+
}
|
|
2782
|
+
else if (action === "Open script in editor") void openArtifact(readFile(join(store.directory, "workflow.js"), "utf8").then(workflowScriptArtifact), "workflow script");
|
|
2783
|
+
else if (action === "Stop") requestStop();
|
|
2784
|
+
else done(action);
|
|
2785
|
+
}
|
|
2786
|
+
tui.requestRender();
|
|
2787
|
+
return;
|
|
2111
2788
|
}
|
|
2789
|
+
const current = selectedNodeId ? tree.byId.get(selectedNodeId) : tree.nodes[0];
|
|
2790
|
+
if (keybindings.matches(data, "tui.select.cancel")) {
|
|
2791
|
+
if (narrow && detailsMode) { detailsMode = false; selectionNeedsScroll = true; } else done(undefined);
|
|
2792
|
+
} else if (narrow && detailsMode) {
|
|
2793
|
+
if (keybindings.matches(data, "tui.select.pageUp")) dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, terminalRows() - 1));
|
|
2794
|
+
else if (keybindings.matches(data, "tui.select.pageDown")) dashboardOffset += Math.max(1, terminalRows() - 1);
|
|
2795
|
+
else if (keybindings.matches(data, "tui.select.confirm")) {
|
|
2796
|
+
if (current?.kind === "agent" && current.agentId) { actionMode = true; actionIndex = 0; }
|
|
2797
|
+
else if (current?.children.length) { if (expandedNodeIds.has(current.id)) expandedNodeIds.delete(current.id); else expandedNodeIds.add(current.id); }
|
|
2798
|
+
}
|
|
2799
|
+
} else if (keybindings.matches(data, "tui.editor.cursorLeft")) {
|
|
2800
|
+
const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "left");
|
|
2801
|
+
selectedNodeId = next.nodeId; expandedNodeIds = new Set(next.expandedNodeIds); selectionNeedsScroll = true;
|
|
2802
|
+
} else if (keybindings.matches(data, "tui.editor.cursorRight")) {
|
|
2803
|
+
const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "right");
|
|
2804
|
+
selectedNodeId = next.nodeId; expandedNodeIds = new Set(next.expandedNodeIds); selectionNeedsScroll = true;
|
|
2805
|
+
} else if (keybindings.matches(data, "tui.select.up")) {
|
|
2806
|
+
const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "up");
|
|
2807
|
+
selectedNodeId = next.nodeId; selectionNeedsScroll = true;
|
|
2808
|
+
} else if (keybindings.matches(data, "tui.select.down")) {
|
|
2809
|
+
const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "down");
|
|
2810
|
+
selectedNodeId = next.nodeId; selectionNeedsScroll = true;
|
|
2811
|
+
} else if (keybindings.matches(data, "tui.select.pageUp")) dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, terminalRows() - 1));
|
|
2812
|
+
else if (keybindings.matches(data, "tui.select.pageDown")) dashboardOffset += Math.max(1, terminalRows() - 1);
|
|
2112
2813
|
else if (keybindings.matches(data, "tui.select.confirm")) {
|
|
2113
|
-
if (
|
|
2114
|
-
else
|
|
2814
|
+
if (narrow) detailsMode = true;
|
|
2815
|
+
else if (current?.kind === "agent" && current.agentId) { actionMode = true; actionIndex = 0; }
|
|
2816
|
+
else if (current?.children.length) { if (expandedNodeIds.has(current.id)) expandedNodeIds.delete(current.id); else expandedNodeIds.add(current.id); }
|
|
2115
2817
|
}
|
|
2116
|
-
else if (keybindings.matches(data, "tui.select.cancel")) done(undefined);
|
|
2117
2818
|
tui.requestRender();
|
|
2118
2819
|
},
|
|
2119
2820
|
dispose() { disposed = true; clearInterval(timer); setWorkflowStatus(undefined); },
|
|
2120
2821
|
}, theme);
|
|
2121
|
-
}, { overlay: true })
|
|
2822
|
+
}, { overlay: true, overlayOptions: WORKFLOW_OVERLAY_OPTIONS })
|
|
2122
2823
|
: await ctx.ui.select(view.dashboard, [...view.actions.keys(), "Close"]);
|
|
2123
2824
|
if (!actionChoice || actionChoice === "Close") return;
|
|
2124
2825
|
if (actionChoice === "Agents...") { await selectAgent(view); continue; }
|
|
2125
|
-
if (actionChoice
|
|
2126
|
-
if (actionChoice
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
let renderedLines: string[] = [];
|
|
2131
|
-
const viewport = () => Math.max(1, tuiRows(tui) - 3 - WORKFLOW_OVERLAY_BORDER_ROWS);
|
|
2132
|
-
const move = (delta: number) => {
|
|
2133
|
-
const maxOffset = Math.max(0, renderedLines.length - viewport());
|
|
2134
|
-
offset = Math.max(0, Math.min(maxOffset, offset + delta));
|
|
2135
|
-
};
|
|
2136
|
-
return borderWorkflowOverlay({
|
|
2137
|
-
render(width: number) {
|
|
2138
|
-
renderedLines = highlighted.flatMap((line) => line ? truncateToVisualLines(line, Number.MAX_SAFE_INTEGER, width, 0).visualLines : [""]);
|
|
2139
|
-
const maxOffset = Math.max(0, renderedLines.length - viewport());
|
|
2140
|
-
offset = Math.min(offset, maxOffset);
|
|
2141
|
-
return [
|
|
2142
|
-
theme.fg("accent", "Workflow script"),
|
|
2143
|
-
...renderedLines.slice(offset, offset + viewport()),
|
|
2144
|
-
"",
|
|
2145
|
-
theme.fg("dim", "↑↓/pgup/pgdn scroll · esc close"),
|
|
2146
|
-
];
|
|
2147
|
-
},
|
|
2148
|
-
invalidate() {},
|
|
2149
|
-
handleInput(data: string) {
|
|
2150
|
-
if (keybindings.matches(data, "tui.select.up")) move(-1);
|
|
2151
|
-
else if (keybindings.matches(data, "tui.select.down")) move(1);
|
|
2152
|
-
else if (keybindings.matches(data, "tui.select.pageUp")) move(-viewport());
|
|
2153
|
-
else if (keybindings.matches(data, "tui.select.pageDown")) move(viewport());
|
|
2154
|
-
else if (keybindings.matches(data, "tui.select.cancel")) done(undefined);
|
|
2155
|
-
tui.requestRender();
|
|
2156
|
-
},
|
|
2157
|
-
}, theme);
|
|
2158
|
-
}, { overlay: true, overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%" } });
|
|
2826
|
+
if (actionChoice.startsWith("__workflow_agent__:")) { await selectAgent(view, actionChoice.slice("__workflow_agent__:".length)); continue; }
|
|
2827
|
+
if (actionChoice.startsWith("__workflow_fork__:")) {
|
|
2828
|
+
const agentId = actionChoice.slice("__workflow_fork__:".length);
|
|
2829
|
+
const agent = view.agents.find((candidate) => candidate.id === agentId);
|
|
2830
|
+
if (agent) await forkAgentSession(view, agent);
|
|
2159
2831
|
continue;
|
|
2160
2832
|
}
|
|
2833
|
+
if (actionChoice === "Refresh") continue;
|
|
2161
2834
|
const copy = view.copies.get(actionChoice);
|
|
2162
2835
|
if (copy) { await copyArtifact(copy.value, copy.artifact); continue; }
|
|
2163
2836
|
if (actionChoice.startsWith("Review ")) {
|
|
@@ -2211,7 +2884,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2211
2884
|
tui.requestRender();
|
|
2212
2885
|
},
|
|
2213
2886
|
}, theme);
|
|
2214
|
-
}, { overlay: true, overlayOptions:
|
|
2887
|
+
}, { overlay: true, overlayOptions: WORKFLOW_OVERLAY_OPTIONS });
|
|
2215
2888
|
if (decision) {
|
|
2216
2889
|
const accepted = await answerCheckpoint(store.runId, checkpoint.name, decision === "Approve", true);
|
|
2217
2890
|
if (!accepted) ctx.ui.notify("Checkpoint is not awaiting a response.", "warning");
|
|
@@ -2231,9 +2904,9 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2231
2904
|
pi.on("session_shutdown", async () => {
|
|
2232
2905
|
try {
|
|
2233
2906
|
await Promise.all([...runs.entries()].map(async ([runId, run]) => {
|
|
2234
|
-
|
|
2235
|
-
if (!
|
|
2236
|
-
try { await run.lifecycle.terminal("interrupted"); } catch (error) { if (!
|
|
2907
|
+
const isTerminal = SHUTDOWN_TERMINAL_RUN_STATES.has(run.lifecycle.state);
|
|
2908
|
+
if (!isTerminal) {
|
|
2909
|
+
try { await run.lifecycle.terminal("interrupted"); } catch (error) { if (!SHUTDOWN_TERMINAL_RUN_STATES.has(run.lifecycle.state)) throw error; }
|
|
2237
2910
|
run.abortController.abort();
|
|
2238
2911
|
run.execution?.cancel();
|
|
2239
2912
|
await scheduler.cancelRun(runId);
|