pi-extensible-workflows 3.1.0 → 3.3.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 +11 -47
- package/dist/src/agent-execution.d.ts +4 -0
- package/dist/src/agent-execution.js +146 -60
- package/dist/src/bundles.d.ts +53 -0
- package/dist/src/bundles.js +457 -0
- package/dist/src/cli.d.ts +3 -1
- package/dist/src/cli.js +146 -21
- package/dist/src/doctor-cleanup.js +4 -2
- package/dist/src/eval-capture-extension.js +16 -1
- package/dist/src/execution.js +13 -4
- package/dist/src/host.d.ts +51 -8
- package/dist/src/host.js +1181 -487
- package/dist/src/index.d.ts +4 -2
- package/dist/src/index.js +3 -1
- package/dist/src/persistence.d.ts +40 -1
- package/dist/src/persistence.js +51 -0
- package/dist/src/session-inspector.d.ts +12 -2
- package/dist/src/session-inspector.js +36 -2
- package/dist/src/types.d.ts +19 -0
- package/dist/src/types.js +1 -0
- package/dist/src/validation.js +42 -2
- package/dist/src/workflow-artifacts.d.ts +13 -0
- package/dist/src/workflow-artifacts.js +39 -0
- package/dist/src/workflow-evals.d.ts +7 -1
- package/dist/src/workflow-evals.js +23 -3
- package/evals/cases/recovery-completed-worktree.yaml +17 -0
- package/evals/cases/recovery-failed-run.yaml +14 -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 +3 -2
- package/skills/pi-extensible-workflows/SKILL.md +21 -28
- package/src/agent-execution.ts +102 -30
- package/src/bundles.ts +471 -0
- package/src/cli.ts +118 -21
- package/src/doctor-cleanup.ts +3 -2
- package/src/eval-capture-extension.ts +15 -1
- package/src/execution.ts +13 -4
- package/src/host.ts +992 -447
- package/src/index.ts +4 -2
- package/src/persistence.ts +53 -1
- package/src/session-inspector.ts +36 -4
- package/src/types.ts +12 -5
- package/src/validation.ts +33 -2
- package/src/workflow-artifacts.ts +34 -0
- package/src/workflow-evals.ts +24 -3
package/src/host.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
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 { copyToClipboard, getAgentDir, SettingsManager, truncateToVisualLines, type ExtensionAPI, type Theme } from "@earendil-works/pi-coding-agent";
|
|
9
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";
|
|
@@ -15,8 +16,12 @@ import { asWorkflowError, aliasDrift, createLaunchSnapshot, deepFreeze, errorCod
|
|
|
15
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_STALL_THRESHOLD_MS, 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;
|
|
@@ -28,14 +33,14 @@ export interface WorkflowProgressStyles {
|
|
|
28
33
|
}
|
|
29
34
|
function snapshotResourcePolicy(snapshot: Readonly<LaunchSnapshot>, cwd: string, projectTrusted: boolean, globalSettingsPath: string): AgentResourcePolicy {
|
|
30
35
|
const empty = { skills: [], extensions: [] };
|
|
31
|
-
return { globalSettingsPath, projectSettingsPath:
|
|
36
|
+
return { globalSettingsPath, projectSettingsPath: workflowProjectSettingsPath(cwd), projectTrusted, global: empty, project: empty, effective: snapshot.settings.disabledAgentResources ?? empty, unmatchedSkills: [], unmatchedExtensions: [] };
|
|
32
37
|
}
|
|
33
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 };
|
|
34
|
-
type WorkflowLaunchSettings = { settings: Readonly<WorkflowSettings>; resolution: WorkflowSettingsResolution; resourcePolicy: AgentResourcePolicy
|
|
39
|
+
type WorkflowLaunchSettings = { settings: Readonly<WorkflowSettings>; resolution: WorkflowSettingsResolution; resourcePolicy: AgentResourcePolicy };
|
|
35
40
|
function workflowLaunchSettings(cwd: string, projectTrusted: boolean, globalSettingsPath: string, concurrency?: number): WorkflowLaunchSettings {
|
|
36
41
|
const resolution = resolveWorkflowSettings(cwd, projectTrusted, globalSettingsPath);
|
|
37
42
|
const settings = Object.freeze({ ...resolution.effective, ...(concurrency === undefined ? {} : { concurrency }) });
|
|
38
|
-
return { settings, resolution, resourcePolicy: resolveAgentResourcePolicy(cwd, projectTrusted, globalSettingsPath)
|
|
43
|
+
return { settings, resolution, resourcePolicy: resolveAgentResourcePolicy(cwd, projectTrusted, globalSettingsPath) };
|
|
39
44
|
}
|
|
40
45
|
function frozenResourcePolicy(policy: AgentResourcePolicy): () => AgentResourcePolicy { return () => structuredClone(policy); }
|
|
41
46
|
function resumedSnapshotSettings(snapshot: Readonly<LaunchSnapshot>, resolution: WorkflowSettingsResolution, modelAliases: Readonly<Record<string, string>>): { settings: WorkflowSettings; settingsSources?: NonNullable<LaunchSnapshot["settingsSources"]> } {
|
|
@@ -89,9 +94,10 @@ function mainAgentError(error: unknown): WorkflowError {
|
|
|
89
94
|
const typed = asWorkflowError(error);
|
|
90
95
|
const presented = new WorkflowError(typed.code, formatWorkflowFailure(typed));
|
|
91
96
|
Object.assign(presented, typed);
|
|
92
|
-
presented.message = formatWorkflowFailure(typed);
|
|
93
97
|
return presented;
|
|
94
98
|
}
|
|
99
|
+
function workflowFailedAt(error: unknown): string | undefined { return object(error) && typeof error.failedAt === "string" && error.failedAt ? error.failedAt : undefined; }
|
|
100
|
+
function persistedFailure(run: PersistedRun, error: WorkflowError): PersistedRun { const failedAt = workflowFailedAt(error); return { ...run, error: { code: error.code, message: error.message, ...(failedAt ? { failedAt } : {}) }, ...(failedAt ? { failedAt } : {}) }; }
|
|
95
101
|
|
|
96
102
|
export class RunLifecycle {
|
|
97
103
|
#state: RunState;
|
|
@@ -144,7 +150,7 @@ export class RunLifecycle {
|
|
|
144
150
|
}
|
|
145
151
|
|
|
146
152
|
async terminal(state: "completed" | "failed" | "stopped" | "interrupted" | "budget_exhausted", reason?: string): Promise<void> {
|
|
147
|
-
if (
|
|
153
|
+
if (HARD_TERMINAL_RUN_STATES.has(this.#state)) throw new WorkflowError("RESUME_INCOMPATIBLE", `${this.#state} runs are terminal`);
|
|
148
154
|
await this.#set(state, reason ?? state);
|
|
149
155
|
for (const resolve of this.#waiters.splice(0)) resolve();
|
|
150
156
|
}
|
|
@@ -164,25 +170,39 @@ export function formatWorkflowPreview(args: { script?: unknown; workflow?: unkno
|
|
|
164
170
|
return [`workflow ${name}`, typeof args.description === "string" && args.description.trim() ? args.description.trim() : ""].filter(Boolean).join("\n");
|
|
165
171
|
}
|
|
166
172
|
export const WORKFLOW_TOOL_LABEL = "Workflow";
|
|
167
|
-
export const WORKFLOW_TOOL_DESCRIPTION = "Run a deterministic JavaScript workflow";
|
|
168
|
-
export const WORKFLOW_TOOL_PROMPT_SNIPPET = "Run a deterministic, resumable JavaScript workflow that
|
|
173
|
+
export const WORKFLOW_TOOL_DESCRIPTION = "Run a deterministic JavaScript workflow with a named inline parallel-to-summary path by default";
|
|
174
|
+
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. If a foreground call detaches before its result is accepted, its terminal success or failure is promoted to one follow-up message. Foreground results include the completed run ID. Recovery inherits the source launch mode; legacy snapshots without launchMode recover in the background. Set foreground: true or false on workflow_resume/workflow_retry to override it; foreground recovery waits for terminal value and run details, while background recovery returns immediately with a follow-up. Recovery map: agent(..., { retries }) reruns one agent call in the same run for transient failures; workflow_retry({ runId, foreground? }) replays a failed run into a child; workflow_resume({ runId, budget?, foreground? }) continues a budget_exhausted run; parentRunId on a new launch only borrows named worktrees and never replays or resumes."
|
|
175
|
+
function workflowRecoveryGuidance(action: "resume" | "retry", state: RunState): string {
|
|
176
|
+
if (action === "resume") {
|
|
177
|
+
if (state === "failed") return "Failed workflow runs must use workflow_retry({ runId })";
|
|
178
|
+
if (state === "completed") return "Completed workflow runs have no recovery action";
|
|
179
|
+
if (state === "stopped") return "Stopped workflow runs have no recovery action; launch a new workflow";
|
|
180
|
+
if (state === "interrupted") return "Interrupted workflow runs use /workflow resume, not workflow_resume";
|
|
181
|
+
return `Only budget-exhausted runs can be resumed with workflow_resume; source is ${state}`;
|
|
182
|
+
}
|
|
183
|
+
if (state === "budget_exhausted") return "Budget-exhausted workflow runs must use workflow_resume({ runId, budget? })";
|
|
184
|
+
if (state === "completed") return "Completed workflow runs have no recovery action";
|
|
185
|
+
if (state === "stopped") return "Stopped workflow runs cannot be retried; launch a new workflow";
|
|
186
|
+
if (state === "interrupted") return "Interrupted workflow runs use /workflow resume, not workflow_retry";
|
|
187
|
+
return `Only failed workflow runs can be retried; source is ${state}`;
|
|
188
|
+
}
|
|
169
189
|
export const WORKFLOW_TOOL_PARAMETERS = Type.Object({
|
|
170
|
-
name: Type.Optional(Type.String({ description: "Required non-empty name for inline workflow
|
|
190
|
+
name: Type.Optional(Type.String({ description: "Required non-empty name for the default inline workflow path; invalid for registered function launches" })),
|
|
171
191
|
description: Type.Optional(Type.String({ description: "Optional human-readable workflow description" })),
|
|
172
|
-
script: Type.Optional(Type.String({ description: "Immutable workflow source
|
|
173
|
-
workflow: Type.Optional(Type.String({ description: "
|
|
192
|
+
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(...)" })),
|
|
193
|
+
workflow: Type.Optional(Type.String({ description: "Advanced: registered reusable function as an unqualified name" })),
|
|
174
194
|
args: Type.Optional(Type.Unknown({ description: "JSON-compatible workflow arguments" })),
|
|
175
|
-
foreground: Type.Optional(Type.Boolean({ description: "Wait for completion instead of
|
|
176
|
-
concurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: 16 })),
|
|
177
|
-
budget: Type.Optional(Type.Unknown({ description: "
|
|
178
|
-
parentRunId: Type.Optional(Type.String({ description: "
|
|
195
|
+
foreground: Type.Optional(Type.Boolean({ description: "Wait for completion instead of the default background launch" })),
|
|
196
|
+
concurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: 16, description: "Advanced: optional per-run active-agent limit" })),
|
|
197
|
+
budget: Type.Optional(Type.Unknown({ description: "Advanced: optional aggregate soft and hard run budgets" })),
|
|
198
|
+
parentRunId: Type.Optional(Type.String({ description: "Advanced: terminal run whose named worktrees may be reused" })),
|
|
179
199
|
});
|
|
180
|
-
export const WORKFLOW_RETRY_PARAMETERS = Type.Object({ runId: Type.String({ description: "Explicit failed workflow run ID" }) });
|
|
200
|
+
export const WORKFLOW_RETRY_PARAMETERS = Type.Object({ runId: Type.String({ description: "Explicit failed workflow run ID" }), foreground: Type.Optional(Type.Boolean({ description: "Override the source launch mode for this recovery" })) });
|
|
181
201
|
|
|
182
202
|
type WorkflowToolUpdate = { content: [{ type: "text"; text: string }]; details: { runId: string; run: PersistedRun } };
|
|
183
203
|
export type WorkflowPhaseState = "not started" | "running" | "completed" | "failed" | "cancelled" | "interrupted" | "budget_exhausted";
|
|
184
204
|
export interface WorkflowPhaseAgentCounts { total: number; completed: number; running: number; failed: number; cancelled: number; pending: number }
|
|
185
|
-
export interface WorkflowPhaseView { id: string; name: string; occurrence: number; state: WorkflowPhaseState;
|
|
205
|
+
export interface WorkflowPhaseView { id: string; name: string; occurrence: number; state: WorkflowPhaseState; observed: boolean; afterAgent?: number; agents: readonly AgentRecord[]; counts: WorkflowPhaseAgentCounts }
|
|
186
206
|
export interface WorkflowPhaseModel { declaredPhases: readonly string[]; phases: readonly WorkflowPhaseView[]; currentPhaseIndex?: number; currentPhaseId?: string; counts: Readonly<Partial<Record<WorkflowPhaseState, number>>>; unassignedAgents?: readonly AgentRecord[] }
|
|
187
207
|
type WorkflowPhaseSource = readonly string[] | Pick<LaunchSnapshot, "phases"> | undefined;
|
|
188
208
|
function phaseNames(source: WorkflowPhaseSource): string[] {
|
|
@@ -244,7 +264,7 @@ export function buildWorkflowPhaseModel(run: Pick<PersistedRun, "state" | "phase
|
|
|
244
264
|
const agents = observation?.agents ?? [];
|
|
245
265
|
const counts = phaseAgentCounts(agents);
|
|
246
266
|
const state = observation ? phaseState(run.state, counts, entry.observedIndex === observedEntries.length - 1) : "not started";
|
|
247
|
-
return { id: `${entry.name}#${String(occurrence)}`, name: entry.name, occurrence, state,
|
|
267
|
+
return { id: `${entry.name}#${String(occurrence)}`, name: entry.name, occurrence, state, observed: observation !== undefined, ...(observation ? { afterAgent: observation.afterAgent } : {}), agents, counts };
|
|
248
268
|
});
|
|
249
269
|
let currentPhaseIndex: number | undefined;
|
|
250
270
|
for (let index = phases.length - 1; index >= 0; index -= 1) { if (phases[index]?.observed) { currentPhaseIndex = index; break; } }
|
|
@@ -258,12 +278,167 @@ export function buildWorkflowPhaseModel(run: Pick<PersistedRun, "state" | "phase
|
|
|
258
278
|
if (unassignedAgents.length) result.unassignedAgents = unassignedAgents;
|
|
259
279
|
return result;
|
|
260
280
|
}
|
|
261
|
-
export interface WorkflowPhaseSelection { phaseId?: string | undefined; agentId?: string | undefined }
|
|
281
|
+
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 }
|
|
282
|
+
export type WorkflowPhaseTreeNodeKind = "phase" | "operation" | "agent";
|
|
283
|
+
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 }
|
|
284
|
+
export interface WorkflowPhaseTree { roots: readonly string[]; nodes: readonly WorkflowPhaseTreeNode[]; byId: ReadonlyMap<string, WorkflowPhaseTreeNode> }
|
|
285
|
+
export interface WorkflowPhaseTreeSelection { nodeId?: string | undefined }
|
|
286
|
+
export type WorkflowPhaseTreeDirection = "up" | "down" | "left" | "right";
|
|
287
|
+
function workflowPhaseTreePath(kind: WorkflowPhaseTreeNodeKind, phaseId: string, operationPath: readonly string[], agentId?: string): string {
|
|
288
|
+
const root = `phase/${encodeURIComponent(phaseId)}`;
|
|
289
|
+
if (kind === "phase") return root;
|
|
290
|
+
const operation = operationPath.map((part) => encodeURIComponent(part)).join("/");
|
|
291
|
+
if (kind === "operation") return `${root}/operation/${operation}`;
|
|
292
|
+
return operation ? `${root}/operation/${operation}/agent/${encodeURIComponent(agentId ?? "")}` : `${root}/agent/${encodeURIComponent(agentId ?? "")}`;
|
|
293
|
+
}
|
|
294
|
+
function workflowPhaseTreeAggregateState(states: readonly AgentRecord["state"][]): AgentRecord["state"] {
|
|
295
|
+
if (!states.length || states.every((state) => state === "completed")) return "completed";
|
|
296
|
+
if (states.some((state) => state === "failed")) return "failed";
|
|
297
|
+
if (states.some((state) => state === "cancelled")) return "cancelled";
|
|
298
|
+
if (states.some((state) => state === "running")) return "running";
|
|
299
|
+
return "queued";
|
|
300
|
+
}
|
|
301
|
+
export function buildWorkflowPhaseTree(model: WorkflowPhaseModel): WorkflowPhaseTree {
|
|
302
|
+
type Draft = Omit<WorkflowPhaseTreeNode, "children"> & { children: string[] };
|
|
303
|
+
type AgentEntry = { agent: AgentRecord; node: Draft; path: readonly string[]; defaultParentId: string };
|
|
304
|
+
const drafts = new Map<string, Draft>();
|
|
305
|
+
const roots: string[] = [];
|
|
306
|
+
const add = (node: Omit<Draft, "children">, parentId?: string): Draft => {
|
|
307
|
+
const existing = drafts.get(node.id);
|
|
308
|
+
if (existing) return existing;
|
|
309
|
+
const draft: Draft = { ...node, ...(parentId === undefined ? {} : { parentId }), children: [] };
|
|
310
|
+
drafts.set(draft.id, draft);
|
|
311
|
+
if (parentId === undefined) roots.push(draft.id); else drafts.get(parentId)?.children.push(draft.id);
|
|
312
|
+
return draft;
|
|
313
|
+
};
|
|
314
|
+
const samePath = (left: readonly string[], right: readonly string[]): boolean => left.length === right.length && left.every((part, index) => part === right[index]);
|
|
315
|
+
const addPhase = (phaseId: string, label: string, agents: readonly AgentRecord[], phase?: WorkflowPhaseView): void => {
|
|
316
|
+
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 } : {}) });
|
|
317
|
+
const operationNodes = new Map<string, Draft>();
|
|
318
|
+
const entries: AgentEntry[] = agents.map((agent) => ({ agent, path: [...(agent.structuralPath ?? [])], node: undefined as unknown as Draft, defaultParentId: phaseNode.id }));
|
|
319
|
+
const agentEntries = new Map(entries.map((entry) => [entry.agent.id, entry]));
|
|
320
|
+
const acceptedParents = new Map<string, string>();
|
|
321
|
+
const wouldCycle = (childId: string, parentId: string): boolean => {
|
|
322
|
+
const seen = new Set<string>([childId]);
|
|
323
|
+
let current: string | undefined = parentId;
|
|
324
|
+
while (current) {
|
|
325
|
+
if (seen.has(current)) return true;
|
|
326
|
+
seen.add(current);
|
|
327
|
+
current = acceptedParents.get(current);
|
|
328
|
+
}
|
|
329
|
+
return false;
|
|
330
|
+
};
|
|
331
|
+
for (const entry of entries) {
|
|
332
|
+
const parent = entry.agent.parentId ? agentEntries.get(entry.agent.parentId) : undefined;
|
|
333
|
+
if (parent && !wouldCycle(entry.agent.id, parent.agent.id)) acceptedParents.set(entry.agent.id, parent.agent.id);
|
|
334
|
+
}
|
|
335
|
+
const operationChain = (path: readonly string[], owner: Draft, startIndex = 0): Draft => {
|
|
336
|
+
let parent = owner;
|
|
337
|
+
for (let index = startIndex; index < path.length; index += 1) {
|
|
338
|
+
const prefix = path.slice(0, index + 1);
|
|
339
|
+
const key = `${owner.id}:${JSON.stringify(prefix)}`;
|
|
340
|
+
const existing = operationNodes.get(key);
|
|
341
|
+
if (existing) { parent = existing; continue; }
|
|
342
|
+
const suffix = path.slice(startIndex, index + 1).map((part) => encodeURIComponent(part)).join("/");
|
|
343
|
+
const id = owner.id === phaseNode.id ? workflowPhaseTreePath("operation", phaseId, prefix) : `${owner.id}/operation/${suffix}`;
|
|
344
|
+
const operation = add({ id, kind: "operation", label: prefix.at(-1) ?? "", depth: 0, phaseId, operationPath: prefix, state: "queued", ...(phase ? { phase } : {}) }, parent.id);
|
|
345
|
+
operationNodes.set(key, operation);
|
|
346
|
+
parent = operation;
|
|
347
|
+
}
|
|
348
|
+
return parent;
|
|
349
|
+
};
|
|
350
|
+
for (const entry of entries) {
|
|
351
|
+
if (!acceptedParents.has(entry.agent.id)) entry.defaultParentId = operationChain(entry.path, phaseNode).id;
|
|
352
|
+
}
|
|
353
|
+
for (const entry of entries) {
|
|
354
|
+
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);
|
|
355
|
+
}
|
|
356
|
+
const attach = (entry: AgentEntry, parentId: string): void => {
|
|
357
|
+
const previous = entry.node.parentId ? drafts.get(entry.node.parentId) : undefined;
|
|
358
|
+
if (previous) previous.children = previous.children.filter((childId) => childId !== entry.node.id);
|
|
359
|
+
entry.node.parentId = parentId;
|
|
360
|
+
const parent = drafts.get(parentId);
|
|
361
|
+
if (parent && !parent.children.includes(entry.node.id)) parent.children.push(entry.node.id);
|
|
362
|
+
};
|
|
363
|
+
for (const entry of entries) {
|
|
364
|
+
const parentId = acceptedParents.get(entry.agent.id);
|
|
365
|
+
const parent = parentId ? agentEntries.get(parentId) : undefined;
|
|
366
|
+
if (parent) {
|
|
367
|
+
if (samePath(entry.path, parent.path)) entry.defaultParentId = parent.node.id;
|
|
368
|
+
else {
|
|
369
|
+
const commonLength = entry.path.findIndex((part, index) => parent.path[index] !== part);
|
|
370
|
+
const startIndex = commonLength < 0 ? Math.min(entry.path.length, parent.path.length) : commonLength;
|
|
371
|
+
entry.defaultParentId = operationChain(entry.path, parent.node, startIndex).id;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
attach(entry, entry.defaultParentId);
|
|
375
|
+
}
|
|
376
|
+
const setDepth = (node: Draft, depth: number, seen = new Set<string>()): void => {
|
|
377
|
+
if (seen.has(node.id)) return;
|
|
378
|
+
seen.add(node.id);
|
|
379
|
+
node.depth = depth;
|
|
380
|
+
for (const childId of node.children) { const child = drafts.get(childId); if (child) setDepth(child, depth + 1, seen); }
|
|
381
|
+
};
|
|
382
|
+
setDepth(phaseNode, 0);
|
|
383
|
+
const statesFor = (node: Draft, seen = new Set<string>()): AgentRecord["state"][] => {
|
|
384
|
+
if (seen.has(node.id)) return [];
|
|
385
|
+
const nextSeen = new Set(seen).add(node.id);
|
|
386
|
+
return node.children.flatMap((childId) => {
|
|
387
|
+
const child = drafts.get(childId);
|
|
388
|
+
return child?.kind === "agent" ? [child.agent?.state ?? "queued", ...statesFor(child, nextSeen)] : child ? statesFor(child, nextSeen) : [];
|
|
389
|
+
});
|
|
390
|
+
};
|
|
391
|
+
for (const operation of operationNodes.values()) operation.state = workflowPhaseTreeAggregateState(statesFor(operation));
|
|
392
|
+
};
|
|
393
|
+
for (const phase of model.phases) addPhase(phase.id, `${phase.name}${phase.occurrence > 1 ? ` #${String(phase.occurrence)}` : ""}`, phase.agents, phase);
|
|
394
|
+
if (model.unassignedAgents?.length) addPhase("unassigned", "Unassigned", model.unassignedAgents);
|
|
395
|
+
const nodes = [...drafts.values()].map((node) => ({ ...node, children: [...node.children] }));
|
|
396
|
+
return { roots, nodes, byId: new Map(nodes.map((node) => [node.id, node])) };
|
|
397
|
+
}
|
|
398
|
+
export function workflowPhaseTreeVisibleNodes(tree: WorkflowPhaseTree, expanded: ReadonlySet<string> = new Set()): readonly WorkflowPhaseTreeNode[] {
|
|
399
|
+
const visible: WorkflowPhaseTreeNode[] = [];
|
|
400
|
+
const visit = (id: string): void => {
|
|
401
|
+
const node = tree.byId.get(id);
|
|
402
|
+
if (!node) return;
|
|
403
|
+
visible.push(node);
|
|
404
|
+
if (expanded.has(node.id)) for (const childId of node.children) visit(childId);
|
|
405
|
+
};
|
|
406
|
+
for (const root of tree.roots) visit(root);
|
|
407
|
+
return visible;
|
|
408
|
+
}
|
|
409
|
+
export function workflowPhaseTreeInitialExpanded(tree: WorkflowPhaseTree): ReadonlySet<string> {
|
|
410
|
+
return new Set(tree.nodes.filter((node) => node.children.length > 0).map((node) => node.id));
|
|
411
|
+
}
|
|
412
|
+
export function preserveWorkflowPhaseTreeSelection(tree: WorkflowPhaseTree, selection: WorkflowPhaseTreeSelection): WorkflowPhaseTreeSelection {
|
|
413
|
+
const node = (selection.nodeId ? tree.byId.get(selection.nodeId) : undefined) ?? tree.nodes[0];
|
|
414
|
+
return node ? { nodeId: node.id } : {};
|
|
415
|
+
}
|
|
416
|
+
export function navigateWorkflowPhaseTree(tree: WorkflowPhaseTree, selectedNodeId: string | undefined, expandedNodeIds: ReadonlySet<string>, direction: WorkflowPhaseTreeDirection): { nodeId?: string; expandedNodeIds: ReadonlySet<string> } {
|
|
417
|
+
const expanded = new Set(expandedNodeIds);
|
|
418
|
+
const current = (selectedNodeId ? tree.byId.get(selectedNodeId) : undefined) ?? tree.nodes[0];
|
|
419
|
+
if (!current) return { expandedNodeIds: expanded };
|
|
420
|
+
if (direction === "left") {
|
|
421
|
+
if (current.children.length && expanded.delete(current.id)) return { nodeId: current.id, expandedNodeIds: expanded };
|
|
422
|
+
return { nodeId: current.parentId ?? current.id, expandedNodeIds: expanded };
|
|
423
|
+
}
|
|
424
|
+
if (direction === "right") {
|
|
425
|
+
if (current.children.length && !expanded.has(current.id)) { expanded.add(current.id); return { nodeId: current.id, expandedNodeIds: expanded }; }
|
|
426
|
+
return { nodeId: current.children[0] ?? current.id, expandedNodeIds: expanded };
|
|
427
|
+
}
|
|
428
|
+
const visible = workflowPhaseTreeVisibleNodes(tree, expanded);
|
|
429
|
+
const index = Math.max(0, visible.findIndex((node) => node.id === current.id));
|
|
430
|
+
const next = visible[(index + (direction === "up" ? visible.length - 1 : 1)) % visible.length];
|
|
431
|
+
return { nodeId: next?.id ?? current.id, expandedNodeIds: expanded };
|
|
432
|
+
}
|
|
262
433
|
export function preserveWorkflowPhaseSelection(model: WorkflowPhaseModel, selection: WorkflowPhaseSelection): WorkflowPhaseSelection {
|
|
263
434
|
const phase = model.phases.find((candidate) => candidate.id === selection.phaseId) ?? (model.currentPhaseIndex === undefined ? undefined : model.phases[model.currentPhaseIndex]) ?? model.phases[0];
|
|
264
|
-
if (!phase) return {};
|
|
265
|
-
const
|
|
266
|
-
|
|
435
|
+
if (!phase) return model.unassignedAgents?.length ? { nodeId: workflowPhaseTreePath("phase", "unassigned", []) } : {};
|
|
436
|
+
const tree = buildWorkflowPhaseTree(model);
|
|
437
|
+
const selectedAgent = selection.agentId ? phase.agents.find((candidate) => candidate.id === selection.agentId) : undefined;
|
|
438
|
+
const selectedCandidate = selection.nodeId ? tree.byId.get(selection.nodeId) : undefined;
|
|
439
|
+
const selected = selectedCandidate?.phaseId === phase.id ? selectedCandidate : selectedAgent ? tree.byId.get(workflowPhaseTreePath("agent", phase.id, selectedAgent.structuralPath ?? [], selectedAgent.id)) : undefined;
|
|
440
|
+
const nodeId = selected?.id ?? tree.byId.get(workflowPhaseTreePath("phase", phase.id, []))?.id;
|
|
441
|
+
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 } : {}) };
|
|
267
442
|
}
|
|
268
443
|
type AgentGroup = { label: string; entries: readonly { agent: AgentRecord; index: number; depth: number }[] };
|
|
269
444
|
function agentGroupKey(agent: AgentRecord): string { return JSON.stringify([agent.structuralPath ?? [], agent.parentBreadcrumb ?? null]); }
|
|
@@ -294,25 +469,36 @@ function renderGroupedAgents(agents: readonly AgentRecord[], render: (entry: { a
|
|
|
294
469
|
...group.entries.map((entry) => render(entry, grouped)),
|
|
295
470
|
]);
|
|
296
471
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
}
|
|
303
|
-
|
|
472
|
+
const RUN_STATE_GLYPH: Record<string, string> = { completed: "✓", failed: "✗", stopped: "✗", budget_exhausted: "!", awaiting_input: "●" };
|
|
473
|
+
const AGENT_STATE_GLYPH: Record<string, string> = { completed: "✓", failed: "✗", cancelled: "✗" };
|
|
474
|
+
function runStateGlyph(state: string, running: string): string { return state === "running" ? running : RUN_STATE_GLYPH[state] ?? "◆"; }
|
|
475
|
+
function agentStateGlyph(state: string, running: string): string { return state === "running" ? running : AGENT_STATE_GLYPH[state] ?? "○"; }
|
|
476
|
+
type ProgressStyleKey = "success" | "error" | "warning" | "accent" | "muted";
|
|
477
|
+
const PROGRESS_STATE_STYLE: Record<string, ProgressStyleKey> = { completed: "success", failed: "error", cancelled: "error", running: "accent" };
|
|
478
|
+
const WORKFLOW_ICON_STYLE: Record<string, ProgressStyleKey> = { completed: "success", failed: "error", stopped: "error", budget_exhausted: "warning", running: "accent" };
|
|
479
|
+
const PHASE_STATE_STYLE: Record<string, ProgressStyleKey> = { completed: "success", failed: "error", cancelled: "error", running: "accent", interrupted: "warning", budget_exhausted: "warning" };
|
|
480
|
+
function styleForState(map: Record<string, ProgressStyleKey>, state: string, styles: WorkflowProgressStyles): (text: string) => string {
|
|
481
|
+
const key = map[state] ?? "muted";
|
|
482
|
+
return (text) => styles[key](text);
|
|
483
|
+
}
|
|
484
|
+
function progressStyleForState(state: string, styles: WorkflowProgressStyles): (text: string) => string { return styleForState(PROGRESS_STATE_STYLE, state, styles); }
|
|
485
|
+
function workflowIconStyle(state: string, styles: WorkflowProgressStyles): (text: string) => string { return styleForState(WORKFLOW_ICON_STYLE, state, styles); }
|
|
486
|
+
function phaseStyleForState(state: string, styles: WorkflowProgressStyles): (text: string) => string { return styleForState(PHASE_STATE_STYLE, state, styles); }
|
|
487
|
+
export function formatWorkflowProgress(run: PersistedRun, spinner = "◇", styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES, now = Date.now()): string {
|
|
304
488
|
const done = run.agents.filter((agent) => SETTLED_AGENT_STATES.has(agent.state)).length;
|
|
305
|
-
const workflowIcon = run.state
|
|
306
|
-
const
|
|
489
|
+
const workflowIcon = runStateGlyph(run.state, spinner);
|
|
490
|
+
const iconStyle = workflowIconStyle(run.state, styles);
|
|
307
491
|
const header = styles.bold(styles.accent(`Workflow: ${run.workflowName} (${String(done)}/${String(run.agents.length)} done)`));
|
|
308
|
-
const lines = [`${
|
|
492
|
+
const lines = [`${iconStyle(workflowIcon)} ${header}`];
|
|
309
493
|
const budgetWarning = run.state === "budget_exhausted" || (run.budgetEvents ?? []).some((event) => event.type === "hard_exhausted");
|
|
310
494
|
lines.push(...formatCompactBudgetStatus(run).map((line) => ` ${budgetWarning ? styles.warning(line) : line}`));
|
|
495
|
+
const activeShells = run.activeShells ?? 0;
|
|
496
|
+
if (activeShells > 0) lines.push(` ${styles.accent(spinner)} shell ${styles.accent("[running]")} ${styles.dim(`(${String(activeShells)} active)`)}`);
|
|
311
497
|
const byId = new Map(run.agents.map((agent) => [agent.id, agent]));
|
|
312
498
|
const renderAgents = (agents: readonly AgentRecord[], offset: number, nested: boolean) => renderGroupedAgents(agents, ({ agent, index, depth }, grouped) => {
|
|
313
|
-
const icon = agent.state
|
|
499
|
+
const icon = agentStateGlyph(agent.state, spinner);
|
|
314
500
|
const indent = " ".repeat((grouped ? 2 : 1) + depth);
|
|
315
|
-
const activity = SETTLED_AGENT_STATES.has(agent.state) ? "" : formatAgentActivity(agent, spinner, styles);
|
|
501
|
+
const activity = SETTLED_AGENT_STATES.has(agent.state) ? "" : formatAgentActivity(agent, spinner, styles, now);
|
|
316
502
|
const name = grouped ? agent.label ?? agent.name : styledAgentBreadcrumb(agent, byId, styles);
|
|
317
503
|
const state = progressStyleForState(agent.state, styles);
|
|
318
504
|
return `${indent}#${String(offset + index + 1)} ${state(icon)} ${name} ${state(`[${agent.state}]`)}${activity ? ` ${activity}` : ""}`;
|
|
@@ -364,6 +550,94 @@ function workflowCatalogBlock(text: string, expanded: boolean) {
|
|
|
364
550
|
};
|
|
365
551
|
}
|
|
366
552
|
|
|
553
|
+
type WorkflowControlResult = { details?: unknown; content?: readonly { type: string; text?: string }[] };
|
|
554
|
+
function controlString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; }
|
|
555
|
+
function controlValue(value: unknown): string {
|
|
556
|
+
if (value === null) return "removed";
|
|
557
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
|
|
558
|
+
const json = JSON.stringify(value);
|
|
559
|
+
return typeof json === "string" ? json : "unknown";
|
|
560
|
+
}
|
|
561
|
+
function controlTitle(name: string, theme: Theme): string { return theme.fg("toolTitle", theme.bold(name)); }
|
|
562
|
+
function controlState(state: string, theme: Theme): string {
|
|
563
|
+
const color = state === "completed" || state === "running" || state === "stopped" ? "success" : state === "failed" || state === "unknown" ? "error" : state === "budget_exhausted" || state === "awaiting_approval" ? "warning" : "accent";
|
|
564
|
+
return theme.fg(color, state);
|
|
565
|
+
}
|
|
566
|
+
function controlAction(action: string, theme: Theme): string {
|
|
567
|
+
const color = /approved|completed|stopped|started|resumed/.test(action) ? "success" : /rejected|failed/.test(action) ? "error" : "warning";
|
|
568
|
+
return theme.fg(color, action);
|
|
569
|
+
}
|
|
570
|
+
function budgetPatchEntries(value: unknown): string[] {
|
|
571
|
+
if (!object(value)) return value === undefined ? [] : [controlValue(value)];
|
|
572
|
+
return Object.entries(value).map(([dimension, limits]) => {
|
|
573
|
+
if (limits === null) return `${dimension}=removed`;
|
|
574
|
+
if (!object(limits)) return `${dimension}=${controlValue(limits)}`;
|
|
575
|
+
const parts = ["soft", "hard"].filter((key) => Object.prototype.hasOwnProperty.call(limits, key)).map((key) => `${key}=${controlValue(limits[key])}`);
|
|
576
|
+
return `${dimension} ${parts.join(" ")}`;
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
function budgetPatchSummary(value: unknown): string {
|
|
580
|
+
const entries = budgetPatchEntries(value);
|
|
581
|
+
return entries.length ? entries.join(", ") : "unchanged";
|
|
582
|
+
}
|
|
583
|
+
function budgetPatchDetails(value: unknown, theme: Theme): string[] {
|
|
584
|
+
const entries = budgetPatchEntries(value);
|
|
585
|
+
return entries.length ? [theme.fg("accent", theme.bold("Budget patch")), ...entries.map((entry) => ` ${theme.fg("toolOutput", entry)}`)] : [];
|
|
586
|
+
}
|
|
587
|
+
function workflowControlValue(result: WorkflowControlResult): unknown { return catalogResultValue(result); }
|
|
588
|
+
function workflowControlCall(name: string, args: Record<string, unknown>, theme: Theme): string {
|
|
589
|
+
const runId = controlString(args.runId) ?? "(missing run ID)";
|
|
590
|
+
if (name === "workflow_respond") {
|
|
591
|
+
const proposalId = controlString(args.proposalId);
|
|
592
|
+
const target = proposalId ? `budget proposal ${proposalId}` : `checkpoint ${controlString(args.name) ?? "(missing name)"}`;
|
|
593
|
+
const decision = args.approved === true ? "approve" : "reject";
|
|
594
|
+
return [`${controlTitle(name, theme)} ${theme.fg("accent", runId)}`, `${theme.fg("muted", target)} · ${controlAction(decision, theme)}`].join("\n");
|
|
595
|
+
}
|
|
596
|
+
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");
|
|
597
|
+
if (name === "workflow_retry") return `${controlTitle(name, theme)} ${theme.fg("accent", runId)} ${theme.fg("muted", "failed run")}`;
|
|
598
|
+
return `${controlTitle(name, theme)} ${theme.fg("accent", runId)}`;
|
|
599
|
+
}
|
|
600
|
+
function workflowControlResult(name: string, args: Record<string, unknown>, result: WorkflowControlResult, expanded: boolean, theme: Theme, isError: boolean): string {
|
|
601
|
+
if (isError) {
|
|
602
|
+
const text = result.content?.filter(({ type }) => type === "text").map(({ text }) => text ?? "").join("\n").trim();
|
|
603
|
+
return theme.fg("error", text || `The ${name} tool failed.`);
|
|
604
|
+
}
|
|
605
|
+
const value = workflowControlValue(result);
|
|
606
|
+
if (!object(value)) return theme.fg("error", `The ${name} tool returned an invalid result.`);
|
|
607
|
+
const runId = controlString(args.runId) ?? controlString(value.runId) ?? "(unknown)";
|
|
608
|
+
const title = controlTitle(name, theme);
|
|
609
|
+
if (name === "workflow_stop") {
|
|
610
|
+
const state = controlString(value.state) ?? "unknown";
|
|
611
|
+
const action = value.stopped === true ? "stopped" : value.reason === "already_terminal" ? "already terminal" : value.reason === "unknown_run" ? "run not found" : "no change";
|
|
612
|
+
if (!expanded) return `${title}\nRun ${theme.fg("accent", runId)} · ${controlState(state, theme)} · ${controlAction(action, theme)}`;
|
|
613
|
+
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");
|
|
614
|
+
}
|
|
615
|
+
if (name === "workflow_retry") {
|
|
616
|
+
const childRunId = controlString(value.runId) ?? "(unknown)";
|
|
617
|
+
const state = controlString(value.state) ?? "unknown";
|
|
618
|
+
const action = state === "completed" ? "completed" : "started";
|
|
619
|
+
if (!expanded) return [title, `Source ${theme.fg("accent", runId)}`, `Child ${theme.fg("accent", childRunId)} · ${controlState(state, theme)} · ${controlAction(action, theme)}`].join("\n");
|
|
620
|
+
return [title, `Source run: ${theme.fg("accent", runId)}`, `Retry run: ${theme.fg("accent", childRunId)}`, `State: ${controlState(state, theme)}`, `Action: ${controlAction(action === "completed" ? "completed" : "started; completed work will be replayed", theme)}`].join("\n");
|
|
621
|
+
}
|
|
622
|
+
if (name === "workflow_resume") {
|
|
623
|
+
const state = controlString(value.state) ?? "unknown";
|
|
624
|
+
const proposalId = controlString(value.proposalId);
|
|
625
|
+
const action = state === "awaiting_approval" ? "approval required" : state === "running" ? "resumed" : state === "completed" ? "completed" : "no change";
|
|
626
|
+
if (!expanded) return [title, `Run ${theme.fg("accent", runId)} · ${controlState(state, theme)} · ${controlAction(action, theme)}`, ...(proposalId ? [`Proposal ${theme.fg("accent", proposalId)}`] : [])].join("\n");
|
|
627
|
+
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");
|
|
628
|
+
}
|
|
629
|
+
const proposalId = controlString(args.proposalId);
|
|
630
|
+
const checkpointName = controlString(args.name);
|
|
631
|
+
const target = proposalId ? `Budget proposal ${theme.fg("accent", proposalId)}` : `Checkpoint ${theme.fg("accent", checkpointName ?? "(missing)")}`;
|
|
632
|
+
const accepted = value.accepted === true;
|
|
633
|
+
const approved = value.approved === true;
|
|
634
|
+
const reason = controlString(value.reason);
|
|
635
|
+
const action = reason === "proposal_not_pending" ? "not pending" : reason === "checkpoint" && !accepted ? "not pending" : approved ? "approved" : "rejected";
|
|
636
|
+
const state = controlString(value.state);
|
|
637
|
+
if (!expanded) return [title, target, `Run ${theme.fg("accent", runId)} · ${controlAction(action, theme)}${state ? ` · ${controlState(state, theme)}` : ""}`].join("\n");
|
|
638
|
+
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");
|
|
639
|
+
}
|
|
640
|
+
|
|
367
641
|
function catalogText(value: string): string { return value.replace(/\s+/g, " ").trim(); }
|
|
368
642
|
|
|
369
643
|
type CatalogToolResult = { details?: unknown; content?: readonly { type: string; text?: string }[] };
|
|
@@ -439,7 +713,8 @@ function formatWorkflowCatalog(value: unknown, expanded: boolean, theme: Theme):
|
|
|
439
713
|
return theme.fg("error", "The workflow catalog returned an invalid result.");
|
|
440
714
|
}
|
|
441
715
|
|
|
442
|
-
const
|
|
716
|
+
const ANSI_SGR_SOURCE = `${String.fromCharCode(27)}\\[[0-9;]*m`;
|
|
717
|
+
const ANSI_SGR = new RegExp(ANSI_SGR_SOURCE);
|
|
443
718
|
export function truncateWorkflowProgress(text: string, width: number): string[] {
|
|
444
719
|
const safeWidth = Math.max(1, width);
|
|
445
720
|
return text.split("\n").flatMap((line) => {
|
|
@@ -506,7 +781,7 @@ function navigatorRunLabels(entries: readonly { store: RunStore; loaded: { run:
|
|
|
506
781
|
for (const { loaded: { run } } of entries) nameCount.set(run.workflowName, (nameCount.get(run.workflowName) ?? 0) + 1);
|
|
507
782
|
return entries.map(({ store, loaded: { run } }) => {
|
|
508
783
|
const done = run.agents.filter((a) => SETTLED_AGENT_STATES.has(a.state)).length;
|
|
509
|
-
const glyph = run.state
|
|
784
|
+
const glyph = runStateGlyph(run.state, "⠦");
|
|
510
785
|
const suffix = (nameCount.get(run.workflowName) ?? 0) > 1 ? ` ${store.runId.slice(0, 8)}` : "";
|
|
511
786
|
const cost = run.agents.reduce((sum, a) => sum + (a.accounting?.cost ?? 0), 0);
|
|
512
787
|
const costStr = cost > 0 ? ` $${cost.toFixed(2)}` : "";
|
|
@@ -539,23 +814,47 @@ function styledAgentBreadcrumb(agent: AgentRecord, byId: Map<string, AgentRecord
|
|
|
539
814
|
return `${styles.muted(parts.slice(0, -1).join(" > "))} > ${styles.bold(parts[parts.length - 1] ?? "")}`;
|
|
540
815
|
}
|
|
541
816
|
|
|
542
|
-
function
|
|
817
|
+
export function formatStalledDuration(durationMs: number): string {
|
|
818
|
+
const minutes = Math.max(0, Math.floor(durationMs / 60_000));
|
|
819
|
+
if (minutes < 60) return `${String(minutes)}m`;
|
|
820
|
+
const hours = Math.floor(minutes / 60);
|
|
821
|
+
const remainingMinutes = minutes % 60;
|
|
822
|
+
return `${String(hours)}h${remainingMinutes ? ` ${String(remainingMinutes)}m` : ""}`;
|
|
823
|
+
}
|
|
824
|
+
function stalledDuration(agent: AgentRecord, now: number): number | undefined {
|
|
825
|
+
if (agent.state !== "running" || agent.lastEventAt === undefined || !Number.isFinite(agent.lastEventAt)) return undefined;
|
|
826
|
+
const duration = now - agent.lastEventAt;
|
|
827
|
+
return duration >= WORKFLOW_AGENT_STALL_THRESHOLD_MS ? duration : undefined;
|
|
828
|
+
}
|
|
829
|
+
function formatAgentActivity(agent: AgentRecord, spinner: string, styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES, now = Date.now()): string {
|
|
543
830
|
const label = agent.activity?.kind === "reasoning" ? "reasoning" : agent.activity?.kind === "text" ? "responding" : agent.activity?.kind === "tool" ? agent.activity.text : [...(agent.toolCalls ?? [])].reverse().find(({ state }) => state === "running")?.name ?? "";
|
|
544
|
-
|
|
831
|
+
const activity = label ? `${styles.accent(spinner)} ${styles.dim(label)}` : "";
|
|
832
|
+
const stalled = stalledDuration(agent, now);
|
|
833
|
+
if (stalled === undefined) return activity;
|
|
834
|
+
const warning = `stalled? ${formatStalledDuration(stalled)}`;
|
|
835
|
+
return activity ? `${activity} ${styles.warning(`- ${warning}`)}` : styles.warning(warning);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function formatAccountingValue(value: number): string {
|
|
839
|
+
return new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(value).toLowerCase();
|
|
545
840
|
}
|
|
546
841
|
|
|
547
842
|
function formatAccounting(accounting: NonNullable<AgentRecord["accounting"]>): string {
|
|
548
843
|
const total = accounting.input + accounting.output + accounting.cacheRead + accounting.cacheWrite;
|
|
549
|
-
return `${
|
|
844
|
+
return `${formatAccountingValue(total)} tok`;
|
|
550
845
|
}
|
|
551
846
|
|
|
847
|
+
function formatAgentAccounting(accounting: NonNullable<AgentRecord["accounting"]>): string[] {
|
|
848
|
+
const total = accounting.input + accounting.output + accounting.cacheRead + accounting.cacheWrite;
|
|
849
|
+
return [`Tokens: ${formatAccountingValue(total)} (in=${formatAccountingValue(accounting.input)} out=${formatAccountingValue(accounting.output)} cache-read=${formatAccountingValue(accounting.cacheRead)} cache-write=${formatAccountingValue(accounting.cacheWrite)})`, `Cost: $${accounting.cost.toFixed(2)}`];
|
|
850
|
+
}
|
|
552
851
|
|
|
553
|
-
export function formatNavigatorDashboard(run: PersistedRun, checkpoints: readonly AwaitingCheckpoint[], worktrees: readonly WorktreeReference[]): string {
|
|
852
|
+
export function formatNavigatorDashboard(run: PersistedRun, checkpoints: readonly AwaitingCheckpoint[], worktrees: readonly WorktreeReference[], now = Date.now()): string {
|
|
554
853
|
void worktrees;
|
|
555
854
|
const done = run.agents.filter((a) => SETTLED_AGENT_STATES.has(a.state)).length;
|
|
556
855
|
const totalAccounting = run.agents.reduce((sum, a) => ({ input: sum.input + (a.accounting?.input ?? 0), output: sum.output + (a.accounting?.output ?? 0), cacheRead: sum.cacheRead + (a.accounting?.cacheRead ?? 0), cacheWrite: sum.cacheWrite + (a.accounting?.cacheWrite ?? 0), cost: sum.cost + (a.accounting?.cost ?? 0) }), { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 });
|
|
557
856
|
const hasAccounting = run.agents.some((a) => a.accounting);
|
|
558
|
-
const glyph = run.state
|
|
857
|
+
const glyph = runStateGlyph(run.state, "⠦");
|
|
559
858
|
const header = `${glyph} ${run.workflowName}`;
|
|
560
859
|
const meta = [run.state, run.phase ? `phase: ${run.phase}` : "", `${String(done)}/${String(run.agents.length)} agents`, hasAccounting ? formatAccounting(totalAccounting) : "", totalAccounting.cost > 0 ? `$${totalAccounting.cost.toFixed(2)}` : ""].filter(Boolean).join(" · ");
|
|
561
860
|
const lines = [header, meta, ...formatCompactBudgetStatus(run)];
|
|
@@ -564,7 +863,7 @@ export function formatNavigatorDashboard(run: PersistedRun, checkpoints: readonl
|
|
|
564
863
|
lines.push("");
|
|
565
864
|
const byId = new Map(run.agents.map((a) => [a.id, a]));
|
|
566
865
|
const render = ({ agent, depth }: { agent: AgentRecord; index: number; depth: number }, grouped: boolean) => {
|
|
567
|
-
const icon = agent.state
|
|
866
|
+
const icon = agentStateGlyph(agent.state, "⠦");
|
|
568
867
|
const breadcrumb = grouped ? agent.label ?? agent.name : agentBreadcrumb(agent, byId);
|
|
569
868
|
const tokens = agent.accounting ? formatAccounting(agent.accounting) : "";
|
|
570
869
|
const indent = " ".repeat((grouped ? 2 : 1) + depth);
|
|
@@ -573,7 +872,7 @@ export function formatNavigatorDashboard(run: PersistedRun, checkpoints: readonl
|
|
|
573
872
|
const last = agent.attemptDetails[agent.attemptDetails.length - 1];
|
|
574
873
|
if (last?.error) result.push(`${indent} error: ${last.error.code}: ${last.error.message}`);
|
|
575
874
|
}
|
|
576
|
-
const activity = !SETTLED_AGENT_STATES.has(agent.state) ? formatAgentActivity(agent, "⠦") : "";
|
|
875
|
+
const activity = !SETTLED_AGENT_STATES.has(agent.state) ? formatAgentActivity(agent, "⠦", PLAIN_WORKFLOW_PROGRESS_STYLES, now) : "";
|
|
577
876
|
if (activity) result.push(`${indent} ${activity}`);
|
|
578
877
|
return result.join("\n");
|
|
579
878
|
};
|
|
@@ -582,7 +881,7 @@ export function formatNavigatorDashboard(run: PersistedRun, checkpoints: readonl
|
|
|
582
881
|
return lines.join("\n");
|
|
583
882
|
}
|
|
584
883
|
|
|
585
|
-
export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> }, checkpoints: readonly AwaitingCheckpoint[],
|
|
884
|
+
export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> }, checkpoints: readonly AwaitingCheckpoint[], worktrees: readonly WorktreeReference[], now = Date.now()): string {
|
|
586
885
|
const { run, snapshot } = loaded;
|
|
587
886
|
const lines = [
|
|
588
887
|
`Workflow: ${run.workflowName}`,
|
|
@@ -611,67 +910,102 @@ export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readon
|
|
|
611
910
|
const result = [`${indent}#${String(index + 1)} ${grouped ? agent.label ?? agent.name : agentBreadcrumb(agent, byId)} state=${agent.state} model=${model}${agent.requestedModel ? ` requested=${agent.requestedModel}` : ""}${role}${tools} attempts=${String(agent.attempts)} retries=${String(Math.max(0, agent.attempts - 1))}${accounting}`];
|
|
612
911
|
for (const attempt of agent.attemptDetails ?? []) result.push(`${indent} attempt ${String(attempt.attempt)}${attempt.error ? ` error=${attempt.error.code}: ${attempt.error.message}` : ""}`);
|
|
613
912
|
for (const call of agent.toolCalls ?? []) result.push(`${indent} tool ${call.name} state=${call.state}`);
|
|
913
|
+
const activity = !SETTLED_AGENT_STATES.has(agent.state) ? formatAgentActivity(agent, "⠦", PLAIN_WORKFLOW_PROGRESS_STYLES, now) : "";
|
|
914
|
+
if (activity) result.push(`${indent} ${activity}`);
|
|
614
915
|
return result.join("\n");
|
|
615
916
|
}));
|
|
616
917
|
lines.push("Checkpoints:");
|
|
617
918
|
if (!checkpoints.length) lines.push(" (none)");
|
|
618
919
|
for (const checkpoint of checkpoints) lines.push(` ${checkpoint.name}: ${checkpoint.prompt} context=${JSON.stringify(checkpoint.context)}`);
|
|
619
|
-
lines.push(`Worktrees: ${String(
|
|
920
|
+
lines.push(`Worktrees: ${String(worktrees.length)}`);
|
|
620
921
|
lines.push(`Native Pi transcripts: ${String(run.nativeSessions.length)}`);
|
|
621
922
|
return lines.join("\n");
|
|
622
923
|
}
|
|
623
|
-
export function formatWorkflowPhaseDashboard(run: PersistedRun, snapshot: Readonly<LaunchSnapshot>, width: number, selection: WorkflowPhaseSelection = {}, styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES): string[] {
|
|
924
|
+
export function formatWorkflowPhaseDashboard(run: PersistedRun, snapshot: Readonly<LaunchSnapshot>, width: number, selection: WorkflowPhaseSelection = {}, styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES, now = Date.now()): string[] {
|
|
624
925
|
const safeWidth = Math.max(1, width);
|
|
625
926
|
const model = buildWorkflowPhaseModel(run, snapshot);
|
|
927
|
+
const tree = buildWorkflowPhaseTree(model);
|
|
928
|
+
const expanded = selection.expandedNodeIds === undefined ? workflowPhaseTreeInitialExpanded(tree) : new Set(selection.expandedNodeIds);
|
|
626
929
|
const wrap = (text: string, limit = safeWidth): string[] => truncateToVisualLines(text, Number.MAX_SAFE_INTEGER, Math.max(1, limit), 0).visualLines.map((line) => line.trimEnd());
|
|
627
|
-
|
|
628
|
-
const
|
|
629
|
-
const
|
|
630
|
-
const
|
|
930
|
+
// ponytail: ANSI-only width, good enough for the ASCII labels the tree renders
|
|
931
|
+
const ansiPattern = new RegExp(ANSI_SGR_SOURCE, "g");
|
|
932
|
+
const visibleLength = (text: string): number => text.replace(ansiPattern, "").length;
|
|
933
|
+
const padTo = (text: string, limit: number): string => `${text}${" ".repeat(Math.max(0, limit - visibleLength(text)))}`;
|
|
934
|
+
const phaseStyle = (state: WorkflowPhaseState | AgentRecord["state"]): ((text: string) => string) => phaseStyleForState(state, styles);
|
|
935
|
+
const phase = selection.phaseId ? model.phases.find((candidate) => candidate.id === selection.phaseId) : undefined;
|
|
936
|
+
const selectedByAgent = selection.agentId ? tree.nodes.find((node) => node.kind === "agent" && node.agentId === selection.agentId && (!selection.phaseId || node.phaseId === selection.phaseId)) : undefined;
|
|
937
|
+
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];
|
|
938
|
+
const selectedPhase = selectedNode?.phase ?? (selectedNode ? model.phases.find((candidate) => candidate.id === selectedNode.phaseId) : undefined);
|
|
939
|
+
const visibleNodes = workflowPhaseTreeVisibleNodes(tree, expanded);
|
|
940
|
+
const nodeAgents = (node: WorkflowPhaseTreeNode): AgentRecord[] => {
|
|
941
|
+
const agents: AgentRecord[] = [];
|
|
942
|
+
const visit = (id: string): void => {
|
|
943
|
+
const child = tree.byId.get(id);
|
|
944
|
+
if (!child) return;
|
|
945
|
+
if (child.agent) agents.push(child.agent); else for (const childId of child.children) visit(childId);
|
|
946
|
+
};
|
|
947
|
+
if (node.agent) agents.push(node.agent); else for (const childId of node.children) visit(childId);
|
|
948
|
+
return agents;
|
|
949
|
+
};
|
|
950
|
+
const nodeStatus = (node: WorkflowPhaseTreeNode): string => phaseStyle(node.state)(node.state);
|
|
951
|
+
const nodeIcon = (node: WorkflowPhaseTreeNode): string => node.children.length ? expanded.has(node.id) ? "▾" : "▸" : node.kind === "agent" ? "•" : " ";
|
|
952
|
+
const treeLine = (node: WorkflowPhaseTreeNode): string => {
|
|
953
|
+
const selected = node.id === selectedNode?.id;
|
|
954
|
+
const state = progressStyleForState(node.state, styles);
|
|
955
|
+
const activity = node.agent && !SETTLED_AGENT_STATES.has(node.agent.state) ? formatAgentActivity(node.agent, "⠦", styles, now) : "";
|
|
956
|
+
return `${selected ? "→" : " "} ${" ".repeat(node.depth)}${nodeIcon(node)} ${node.label} · ${state(node.state)}${activity ? ` ${activity}` : ""}`;
|
|
957
|
+
};
|
|
958
|
+
const details = (node: WorkflowPhaseTreeNode | undefined): string[] => {
|
|
959
|
+
if (!node) return [styles.muted("No workflow node is selected")];
|
|
960
|
+
const agents = nodeAgents(node);
|
|
961
|
+
if (node.kind === "phase") {
|
|
962
|
+
const selected = node.phase;
|
|
963
|
+
const counts = selected?.counts ?? phaseAgentCounts(agents);
|
|
964
|
+
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)}`];
|
|
965
|
+
}
|
|
966
|
+
if (node.kind === "operation") {
|
|
967
|
+
const states = phaseAgentCounts(agents);
|
|
968
|
+
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)}`];
|
|
969
|
+
}
|
|
970
|
+
const agent = node.agent;
|
|
971
|
+
if (!agent) return [styles.muted("Agent details are unavailable")];
|
|
972
|
+
const byId = new Map(run.agents.map((candidate) => [candidate.id, candidate]));
|
|
973
|
+
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)}`, ...(agent.accounting ? formatAgentAccounting(agent.accounting) : []), ...(selection.actions ? [] : [styles.muted("enter for agent actions")])];
|
|
974
|
+
const error = agent.attemptDetails?.at(-1)?.error;
|
|
975
|
+
if (error) result.push(styles.error(`Error: ${error.code}: ${error.message}`));
|
|
976
|
+
if (agent.activity) result.push(`Activity: ${agent.activity.text}`);
|
|
977
|
+
const stalled = stalledDuration(agent, now);
|
|
978
|
+
if (stalled !== undefined) result.push(styles.warning(`stalled? ${formatStalledDuration(stalled)}`));
|
|
979
|
+
return result;
|
|
980
|
+
};
|
|
631
981
|
const stateNames: readonly WorkflowPhaseState[] = ["not started", "running", "completed", "failed", "cancelled", "interrupted", "budget_exhausted"];
|
|
632
982
|
const statusSummary = stateNames.filter((state) => (model.counts[state] ?? 0) > 0).map((state) => `${String(model.counts[state])} ${state}`).join(" · ") || "0 phases";
|
|
633
|
-
const selectedIndex = selection.phaseId ? model.phases.findIndex((phase) => phase.id === selection.phaseId) : -1;
|
|
634
|
-
const activeIndex = selectedIndex >= 0 ? selectedIndex : model.currentPhaseIndex ?? (model.phases.length ? 0 : -1);
|
|
635
|
-
const selectedPhase = activeIndex >= 0 ? model.phases[activeIndex] : undefined;
|
|
636
983
|
const lines: string[] = [styles.bold(styles.accent(`Workflow: ${run.workflowName}`))];
|
|
637
984
|
if (run.error) lines.push(styles.error(`ERROR ${run.error.code}: ${run.error.message}`));
|
|
638
985
|
lines.push(`phase: ${run.phase ?? selectedPhase?.name ?? "none"}`, `Run state: ${run.state}`, `Phases: ${statusSummary}`);
|
|
639
986
|
for (const event of run.events ?? []) lines.push(styles.warning(`Warning: ${event.message}`));
|
|
640
987
|
lines.push(...formatCompactBudgetStatus(run));
|
|
641
|
-
const
|
|
642
|
-
const
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
const selected = agent.id === selectedAgentId;
|
|
647
|
-
const stateStyle = progressStyleForState(agent.state, styles);
|
|
648
|
-
const icon = agent.state === "completed" ? "✓" : agent.state === "failed" || agent.state === "cancelled" ? "✗" : agent.state === "running" ? "⠦" : "○";
|
|
649
|
-
rendered.push(`${selected ? "→" : " "} ${stateStyle(icon)} ${styledAgentBreadcrumb(agent, byId, styles)} · ${stateStyle(agent.state)}`);
|
|
650
|
-
if (agent.state === "failed") {
|
|
651
|
-
const error = agent.attemptDetails?.at(-1)?.error;
|
|
652
|
-
if (error) rendered.push(styles.error(` error: ${error.code}: ${error.message}`));
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
return rendered;
|
|
988
|
+
const renderTree = (limit: number): string[] => [styles.bold("Tree"), ...(visibleNodes.length ? visibleNodes.flatMap((node) => wrap(treeLine(node), limit)) : [styles.muted("(empty)")])];
|
|
989
|
+
const actionRows = (): string[] => {
|
|
990
|
+
const actions = selection.actions;
|
|
991
|
+
if (!actions) return [];
|
|
992
|
+
return ["", styles.bold(actions.title), ...actions.options.map((option, index) => `${index === actions.index ? "→ " : " "}${index === actions.index ? styles.accent(option) : option}`)];
|
|
656
993
|
};
|
|
657
|
-
|
|
658
|
-
lines.push(styles.muted("Phases: no declared or observed phases (legacy snapshot)"), styles.bold("Agents"), ...renderAgentLines(run.agents, selection.agentId));
|
|
659
|
-
return lines.flatMap((line) => wrap(line));
|
|
660
|
-
}
|
|
661
|
-
const selectedAgent = selectedPhase?.agents.find((agent) => agent.id === selection.agentId) ?? selectedPhase?.agents[0];
|
|
994
|
+
const detailRows = (): string[] => [...details(selectedNode), ...actionRows()];
|
|
662
995
|
if (safeWidth >= 80) {
|
|
663
|
-
const sidebarWidth = Math.min(
|
|
996
|
+
const sidebarWidth = Math.min(42, Math.max(24, Math.floor((safeWidth - 3) * 0.38)));
|
|
664
997
|
const detailWidth = Math.max(1, safeWidth - sidebarWidth - 3);
|
|
665
|
-
const sidebar =
|
|
666
|
-
const detail =
|
|
998
|
+
const sidebar = renderTree(sidebarWidth).flatMap((line) => wrap(line, sidebarWidth));
|
|
999
|
+
const detail = detailRows().flatMap((line) => wrap(line, detailWidth));
|
|
667
1000
|
const rows = Math.max(sidebar.length, detail.length);
|
|
668
|
-
for (let index = 0; index < rows; index += 1) lines.push(`${sidebar[index] ?? ""} | ${detail[index] ?? ""}`);
|
|
1001
|
+
for (let index = 0; index < rows; index += 1) lines.push(`${padTo(sidebar[index] ?? "", sidebarWidth)} | ${detail[index] ?? ""}`);
|
|
1002
|
+
} else if (selection.detailsOnly) {
|
|
1003
|
+
lines.push(...detailRows().flatMap((line) => wrap(line)));
|
|
669
1004
|
} else {
|
|
670
|
-
lines.push(
|
|
671
|
-
|
|
672
|
-
if (selectedPhase) lines.push("", styles.bold(`Selected phase: ${phaseName(selectedPhase)}`), ...wrap(`Status: ${phaseStyle(selectedPhase.state)(selectedPhase.state)}`), ...wrap(phaseCounts(selectedPhase)), styles.bold("Agents"), ...renderAgentLines(selectedPhase.agents, selectedAgent?.id).flatMap((line) => wrap(line)));
|
|
1005
|
+
lines.push(...renderTree(safeWidth));
|
|
1006
|
+
if (!selection.treeOnly) lines.push("", ...detailRows().flatMap((line) => wrap(line)));
|
|
673
1007
|
}
|
|
674
|
-
if (model.unassignedAgents?.length) lines.push(...wrap(styles.muted(`Unassigned agents: ${String(model.unassignedAgents.length)}`)));
|
|
1008
|
+
if (model.unassignedAgents?.length && !tree.nodes.some((node) => node.phaseId === "unassigned")) lines.push(...wrap(styles.muted(`Unassigned agents: ${String(model.unassignedAgents.length)}`)));
|
|
675
1009
|
return lines.flatMap((line) => wrap(line));
|
|
676
1010
|
}
|
|
677
1011
|
function formatCheckpointReview(checkpoint: AwaitingCheckpoint): string {
|
|
@@ -827,16 +1161,35 @@ export function formatWorkflowFailureDiagnostics(diagnostic: WorkflowFailureDiag
|
|
|
827
1161
|
const retry = diagnostic.retry ? [` Retry: ${diagnostic.retry.action}`, ` Replayable completed paths: ${diagnostic.retry.completedPaths.join(", ") || "(none)"}`, ` Incomplete paths: ${diagnostic.retry.incompletePaths.join(", ") || "(unknown)"}`, ` Named worktrees: ${diagnostic.retry.namedWorktrees.join(", ") || "(none)"}`, ` Warning: ${diagnostic.retry.warning}`] : [];
|
|
828
1162
|
return [`✗ Workflow: ${diagnostic.workflowName}`, ` Run: ${diagnostic.runId}`, ` State: ${diagnostic.state}`, ` Error: ${diagnostic.error.code}: ${diagnostic.error.message}`, ` Failed at: ${diagnostic.failedAt ?? "(unknown)"}`, ` Failed agent: ${failedAgent}`, ` Completed sibling ${siblingAgents ? "agents" : "paths"}: ${siblings}`, ...retry, ` Artifacts: state=${diagnostic.artifacts.statePath} journal=${diagnostic.artifacts.journalPath}`].join("\n");
|
|
829
1163
|
}
|
|
1164
|
+
function deliveryPart(value: string, maxBytes: number): string { return utf8Prefix(value.replace(/\s+/g, " ").trim(), maxBytes) || "(unknown)"; }
|
|
1165
|
+
export function formatWorkflowFailureDelivery(diagnostic: WorkflowFailureDiagnostics): string {
|
|
1166
|
+
const name = deliveryPart(diagnostic.workflowName, 128);
|
|
1167
|
+
const runId = deliveryPart(diagnostic.runId, 128);
|
|
1168
|
+
const error = `${diagnostic.error.code}: ${deliveryPart(diagnostic.error.message, 768)}`;
|
|
1169
|
+
const failedPath = diagnostic.failedAt ? `; failed path=${deliveryPart(diagnostic.failedAt, 512)}` : "";
|
|
1170
|
+
const nextAction = diagnostic.retry ? `; next action: ${deliveryPart(diagnostic.retry.action, 256)}` : "";
|
|
1171
|
+
const artifacts = `; artifacts: runDirectory=${deliveryPart(diagnostic.artifacts.runDirectory, 512)} statePath=${deliveryPart(diagnostic.artifacts.statePath, 512)} journalPath=${deliveryPart(diagnostic.artifacts.journalPath, 512)}`;
|
|
1172
|
+
const line = `Workflow ${name} failed (runId=${runId}): error=${error}${failedPath}${nextAction}${artifacts}`;
|
|
1173
|
+
return Buffer.byteLength(line) <= DELIVERY_LIMIT_BYTES ? line : utf8Prefix(line, DELIVERY_LIMIT_BYTES);
|
|
1174
|
+
}
|
|
1175
|
+
function formatWorkflowFailureDeliveryFallback(workflowName: string, runId: string, runDirectory: string, error: unknown): string {
|
|
1176
|
+
const code = errorCode(error) ?? "INTERNAL_ERROR";
|
|
1177
|
+
const failedPath = workflowFailedAt(error);
|
|
1178
|
+
const nextAction = code === "BUDGET_EXHAUSTED" || code === "CANCELLED" ? "" : `; next action: workflow_retry({ runId: ${JSON.stringify(runId)} })`;
|
|
1179
|
+
const line = `Workflow ${deliveryPart(workflowName, 128)} failed (runId=${deliveryPart(runId, 128)}): error=${code}: ${deliveryPart(formatWorkflowFailure(error), 768)}${failedPath ? `; failed path=${deliveryPart(failedPath, 512)}` : ""}${nextAction}; artifacts: runDirectory=${deliveryPart(runDirectory, 512)} statePath=${deliveryPart(join(runDirectory, "state.json"), 512)} journalPath=${deliveryPart(join(runDirectory, "journal.json"), 512)}`;
|
|
1180
|
+
return Buffer.byteLength(line) <= DELIVERY_LIMIT_BYTES ? line : utf8Prefix(line, DELIVERY_LIMIT_BYTES);
|
|
1181
|
+
}
|
|
830
1182
|
|
|
831
1183
|
function serializeWorkflowFailureDiagnostics(diagnostic: WorkflowFailureDiagnostics): string { return JSON.stringify(diagnostic); }
|
|
832
1184
|
function isWorkflowFailureDiagnostics(value: unknown): value is WorkflowFailureDiagnostics {
|
|
833
1185
|
return object(value) && typeof value.runId === "string" && typeof value.workflowName === "string" && typeof value.state === "string" && "failedAt" in value && object(value.error) && object(value.artifacts);
|
|
834
1186
|
}
|
|
835
1187
|
function deliver(pi: ExtensionAPI, content: string): void {
|
|
1188
|
+
if (typeof pi.sendMessage !== "function") return;
|
|
836
1189
|
pi.sendMessage({ customType: "workflow", content, display: true }, { deliverAs: "followUp", triggerTurn: true });
|
|
837
1190
|
}
|
|
838
1191
|
function deliverFailure(pi: ExtensionAPI, diagnostic: WorkflowFailureDiagnostics): void {
|
|
839
|
-
deliver(pi,
|
|
1192
|
+
deliver(pi, formatWorkflowFailureDelivery(diagnostic));
|
|
840
1193
|
}
|
|
841
1194
|
|
|
842
1195
|
type WorkflowEventSink = { emit: (name: string, payload: unknown) => unknown };
|
|
@@ -1094,26 +1447,25 @@ function projectTrusted(ctx: unknown): boolean {
|
|
|
1094
1447
|
const check = object(ctx) ? ctx.isProjectTrusted : undefined;
|
|
1095
1448
|
return typeof check === "function" ? Boolean(Reflect.apply(check, ctx, [])) : true;
|
|
1096
1449
|
}
|
|
1450
|
+
function asFn(value: unknown): ((...args: never[]) => unknown) | undefined { return typeof value === "function" ? value as (...args: never[]) => unknown : undefined; }
|
|
1097
1451
|
type PiHostCapabilities = { registerEntryRenderer?: ExtensionAPI["registerEntryRenderer"]; events?: WorkflowEventSink };
|
|
1098
|
-
function isEntryRenderer(value: unknown): value is NonNullable<PiHostCapabilities["registerEntryRenderer"]> { return typeof value === "function"; }
|
|
1099
1452
|
function isWorkflowEventSink(value: unknown): value is WorkflowEventSink { return object(value) && typeof value.emit === "function"; }
|
|
1100
1453
|
function piHostCapabilities(pi: unknown): PiHostCapabilities {
|
|
1101
1454
|
if (!object(pi)) return {};
|
|
1102
|
-
const registerEntryRenderer = pi.registerEntryRenderer;
|
|
1455
|
+
const registerEntryRenderer = asFn(pi.registerEntryRenderer) as NonNullable<PiHostCapabilities["registerEntryRenderer"]> | undefined;
|
|
1103
1456
|
const events = pi.events;
|
|
1104
|
-
return { ...(
|
|
1457
|
+
return { ...(registerEntryRenderer ? { registerEntryRenderer } : {}), ...(isWorkflowEventSink(events) ? { events } : {}) };
|
|
1105
1458
|
}
|
|
1106
1459
|
type ContextHostCapabilities = { modelRegistry?: ModelRegistryCapability };
|
|
1107
1460
|
type ModelSummary = { provider: string; id: string };
|
|
1108
1461
|
type ModelRegistryGetter = () => readonly ModelSummary[];
|
|
1109
1462
|
type ModelRegistryCapability = { getAll?: ModelRegistryGetter; getAvailable?: ModelRegistryGetter };
|
|
1110
|
-
function isModelRegistryGetter(value: unknown): value is ModelRegistryGetter { return typeof value === "function"; }
|
|
1111
1463
|
function contextHostCapabilities(ctx: unknown): ContextHostCapabilities {
|
|
1112
1464
|
if (!object(ctx) || !object(ctx.modelRegistry)) return {};
|
|
1113
1465
|
const registry = ctx.modelRegistry;
|
|
1114
|
-
const getAll = registry.getAll;
|
|
1115
|
-
const getAvailable = registry.getAvailable;
|
|
1116
|
-
return { modelRegistry: { ...(
|
|
1466
|
+
const getAll = (asFn(registry.getAll) as ModelRegistryGetter | undefined);
|
|
1467
|
+
const getAvailable = (asFn(registry.getAvailable) as ModelRegistryGetter | undefined);
|
|
1468
|
+
return { modelRegistry: { ...(getAll ? { getAll: () => getAll.call(registry) } : {}), ...(getAvailable ? { getAvailable: () => getAvailable.call(registry) } : {}) } };
|
|
1117
1469
|
}
|
|
1118
1470
|
function modelInventory(root: ModelSpec | undefined, registry: ModelRegistryCapability | undefined): { knownModels: ReadonlySet<string>; availableModels: ReadonlySet<string> } {
|
|
1119
1471
|
const all = registry?.getAll?.() ?? registry?.getAvailable?.() ?? [];
|
|
@@ -1146,23 +1498,21 @@ type UiSelect = (title: string, options: string[]) => Promise<string | undefined
|
|
|
1146
1498
|
type UiInput = (title: string, placeholder?: string) => Promise<string | undefined>;
|
|
1147
1499
|
type UiSetStatus = (key: string, text?: string) => void;
|
|
1148
1500
|
type UiHostCapabilities = { select?: UiSelect; input?: UiInput; setStatus?: UiSetStatus };
|
|
1149
|
-
function isUiSelect(value: unknown): value is UiSelect { return typeof value === "function"; }
|
|
1150
|
-
function isUiInput(value: unknown): value is UiInput { return typeof value === "function"; }
|
|
1151
|
-
function isUiSetStatus(value: unknown): value is UiSetStatus { return typeof value === "function"; }
|
|
1152
1501
|
function uiHostCapabilities(ui: unknown): UiHostCapabilities | undefined {
|
|
1153
1502
|
if (!object(ui)) return undefined;
|
|
1154
|
-
const select = ui.select;
|
|
1155
|
-
const input = ui.input;
|
|
1156
|
-
const setStatus = ui.setStatus;
|
|
1157
|
-
return { ...(
|
|
1503
|
+
const select = (asFn(ui.select) as UiSelect | undefined);
|
|
1504
|
+
const input = (asFn(ui.input) as UiInput | undefined);
|
|
1505
|
+
const setStatus = (asFn(ui.setStatus) as UiSetStatus | undefined);
|
|
1506
|
+
return { ...(select ? { select } : {}), ...(input ? { input } : {}), ...(setStatus ? { setStatus } : {}) };
|
|
1158
1507
|
}
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
return { terminal: { ...(tui.terminal.rows === undefined ? {} : { rows: tui.terminal.rows }) } };
|
|
1508
|
+
function tuiRows(tui: unknown): number {
|
|
1509
|
+
const rows = object(tui) && object(tui.terminal) ? tui.terminal.rows : undefined;
|
|
1510
|
+
return typeof rows === "number" && Number.isFinite(rows) ? rows : 24;
|
|
1163
1511
|
}
|
|
1164
|
-
|
|
1512
|
+
const WORKFLOW_PANEL_FOOTER_ROWS = 2;
|
|
1165
1513
|
const WORKFLOW_OVERLAY_BORDER_ROWS = 2;
|
|
1514
|
+
const WORKFLOW_OVERLAY_TOP_MARGIN = 1;
|
|
1515
|
+
const WORKFLOW_OVERLAY_OPTIONS = { anchor: "top-left", width: "100%", maxHeight: "100%", margin: { top: WORKFLOW_OVERLAY_TOP_MARGIN } } as const;
|
|
1166
1516
|
type WorkflowOverlayComponent = { render(width: number): string[]; invalidate(): void; handleInput?(data: string): void; dispose?(): void };
|
|
1167
1517
|
function borderWorkflowOverlay(component: WorkflowOverlayComponent, theme: { fg(color: "border", text: string): string }): WorkflowOverlayComponent {
|
|
1168
1518
|
return {
|
|
@@ -1174,14 +1524,21 @@ function borderWorkflowOverlay(component: WorkflowOverlayComponent, theme: { fg(
|
|
|
1174
1524
|
};
|
|
1175
1525
|
}
|
|
1176
1526
|
type KeybindingsHostCapabilities = { getKeys?: (name: string) => readonly string[] };
|
|
1177
|
-
function
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1527
|
+
function keybindingKeys(keybindings: unknown, name: string): readonly string[] | undefined {
|
|
1528
|
+
const getKeys = object(keybindings) ? asFn(keybindings.getKeys) as NonNullable<KeybindingsHostCapabilities["getKeys"]> | undefined : undefined;
|
|
1529
|
+
return getKeys ? getKeys.call(keybindings, name) : undefined;
|
|
1530
|
+
}
|
|
1531
|
+
type WorkflowKeybindings = { matches(data: string, binding: string): boolean };
|
|
1532
|
+
const WORKFLOW_VIM_KEYS: Readonly<Record<string, string>> = { "tui.select.up": "k", "tui.select.down": "j", "tui.editor.cursorLeft": "h", "tui.editor.cursorRight": "l" };
|
|
1533
|
+
function workflowKeyMatches(keybindings: WorkflowKeybindings, data: string, binding: string): boolean { return keybindings.matches(data, binding) || WORKFLOW_VIM_KEYS[binding] === data; }
|
|
1534
|
+
function workflowKeyLabel(keybindings: unknown, binding: string, fallback: string, labels: Readonly<Record<string, string>>): string {
|
|
1535
|
+
const keys = keybindingKeys(keybindings, binding);
|
|
1536
|
+
const configured = keys?.length ? keys.map((key) => labels[key] ?? key) : [fallback];
|
|
1537
|
+
const vim = WORKFLOW_VIM_KEYS[binding];
|
|
1538
|
+
return [...new Set(vim ? [...configured, vim] : configured)].join("/");
|
|
1181
1539
|
}
|
|
1182
|
-
function keybindingKeys(keybindings: unknown, name: string): readonly string[] | undefined { const getKeys = keybindingsHostCapabilities(keybindings).getKeys; return typeof getKeys === "function" ? getKeys.call(keybindings, name) : undefined; }
|
|
1183
1540
|
|
|
1184
|
-
export default function workflowExtension(pi: ExtensionAPI, home?: string, clipboard = copyToClipboard, createSession: SessionFactory = createNativeAgentSession, agentDir?: string) {
|
|
1541
|
+
export default function workflowExtension(pi: ExtensionAPI, home?: string, clipboard = copyToClipboard, createSession: SessionFactory = createNativeAgentSession, agentDir?: string, additionalSkillPaths: readonly string[] = []) {
|
|
1185
1542
|
beginWorkflowExtensionLoading();
|
|
1186
1543
|
const registry = loadingRegistry();
|
|
1187
1544
|
const extensionAgentDir = agentDir ?? getAgentDir();
|
|
@@ -1202,7 +1559,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1202
1559
|
const skillPath = [join(extensionDir, "../skills"), join(extensionDir, "../../skills")].find((path) => existsSync(path));
|
|
1203
1560
|
return skillPath ? { skillPaths: [skillPath] } : undefined;
|
|
1204
1561
|
});
|
|
1205
|
-
type BudgetDecisionResult = { state: "running" | "budget_exhausted"; approved: boolean };
|
|
1562
|
+
type BudgetDecisionResult = { state: "running" | "completed" | "budget_exhausted"; approved: boolean; value?: JsonValue; run?: PersistedRun };
|
|
1206
1563
|
const runs = new Map<string, { executor: WorkflowAgentExecutor; store: RunStore; metadata: WorkflowMetadata; model: ModelSpec; lifecycle: RunLifecycle; budget: WorkflowBudgetRuntime; abortController: AbortController; projectTrusted: () => boolean; providerErrorRecovery?: (failure: AgentProviderFailure) => Promise<AgentProviderRecovery>; execution?: WorkflowExecution; completion?: Promise<unknown>; checkpointResolvers: Map<string, (value: boolean) => void>; update?: (result: WorkflowToolUpdate) => void }>();
|
|
1207
1564
|
let providerRecoveryQueue = Promise.resolve();
|
|
1208
1565
|
const enqueueProviderRecovery = <T>(task: () => Promise<T>): Promise<T> => { const next = providerRecoveryQueue.then(task, task); providerRecoveryQueue = next.then(() => undefined, () => undefined); return next; };
|
|
@@ -1226,14 +1583,9 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1226
1583
|
});
|
|
1227
1584
|
};
|
|
1228
1585
|
const pendingFailureDiagnostics = new Map<string, WorkflowFailureDiagnostics>();
|
|
1229
|
-
|
|
1230
|
-
if (event.toolName !== "workflow" || !event.isError) return;
|
|
1231
|
-
const diagnostic = pendingFailureDiagnostics.get(event.toolCallId);
|
|
1232
|
-
if (!diagnostic) return;
|
|
1233
|
-
pendingFailureDiagnostics.delete(event.toolCallId);
|
|
1234
|
-
return { content: [{ type: "text" as const, text: serializeWorkflowFailureDiagnostics(diagnostic) }], details: diagnostic, isError: true };
|
|
1235
|
-
});
|
|
1586
|
+
const foregroundDeliveries = new Map<string, { store: RunStore; inline: boolean; timer?: ReturnType<typeof setTimeout> }>();
|
|
1236
1587
|
const liveActivities = new Map<string, Map<string, AgentActivity>>();
|
|
1588
|
+
const liveEventTimes = new Map<string, Map<string, number>>();
|
|
1237
1589
|
const setLiveActivity = (runId: string, agentId: string, activity?: AgentActivity) => {
|
|
1238
1590
|
const activities = liveActivities.get(runId);
|
|
1239
1591
|
if (activity) {
|
|
@@ -1244,12 +1596,22 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1244
1596
|
if (activities?.size === 0) liveActivities.delete(runId);
|
|
1245
1597
|
}
|
|
1246
1598
|
};
|
|
1599
|
+
const setLiveEventTime = (runId: string, agentId: string, timestamp?: number) => {
|
|
1600
|
+
if (timestamp === undefined) return;
|
|
1601
|
+
const timestamps = liveEventTimes.get(runId);
|
|
1602
|
+
if (timestamps) timestamps.set(agentId, timestamp);
|
|
1603
|
+
else liveEventTimes.set(runId, new Map([[agentId, timestamp]]));
|
|
1604
|
+
};
|
|
1247
1605
|
const withLiveActivities = (run: PersistedRun): PersistedRun => {
|
|
1248
1606
|
const activities = liveActivities.get(run.id);
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1607
|
+
const timestamps = liveEventTimes.get(run.id);
|
|
1608
|
+
if (!activities?.size && !timestamps?.size) return run;
|
|
1609
|
+
return { ...run, agents: run.agents.map((agent) => {
|
|
1610
|
+
const activity = activities?.get(agent.id);
|
|
1611
|
+
const lastEventAt = timestamps?.get(agent.id);
|
|
1612
|
+
if (activity === undefined && lastEventAt === undefined) return agent;
|
|
1613
|
+
return { ...agent, ...(activity === undefined ? {} : { activity }), ...(lastEventAt === undefined ? {} : { lastEventAt }) };
|
|
1614
|
+
}) };
|
|
1253
1615
|
};
|
|
1254
1616
|
const terminalRunStates = new Map<string, "completed" | "failed" | "stopped">();
|
|
1255
1617
|
let sessionLease: SessionLease | undefined;
|
|
@@ -1271,6 +1633,42 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1271
1633
|
await eventPublisher.budget(store, metadata, persisted);
|
|
1272
1634
|
return persisted;
|
|
1273
1635
|
};
|
|
1636
|
+
pi.on("tool_result", async (event) => {
|
|
1637
|
+
const delivery = event.toolName === "workflow" ? foregroundDeliveries.get(event.toolCallId) : undefined;
|
|
1638
|
+
if (delivery) {
|
|
1639
|
+
if (delivery.timer) clearTimeout(delivery.timer);
|
|
1640
|
+
delivery.inline = true;
|
|
1641
|
+
await delivery.store.updateState((current) => {
|
|
1642
|
+
if (current.delivery?.toolCallId !== event.toolCallId || current.delivery.state === "delivered") return current;
|
|
1643
|
+
return { ...current, delivery: { ...current.delivery, state: "delivered" } };
|
|
1644
|
+
});
|
|
1645
|
+
foregroundDeliveries.delete(event.toolCallId);
|
|
1646
|
+
}
|
|
1647
|
+
if (event.toolName !== "workflow" || !event.isError) return;
|
|
1648
|
+
const diagnostic = pendingFailureDiagnostics.get(event.toolCallId);
|
|
1649
|
+
if (!diagnostic) return;
|
|
1650
|
+
pendingFailureDiagnostics.delete(event.toolCallId);
|
|
1651
|
+
return { content: [{ type: "text" as const, text: serializeWorkflowFailureDiagnostics(diagnostic) }], details: diagnostic, isError: true };
|
|
1652
|
+
});
|
|
1653
|
+
const deliverTerminal = async (store: RunStore, content: string): Promise<void> => {
|
|
1654
|
+
let claimed: boolean | undefined;
|
|
1655
|
+
await store.updateState((current) => {
|
|
1656
|
+
if (current.delivery?.state === "delivered") return current;
|
|
1657
|
+
if (!current.delivery) { claimed = true; return current; }
|
|
1658
|
+
claimed = true;
|
|
1659
|
+
return { ...current, delivery: { ...current.delivery, mode: "background", state: "delivered" } };
|
|
1660
|
+
});
|
|
1661
|
+
if (claimed === true) deliver(pi, content);
|
|
1662
|
+
};
|
|
1663
|
+
const scheduleForegroundDelivery = (toolCallId: string, send: () => Promise<void>): void => {
|
|
1664
|
+
const delivery = foregroundDeliveries.get(toolCallId);
|
|
1665
|
+
if (!delivery || delivery.inline || typeof (pi as unknown as { sendMessage?: unknown }).sendMessage !== "function") return;
|
|
1666
|
+
//NOTE: Give Pi one event-loop turn to deliver an uninterrupted tool result before promoting.
|
|
1667
|
+
delivery.timer = setTimeout(() => {
|
|
1668
|
+
delete delivery.timer;
|
|
1669
|
+
void send().finally(() => foregroundDeliveries.delete(toolCallId));
|
|
1670
|
+
}, 0);
|
|
1671
|
+
};
|
|
1274
1672
|
const phaseBridge = (store: RunStore, metadata: WorkflowMetadata, lifecycle: RunLifecycle) => {
|
|
1275
1673
|
let cursor = 0;
|
|
1276
1674
|
return async (phase: string): Promise<void> => {
|
|
@@ -1311,17 +1709,30 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1311
1709
|
const path = shellIdentityPath(identity);
|
|
1312
1710
|
const replayed = await store.replay(path);
|
|
1313
1711
|
if (replayed) return readShellResult(replayed.value);
|
|
1314
|
-
const
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1712
|
+
const started = await persistRunState(store, metadata, (current) => ({ ...current, activeShells: (current.activeShells ?? 0) + 1 }));
|
|
1713
|
+
runs.get(store.runId)?.update?.(workflowToolUpdate(withLiveActivities(started)));
|
|
1714
|
+
try {
|
|
1715
|
+
const cwd = identity.worktreeOwner ? (await persistWorktree(store, metadata, identity.worktreeOwner)).cwd : store.cwd;
|
|
1716
|
+
const result = await executeShellCommand(command, options, signal, cwd);
|
|
1717
|
+
await store.complete(path, result as unknown as JsonValue);
|
|
1718
|
+
return result;
|
|
1719
|
+
} finally {
|
|
1720
|
+
const stopped = await persistRunState(store, metadata, (current) => {
|
|
1721
|
+
const activeShells = Math.max(0, (current.activeShells ?? 0) - 1);
|
|
1722
|
+
if (activeShells > 0) return { ...current, activeShells };
|
|
1723
|
+
const next = { ...current };
|
|
1724
|
+
delete next.activeShells;
|
|
1725
|
+
return next;
|
|
1726
|
+
});
|
|
1727
|
+
runs.get(store.runId)?.update?.(workflowToolUpdate(withLiveActivities(stopped)));
|
|
1728
|
+
}
|
|
1318
1729
|
} finally { await lifecycle.leave(); }
|
|
1319
1730
|
};
|
|
1320
1731
|
const lifecycleFor = (store: RunStore, state: RunState, budget: WorkflowBudgetRuntime, metadata: WorkflowMetadata) => new RunLifecycle(state, async (next, previous, reason) => {
|
|
1321
1732
|
if (next !== "pausing") budget.transition(next);
|
|
1322
1733
|
const persisted = await persistRunState(store, metadata, (current) => {
|
|
1323
1734
|
const nextRun = { ...current, state: next, ...budget.snapshot() };
|
|
1324
|
-
if (next === "running" || next === "completed") delete nextRun.error;
|
|
1735
|
+
if (next === "running" || next === "completed") { delete nextRun.error; delete nextRun.failedAt; }
|
|
1325
1736
|
return nextRun;
|
|
1326
1737
|
});
|
|
1327
1738
|
await eventPublisher.runState(store, metadata, previous, next, reason);
|
|
@@ -1335,25 +1746,28 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1335
1746
|
const onProgress = async (progress: AgentProgress) => {
|
|
1336
1747
|
let runState: PersistedRun;
|
|
1337
1748
|
if (progress.persist) {
|
|
1338
|
-
runState = await persistRunState(run.store, run.metadata, (current) => current.agents.some((agent) => agent.id === id) ? { ...current, ...run.budget.snapshot(), agents: current.agents.map((agent) => agent.id === id ? { ...agent, accounting: progress.accounting, toolCalls: progress.toolCalls, activity: progress.activity } : agent) } : current);
|
|
1749
|
+
runState = await persistRunState(run.store, run.metadata, (current) => current.agents.some((agent) => agent.id === id) ? { ...current, ...run.budget.snapshot(), agents: current.agents.map((agent) => agent.id === id ? { ...agent, accounting: progress.accounting, toolCalls: progress.toolCalls, activity: progress.activity, ...(progress.lastEventAt === undefined ? {} : { lastEventAt: progress.lastEventAt }) } : agent) } : current);
|
|
1339
1750
|
} else {
|
|
1340
1751
|
const loaded = await run.store.load();
|
|
1341
1752
|
if (!loaded.run.agents.some((agent) => agent.id === id)) return;
|
|
1342
|
-
runState = { ...loaded.run, ...run.budget.snapshot(), agents: loaded.run.agents.map((agent) => agent.id === id ? { ...agent, accounting: progress.accounting, toolCalls: progress.toolCalls, activity: progress.activity } : agent) };
|
|
1753
|
+
runState = { ...loaded.run, ...run.budget.snapshot(), agents: loaded.run.agents.map((agent) => agent.id === id ? { ...agent, accounting: progress.accounting, toolCalls: progress.toolCalls, activity: progress.activity, ...(progress.lastEventAt === undefined ? {} : { lastEventAt: progress.lastEventAt }) } : agent) };
|
|
1343
1754
|
}
|
|
1344
1755
|
if (!runState.agents.some((agent) => agent.id === id)) return;
|
|
1345
1756
|
setLiveActivity(runId, id, progress.activity);
|
|
1757
|
+
setLiveEventTime(runId, id, progress.lastEventAt);
|
|
1346
1758
|
run.update?.(workflowToolUpdate(withLiveActivities(runState)));
|
|
1347
1759
|
};
|
|
1348
1760
|
const onAttempt = async (attempt: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile" | "setup">) => {
|
|
1349
1761
|
await scheduler.flush();
|
|
1350
1762
|
scheduler.attemptStarted(id);
|
|
1763
|
+
const lastEventAt = Date.now();
|
|
1764
|
+
setLiveEventTime(runId, id, lastEventAt);
|
|
1351
1765
|
await scheduler.flush();
|
|
1352
1766
|
const before = (await run.store.load()).run;
|
|
1353
1767
|
await persistActiveAgentAttempt(run.store, id, attempt);
|
|
1354
1768
|
const active = (await run.store.load()).run;
|
|
1355
1769
|
await eventPublisher.agentStates(run.store, run.metadata, before.agents, active.agents);
|
|
1356
|
-
const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
|
|
1770
|
+
const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot(), agents: current.agents.map((agent) => agent.id === id ? { ...agent, lastEventAt } : agent) }));
|
|
1357
1771
|
run.update?.(workflowToolUpdate(withLiveActivities(persisted)));
|
|
1358
1772
|
};
|
|
1359
1773
|
const result = await run.executor.execute(prompt, { label: options.label, workflowName: run.metadata.name, onProgress, onAttempt, budget, ...(run.providerErrorRecovery ? { providerErrorRecovery: run.providerErrorRecovery } : {}), ...(parentId ? { parent: parentId, cwd: options.cwd, ...(options.worktreeOwner ? { worktreeOwner: options.worktreeOwner } : {}) } : options.worktreeOwner ? { worktreeOwner: options.worktreeOwner } : {}), ...(options.model ? { model: options.model } : {}), ...(options.thinking ? { thinking: options.thinking } : {}), ...(options.role ? { role: options.role } : {}), ...(options.role ? {} : { tools: options.tools }), effectiveTools: options.tools, ...(options.schema ? { schema: options.schema } : {}), ...(options.retries === undefined ? {} : { retries: options.retries }), ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }), ...(options.agentOptions ? { agentOptions: options.agentOptions } : {}), ...(options.agentIdentity ? { agentIdentity: options.agentIdentity } : {}) }, signal, scheduler.toolsFor(id, (role, tools, model, inheritedTools, thinking) => run.executor.resolve({ label: "child", workflowName: run.metadata.name, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(tools !== undefined ? { tools } : {}) }, inheritedTools).tools), setSteer, () => { scheduler.cancelChildren(id); scheduler.retry(id); });
|
|
@@ -1392,7 +1806,9 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1392
1806
|
let effective: { model: ModelSpec; requestedModel?: string; tools: readonly string[] };
|
|
1393
1807
|
try { effective = run.executor.resolve(requested); }
|
|
1394
1808
|
catch { effective = previous ? { model: previous.model, ...(previous.requestedModel ? { requestedModel: previous.requestedModel } : {}), tools: previous.tools } : { model: node.options.model ? modelSpec(node.options.model, run.model) : { ...run.model, ...(node.options.thinking ? { thinking: node.options.thinking } : {}) }, ...(node.options.model ? { requestedModel: node.options.model } : {}), tools: node.options.tools }; }
|
|
1395
|
-
|
|
1809
|
+
const resultPath = !node.parentId && node.options.agentIdentity ? agentIdentityPath(node.options.agentIdentity) : undefined;
|
|
1810
|
+
const lastEventAt = node.state === "running" ? previous?.state === "running" && previous.lastEventAt !== undefined ? previous.lastEventAt : Date.now() : previous?.lastEventAt;
|
|
1811
|
+
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 } : {}), ...(lastEventAt === undefined ? {} : { lastEventAt }) };
|
|
1396
1812
|
});
|
|
1397
1813
|
return { ...current, agents };
|
|
1398
1814
|
});
|
|
@@ -1401,7 +1817,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1401
1817
|
});
|
|
1402
1818
|
const cleanupTerminalRun = async (runId: string): Promise<void> => {
|
|
1403
1819
|
const run = runs.get(runId);
|
|
1404
|
-
if (!run || !
|
|
1820
|
+
if (!run || !HARD_TERMINAL_RUN_STATES.has(run.lifecycle.state)) return;
|
|
1405
1821
|
await scheduler.cancelRun(runId);
|
|
1406
1822
|
await scheduler.flush();
|
|
1407
1823
|
if (runs.get(runId) !== run) return;
|
|
@@ -1409,6 +1825,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1409
1825
|
terminalRunStates.set(runId, run.lifecycle.state as "completed" | "failed" | "stopped");
|
|
1410
1826
|
run.checkpointResolvers.clear();
|
|
1411
1827
|
liveActivities.delete(runId);
|
|
1828
|
+
liveEventTimes.delete(runId);
|
|
1412
1829
|
eventPublisher.removeRun(runId);
|
|
1413
1830
|
runs.delete(runId);
|
|
1414
1831
|
};
|
|
@@ -1444,13 +1861,13 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1444
1861
|
run.budget.recordEvent({ type, budgetVersion: request.budgetVersion, dimensions: [], usage: structuredClone(request.consumed), limits: structuredClone(request.proposed), at: Date.now(), proposalId: request.proposalId, previous: structuredClone(request.previous), proposed: structuredClone(request.proposed) });
|
|
1445
1862
|
await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
|
|
1446
1863
|
};
|
|
1447
|
-
const answerBudgetDecision = async (runId: string, proposalId: string, approved: boolean, silent = false, context?: unknown, signal?: AbortSignal): Promise<BudgetDecisionResult | undefined> => {
|
|
1864
|
+
const answerBudgetDecision = async (runId: string, proposalId: string, approved: boolean, silent = false, context?: unknown, signal?: AbortSignal, waitForCompletion = true): Promise<BudgetDecisionResult | undefined> => {
|
|
1448
1865
|
const run = runs.get(runId);
|
|
1449
1866
|
if (!run) return undefined;
|
|
1450
1867
|
const request = await run.store.answerWorkflowDecision(proposalId, approved);
|
|
1451
1868
|
if (!request) return undefined;
|
|
1452
1869
|
await appendBudgetDecisionEvent(run, request, approved ? "adjustment_approved" : "adjustment_rejected");
|
|
1453
|
-
const result = await applyBudgetDecision(request, approved, context, signal);
|
|
1870
|
+
const result = await applyBudgetDecision(request, approved, context, signal, waitForCompletion);
|
|
1454
1871
|
if (!silent) deliver(pi, `Workflow ${run.metadata.name} budget adjustment ${proposalId}: ${approved ? "Approved" : "Rejected"}.`);
|
|
1455
1872
|
return result;
|
|
1456
1873
|
};
|
|
@@ -1514,6 +1931,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1514
1931
|
throw mainAgentError(error);
|
|
1515
1932
|
}
|
|
1516
1933
|
},
|
|
1934
|
+
renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_respond", args, theme)); },
|
|
1935
|
+
renderResult(result, options, theme, context) { return workflowCatalogBlock(workflowControlResult("workflow_respond", context.args, result, options.expanded, theme, context.isError), options.expanded); },
|
|
1517
1936
|
});
|
|
1518
1937
|
pi.registerTool({
|
|
1519
1938
|
name: "workflow_stop",
|
|
@@ -1528,6 +1947,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1528
1947
|
throw mainAgentError(error);
|
|
1529
1948
|
}
|
|
1530
1949
|
},
|
|
1950
|
+
renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_stop", args, theme)); },
|
|
1951
|
+
renderResult(result, options, theme, context) { return workflowCatalogBlock(workflowControlResult("workflow_stop", context.args, result, options.expanded, theme, context.isError), options.expanded); },
|
|
1531
1952
|
});
|
|
1532
1953
|
let catalogRegistered = false;
|
|
1533
1954
|
let sessionStarted = false;
|
|
@@ -1557,35 +1978,102 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1557
1978
|
});
|
|
1558
1979
|
catalogRegistered = true;
|
|
1559
1980
|
};
|
|
1560
|
-
const
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1981
|
+
const createAgentExecutor = (root: Omit<import("./agent-execution.js").AgentExecutionRoot, "agentDir" | "agentSetupHooks">) => new WorkflowAgentExecutor({ ...root, agentDir: extensionAgentDir, ...(additionalSkillPaths.length ? { additionalSkillPaths } : {}), agentSetupHooks: registry.agentSetupHooks() }, createSession);
|
|
1982
|
+
const activeSnapshotTools = (tools: readonly string[], active: ReadonlySet<string> | "session") => active === "session"
|
|
1983
|
+
? new Set(tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog"))
|
|
1984
|
+
: new Set(tools.filter((tool) => active.has(tool) || tool === "workflow_catalog"));
|
|
1985
|
+
const resumeLaunchPrologue = async (input: {
|
|
1986
|
+
snapshot: Readonly<LaunchSnapshot>;
|
|
1987
|
+
cwd: string;
|
|
1988
|
+
trustedProject: boolean;
|
|
1989
|
+
rootModel: ModelSpec;
|
|
1990
|
+
modelRegistry?: ModelRegistryCapability | undefined;
|
|
1991
|
+
signal: AbortSignal;
|
|
1992
|
+
resolvedAliases?: Readonly<Record<string, string>>;
|
|
1993
|
+
blockedAliases?: ReadonlySet<string>;
|
|
1994
|
+
blockedAliasTargets?: Readonly<Record<string, string>>;
|
|
1995
|
+
withPreflight: boolean;
|
|
1996
|
+
}) => {
|
|
1997
|
+
const active = new Set(pi.getActiveTools().filter((tool) => !INTERNAL_WORKFLOW_TOOLS.includes(tool)));
|
|
1998
|
+
const missing = input.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
|
|
1564
1999
|
if (missing) throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
|
|
1565
2000
|
const settingsPath = workflowSettingsPath(extensionAgentDir);
|
|
1566
|
-
const
|
|
1567
|
-
const
|
|
1568
|
-
const currentPolicy = resolveAgentResourcePolicy(run.store.cwd, trustedProject, settingsPath);
|
|
2001
|
+
const resolution = resolveWorkflowSettings(input.cwd, input.trustedProject, settingsPath);
|
|
2002
|
+
const currentPolicy = resolveAgentResourcePolicy(input.cwd, input.trustedProject, settingsPath);
|
|
1569
2003
|
const staticAliases = resolution.effective.modelAliases ?? {};
|
|
1570
|
-
const previousAliases =
|
|
2004
|
+
const previousAliases = input.snapshot.modelAliases ?? input.snapshot.settings.modelAliases ?? {};
|
|
2005
|
+
const inventory = modelInventory(input.rootModel, input.modelRegistry);
|
|
2006
|
+
const knownModels = input.modelRegistry ? inventory.knownModels : new Set([...input.snapshot.models, ...inventory.knownModels]);
|
|
2007
|
+
const availableModels = input.modelRegistry ? inventory.availableModels : new Set([...input.snapshot.models, ...inventory.availableModels]);
|
|
2008
|
+
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;
|
|
2009
|
+
const blockedAliases = input.blockedAliases ?? new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
2010
|
+
const blockedAliasTargets = input.blockedAliasTargets ?? Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
2011
|
+
let script: ReturnType<typeof launchScriptForSnapshot> | undefined;
|
|
2012
|
+
if (input.withPreflight) {
|
|
2013
|
+
const resumeAliases = { ...previousAliases, ...currentAliases };
|
|
2014
|
+
script = launchScriptForSnapshot(input.snapshot, registry);
|
|
2015
|
+
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);
|
|
2016
|
+
}
|
|
2017
|
+
const refreshed = resumedSnapshotSettings(input.snapshot, resolution, currentAliases);
|
|
2018
|
+
const snapshot = createLaunchSnapshot({ ...input.snapshot, settingsPath, ...refreshed, modelAliases: currentAliases });
|
|
2019
|
+
return { active, settingsPath, resolution, currentPolicy, previousAliases, knownModels, availableModels, currentAliases, blockedAliases, blockedAliasTargets, snapshot, script };
|
|
2020
|
+
};
|
|
2021
|
+
const workflowAgentHandler = (store: RunStore, metadata: WorkflowMetadata, lifecycle: RunLifecycle, executor: WorkflowAgentExecutor, cwd: string, runId: string, captureRole?: (role: string, model: ModelSpec) => Promise<void>) => async (prompt: string, options: Readonly<Record<string, JsonValue>>, agentSignal: AbortSignal, identity: import("./types.js").AgentIdentity) => {
|
|
2022
|
+
await lifecycle.enter();
|
|
2023
|
+
try {
|
|
2024
|
+
const path = agentIdentityPath(identity);
|
|
2025
|
+
const replayed = await store.replay(path);
|
|
2026
|
+
if (replayed) {
|
|
2027
|
+
return replayed.value;
|
|
2028
|
+
}
|
|
2029
|
+
const worktree = agentWorktree(identity);
|
|
2030
|
+
const agentCwd = worktree.worktreeOwner ? (await persistWorktree(store, metadata, worktree.worktreeOwner)).cwd : cwd;
|
|
2031
|
+
const role = typeof options.role === "string" ? options.role : undefined;
|
|
2032
|
+
const model = typeof options.model === "string" ? options.model : undefined;
|
|
2033
|
+
const thinking = parseThinking(options.thinking);
|
|
2034
|
+
const requestedLabel = typeof options.label === "string" ? options.label : undefined;
|
|
2035
|
+
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[] } : {}) });
|
|
2036
|
+
if (role) await captureRole?.(role, resolved.model);
|
|
2037
|
+
const label = displayAgentName(requestedLabel, role, resolved.model);
|
|
2038
|
+
const tools = resolved.tools;
|
|
2039
|
+
const schema = object(options.outputSchema) ? options.outputSchema : undefined;
|
|
2040
|
+
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 });
|
|
2041
|
+
const cancel = () => { scheduler.cancel(spawned.id); };
|
|
2042
|
+
if (agentSignal.aborted) cancel(); else agentSignal.addEventListener("abort", cancel, { once: true });
|
|
2043
|
+
const outcome = await spawned.result.finally(() => { agentSignal.removeEventListener("abort", cancel); });
|
|
2044
|
+
if (!outcome.ok) throw new WorkflowError(outcome.error.code as WorkflowErrorCode, outcome.error.message);
|
|
2045
|
+
await store.complete(path, outcome.value);
|
|
2046
|
+
return outcome.value;
|
|
2047
|
+
} finally { await lifecycle.leave(); }
|
|
2048
|
+
};
|
|
2049
|
+
const refreshPausedRunAliases = async (run: NonNullable<ReturnType<typeof runs.get>>, context?: { model: { provider: string; id: string } | undefined; modelRegistry: ModelRegistryCapability | undefined; projectTrusted?: boolean }) => {
|
|
2050
|
+
const loaded = await run.store.load();
|
|
2051
|
+
const trustedProject = context?.projectTrusted ?? run.projectTrusted();
|
|
1571
2052
|
const rootModel = context?.model ? { ...run.model, provider: context.model.provider, model: context.model.id } : run.model;
|
|
1572
|
-
const
|
|
1573
|
-
const knownModels = context?.modelRegistry ? inventory.knownModels : new Set([...loaded.snapshot.models, ...inventory.knownModels]);
|
|
1574
|
-
const availableModels = context?.modelRegistry ? inventory.availableModels : new Set([...loaded.snapshot.models, ...inventory.availableModels]);
|
|
1575
|
-
const currentAliases = (await resolveLaunchAliases(registry, staticAliases, { cwd: run.store.cwd, projectTrusted: trustedProject, rootModel, knownModels, availableModels, signal: run.abortController.signal }, availableModels, knownModels, settingsPath)).aliases;
|
|
1576
|
-
const blockedAliases = new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
1577
|
-
const blockedAliasTargets = Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
1578
|
-
const refreshed = resumedSnapshotSettings(loaded.snapshot, resolution, currentAliases);
|
|
1579
|
-
const snapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, ...refreshed, modelAliases: currentAliases });
|
|
2053
|
+
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 });
|
|
1580
2054
|
await run.store.saveSnapshot(snapshot);
|
|
1581
2055
|
scheduler.updateRunLimit(run.store.runId, snapshot.settings.concurrency);
|
|
1582
|
-
run.executor =
|
|
2056
|
+
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) });
|
|
1583
2057
|
run.executor.setRunContext(workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, run.abortController.signal));
|
|
1584
2058
|
const drift = aliasDrift(previousAliases, currentAliases);
|
|
1585
2059
|
if (drift.length) await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
|
|
1586
2060
|
};
|
|
1587
|
-
const
|
|
2061
|
+
const recoveryUi = (context: unknown): { hasUI: boolean; ui: { select?: (prompt: string, options: string[]) => Promise<string | undefined> } } => {
|
|
2062
|
+
const host = object(context) ? context : undefined;
|
|
2063
|
+
const ui = host && object(host.ui) ? host.ui as { select?: (prompt: string, options: string[]) => Promise<string | undefined> } : {};
|
|
2064
|
+
return { hasUI: host?.hasUI === true, ui };
|
|
2065
|
+
};
|
|
2066
|
+
type ColdResumeResult = { value: JsonValue; resultPath: string };
|
|
2067
|
+
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>> }, modeOverride?: boolean, waitForCompletion = true): Promise<ColdResumeResult | undefined> => {
|
|
1588
2068
|
const loaded = await run.store.load();
|
|
2069
|
+
const foreground = modeOverride ?? loaded.snapshot.launchMode === "foreground";
|
|
2070
|
+
if (loaded.run.activeShells !== undefined) {
|
|
2071
|
+
await persistRunState(run.store, run.metadata, (current) => {
|
|
2072
|
+
const next = { ...current };
|
|
2073
|
+
delete next.activeShells;
|
|
2074
|
+
return next;
|
|
2075
|
+
});
|
|
2076
|
+
}
|
|
1589
2077
|
await run.store.validateRetrySource();
|
|
1590
2078
|
await run.store.validateBorrowedWorktrees();
|
|
1591
2079
|
if (loaded.snapshot.identityVersion !== LAUNCH_SNAPSHOT_IDENTITY_VERSION) throw new WorkflowError("RESUME_INCOMPATIBLE", "Workflow launch snapshot identity version is incompatible");
|
|
@@ -1593,32 +2081,16 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1593
2081
|
if ((loaded.snapshot.projectRoles?.length ?? 0) > 0 && !trustedProject) throw new WorkflowError("RESUME_INCOMPATIBLE", "Cannot restore project roles in an untrusted project");
|
|
1594
2082
|
const missingRole = loaded.snapshot.agentTypes.find((role) => !loaded.snapshot.roles?.[role]);
|
|
1595
2083
|
if (missingRole) throw new WorkflowError("RESUME_INCOMPATIBLE", `Role definition is missing from the launch snapshot: ${missingRole}`);
|
|
1596
|
-
const active = new Set(pi.getActiveTools().filter((tool) => !["workflow", "workflow_respond", "workflow_stop", "workflow_resume", "workflow_retry", "workflow_catalog"].includes(tool)));
|
|
1597
|
-
const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
|
|
1598
|
-
if (missing) throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
|
|
1599
|
-
const settingsPath = workflowSettingsPath(extensionAgentDir);
|
|
1600
|
-
const resolution = resolveWorkflowSettings(run.store.cwd, trustedProject, settingsPath);
|
|
1601
|
-
const currentPolicy = resolveAgentResourcePolicy(run.store.cwd, trustedProject, settingsPath);
|
|
1602
|
-
const staticAliases = resolution.effective.modelAliases ?? {};
|
|
1603
|
-
const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
|
|
1604
2084
|
const rootModel = context?.model ? { ...run.model, provider: context.model.provider, model: context.model.id } : run.model;
|
|
1605
|
-
const inventory = modelInventory(rootModel, context?.modelRegistry);
|
|
1606
|
-
const knownModels = context?.modelRegistry ? inventory.knownModels : new Set([...loaded.snapshot.models, ...inventory.knownModels]);
|
|
1607
|
-
const availableModels = context?.modelRegistry ? inventory.availableModels : new Set([...loaded.snapshot.models, ...inventory.availableModels]);
|
|
1608
2085
|
const controller = new AbortController();
|
|
1609
2086
|
if (context?.signal?.aborted) controller.abort(); else { context?.signal?.addEventListener("abort", () => { controller.abort(); }, { once: true }); }
|
|
1610
2087
|
run.abortController = controller;
|
|
1611
|
-
const currentAliases
|
|
1612
|
-
|
|
1613
|
-
const
|
|
1614
|
-
|
|
1615
|
-
const script = launchScriptForSnapshot(loaded.snapshot, registry);
|
|
1616
|
-
preflight(script, { models: availableModels, tools: active, agentTypes: new Set(loaded.snapshot.agentTypes), modelAliases: resumeAliases, knownModels, settingsPath, skipModelAvailability: true }, loaded.snapshot.schemas, loaded.snapshot.metadata, true);
|
|
1617
|
-
const refreshed = resumedSnapshotSettings(loaded.snapshot, resolution, currentAliases);
|
|
1618
|
-
const snapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, ...refreshed, modelAliases: currentAliases });
|
|
1619
|
-
await run.store.saveSnapshot(snapshot);
|
|
2088
|
+
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 });
|
|
2089
|
+
if (!script) throw new WorkflowError("INTERNAL_ERROR", "Resume preflight did not produce a launch script");
|
|
2090
|
+
const persistedSnapshot = modeOverride === undefined ? snapshot : createLaunchSnapshot({ ...snapshot, launchMode: foreground ? "foreground" : "background" });
|
|
2091
|
+
await run.store.saveSnapshot(persistedSnapshot);
|
|
1620
2092
|
scheduler.updateRunLimit(run.store.runId, snapshot.settings.concurrency);
|
|
1621
|
-
run.executor =
|
|
2093
|
+
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) });
|
|
1622
2094
|
const drift = aliasDrift(previousAliases, currentAliases);
|
|
1623
2095
|
if (drift.length) await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
|
|
1624
2096
|
const runContext = workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, controller.signal);
|
|
@@ -1627,39 +2099,13 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1627
2099
|
try { variables = await resolveWorkflowVariables(runContext, controller, registry); }
|
|
1628
2100
|
catch (error) {
|
|
1629
2101
|
const typed = asWorkflowError(error);
|
|
1630
|
-
if (!
|
|
2102
|
+
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) => persistedFailure({ ...current }, typed)); await eventPublisher.runFailed(run.store, run.metadata, typed, run.lifecycle.state === "interrupted" ? "interrupted" : "failed"); run.update?.(workflowToolUpdate(persisted)); if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state)) await createWorkflowFailureDiagnostics(run.store, run.metadata, typed, persisted).then((diagnostic) => { deliverFailure(pi, diagnostic); }).catch(() => undefined); }
|
|
1631
2103
|
await cleanupTerminalRun(run.store.runId);
|
|
1632
2104
|
throw typed;
|
|
1633
2105
|
}
|
|
1634
2106
|
await scheduler.cancelRun(run.store.runId);
|
|
1635
2107
|
await run.lifecycle.resume();
|
|
1636
|
-
const execution = runWorkflow(script, loaded.snapshot.args, withWorkflowFunctions({ shell: (command, options, signal, identity) => shellForRun(run.store, run.metadata, run.lifecycle, command, options, signal, identity), agent: async (
|
|
1637
|
-
await run.lifecycle.enter();
|
|
1638
|
-
try {
|
|
1639
|
-
const path = agentIdentityPath(identity);
|
|
1640
|
-
const replayed = await run.store.replay(path);
|
|
1641
|
-
if (replayed) {
|
|
1642
|
-
return replayed.value;
|
|
1643
|
-
}
|
|
1644
|
-
const worktree = agentWorktree(identity);
|
|
1645
|
-
const cwd = worktree.worktreeOwner ? (await persistWorktree(run.store, run.metadata, worktree.worktreeOwner)).cwd : run.store.cwd;
|
|
1646
|
-
const role = typeof options.role === "string" ? options.role : undefined;
|
|
1647
|
-
const model = typeof options.model === "string" ? options.model : undefined;
|
|
1648
|
-
const thinking = parseThinking(options.thinking);
|
|
1649
|
-
const requestedLabel = typeof options.label === "string" ? options.label : undefined;
|
|
1650
|
-
const resolved = run.executor.resolve({ label: requestedLabel ?? role ?? "agent", workflowName: run.metadata.name, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(Array.isArray(options.tools) ? { tools: options.tools as string[] } : {}) });
|
|
1651
|
-
const label = displayAgentName(requestedLabel, role, resolved.model);
|
|
1652
|
-
const tools = resolved.tools;
|
|
1653
|
-
const schema = object(options.outputSchema) ? options.outputSchema : undefined;
|
|
1654
|
-
const spawned = scheduler.spawn(run.store.runId, prompt, { label, ...(requestedLabel ? { requestedLabel } : {}), ...(identity.parentBreadcrumb ? { parentBreadcrumb: identity.parentBreadcrumb } : {}), cwd, tools, ...worktree, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(schema ? { schema } : {}), ...(typeof options.retries === "number" ? { retries: options.retries } : {}), ...(positiveInteger(options.timeoutMs) || options.timeoutMs === null ? { timeoutMs: options.timeoutMs } : {}), agentOptions: options, agentIdentity: identity });
|
|
1655
|
-
const cancel = () => { scheduler.cancel(spawned.id); };
|
|
1656
|
-
signal.addEventListener("abort", cancel, { once: true });
|
|
1657
|
-
const outcome = await spawned.result.finally(() => { signal.removeEventListener("abort", cancel); });
|
|
1658
|
-
if (!outcome.ok) throw new WorkflowError(outcome.error.code as WorkflowErrorCode, outcome.error.message);
|
|
1659
|
-
await run.store.complete(path, outcome.value);
|
|
1660
|
-
return outcome.value;
|
|
1661
|
-
} finally { await run.lifecycle.leave(); }
|
|
1662
|
-
}, worktree: async (owner) => resolveWorktree(run.store, run.metadata, owner), checkpoint: checkpointBridge(run.store.runId, run.store, run.metadata, false, hasUI ? ui : undefined), phase: phaseBridge(run.store, run.metadata, run.lifecycle), log: logBridge(run.lifecycle, run.metadata.name) }, run.store, runContext, variables, registry), controller.signal);
|
|
2108
|
+
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, foreground, hasUI ? ui : undefined), phase: phaseBridge(run.store, run.metadata, run.lifecycle), log: logBridge(run.lifecycle, run.metadata.name) }, run.store, runContext, variables, registry), controller.signal);
|
|
1663
2109
|
run.execution = execution;
|
|
1664
2110
|
const completion = execution.result.then(async (value) => {
|
|
1665
2111
|
await scheduler.flush();
|
|
@@ -1672,17 +2118,28 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1672
2118
|
await scheduler.flush();
|
|
1673
2119
|
const typed = error instanceof WorkflowError ? error : new WorkflowError(errorCode(error) ?? "INTERNAL_ERROR", errorText(error));
|
|
1674
2120
|
if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state)) await run.lifecycle.terminal(typed.code === "BUDGET_EXHAUSTED" ? "budget_exhausted" : "failed", typed.code);
|
|
1675
|
-
const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot()
|
|
2121
|
+
const persisted = await persistRunState(run.store, run.metadata, (current) => persistedFailure({ ...current, ...run.budget.snapshot() }, typed));
|
|
1676
2122
|
const state = run.lifecycle.state === "stopped" || run.lifecycle.state === "interrupted" || run.lifecycle.state === "budget_exhausted" ? run.lifecycle.state : "failed";
|
|
1677
2123
|
if (state === "failed") retryReservations.delete(persisted.retry?.lineageRootRunId ?? run.store.runId);
|
|
1678
2124
|
await eventPublisher.runFailed(run.store, run.metadata, typed, state);
|
|
1679
2125
|
run.update?.(workflowToolUpdate(persisted));
|
|
1680
|
-
if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state)) await createWorkflowFailureDiagnostics(run.store, run.metadata, typed, persisted).
|
|
2126
|
+
if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state)) { const diagnostic = await createWorkflowFailureDiagnostics(run.store, run.metadata, typed, persisted); Object.defineProperty(typed, WORKFLOW_FAILURE_DIAGNOSTICS, { value: diagnostic }); }
|
|
2127
|
+
throw typed;
|
|
1681
2128
|
}).finally(() => cleanupTerminalRun(run.store.runId));
|
|
1682
2129
|
run.completion = completion;
|
|
1683
|
-
|
|
2130
|
+
if (!foreground || !waitForCompletion) {
|
|
2131
|
+
void completion.then(async ({ value, resultPath }) => {
|
|
2132
|
+
deliver(pi, completionDelivery(run.metadata.name, value, resultPath, await run.store.changedWorktrees()));
|
|
2133
|
+
}, (error: unknown) => {
|
|
2134
|
+
const diagnostic = failureDiagnosticsFrom(error);
|
|
2135
|
+
if (diagnostic) deliverFailure(pi, diagnostic);
|
|
2136
|
+
else deliver(pi, formatWorkflowFailureDeliveryFallback(run.metadata.name, run.store.runId, run.store.directory, error));
|
|
2137
|
+
});
|
|
2138
|
+
return undefined;
|
|
2139
|
+
}
|
|
2140
|
+
return completion;
|
|
1684
2141
|
};
|
|
1685
|
-
const applyBudgetDecision = async (request: BudgetApprovalRequest, approved: boolean, context?: unknown, signal?: AbortSignal): Promise<BudgetDecisionResult> => {
|
|
2142
|
+
const applyBudgetDecision = async (request: BudgetApprovalRequest, approved: boolean, context?: unknown, signal?: AbortSignal, waitForCompletion = true): Promise<BudgetDecisionResult> => {
|
|
1686
2143
|
const run = runs.get(request.runId);
|
|
1687
2144
|
if (!run) throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run: ${request.runId}`);
|
|
1688
2145
|
if (!approved) return { state: "budget_exhausted", approved: false };
|
|
@@ -1691,14 +2148,30 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1691
2148
|
const runtime = new WorkflowBudgetRuntime(nextBudget, nextVersion, request.consumed, run.budget.events, { active: false });
|
|
1692
2149
|
run.budget = runtime;
|
|
1693
2150
|
await persistRunState(run.store, run.metadata, (current) => { const next = { ...current, ...runtime.snapshot(), budgetVersion: nextVersion }; if (nextBudget) next.budget = nextBudget; else delete next.budget; return next; });
|
|
1694
|
-
|
|
2151
|
+
const { hasUI, ui } = recoveryUi(context);
|
|
2152
|
+
const completed = await coldResumeRun(run, hasUI, ui, projectTrusted(context), { ...resumeHostContext(context), ...(signal ? { signal } : {}) }, undefined, waitForCompletion);
|
|
2153
|
+
if (completed) return { state: "completed", approved: true, value: completed.value, run: (await run.store.load()).run };
|
|
1695
2154
|
return { state: "running", approved: true };
|
|
1696
2155
|
};
|
|
1697
|
-
const resumeWorkflowRun = async (runId: string, rawPatch?: unknown, context?: unknown, signal?: AbortSignal): Promise<Record<string, JsonValue>> => {
|
|
2156
|
+
const resumeWorkflowRun = async (runId: string, rawPatch?: unknown, context?: unknown, signal?: AbortSignal, modeOverride?: boolean, waitForCompletion = true): Promise<Record<string, JsonValue>> => {
|
|
1698
2157
|
const run = runs.get(runId);
|
|
1699
|
-
if (!run)
|
|
2158
|
+
if (!run) {
|
|
2159
|
+
const host = object(context) ? context : {};
|
|
2160
|
+
const cwd = typeof host.cwd === "string" ? host.cwd : undefined;
|
|
2161
|
+
const sessionManager = object(host.sessionManager) ? host.sessionManager : undefined;
|
|
2162
|
+
const sessionId = typeof sessionManager?.getSessionId === "function" ? String(Reflect.apply(sessionManager.getSessionId, sessionManager, [])) : undefined;
|
|
2163
|
+
if (cwd && sessionId) {
|
|
2164
|
+
try {
|
|
2165
|
+
const state = (await new RunStore(cwd, sessionId, runId, home).load()).run.state;
|
|
2166
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", workflowRecoveryGuidance("resume", state));
|
|
2167
|
+
} catch (error) {
|
|
2168
|
+
if (error instanceof WorkflowError) throw error;
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run ${runId} in the current project and Pi session`);
|
|
2172
|
+
}
|
|
1700
2173
|
const loaded = await run.store.load();
|
|
1701
|
-
if (loaded.run.state !== "budget_exhausted") throw new WorkflowError("RESUME_INCOMPATIBLE", "
|
|
2174
|
+
if (loaded.run.state !== "budget_exhausted") throw new WorkflowError("RESUME_INCOMPATIBLE", workflowRecoveryGuidance("resume", loaded.run.state));
|
|
1702
2175
|
const currentBudget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
|
|
1703
2176
|
const patch = rawPatch === undefined ? {} : validateBudgetPatch(rawPatch);
|
|
1704
2177
|
const nextBudget = mergeBudget(currentBudget, patch);
|
|
@@ -1719,11 +2192,13 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1719
2192
|
run.budget = runtime;
|
|
1720
2193
|
await persistRunState(run.store, run.metadata, (current) => { const next = { ...current, ...runtime.snapshot(), budgetVersion: nextVersion }; if (nextBudget) next.budget = nextBudget; else delete next.budget; return next; });
|
|
1721
2194
|
}
|
|
1722
|
-
|
|
2195
|
+
const { hasUI, ui } = recoveryUi(context);
|
|
2196
|
+
const completed = await coldResumeRun(run, hasUI, ui, projectTrusted(context), { ...resumeHostContext(context), ...(signal ? { signal } : {}) }, modeOverride, waitForCompletion);
|
|
2197
|
+
if (completed) return { state: "completed", runId, value: completed.value, run: (await run.store.load()).run as unknown as JsonValue };
|
|
1723
2198
|
return { state: "running" };
|
|
1724
2199
|
};
|
|
1725
2200
|
const retryReservations = new Set<string>();
|
|
1726
|
-
const retryWorkflowRun = async (runId: string, context: unknown, signal?: AbortSignal): Promise<{ runId: string; parentRunId: string; state: "running" }> => {
|
|
2201
|
+
const retryWorkflowRun = async (runId: string, context: unknown, signal?: AbortSignal, modeOverride?: boolean): Promise<{ runId: string; parentRunId: string; state: "running" | "completed"; value?: JsonValue; run?: PersistedRun }> => {
|
|
1727
2202
|
if (typeof runId !== "string" || !runId.trim()) throw new WorkflowError("RESUME_INCOMPATIBLE", "workflow_retry requires an explicit run ID");
|
|
1728
2203
|
const host = object(context) ? context : {};
|
|
1729
2204
|
const cwd = typeof host.cwd === "string" ? host.cwd : undefined;
|
|
@@ -1733,8 +2208,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1733
2208
|
await ensureSessionLease(cwd, sessionId);
|
|
1734
2209
|
const sourceStore = new RunStore(cwd, sessionId, runId, home);
|
|
1735
2210
|
let loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> };
|
|
1736
|
-
try { loaded = await sourceStore.load(); } catch (error) { throw new WorkflowError("RESUME_INCOMPATIBLE", `
|
|
1737
|
-
if (loaded.run.state !== "failed") throw new WorkflowError("RESUME_INCOMPATIBLE",
|
|
2211
|
+
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)}`); }
|
|
2212
|
+
if (loaded.run.state !== "failed") throw new WorkflowError("RESUME_INCOMPATIBLE", workflowRecoveryGuidance("retry", loaded.run.state));
|
|
1738
2213
|
if (loaded.run.retry && (typeof loaded.run.retry.sourceRunId !== "string" || !loaded.run.retry.sourceRunId || typeof loaded.run.retry.lineageRootRunId !== "string" || !loaded.run.retry.lineageRootRunId || !Array.isArray(loaded.run.retry.completedPaths) || loaded.run.retry.completedPaths.some((path) => typeof path !== "string") || !Array.isArray(loaded.run.retry.incompletePaths) || loaded.run.retry.incompletePaths.some((path) => typeof path !== "string") || !Array.isArray(loaded.run.retry.namedWorktrees) || loaded.run.retry.namedWorktrees.some((name) => typeof name !== "string"))) throw new WorkflowError("RESUME_INCOMPATIBLE", "The source retry provenance is incomplete");
|
|
1739
2214
|
const lineageRootRunId = loaded.run.retry?.lineageRootRunId ?? loaded.run.id;
|
|
1740
2215
|
if (retryReservations.has(lineageRootRunId)) throw new WorkflowError("RESUME_INCOMPATIBLE", `An active retry already owns lineage ${lineageRootRunId}`);
|
|
@@ -1760,27 +2235,10 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1760
2235
|
if ((loaded.snapshot.projectRoles?.length ?? 0) > 0 && !trustedProject) throw new WorkflowError("RESUME_INCOMPATIBLE", "Cannot restore project roles in an untrusted project");
|
|
1761
2236
|
const missingRole = loaded.snapshot.agentTypes.find((role) => !loaded.snapshot.roles?.[role]);
|
|
1762
2237
|
if (missingRole) throw new WorkflowError("RESUME_INCOMPATIBLE", `Role definition is missing from the launch snapshot: ${missingRole}`);
|
|
1763
|
-
const active = new Set<string>(pi.getActiveTools().filter((tool) => !["workflow", "workflow_respond", "workflow_stop", "workflow_resume", "workflow_retry", "workflow_catalog"].includes(tool)));
|
|
1764
|
-
const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
|
|
1765
|
-
if (missing) throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
|
|
1766
|
-
const settingsPath = workflowSettingsPath(extensionAgentDir);
|
|
1767
|
-
const resolution = resolveWorkflowSettings(cwd, trustedProject, settingsPath);
|
|
1768
|
-
const currentPolicy = resolveAgentResourcePolicy(cwd, trustedProject, settingsPath);
|
|
1769
|
-
const staticAliases = resolution.effective.modelAliases ?? {};
|
|
1770
|
-
const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
|
|
1771
2238
|
const modelRegistry = contextHostCapabilities(context).modelRegistry;
|
|
1772
2239
|
const hostModel = object(host.model) && typeof host.model.provider === "string" && typeof host.model.id === "string" ? { provider: host.model.provider, id: host.model.id } : { provider: "", id: "" };
|
|
1773
2240
|
const rootModel: ModelSpec = { provider: hostModel.provider, model: hostModel.id, thinking: pi.getThinkingLevel() };
|
|
1774
|
-
const
|
|
1775
|
-
const knownModels = modelRegistry ? inventory.knownModels : new Set([...loaded.snapshot.models, ...inventory.knownModels]);
|
|
1776
|
-
const availableModels = modelRegistry ? inventory.availableModels : new Set([...loaded.snapshot.models, ...inventory.availableModels]);
|
|
1777
|
-
const resolvedAliases = await resolveLaunchAliases(registry, staticAliases, { cwd, projectTrusted: trustedProject, rootModel, knownModels, availableModels, signal: signal ?? new AbortController().signal }, availableModels, knownModels, settingsPath);
|
|
1778
|
-
const currentAliases = resolvedAliases.aliases;
|
|
1779
|
-
const resumeAliases = { ...previousAliases, ...currentAliases };
|
|
1780
|
-
const blockedAliases = new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
1781
|
-
const blockedAliasTargets = Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
1782
|
-
const script = launchScriptForSnapshot(loaded.snapshot, registry);
|
|
1783
|
-
preflight(script, { models: availableModels, tools: active, agentTypes: new Set(loaded.snapshot.agentTypes), modelAliases: resumeAliases, knownModels, settingsPath, skipModelAvailability: true }, loaded.snapshot.schemas, loaded.snapshot.metadata, true);
|
|
2241
|
+
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 });
|
|
1784
2242
|
await sourceStore.validateNamedWorktrees();
|
|
1785
2243
|
for (const name of loaded.run.retry?.namedWorktrees ?? []) await sourceStore.resolveNamedWorktree(name);
|
|
1786
2244
|
const completedPaths = (await sourceStore.replayableOperations()).map(({ path }) => path);
|
|
@@ -1789,8 +2247,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1789
2247
|
const budget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
|
|
1790
2248
|
const childRunId = randomUUID();
|
|
1791
2249
|
const childStore = new RunStore(cwd, sessionId, childRunId, home);
|
|
1792
|
-
const
|
|
1793
|
-
const childSnapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, ...refreshed, modelAliases: currentAliases });
|
|
2250
|
+
const childSnapshot = childBaseSnapshot;
|
|
1794
2251
|
const childBudget = new WorkflowBudgetRuntime(budget, loaded.run.budgetVersion ?? 1, loaded.run.usage, loaded.run.budgetEvents);
|
|
1795
2252
|
const childInitialBudget = childBudget.snapshot();
|
|
1796
2253
|
const retry: WorkflowRetryProvenance = { sourceRunId: loaded.run.id, lineageRootRunId, completedPaths, incompletePaths, namedWorktrees };
|
|
@@ -1801,16 +2258,21 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1801
2258
|
const abortController = new AbortController();
|
|
1802
2259
|
const providerErrorRecovery = createProviderErrorRecovery(context, availableModels, () => { abortController.abort(); });
|
|
1803
2260
|
const providerPause = async () => { deliver(pi, `Workflow ${loaded.snapshot.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
|
|
1804
|
-
const childRun = { executor:
|
|
2261
|
+
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 } : {}) };
|
|
1805
2262
|
runs.set(childRunId, childRun);
|
|
1806
2263
|
scheduler.addRun(childRunId, loaded.snapshot.settings.concurrency, () => { childBudget.checkAgentLaunch(); });
|
|
1807
2264
|
await eventPublisher.runStarted(childStore, loaded.snapshot.metadata);
|
|
1808
|
-
|
|
2265
|
+
const { hasUI, ui } = recoveryUi(context);
|
|
2266
|
+
const completed = await coldResumeRun(childRun, hasUI, ui, trustedProject, { model: hostModel, modelRegistry, resolvedAliases: currentAliases, blockedAliases, blockedAliasTargets, ...(signal ? { signal } : {}) }, modeOverride);
|
|
1809
2267
|
const completion = runs.get(childRunId)?.completion;
|
|
1810
2268
|
if (completion) {
|
|
1811
2269
|
childStarted = true;
|
|
1812
2270
|
void completion.then(() => { retryReservations.delete(lineageRootRunId); }, () => { retryReservations.delete(lineageRootRunId); });
|
|
2271
|
+
} else if (completed) {
|
|
2272
|
+
childStarted = true;
|
|
2273
|
+
retryReservations.delete(lineageRootRunId);
|
|
1813
2274
|
}
|
|
2275
|
+
if (completed) return { runId: childRunId, parentRunId: loaded.run.id, state: "completed", value: completed.value, run: (await childStore.load()).run };
|
|
1814
2276
|
return { runId: childRunId, parentRunId: loaded.run.id, state: "running" };
|
|
1815
2277
|
} finally {
|
|
1816
2278
|
if (!childStarted) retryReservations.delete(lineageRootRunId);
|
|
@@ -1822,19 +2284,23 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1822
2284
|
description: "Retry a failed workflow run by replaying its completed structural operations",
|
|
1823
2285
|
parameters: WORKFLOW_RETRY_PARAMETERS,
|
|
1824
2286
|
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
1825
|
-
try { const result = await retryWorkflowRun(params.runId, ctx, signal); return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result }; }
|
|
2287
|
+
try { const result = await retryWorkflowRun(params.runId, ctx, signal, params.foreground); return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result }; }
|
|
1826
2288
|
catch (error) { throw mainAgentError(error); }
|
|
1827
2289
|
},
|
|
2290
|
+
renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_retry", args, theme)); },
|
|
2291
|
+
renderResult(result, options, theme, context) { return workflowCatalogBlock(workflowControlResult("workflow_retry", context.args, result, options.expanded, theme, context.isError), options.expanded); },
|
|
1828
2292
|
});
|
|
1829
2293
|
pi.registerTool({
|
|
1830
2294
|
name: "workflow_resume",
|
|
1831
2295
|
label: "Workflow Resume",
|
|
1832
2296
|
description: "Resume an exhausted workflow with unchanged or patched aggregate budgets",
|
|
1833
|
-
parameters: Type.Object({ runId: Type.String(), budget: Type.Optional(Type.Unknown()) }, { additionalProperties: false }),
|
|
2297
|
+
parameters: Type.Object({ runId: Type.String(), budget: Type.Optional(Type.Unknown()), foreground: Type.Optional(Type.Boolean({ description: "Override the source launch mode for this recovery" })) }, { additionalProperties: false }),
|
|
1834
2298
|
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
1835
|
-
try { const result = await resumeWorkflowRun(params.runId, params.budget, ctx, signal); return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result }; }
|
|
2299
|
+
try { const result = await resumeWorkflowRun(params.runId, params.budget, ctx, signal, params.foreground); return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result }; }
|
|
1836
2300
|
catch (error) { throw mainAgentError(error); }
|
|
1837
2301
|
},
|
|
2302
|
+
renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_resume", args, theme)); },
|
|
2303
|
+
renderResult(result, options, theme, context) { return workflowCatalogBlock(workflowControlResult("workflow_resume", context.args, result, options.expanded, theme, context.isError), options.expanded); },
|
|
1838
2304
|
});
|
|
1839
2305
|
pi.on("session_start", async (_event, ctx) => {
|
|
1840
2306
|
if (sessionStarted) return;
|
|
@@ -1851,10 +2317,23 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1851
2317
|
if (loaded.run.state === "completed" || loaded.run.state === "failed" || loaded.run.state === "stopped") { terminalRunStates.set(runId, loaded.run.state); continue; }
|
|
1852
2318
|
if (loaded.run.state !== "interrupted" && loaded.run.state !== "budget_exhausted") {
|
|
1853
2319
|
const previousState = loaded.run.state;
|
|
1854
|
-
await store.updateState((current) =>
|
|
2320
|
+
await store.updateState((current) => {
|
|
2321
|
+
if (["completed", "failed", "stopped", "interrupted", "budget_exhausted"].includes(current.state)) return current;
|
|
2322
|
+
const next = { ...current, state: "interrupted" as const };
|
|
2323
|
+
delete next.activeShells;
|
|
2324
|
+
return next;
|
|
2325
|
+
});
|
|
1855
2326
|
loaded = { ...loaded, run: (await store.load()).run };
|
|
1856
2327
|
await eventPublisher.runState(store, loaded.snapshot.metadata, previousState, "interrupted", "session_shutdown");
|
|
1857
2328
|
loaded = { ...loaded, run: (await store.load()).run };
|
|
2329
|
+
} else if (loaded.run.activeShells !== undefined) {
|
|
2330
|
+
await store.updateState((current) => {
|
|
2331
|
+
if (["completed", "failed", "stopped"].includes(current.state)) return current;
|
|
2332
|
+
const next = { ...current };
|
|
2333
|
+
delete next.activeShells;
|
|
2334
|
+
return next;
|
|
2335
|
+
});
|
|
2336
|
+
loaded = { ...loaded, run: (await store.load()).run };
|
|
1858
2337
|
}
|
|
1859
2338
|
const model = modelSpec(loaded.snapshot.models[0] ?? "", { provider: ctx.model?.provider ?? "", model: ctx.model?.id ?? "", thinking: pi.getThinkingLevel() });
|
|
1860
2339
|
const budget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
|
|
@@ -1865,7 +2344,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1865
2344
|
const roleDefinitions = loaded.snapshot.roles ?? {};
|
|
1866
2345
|
const abortController = new AbortController();
|
|
1867
2346
|
const providerErrorRecovery = createProviderErrorRecovery(ctx, new Set(loaded.snapshot.models), () => { abortController.abort(); });
|
|
1868
|
-
runs.set(runId, { executor:
|
|
2347
|
+
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 } : {}) });
|
|
1869
2348
|
for (const checkpoint of await store.awaitingCheckpoints()) deliver(pi, `Workflow ${loaded.snapshot.metadata.name} checkpoint ${checkpoint.name}: ${checkpoint.prompt}\nContext: ${JSON.stringify(checkpoint.context)}\nRespond with workflow_respond.`);
|
|
1870
2349
|
for (const decision of await store.pendingWorkflowDecisions()) deliver(pi, budgetDecisionDelivery(loaded.snapshot.metadata, decision));
|
|
1871
2350
|
scheduler.restoreRun(runId, loaded.snapshot.settings.concurrency, loaded.snapshot.identityVersion === LAUNCH_SNAPSHOT_IDENTITY_VERSION ? await store.loadOwnership() : [], () => runs.get(runId)?.budget.checkAgentLaunch());
|
|
@@ -1880,7 +2359,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1880
2359
|
if (choice && choice !== "Skip") {
|
|
1881
2360
|
const toResume = choice === "Resume all" ? interrupted : interrupted.filter((_, i) => labels[i] === choice);
|
|
1882
2361
|
for (const run of toResume) {
|
|
1883
|
-
try { await coldResumeRun(run, true, ctx.ui, projectTrusted(ctx), ctx); ctx.ui.notify(`Resumed workflow ${run.metadata.name}.`, "info"); }
|
|
2362
|
+
try { await coldResumeRun(run, true, ctx.ui, projectTrusted(ctx), ctx, undefined, false); ctx.ui.notify(`Resumed workflow ${run.metadata.name}.`, "info"); }
|
|
1884
2363
|
catch (err) { ctx.ui.notify(`Cannot resume ${run.metadata.name}: ${err instanceof Error ? err.message : String(err)}`, "warning"); }
|
|
1885
2364
|
}
|
|
1886
2365
|
}
|
|
@@ -1913,7 +2392,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1913
2392
|
const inventory = modelInventory(rootModel, modelRegistry);
|
|
1914
2393
|
const knownModels = inventory.knownModels;
|
|
1915
2394
|
const availableModels = inventory.availableModels;
|
|
1916
|
-
const rootTools = pi.getActiveTools().filter((name) => !
|
|
2395
|
+
const rootTools = pi.getActiveTools().filter((name) => !INTERNAL_WORKFLOW_TOOLS.includes(name));
|
|
1917
2396
|
const trustedProject = projectTrusted(ctx);
|
|
1918
2397
|
const launchCwd = typeof ctx.cwd === "string" ? ctx.cwd : process.cwd();
|
|
1919
2398
|
const launch = workflowLaunchSettings(launchCwd, trustedProject, settingsPath, params.concurrency);
|
|
@@ -1937,45 +2416,33 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1937
2416
|
const projectRoles = roleNames.filter((role) => projectAgentDefinitions[role] !== undefined);
|
|
1938
2417
|
const roleModels = roleNames.flatMap((role) => { const model = agentDefinitions[role]?.model; return model ? [modelCapability(model, modelAliases, knownModels, settingsPath)] : []; });
|
|
1939
2418
|
const snapshotModels = [...new Set([rootModelName, ...checked.referenced.models, ...roleModels])];
|
|
1940
|
-
const snapshot = createLaunchSnapshot({ script, args, metadata: checked.metadata, settings, settingsPath, settingsSources: { ...launch.resolution.sources, concurrency: params.concurrency === undefined ? launch.resolution.sources.concurrency : "per-run options" }, ...(functionName ? { launchKind: "function" as const, functionName } : {}), ...(Object.keys(modelAliases).length ? { modelAliases } : {}), ...(budget ? { budget } : {}), ...(checked.referenced.phases.length ? { phases: checked.referenced.phases } : {}), models: snapshotModels, tools: rootTools, agentTypes: checked.referenced.agentTypes, roles, projectRoles, schemas: checked.schemas });
|
|
2419
|
+
const snapshot = createLaunchSnapshot({ script, args, metadata: checked.metadata, launchMode: params.foreground ? "foreground" : "background", 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 });
|
|
2420
|
+
let persistedSnapshot = snapshot;
|
|
2421
|
+
const captureFunctionRole = functionName ? async (role: string, model: ModelSpec): Promise<void> => {
|
|
2422
|
+
const definition = agentDefinitions[role];
|
|
2423
|
+
if (!definition) return;
|
|
2424
|
+
const modelName = `${model.provider}/${model.model}`;
|
|
2425
|
+
const hasProjectRole = projectAgentDefinitions[role] !== undefined;
|
|
2426
|
+
if (persistedSnapshot.roles?.[role] !== undefined && (!hasProjectRole || persistedSnapshot.projectRoles?.includes(role)) && persistedSnapshot.models.includes(modelName)) return;
|
|
2427
|
+
const roles = { ...(persistedSnapshot.roles ?? {}), [role]: definition };
|
|
2428
|
+
const projectRoles = hasProjectRole ? [...new Set([...(persistedSnapshot.projectRoles ?? []), role])] : persistedSnapshot.projectRoles ?? [];
|
|
2429
|
+
const models = [...new Set([...persistedSnapshot.models, modelName])];
|
|
2430
|
+
persistedSnapshot = createLaunchSnapshot({ ...persistedSnapshot, models, roles, projectRoles });
|
|
2431
|
+
await store.saveSnapshot(persistedSnapshot);
|
|
2432
|
+
} : undefined;
|
|
1941
2433
|
const budgetRuntime = new WorkflowBudgetRuntime(budget);
|
|
1942
2434
|
const initialBudget = budgetRuntime.snapshot();
|
|
1943
|
-
await store.create({ id: runId, workflowName: checked.metadata.name, cwd: ctx.cwd, sessionId: ctx.sessionManager.getSessionId(), state: "running", ...(parentRunId !== undefined ? { parentRunId } : {}), agents: [], nativeSessions: [], ...(budget ? { budget } : {}), budgetVersion: 1, ...initialBudget }, snapshot);
|
|
2435
|
+
await store.create({ id: runId, workflowName: checked.metadata.name, cwd: ctx.cwd, sessionId: ctx.sessionManager.getSessionId(), state: "running", ...(parentRunId !== undefined ? { parentRunId } : {}), agents: [], nativeSessions: [], delivery: params.foreground ? { mode: "foreground", state: "attached", toolCallId } : { mode: "background", state: "pending" }, ...(budget ? { budget } : {}), budgetVersion: 1, ...initialBudget }, snapshot);
|
|
2436
|
+
if (params.foreground) foregroundDeliveries.set(toolCallId, { store, inline: false });
|
|
1944
2437
|
const lifecycle = lifecycleFor(store, "running", budgetRuntime, checked.metadata);
|
|
1945
2438
|
const background = !params.foreground;
|
|
1946
2439
|
const providerPause = async () => { if (background) deliver(pi, `Workflow ${checked.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
|
|
1947
2440
|
const providerErrorRecovery = createProviderErrorRecovery(ctx, availableModels, () => { runController.abort(); });
|
|
1948
|
-
const executor =
|
|
2441
|
+
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 });
|
|
1949
2442
|
runs.set(runId, { executor, store, metadata: checked.metadata, model: rootModel, lifecycle, budget: budgetRuntime, abortController: runController, projectTrusted: () => projectTrusted(ctx), checkpointResolvers: new Map(), ...(providerErrorRecovery ? { providerErrorRecovery } : {}), ...(params.foreground && onUpdate ? { update: onUpdate } : {}) });
|
|
1950
2443
|
if (params.foreground && onUpdate) onUpdate(workflowToolUpdate((await store.load()).run));
|
|
1951
2444
|
scheduler.addRun(runId, settings.concurrency, () => runs.get(runId)?.budget.checkAgentLaunch());
|
|
1952
|
-
const execution = runWorkflow(script, args, withWorkflowFunctions({ shell: (command, options, signal, identity) => shellForRun(store, checked.metadata, lifecycle, command, options, signal, identity), agent: async (
|
|
1953
|
-
await lifecycle.enter();
|
|
1954
|
-
try {
|
|
1955
|
-
const path = agentIdentityPath(identity);
|
|
1956
|
-
const replayed = await store.replay(path);
|
|
1957
|
-
if (replayed) {
|
|
1958
|
-
return replayed.value;
|
|
1959
|
-
}
|
|
1960
|
-
const worktree = agentWorktree(identity);
|
|
1961
|
-
const cwd = worktree.worktreeOwner ? (await persistWorktree(store, checked.metadata, worktree.worktreeOwner)).cwd : ctx.cwd;
|
|
1962
|
-
const role = typeof options.role === "string" ? options.role : undefined;
|
|
1963
|
-
const model = typeof options.model === "string" ? options.model : undefined;
|
|
1964
|
-
const thinking = parseThinking(options.thinking);
|
|
1965
|
-
const requestedLabel = typeof options.label === "string" ? options.label : undefined;
|
|
1966
|
-
const resolved = executor.resolve({ label: requestedLabel ?? role ?? "agent", workflowName: checked.metadata.name, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(Array.isArray(options.tools) ? { tools: options.tools as string[] } : {}) });
|
|
1967
|
-
const label = displayAgentName(requestedLabel, role, resolved.model);
|
|
1968
|
-
const tools = resolved.tools;
|
|
1969
|
-
const schema = object(options.outputSchema) ? options.outputSchema : undefined;
|
|
1970
|
-
const spawned = scheduler.spawn(runId, prompt, { label, ...(requestedLabel ? { requestedLabel } : {}), ...(identity.parentBreadcrumb ? { parentBreadcrumb: identity.parentBreadcrumb } : {}), cwd, tools, ...worktree, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(schema ? { schema } : {}), ...(typeof options.retries === "number" ? { retries: options.retries } : {}), ...(positiveInteger(options.timeoutMs) || options.timeoutMs === null ? { timeoutMs: options.timeoutMs } : {}), agentOptions: options, agentIdentity: identity });
|
|
1971
|
-
const cancel = () => { scheduler.cancel(spawned.id); };
|
|
1972
|
-
if (agentSignal.aborted) cancel(); else agentSignal.addEventListener("abort", cancel, { once: true });
|
|
1973
|
-
const outcome = await spawned.result.finally(() => { agentSignal.removeEventListener("abort", cancel); });
|
|
1974
|
-
if (!outcome.ok) throw new WorkflowError(outcome.error.code as WorkflowErrorCode, outcome.error.message);
|
|
1975
|
-
await store.complete(path, outcome.value);
|
|
1976
|
-
return outcome.value;
|
|
1977
|
-
} finally { await lifecycle.leave(); }
|
|
1978
|
-
}, worktree: async (owner) => resolveWorktree(store, checked.metadata, owner), checkpoint: checkpointBridge(runId, store, checked.metadata, Boolean(params.foreground), params.foreground && ctx.hasUI ? ctx.ui : undefined, headless), phase: phaseBridge(store, checked.metadata, lifecycle), log: logBridge(lifecycle, checked.metadata.name) }, store, runContext, variables, registry), runController.signal);
|
|
2445
|
+
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, captureFunctionRole), worktree: async (owner) => resolveWorktree(store, checked.metadata, owner), checkpoint: checkpointBridge(runId, store, checked.metadata, Boolean(params.foreground), params.foreground && ctx.hasUI ? ctx.ui : undefined, headless), phase: phaseBridge(store, checked.metadata, lifecycle), log: logBridge(lifecycle, checked.metadata.name) }, store, runContext, variables, registry), runController.signal);
|
|
1979
2446
|
(runs.get(runId) as NonNullable<ReturnType<typeof runs.get>>).execution = execution;
|
|
1980
2447
|
await eventPublisher.runStarted(store, checked.metadata);
|
|
1981
2448
|
const finish = execution.result.then(async (value) => {
|
|
@@ -1989,7 +2456,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1989
2456
|
await scheduler.flush();
|
|
1990
2457
|
const typed = error instanceof WorkflowError ? error : new WorkflowError("INTERNAL_ERROR", String(error));
|
|
1991
2458
|
if (!["stopped", "interrupted", "budget_exhausted"].includes(lifecycle.state)) await lifecycle.terminal(typed.code === "CANCELLED" ? "stopped" : typed.code === "BUDGET_EXHAUSTED" ? "budget_exhausted" : "failed", typed.code);
|
|
1992
|
-
const persisted = await persistRunState(store, checked.metadata, (current) => ({ ...current, ...budgetRuntime.snapshot()
|
|
2459
|
+
const persisted = await persistRunState(store, checked.metadata, (current) => persistedFailure({ ...current, ...budgetRuntime.snapshot() }, typed));
|
|
1993
2460
|
const state = lifecycle.state === "stopped" || lifecycle.state === "interrupted" || lifecycle.state === "budget_exhausted" ? lifecycle.state : "failed";
|
|
1994
2461
|
await eventPublisher.runFailed(store, checked.metadata, typed, state);
|
|
1995
2462
|
const diagnostic = await createWorkflowFailureDiagnostics(store, checked.metadata, typed, persisted);
|
|
@@ -1999,16 +2466,37 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1999
2466
|
});
|
|
2000
2467
|
const completion = finish.finally(() => cleanupTerminalRun(runId));
|
|
2001
2468
|
(runs.get(runId) as NonNullable<ReturnType<typeof runs.get>>).completion = completion;
|
|
2469
|
+
const deliverFailureContent = (error: unknown): string => {
|
|
2470
|
+
const diagnostic = failureDiagnosticsFrom(error);
|
|
2471
|
+
return diagnostic ? formatWorkflowFailureDelivery(diagnostic) : formatWorkflowFailureDeliveryFallback(checked.metadata.name, runId, store.directory, error);
|
|
2472
|
+
};
|
|
2473
|
+
const queueForegroundDelivery = async (content: string): Promise<void> => {
|
|
2474
|
+
const delivery = foregroundDeliveries.get(toolCallId);
|
|
2475
|
+
if (!delivery) return;
|
|
2476
|
+
await store.updateState((current) => {
|
|
2477
|
+
if (!current.delivery || current.delivery.state === "delivered") return current;
|
|
2478
|
+
return { ...current, delivery: { ...current.delivery, mode: "background", state: "pending" } };
|
|
2479
|
+
});
|
|
2480
|
+
if (delivery.inline) return;
|
|
2481
|
+
scheduleForegroundDelivery(toolCallId, async () => {
|
|
2482
|
+
if (delivery.inline) return;
|
|
2483
|
+
pendingFailureDiagnostics.delete(toolCallId);
|
|
2484
|
+
await deliverTerminal(store, content);
|
|
2485
|
+
});
|
|
2486
|
+
};
|
|
2002
2487
|
if (background) {
|
|
2003
2488
|
void completion.then(async ({ value, resultPath }) => {
|
|
2004
|
-
|
|
2005
|
-
}, (error: unknown) => {
|
|
2006
|
-
|
|
2007
|
-
if (diagnostic) deliverFailure(pi, diagnostic);
|
|
2008
|
-
else deliver(pi, `Workflow ${checked.metadata.name} failed: ${formatWorkflowFailure(error)}`);
|
|
2489
|
+
await deliverTerminal(store, completionDelivery(checked.metadata.name, value, resultPath, await store.changedWorktrees()));
|
|
2490
|
+
}, async (error: unknown) => {
|
|
2491
|
+
await deliverTerminal(store, deliverFailureContent(error));
|
|
2009
2492
|
});
|
|
2010
2493
|
return { content: [{ type: "text" as const, text: JSON.stringify({ runId, state: "running" }) }], details: { runId, preview: `Started workflow ${runId}.` } };
|
|
2011
2494
|
}
|
|
2495
|
+
void completion.then(async ({ value, resultPath }) => {
|
|
2496
|
+
await queueForegroundDelivery(completionDelivery(checked.metadata.name, value, resultPath, await store.changedWorktrees()));
|
|
2497
|
+
}, async (error: unknown) => {
|
|
2498
|
+
await queueForegroundDelivery(deliverFailureContent(error));
|
|
2499
|
+
});
|
|
2012
2500
|
const { value } = await completion;
|
|
2013
2501
|
const run = (await store.load()).run;
|
|
2014
2502
|
return { content: [{ type: "text" as const, text: JSON.stringify(value) }, { type: "text" as const, text: `Workflow run ID: ${runId}` }], details: { runId, value, run } };
|
|
@@ -2050,7 +2538,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2050
2538
|
const loadStores = async () => {
|
|
2051
2539
|
const entries = await Promise.all((await listRunIds(ctx.cwd, ctx.sessionManager.getSessionId(), home)).map(async (runId) => {
|
|
2052
2540
|
const store = new RunStore(ctx.cwd, ctx.sessionManager.getSessionId(), runId, home);
|
|
2053
|
-
try { return { store, loaded:
|
|
2541
|
+
try { const loaded = await store.load(); return { store, loaded: { ...loaded, run: withLiveActivities(loaded.run) } }; }
|
|
2054
2542
|
catch { if (!await store.isComplete()) await store.delete(true).catch(() => undefined); return undefined; }
|
|
2055
2543
|
}));
|
|
2056
2544
|
return entries.filter((entry): entry is { store: RunStore; loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> } } => entry !== undefined);
|
|
@@ -2073,12 +2561,12 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2073
2561
|
return keepContext ? "dashboard" : "done";
|
|
2074
2562
|
}
|
|
2075
2563
|
if ((action === "budget-approve" || action === "budget-reject") && runId && rest[0]) {
|
|
2076
|
-
const result = await answerBudgetDecision(runId, rest[0], action === "budget-approve", true, ctx);
|
|
2564
|
+
const result = await answerBudgetDecision(runId, rest[0], action === "budget-approve", true, ctx, undefined, false);
|
|
2077
2565
|
ctx.ui.notify(result ? `Budget adjustment ${rest[0]} ${result.approved ? "approved" : "rejected"}.` : "Budget proposal is not pending.", result ? "info" : "warning");
|
|
2078
2566
|
return keepContext ? "dashboard" : "done";
|
|
2079
2567
|
}
|
|
2080
2568
|
if (action === "delete" && stored) {
|
|
2081
|
-
if (!
|
|
2569
|
+
if (!HARD_TERMINAL_RUN_STATES.has(stored.loaded.run.state)) { ctx.ui.notify("Stop the workflow before deleting it.", "warning"); return keepContext ? "dashboard" : "done"; }
|
|
2082
2570
|
if (!await ctx.ui.confirm("Delete workflow?", `Delete ${stored.loaded.run.workflowName} (${stored.store.runId}) and all owned artifacts? This cannot be undone.`)) return keepContext ? "dashboard" : "done";
|
|
2083
2571
|
await stored.store.delete(true); runs.delete(stored.store.runId); terminalRunStates.delete(stored.store.runId); ctx.ui.notify(`Deleted workflow ${stored.store.runId}.`, "info"); return keepContext ? "picker" : "done";
|
|
2084
2572
|
}
|
|
@@ -2086,10 +2574,10 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2086
2574
|
if (action === "resume" && run) {
|
|
2087
2575
|
if (run.lifecycle.state === "budget_exhausted") {
|
|
2088
2576
|
const patch: unknown = rest.length ? JSON.parse(rest.join(" ")) as unknown : undefined;
|
|
2089
|
-
const result = await resumeWorkflowRun(run.store.runId, patch, ctx);
|
|
2090
|
-
ctx.ui.notify(result.state === "running" ? `Resumed workflow ${run.store.runId}.` : `Budget adjustment for ${run.store.runId} is awaiting approval.`, result.state === "
|
|
2577
|
+
const result = await resumeWorkflowRun(run.store.runId, patch, ctx, undefined, undefined, false);
|
|
2578
|
+
ctx.ui.notify(result.state === "completed" ? `Workflow ${run.store.runId} completed.` : result.state === "running" ? `Resumed workflow ${run.store.runId}.` : `Budget adjustment for ${run.store.runId} is awaiting approval.`, result.state === "awaiting_approval" ? "warning" : "info");
|
|
2091
2579
|
} else {
|
|
2092
|
-
if (run.lifecycle.state === "interrupted") await coldResumeRun(run, ctx.hasUI, ctx.ui, projectTrusted(ctx), ctx);
|
|
2580
|
+
if (run.lifecycle.state === "interrupted") await coldResumeRun(run, ctx.hasUI, ctx.ui, projectTrusted(ctx), ctx, undefined, false);
|
|
2093
2581
|
else {
|
|
2094
2582
|
if (run.lifecycle.state === "paused") await refreshPausedRunAliases(run, { ...resumeHostContext(ctx), projectTrusted: projectTrusted(ctx) });
|
|
2095
2583
|
await run.lifecycle.resume();
|
|
@@ -2101,8 +2589,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2101
2589
|
if (action === "adjust" && run?.lifecycle.state === "budget_exhausted") {
|
|
2102
2590
|
const input = await uiHostCapabilities(ctx.ui)?.input?.call(ctx.ui, "Budget patch (JSON)", "{\"tokens\":{\"hard\":null}}" );
|
|
2103
2591
|
if (input === undefined) return keepContext ? "dashboard" : "done";
|
|
2104
|
-
const result = await resumeWorkflowRun(run.store.runId, JSON.parse(input), ctx);
|
|
2105
|
-
ctx.ui.notify(result.state === "running" ? `Resumed workflow ${run.store.runId}.` : `Budget adjustment for ${run.store.runId} is awaiting approval.`, result.state === "
|
|
2592
|
+
const result = await resumeWorkflowRun(run.store.runId, JSON.parse(input), ctx, undefined, undefined, false);
|
|
2593
|
+
ctx.ui.notify(result.state === "completed" ? `Workflow ${run.store.runId} completed.` : result.state === "running" ? `Resumed workflow ${run.store.runId}.` : `Budget adjustment for ${run.store.runId} is awaiting approval.`, result.state === "awaiting_approval" ? "warning" : "info");
|
|
2106
2594
|
return keepContext ? "dashboard" : "done";
|
|
2107
2595
|
}
|
|
2108
2596
|
if (action === "stop" && run) {
|
|
@@ -2201,7 +2689,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2201
2689
|
}
|
|
2202
2690
|
const sorted = navigatorAttentionSort(stores);
|
|
2203
2691
|
const labels = navigatorRunLabels(sorted);
|
|
2204
|
-
const terminalStates =
|
|
2692
|
+
const terminalStates = HARD_TERMINAL_RUN_STATES;
|
|
2205
2693
|
const hasCompleted = sorted.some(({ loaded: { run } }) => run.state === "completed");
|
|
2206
2694
|
const hasFailed = sorted.some(({ loaded: { run } }) => run.state === "failed");
|
|
2207
2695
|
const pickerOptions = [...labels, ...(herdrPaneId() ? ["Inspect session in pane"] : []), "Model aliases", "Close", ...(hasCompleted ? ["Delete all completed"] : []), ...(hasFailed ? ["Delete all failed"] : [])];
|
|
@@ -2246,22 +2734,30 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2246
2734
|
};
|
|
2247
2735
|
const loadDashboard = async () => {
|
|
2248
2736
|
const loaded = await store.load();
|
|
2737
|
+
const liveRun = withLiveActivities(loaded.run);
|
|
2249
2738
|
const checkpoints = await store.awaitingCheckpoints();
|
|
2250
2739
|
const worktrees = await store.worktrees();
|
|
2740
|
+
const completedOperations = ctx.mode === "tui" ? await store.replayableOperations().catch(() => []) : [];
|
|
2741
|
+
const agentResults = new Map<string, JsonValue>();
|
|
2742
|
+
for (const agent of liveRun.agents) {
|
|
2743
|
+
if (agent.state !== "completed" || agent.parentId || !agent.resultPath) continue;
|
|
2744
|
+
const operation = completedOperations.find((candidate) => candidate.path === agent.resultPath);
|
|
2745
|
+
if (operation) agentResults.set(agent.id, operation.value);
|
|
2746
|
+
}
|
|
2251
2747
|
const actions = new Map<string, string>();
|
|
2252
2748
|
const copies = new Map<string, { value: string; artifact: string }>();
|
|
2253
2749
|
const reviews = new Map<string, AwaitingCheckpoint>();
|
|
2254
2750
|
const add = (label: string, value: string) => { actions.set(label, `${value} ${store.runId}`); };
|
|
2255
2751
|
const addCopy = (label: string, value: string, artifact: string) => { actions.set(label, "copy"); copies.set(label, { value, artifact }); };
|
|
2256
|
-
if (
|
|
2257
|
-
if (["paused", "interrupted"].includes(
|
|
2258
|
-
if (
|
|
2752
|
+
if (liveRun.state === "running") add("Pause", "pause");
|
|
2753
|
+
if (["paused", "interrupted"].includes(liveRun.state)) add("Resume", "resume");
|
|
2754
|
+
if (liveRun.state === "budget_exhausted") { actions.set("Resume unchanged", `resume ${store.runId}`); actions.set("Adjust budget", `adjust ${store.runId}`); }
|
|
2259
2755
|
for (const decision of await store.pendingWorkflowDecisions()) {
|
|
2260
2756
|
const id = decision.proposalId.slice(0, 8);
|
|
2261
2757
|
actions.set(`Approve budget ${id}`, `budget-approve ${store.runId} ${decision.proposalId}`);
|
|
2262
2758
|
actions.set(`Reject budget ${id}`, `budget-reject ${store.runId} ${decision.proposalId}`);
|
|
2263
2759
|
}
|
|
2264
|
-
if (!terminalStates.has(
|
|
2760
|
+
if (!terminalStates.has(liveRun.state)) add("Stop", "stop");
|
|
2265
2761
|
for (const cp of checkpoints) {
|
|
2266
2762
|
if (ctx.mode === "tui") {
|
|
2267
2763
|
const label = `Review ${cp.name}`;
|
|
@@ -2273,96 +2769,131 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2273
2769
|
}
|
|
2274
2770
|
}
|
|
2275
2771
|
if (ctx.mode !== "tui") actions.set("Refresh", "refresh");
|
|
2276
|
-
else actions.set("
|
|
2277
|
-
if (
|
|
2278
|
-
if (terminalStates.has(
|
|
2772
|
+
else actions.set("Open script in editor", "open-script");
|
|
2773
|
+
if (ctx.mode !== "tui" && liveRun.agents.length) actions.set("Agents...", "agents");
|
|
2774
|
+
if (terminalStates.has(liveRun.state)) add("Delete", "delete");
|
|
2279
2775
|
if (ctx.mode === "tui") {
|
|
2280
2776
|
addCopy("Copy run path", store.directory, "run path");
|
|
2281
2777
|
addCopy("Copy run ID", store.runId, "run ID");
|
|
2282
2778
|
}
|
|
2283
|
-
return { dashboard: formatWorkflowPhaseDashboard(
|
|
2779
|
+
return { dashboard: formatWorkflowPhaseDashboard(liveRun, loaded.snapshot, process.stdout.columns || 80).join("\n"), phaseModel: buildWorkflowPhaseModel(liveRun, loaded.snapshot), run: liveRun, snapshot: loaded.snapshot, actions, copies, reviews, agentResults, agents: liveRun.agents, worktrees, cwd: liveRun.cwd };
|
|
2284
2780
|
};
|
|
2285
|
-
const
|
|
2286
|
-
|
|
2287
|
-
const
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
const selectedIndex = selectedLabel ? labels.indexOf(selectedLabel) : -1;
|
|
2291
|
-
const selected = selectedIndex >= 0 ? dashboard.agents[selectedIndex] : undefined;
|
|
2292
|
-
if (!selected) return;
|
|
2293
|
-
const attempts = [...(selected.attemptDetails ?? [])].sort((left, right) => left.attempt - right.attempt);
|
|
2294
|
-
const worktree = selected.worktreeOwner ? dashboard.worktrees.find((candidate) => candidate.owner === selected.worktreeOwner) : undefined;
|
|
2295
|
-
const actions = [
|
|
2296
|
-
...(attempts.length && herdrPaneId() ? ["Fork as Pi session in pane"] : []),
|
|
2781
|
+
const agentWorktreeFor = (dashboard: Awaited<ReturnType<typeof loadDashboard>>, agent: AgentRecord): WorktreeReference | undefined => agent.worktreeOwner ? dashboard.worktrees.find((candidate) => candidate.owner === agent.worktreeOwner) : undefined;
|
|
2782
|
+
const agentActionLabels = (dashboard: Awaited<ReturnType<typeof loadDashboard>>, agent: AgentRecord): string[] => {
|
|
2783
|
+
const worktree = agentWorktreeFor(dashboard, agent);
|
|
2784
|
+
return [
|
|
2785
|
+
...(agent.attemptDetails?.length && herdrPaneId() ? ["Fork as Pi session in pane"] : []),
|
|
2297
2786
|
...(worktree ? ["Copy branch", "Copy worktree path"] : []),
|
|
2787
|
+
...(ctx.mode === "tui" && dashboard.agentResults.has(agent.id) ? ["Open result in editor"] : []),
|
|
2298
2788
|
"Copy agent ID",
|
|
2299
2789
|
"Back",
|
|
2300
2790
|
];
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2791
|
+
};
|
|
2792
|
+
const forkAgentSession = async (dashboard: Awaited<ReturnType<typeof loadDashboard>>, agent: AgentRecord): Promise<void> => {
|
|
2793
|
+
const attempts = [...(agent.attemptDetails ?? [])].sort((left, right) => left.attempt - right.attempt);
|
|
2794
|
+
const worktree = agentWorktreeFor(dashboard, agent);
|
|
2795
|
+
const choices = attempts.map((attempt) => `Attempt ${String(attempt.attempt)}`);
|
|
2796
|
+
const choice = choices.length === 1 ? choices[0] : await ctx.ui.select("Fork attempts", [...choices, "Back"]);
|
|
2797
|
+
const index = choice ? choices.indexOf(choice) : -1;
|
|
2798
|
+
const attempt = index >= 0 ? attempts[index] : undefined;
|
|
2799
|
+
if (!attempt) return;
|
|
2800
|
+
const running = !SETTLED_AGENT_STATES.has(agent.state) && attempt.attempt === attempts.at(-1)?.attempt && !attempt.error;
|
|
2801
|
+
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;
|
|
2802
|
+
try {
|
|
2803
|
+
await openHerdrPane({ action: "fork", cwd: worktree?.cwd ?? attempt.setup?.cwd ?? dashboard.cwd, original: attempt.sessionFile, ...(running ? { readOnly: true } : {}) });
|
|
2804
|
+
ctx.ui.notify("Forked Pi session in pane.", "info");
|
|
2805
|
+
} catch (error) {
|
|
2806
|
+
ctx.ui.notify(`Cannot fork Pi session in pane: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
2807
|
+
}
|
|
2808
|
+
};
|
|
2809
|
+
const selectAgent = async (dashboard: Awaited<ReturnType<typeof loadDashboard>>, requestedAgentId?: string): Promise<void> => {
|
|
2810
|
+
const byId = new Map(dashboard.agents.map((agent) => [agent.id, agent]));
|
|
2811
|
+
const title = (agent: AgentRecord): string => agentBreadcrumb(agent, byId, true);
|
|
2812
|
+
const labels = dashboard.agents.map((agent, index) => `#${String(index + 1)} ${title(agent)} [${agent.state}]`);
|
|
2813
|
+
let selected: AgentRecord | undefined;
|
|
2814
|
+
if (requestedAgentId) selected = dashboard.agents.find((agent) => agent.id === requestedAgentId);
|
|
2815
|
+
else {
|
|
2816
|
+
const selectedLabel = await ctx.ui.select("Agents", [...labels, "Back"]);
|
|
2817
|
+
const selectedIndex = selectedLabel ? labels.indexOf(selectedLabel) : -1;
|
|
2818
|
+
selected = selectedIndex >= 0 ? dashboard.agents[selectedIndex] : undefined;
|
|
2819
|
+
}
|
|
2820
|
+
if (!selected) return;
|
|
2821
|
+
const worktree = agentWorktreeFor(dashboard, selected);
|
|
2822
|
+
const actions = agentActionLabels(dashboard, selected);
|
|
2307
2823
|
for (;;) {
|
|
2308
2824
|
const action = await ctx.ui.select(title(selected), actions);
|
|
2309
2825
|
if (!action || action === "Back") return;
|
|
2310
2826
|
if (action === "Copy agent ID") { await copyArtifact(selected.id, "agent ID"); continue; }
|
|
2311
2827
|
if (action === "Copy branch" && worktree) { await copyArtifact(worktree.branch, "branch"); continue; }
|
|
2312
2828
|
if (action === "Copy worktree path" && worktree) { await copyArtifact(worktree.path, "worktree path"); continue; }
|
|
2313
|
-
if (action === "Fork as Pi session in pane")
|
|
2314
|
-
const attempt = await chooseAttempt();
|
|
2315
|
-
if (!attempt) continue;
|
|
2316
|
-
const running = !SETTLED_AGENT_STATES.has(selected.state) && attempt.attempt === attempts.at(-1)?.attempt && !attempt.error;
|
|
2317
|
-
if (running && !await ctx.ui.confirm("Fork running attempt?", "This attempt is still running. The snapshot may end mid-turn and will not receive later updates. It opens read-only to avoid concurrent changes to the workflow agent's working directory. Continue?")) continue;
|
|
2318
|
-
try {
|
|
2319
|
-
await openHerdrPane({ action: "fork", cwd: worktree?.cwd ?? attempt.setup?.cwd ?? dashboard.cwd, original: attempt.sessionFile, ...(running ? { readOnly: true } : {}) });
|
|
2320
|
-
ctx.ui.notify("Forked Pi session in pane.", "info");
|
|
2321
|
-
} catch (error) {
|
|
2322
|
-
ctx.ui.notify(`Cannot fork Pi session in pane: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
2323
|
-
}
|
|
2324
|
-
}
|
|
2829
|
+
if (action === "Fork as Pi session in pane") await forkAgentSession(dashboard, selected);
|
|
2325
2830
|
}
|
|
2326
2831
|
};
|
|
2327
2832
|
for (;;) {
|
|
2328
2833
|
let view = await loadDashboard();
|
|
2329
2834
|
const actionChoice = ctx.mode === "tui"
|
|
2330
2835
|
? await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
|
|
2331
|
-
let options = [...view.actions.keys(), "Close"];
|
|
2332
|
-
let selectedIndex = 0;
|
|
2333
2836
|
let dashboardOffset = 0;
|
|
2334
2837
|
let refreshing = false;
|
|
2335
2838
|
let disposed = false;
|
|
2839
|
+
let detailsMode = false;
|
|
2840
|
+
let actionMode = false;
|
|
2841
|
+
let actionIndex = 0;
|
|
2336
2842
|
let stopRequested = false;
|
|
2337
2843
|
let stopStatus: string | undefined;
|
|
2844
|
+
let selectionNeedsScroll = true;
|
|
2845
|
+
let renderedWidth = 80;
|
|
2846
|
+
let refreshGeneration = 0;
|
|
2338
2847
|
const initialSelection = preserveWorkflowPhaseSelection(view.phaseModel, {});
|
|
2339
|
-
let
|
|
2340
|
-
let
|
|
2341
|
-
|
|
2342
|
-
const
|
|
2343
|
-
const
|
|
2344
|
-
|
|
2345
|
-
|
|
2848
|
+
let tree = buildWorkflowPhaseTree(view.phaseModel);
|
|
2849
|
+
let selectedNodeId = initialSelection.nodeId ?? tree.nodes[0]?.id;
|
|
2850
|
+
let expandedNodeIds = new Set(initialSelection.expandedNodeIds ?? workflowPhaseTreeInitialExpanded(tree));
|
|
2851
|
+
const terminalRows = () => Math.max(1, tuiRows(tui) - WORKFLOW_PANEL_FOOTER_ROWS);
|
|
2852
|
+
const keyLabels: Record<string, string> = { up: "↑", down: "↓", left: "←", right: "→", pageUp: "pgup", pageDown: "pgdn" };
|
|
2853
|
+
const keyLabel = (binding: string, fallback: string) => workflowKeyLabel(keybindings, binding, fallback, keyLabels);
|
|
2854
|
+
const selectedAgentRecord = (): AgentRecord | undefined => {
|
|
2855
|
+
const node = selectedNodeId ? tree.byId.get(selectedNodeId) : tree.nodes[0];
|
|
2856
|
+
return node?.kind === "agent" && node.agentId ? view.agents.find((agent) => agent.id === node.agentId) : undefined;
|
|
2346
2857
|
};
|
|
2347
|
-
const
|
|
2348
|
-
const
|
|
2349
|
-
|
|
2350
|
-
const separatorRows = rows >= 8 ? 1 : 0;
|
|
2351
|
-
const available = Math.max(1, rows - hintRows - separatorRows);
|
|
2352
|
-
const actionViewport = Math.min(options.length, Math.max(1, Math.ceil(available / 2)));
|
|
2353
|
-
return { rows, hintRows, separatorRows, actionViewport, dashboardViewport: available - actionViewport };
|
|
2858
|
+
const actionOptions = () => {
|
|
2859
|
+
const agent = selectedAgentRecord();
|
|
2860
|
+
return agent ? agentActionLabels(view, agent) : [...view.actions.keys(), "Back"];
|
|
2354
2861
|
};
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2862
|
+
let editorRunning = false;
|
|
2863
|
+
const openArtifact = async (artifact: Promise<WorkflowArtifact>, label: string): Promise<void> => {
|
|
2864
|
+
if (editorRunning) return;
|
|
2865
|
+
editorRunning = true;
|
|
2866
|
+
try {
|
|
2867
|
+
const command = SettingsManager.create(view.cwd, extensionAgentDir, { projectTrusted: projectTrusted(ctx) }).getExternalEditorCommand();
|
|
2868
|
+
if (!command) { ctx.ui.notify(`Cannot open ${label}: no external editor is configured.`, "warning"); return; }
|
|
2869
|
+
const exitCode = await openWorkflowArtifact(tui, command, await artifact);
|
|
2870
|
+
if (exitCode !== 0) {
|
|
2871
|
+
const detail = exitCode === null ? "could not be started" : `exited with code ${String(exitCode)}`;
|
|
2872
|
+
ctx.ui.notify(`Cannot open ${label}: external editor ${detail}.`, "warning");
|
|
2873
|
+
}
|
|
2874
|
+
} catch (error) {
|
|
2875
|
+
ctx.ui.notify(`Cannot open ${label}: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
2876
|
+
} finally {
|
|
2877
|
+
editorRunning = false;
|
|
2878
|
+
}
|
|
2879
|
+
};
|
|
2880
|
+
const updateDashboard = async () => {
|
|
2881
|
+
const generation = ++refreshGeneration;
|
|
2882
|
+
const hadExpandableNodes = tree.nodes.some((node) => node.children.length > 0);
|
|
2358
2883
|
const next = await loadDashboard();
|
|
2359
|
-
if (disposed) return;
|
|
2884
|
+
if (disposed || generation !== refreshGeneration) return;
|
|
2885
|
+
const previousNodeId = selectedNodeId;
|
|
2886
|
+
const previousExpanded = expandedNodeIds;
|
|
2887
|
+
const selectedAction = actionMode ? actionOptions()[actionIndex] : undefined;
|
|
2360
2888
|
view = next;
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2889
|
+
tree = buildWorkflowPhaseTree(view.phaseModel);
|
|
2890
|
+
selectedNodeId = preserveWorkflowPhaseTreeSelection(tree, { nodeId: previousNodeId }).nodeId;
|
|
2891
|
+
expandedNodeIds = new Set([...previousExpanded].filter((id) => tree.byId.has(id)));
|
|
2892
|
+
if (!hadExpandableNodes && !expandedNodeIds.size && tree.nodes.some((node) => node.children.length > 0)) expandedNodeIds = new Set(workflowPhaseTreeInitialExpanded(tree));
|
|
2893
|
+
const nextActions = actionOptions();
|
|
2894
|
+
const preservedActionIndex = selectedAction ? nextActions.indexOf(selectedAction) : -1;
|
|
2895
|
+
actionIndex = preservedActionIndex >= 0 ? preservedActionIndex : selectedAction ? nextActions.length - 1 : Math.min(actionIndex, Math.max(0, nextActions.length - 1));
|
|
2896
|
+
selectionNeedsScroll = true;
|
|
2366
2897
|
tui.requestRender();
|
|
2367
2898
|
};
|
|
2368
2899
|
const requestStop = () => {
|
|
@@ -2370,14 +2901,14 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2370
2901
|
stopRequested = true;
|
|
2371
2902
|
stopStatus = undefined;
|
|
2372
2903
|
setWorkflowStatus(undefined);
|
|
2373
|
-
const selectedOption = options[selectedIndex];
|
|
2374
2904
|
void runAction(`stop ${store.runId}`, true, (status) => {
|
|
2375
2905
|
stopStatus = status;
|
|
2376
2906
|
setWorkflowStatus(status);
|
|
2377
2907
|
if (!disposed) tui.requestRender();
|
|
2378
|
-
}).then(() => updateDashboard(
|
|
2908
|
+
}).then(() => updateDashboard()).catch((error: unknown) => {
|
|
2379
2909
|
if (disposed) return;
|
|
2380
2910
|
stopStatus = `Could not stop workflow ${store.runId}: ${error instanceof Error ? error.message : String(error)}`;
|
|
2911
|
+
setWorkflowStatus(stopStatus);
|
|
2381
2912
|
tui.requestRender();
|
|
2382
2913
|
}).finally(() => {
|
|
2383
2914
|
stopRequested = false;
|
|
@@ -2387,110 +2918,122 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2387
2918
|
const timer = setInterval(() => {
|
|
2388
2919
|
if (refreshing || stopRequested) return;
|
|
2389
2920
|
refreshing = true;
|
|
2390
|
-
|
|
2391
|
-
void updateDashboard(selectedOption).catch(() => undefined).finally(() => { refreshing = false; });
|
|
2921
|
+
void updateDashboard().catch(() => undefined).finally(() => { refreshing = false; });
|
|
2392
2922
|
}, 1000);
|
|
2393
2923
|
timer.unref();
|
|
2394
|
-
return
|
|
2924
|
+
return {
|
|
2395
2925
|
render(width: number) {
|
|
2926
|
+
renderedWidth = width;
|
|
2927
|
+
const narrow = width < 80;
|
|
2396
2928
|
const styles = themeWorkflowProgressStyles(theme);
|
|
2397
|
-
const
|
|
2398
|
-
const
|
|
2399
|
-
const
|
|
2400
|
-
const
|
|
2401
|
-
const
|
|
2402
|
-
const
|
|
2403
|
-
|
|
2404
|
-
const
|
|
2929
|
+
const agent = selectedAgentRecord();
|
|
2930
|
+
const actions = actionMode ? { title: agent ? "Agent actions" : "Run actions", options: actionOptions(), index: actionIndex } : undefined;
|
|
2931
|
+
const phaseLines = formatWorkflowPhaseDashboard(view.run, view.snapshot, width, { nodeId: selectedNodeId, expandedNodeIds: [...expandedNodeIds], ...(narrow && !detailsMode ? { treeOnly: true } : {}), ...(narrow && detailsMode ? { detailsOnly: true } : {}), ...(actions ? { actions } : {}) }, styles);
|
|
2932
|
+
const statusLines = stopStatus ? truncateToVisualLines(styles.error(stopStatus), Number.MAX_SAFE_INTEGER, width, 0).visualLines.map((line) => line.trimEnd()) : [];
|
|
2933
|
+
const content = [...statusLines, ...phaseLines];
|
|
2934
|
+
const rows = terminalRows();
|
|
2935
|
+
const hintRows = rows >= 3 ? 1 : 0;
|
|
2936
|
+
const viewport = Math.max(1, rows - hintRows);
|
|
2937
|
+
const maxOffset = Math.max(0, content.length - viewport);
|
|
2938
|
+
dashboardOffset = Math.max(0, Math.min(maxOffset, dashboardOffset));
|
|
2939
|
+
if (actionMode) {
|
|
2940
|
+
const label = actions?.options[actionIndex];
|
|
2941
|
+
const actionRow = label ? content.findIndex((line) => line.includes(label)) : -1;
|
|
2942
|
+
if (actionRow >= 0) {
|
|
2943
|
+
if (actionRow < dashboardOffset) dashboardOffset = actionRow;
|
|
2944
|
+
else if (actionRow >= dashboardOffset + viewport) dashboardOffset = actionRow - viewport + 1;
|
|
2945
|
+
}
|
|
2946
|
+
} else if (!detailsMode && selectionNeedsScroll) {
|
|
2947
|
+
const selectedRow = content.findIndex((line) => line.startsWith("→"));
|
|
2948
|
+
if (selectedRow >= 0) {
|
|
2949
|
+
if (selectedRow < dashboardOffset) dashboardOffset = selectedRow;
|
|
2950
|
+
else if (selectedRow >= dashboardOffset + viewport) dashboardOffset = selectedRow - viewport + 1;
|
|
2951
|
+
}
|
|
2952
|
+
selectionNeedsScroll = false;
|
|
2953
|
+
}
|
|
2405
2954
|
dashboardOffset = Math.max(0, Math.min(maxOffset, dashboardOffset));
|
|
2406
|
-
const
|
|
2407
|
-
return [
|
|
2408
|
-
...dashboardLines.slice(dashboardOffset, dashboardOffset + layout.dashboardViewport),
|
|
2409
|
-
...(layout.separatorRows && layout.dashboardViewport ? [""] : []),
|
|
2410
|
-
...actionLines.slice(actionStart, actionStart + layout.actionViewport),
|
|
2411
|
-
...(layout.hintRows ? [hint] : []),
|
|
2412
|
-
];
|
|
2955
|
+
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" : "back"}${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] ?? "";
|
|
2956
|
+
return [...content.slice(dashboardOffset, dashboardOffset + viewport), ...(hintRows ? [hint] : [])];
|
|
2413
2957
|
},
|
|
2414
2958
|
invalidate() {},
|
|
2415
2959
|
handleInput(data: string) {
|
|
2416
|
-
if (stopRequested) return;
|
|
2417
|
-
const
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
if (
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2960
|
+
if (stopRequested || editorRunning) return;
|
|
2961
|
+
const narrow = renderedWidth < 80;
|
|
2962
|
+
if (!actionMode && (data === "a" || data === "A")) { actionMode = true; actionIndex = 0; dashboardOffset = 0; tui.requestRender(); return; }
|
|
2963
|
+
if (actionMode) {
|
|
2964
|
+
const options = actionOptions();
|
|
2965
|
+
if (workflowKeyMatches(keybindings, data, "tui.select.cancel")) { actionMode = false; dashboardOffset = 0; tui.requestRender(); return; }
|
|
2966
|
+
if (workflowKeyMatches(keybindings, data, "tui.select.up")) actionIndex = (actionIndex + options.length - 1) % options.length;
|
|
2967
|
+
else if (workflowKeyMatches(keybindings, data, "tui.select.down")) actionIndex = (actionIndex + 1) % options.length;
|
|
2968
|
+
else if (workflowKeyMatches(keybindings, data, "tui.select.pageUp")) dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, terminalRows() - 1));
|
|
2969
|
+
else if (workflowKeyMatches(keybindings, data, "tui.select.pageDown")) dashboardOffset += Math.max(1, terminalRows() - 1);
|
|
2970
|
+
else if (workflowKeyMatches(keybindings, data, "tui.select.confirm")) {
|
|
2971
|
+
const action = options[actionIndex];
|
|
2972
|
+
const agent = selectedAgentRecord();
|
|
2973
|
+
if (!action || action === "Back") { actionMode = false; dashboardOffset = 0; }
|
|
2974
|
+
else if (agent) {
|
|
2975
|
+
const worktree = agentWorktreeFor(view, agent);
|
|
2976
|
+
if (action === "Open result in editor") {
|
|
2977
|
+
const result = view.agentResults.get(agent.id);
|
|
2978
|
+
if (result !== undefined) void openArtifact(Promise.resolve(workflowResultArtifact(result)), "agent result");
|
|
2979
|
+
}
|
|
2980
|
+
else if (action === "Copy agent ID") void copyArtifact(agent.id, "agent ID");
|
|
2981
|
+
else if (action === "Copy branch" && worktree) void copyArtifact(worktree.branch, "branch");
|
|
2982
|
+
else if (action === "Copy worktree path" && worktree) void copyArtifact(worktree.path, "worktree path");
|
|
2983
|
+
else done(`__workflow_fork__:${agent.id}`);
|
|
2984
|
+
}
|
|
2985
|
+
else if (action === "Open script in editor") void openArtifact(readFile(join(store.directory, "workflow.js"), "utf8").then(workflowScriptArtifact), "workflow script");
|
|
2986
|
+
else if (action === "Stop") requestStop();
|
|
2987
|
+
else done(action);
|
|
2428
2988
|
}
|
|
2989
|
+
tui.requestRender();
|
|
2990
|
+
return;
|
|
2429
2991
|
}
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
if (
|
|
2433
|
-
|
|
2434
|
-
|
|
2992
|
+
const current = selectedNodeId ? tree.byId.get(selectedNodeId) : tree.nodes[0];
|
|
2993
|
+
if (workflowKeyMatches(keybindings, data, "tui.select.cancel")) {
|
|
2994
|
+
if (narrow && detailsMode) { detailsMode = false; selectionNeedsScroll = true; } else done("Back");
|
|
2995
|
+
} else if (narrow && detailsMode) {
|
|
2996
|
+
if (workflowKeyMatches(keybindings, data, "tui.select.pageUp")) dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, terminalRows() - 1));
|
|
2997
|
+
else if (workflowKeyMatches(keybindings, data, "tui.select.pageDown")) dashboardOffset += Math.max(1, terminalRows() - 1);
|
|
2998
|
+
else if (workflowKeyMatches(keybindings, data, "tui.select.confirm")) {
|
|
2999
|
+
if (current?.kind === "agent" && current.agentId) { actionMode = true; actionIndex = 0; }
|
|
3000
|
+
else if (current?.children.length) { if (expandedNodeIds.has(current.id)) expandedNodeIds.delete(current.id); else expandedNodeIds.add(current.id); }
|
|
2435
3001
|
}
|
|
3002
|
+
} else if (workflowKeyMatches(keybindings, data, "tui.editor.cursorLeft")) {
|
|
3003
|
+
const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "left");
|
|
3004
|
+
selectedNodeId = next.nodeId; expandedNodeIds = new Set(next.expandedNodeIds); selectionNeedsScroll = true;
|
|
3005
|
+
} else if (workflowKeyMatches(keybindings, data, "tui.editor.cursorRight")) {
|
|
3006
|
+
const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "right");
|
|
3007
|
+
selectedNodeId = next.nodeId; expandedNodeIds = new Set(next.expandedNodeIds); selectionNeedsScroll = true;
|
|
3008
|
+
} else if (workflowKeyMatches(keybindings, data, "tui.select.up")) {
|
|
3009
|
+
const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "up");
|
|
3010
|
+
selectedNodeId = next.nodeId; selectionNeedsScroll = true;
|
|
3011
|
+
} else if (workflowKeyMatches(keybindings, data, "tui.select.down")) {
|
|
3012
|
+
const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "down");
|
|
3013
|
+
selectedNodeId = next.nodeId; selectionNeedsScroll = true;
|
|
3014
|
+
} else if (workflowKeyMatches(keybindings, data, "tui.select.pageUp")) dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, terminalRows() - 1));
|
|
3015
|
+
else if (workflowKeyMatches(keybindings, data, "tui.select.pageDown")) dashboardOffset += Math.max(1, terminalRows() - 1);
|
|
3016
|
+
else if (workflowKeyMatches(keybindings, data, "tui.select.confirm")) {
|
|
3017
|
+
if (narrow) detailsMode = true;
|
|
3018
|
+
else if (current?.kind === "agent" && current.agentId) { actionMode = true; actionIndex = 0; }
|
|
3019
|
+
else if (current?.children.length) { if (expandedNodeIds.has(current.id)) expandedNodeIds.delete(current.id); else expandedNodeIds.add(current.id); }
|
|
2436
3020
|
}
|
|
2437
|
-
else if (keybindings.matches(data, "tui.select.up")) selectedIndex = (selectedIndex + options.length - 1) % options.length;
|
|
2438
|
-
else if (keybindings.matches(data, "tui.select.down")) selectedIndex = (selectedIndex + 1) % options.length;
|
|
2439
|
-
else if (keybindings.matches(data, "tui.select.pageUp")) {
|
|
2440
|
-
dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, dashboardLayout().dashboardViewport));
|
|
2441
|
-
}
|
|
2442
|
-
else if (keybindings.matches(data, "tui.select.pageDown")) {
|
|
2443
|
-
dashboardOffset += Math.max(1, dashboardLayout().dashboardViewport);
|
|
2444
|
-
}
|
|
2445
|
-
else if (keybindings.matches(data, "tui.select.confirm")) {
|
|
2446
|
-
if (options[selectedIndex] === "Stop") requestStop();
|
|
2447
|
-
else done(options[selectedIndex]);
|
|
2448
|
-
}
|
|
2449
|
-
else if (keybindings.matches(data, "tui.select.cancel")) done(undefined);
|
|
2450
3021
|
tui.requestRender();
|
|
2451
3022
|
},
|
|
2452
3023
|
dispose() { disposed = true; clearInterval(timer); setWorkflowStatus(undefined); },
|
|
2453
|
-
}
|
|
2454
|
-
}
|
|
2455
|
-
: await ctx.ui.select(view.dashboard, [...view.actions.keys(), "
|
|
2456
|
-
if (!actionChoice || actionChoice === "
|
|
3024
|
+
};
|
|
3025
|
+
})
|
|
3026
|
+
: await ctx.ui.select(view.dashboard, [...view.actions.keys(), "Back"]);
|
|
3027
|
+
if (!actionChoice || actionChoice === "Back") { stores = await loadStores(); break; }
|
|
2457
3028
|
if (actionChoice === "Agents...") { await selectAgent(view); continue; }
|
|
2458
|
-
if (actionChoice
|
|
2459
|
-
if (actionChoice
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
let renderedLines: string[] = [];
|
|
2464
|
-
const viewport = () => Math.max(1, tuiRows(tui) - 3 - WORKFLOW_OVERLAY_BORDER_ROWS);
|
|
2465
|
-
const move = (delta: number) => {
|
|
2466
|
-
const maxOffset = Math.max(0, renderedLines.length - viewport());
|
|
2467
|
-
offset = Math.max(0, Math.min(maxOffset, offset + delta));
|
|
2468
|
-
};
|
|
2469
|
-
return borderWorkflowOverlay({
|
|
2470
|
-
render(width: number) {
|
|
2471
|
-
renderedLines = highlighted.flatMap((line) => line ? truncateToVisualLines(line, Number.MAX_SAFE_INTEGER, width, 0).visualLines : [""]);
|
|
2472
|
-
const maxOffset = Math.max(0, renderedLines.length - viewport());
|
|
2473
|
-
offset = Math.min(offset, maxOffset);
|
|
2474
|
-
return [
|
|
2475
|
-
theme.fg("accent", "Workflow script"),
|
|
2476
|
-
...renderedLines.slice(offset, offset + viewport()),
|
|
2477
|
-
"",
|
|
2478
|
-
theme.fg("dim", "↑↓/pgup/pgdn scroll · esc close"),
|
|
2479
|
-
];
|
|
2480
|
-
},
|
|
2481
|
-
invalidate() {},
|
|
2482
|
-
handleInput(data: string) {
|
|
2483
|
-
if (keybindings.matches(data, "tui.select.up")) move(-1);
|
|
2484
|
-
else if (keybindings.matches(data, "tui.select.down")) move(1);
|
|
2485
|
-
else if (keybindings.matches(data, "tui.select.pageUp")) move(-viewport());
|
|
2486
|
-
else if (keybindings.matches(data, "tui.select.pageDown")) move(viewport());
|
|
2487
|
-
else if (keybindings.matches(data, "tui.select.cancel")) done(undefined);
|
|
2488
|
-
tui.requestRender();
|
|
2489
|
-
},
|
|
2490
|
-
}, theme);
|
|
2491
|
-
}, { overlay: true, overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%" } });
|
|
3029
|
+
if (actionChoice.startsWith("__workflow_agent__:")) { await selectAgent(view, actionChoice.slice("__workflow_agent__:".length)); continue; }
|
|
3030
|
+
if (actionChoice.startsWith("__workflow_fork__:")) {
|
|
3031
|
+
const agentId = actionChoice.slice("__workflow_fork__:".length);
|
|
3032
|
+
const agent = view.agents.find((candidate) => candidate.id === agentId);
|
|
3033
|
+
if (agent) await forkAgentSession(view, agent);
|
|
2492
3034
|
continue;
|
|
2493
3035
|
}
|
|
3036
|
+
if (actionChoice === "Refresh") continue;
|
|
2494
3037
|
const copy = view.copies.get(actionChoice);
|
|
2495
3038
|
if (copy) { await copyArtifact(copy.value, copy.artifact); continue; }
|
|
2496
3039
|
if (actionChoice.startsWith("Review ")) {
|
|
@@ -2521,7 +3064,9 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2521
3064
|
const currentLayout = layout();
|
|
2522
3065
|
const maxOffset = Math.max(0, renderedLines.length - currentLayout.contentViewport);
|
|
2523
3066
|
offset = Math.min(offset, maxOffset);
|
|
2524
|
-
const
|
|
3067
|
+
const keyLabels: Record<string, string> = { up: "↑", down: "↓", left: "←", right: "→", pageUp: "pgup", pageDown: "pgdn" };
|
|
3068
|
+
const keyLabel = (binding: string, fallback: string) => workflowKeyLabel(keybindings, binding, fallback, keyLabels);
|
|
3069
|
+
const hint = truncateToVisualLines(theme.fg("dim", `${keyLabel("tui.select.up", "↑")}/${keyLabel("tui.select.down", "↓")}/pgup/pgdn scroll · enter select · esc cancel`), Number.MAX_SAFE_INTEGER, width, 1).visualLines[0] ?? "";
|
|
2525
3070
|
const controls = currentLayout.compactControls
|
|
2526
3071
|
? [options.map((option, index) => `${index === selectedIndex ? "[" : " "}${option}${index === selectedIndex ? "]" : " "}`).join(" ")]
|
|
2527
3072
|
: options.map((option, index) => `${index === selectedIndex ? "→ " : " "}${option}`);
|
|
@@ -2535,16 +3080,16 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2535
3080
|
},
|
|
2536
3081
|
invalidate() {},
|
|
2537
3082
|
handleInput(data: string) {
|
|
2538
|
-
if (keybindings
|
|
2539
|
-
else if (keybindings
|
|
2540
|
-
else if (keybindings
|
|
2541
|
-
else if (keybindings
|
|
2542
|
-
else if (keybindings
|
|
2543
|
-
else if (keybindings
|
|
3083
|
+
if (workflowKeyMatches(keybindings, data, "tui.select.up")) selectedIndex = (selectedIndex + options.length - 1) % options.length;
|
|
3084
|
+
else if (workflowKeyMatches(keybindings, data, "tui.select.down")) selectedIndex = (selectedIndex + 1) % options.length;
|
|
3085
|
+
else if (workflowKeyMatches(keybindings, data, "tui.select.pageUp")) move(-layout().contentViewport);
|
|
3086
|
+
else if (workflowKeyMatches(keybindings, data, "tui.select.pageDown")) move(layout().contentViewport);
|
|
3087
|
+
else if (workflowKeyMatches(keybindings, data, "tui.select.confirm")) done(options[selectedIndex] === "Cancel" ? undefined : options[selectedIndex] as "Approve" | "Reject");
|
|
3088
|
+
else if (workflowKeyMatches(keybindings, data, "tui.select.cancel")) done(undefined);
|
|
2544
3089
|
tui.requestRender();
|
|
2545
3090
|
},
|
|
2546
3091
|
}, theme);
|
|
2547
|
-
}, { overlay: true, overlayOptions:
|
|
3092
|
+
}, { overlay: true, overlayOptions: WORKFLOW_OVERLAY_OPTIONS });
|
|
2548
3093
|
if (decision) {
|
|
2549
3094
|
const accepted = await answerCheckpoint(store.runId, checkpoint.name, decision === "Approve", true);
|
|
2550
3095
|
if (!accepted) ctx.ui.notify("Checkpoint is not awaiting a response.", "warning");
|
|
@@ -2564,9 +3109,9 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2564
3109
|
pi.on("session_shutdown", async () => {
|
|
2565
3110
|
try {
|
|
2566
3111
|
await Promise.all([...runs.entries()].map(async ([runId, run]) => {
|
|
2567
|
-
|
|
2568
|
-
if (!
|
|
2569
|
-
try { await run.lifecycle.terminal("interrupted"); } catch (error) { if (!
|
|
3112
|
+
const isTerminal = SHUTDOWN_TERMINAL_RUN_STATES.has(run.lifecycle.state);
|
|
3113
|
+
if (!isTerminal) {
|
|
3114
|
+
try { await run.lifecycle.terminal("interrupted"); } catch (error) { if (!SHUTDOWN_TERMINAL_RUN_STATES.has(run.lifecycle.state)) throw error; }
|
|
2570
3115
|
run.abortController.abort();
|
|
2571
3116
|
run.execution?.cancel();
|
|
2572
3117
|
await scheduler.cancelRun(runId);
|