pi-extensible-workflows 3.1.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -18
- package/dist/src/execution.js +13 -4
- package/dist/src/host.d.ts +44 -4
- package/dist/src/host.js +843 -395
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/types.d.ts +1 -0
- package/dist/src/validation.js +34 -0
- package/dist/src/workflow-artifacts.d.ts +13 -0
- package/dist/src/workflow-artifacts.js +39 -0
- package/examples/workflow-extension-template/README.md +37 -0
- package/examples/workflow-extension-template/extension.test.mjs +59 -0
- package/examples/workflow-extension-template/index.js +51 -0
- package/examples/workflow-extension-template/roles/reviewer.md +4 -0
- package/package.json +3 -2
- package/skills/pi-extensible-workflows/SKILL.md +21 -28
- package/src/execution.ts +13 -4
- package/src/host.ts +702 -362
- package/src/index.ts +1 -0
- package/src/types.ts +1 -1
- package/src/validation.ts +27 -0
- package/src/workflow-artifacts.ts +34 -0
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_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,7 +94,6 @@ 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
|
}
|
|
95
99
|
|
|
@@ -144,7 +148,7 @@ export class RunLifecycle {
|
|
|
144
148
|
}
|
|
145
149
|
|
|
146
150
|
async terminal(state: "completed" | "failed" | "stopped" | "interrupted" | "budget_exhausted", reason?: string): Promise<void> {
|
|
147
|
-
if (
|
|
151
|
+
if (HARD_TERMINAL_RUN_STATES.has(this.#state)) throw new WorkflowError("RESUME_INCOMPATIBLE", `${this.#state} runs are terminal`);
|
|
148
152
|
await this.#set(state, reason ?? state);
|
|
149
153
|
for (const resolve of this.#waiters.splice(0)) resolve();
|
|
150
154
|
}
|
|
@@ -164,25 +168,39 @@ export function formatWorkflowPreview(args: { script?: unknown; workflow?: unkno
|
|
|
164
168
|
return [`workflow ${name}`, typeof args.description === "string" && args.description.trim() ? args.description.trim() : ""].filter(Boolean).join("\n");
|
|
165
169
|
}
|
|
166
170
|
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
|
|
171
|
+
export const WORKFLOW_TOOL_DESCRIPTION = "Run a deterministic JavaScript workflow with a named inline parallel-to-summary path by default";
|
|
172
|
+
export const WORKFLOW_TOOL_PROMPT_SNIPPET = "Run a deterministic, resumable JavaScript workflow. Prefer a named inline script that fans out independent work with parallel(...), awaits the keyed results before interpolating them into one summarizing agent(...), and returns. Inline launches require an explicit non-empty name; registered function launches reject name and use workflow as the run name. Advanced controls include registered functions, outputSchema, budgets, checkpoints, worktrees, retry/resume, CLI export, and pipelines. Use workflow_retry with an explicit failed run ID; parentRunId only reuses named worktrees. Runs are in the background by default; completion arrives as a follow-up message. Set foreground: true when the caller must wait for the final value. Foreground results include the completed run ID. Recovery map: agent(..., { retries }) reruns one agent call in the same run for transient failures; workflow_retry({ runId }) replays a failed run into a child; workflow_resume({ runId, budget? }) continues a budget_exhausted run; parentRunId on a new launch only borrows named worktrees and never replays or resumes.";
|
|
173
|
+
function workflowRecoveryGuidance(action: "resume" | "retry", state: RunState): string {
|
|
174
|
+
if (action === "resume") {
|
|
175
|
+
if (state === "failed") return "Failed workflow runs must use workflow_retry({ runId })";
|
|
176
|
+
if (state === "completed") return "Completed workflow runs have no recovery action";
|
|
177
|
+
if (state === "stopped") return "Stopped workflow runs have no recovery action; launch a new workflow";
|
|
178
|
+
if (state === "interrupted") return "Interrupted workflow runs use /workflow resume, not workflow_resume";
|
|
179
|
+
return `Only budget-exhausted runs can be resumed with workflow_resume; source is ${state}`;
|
|
180
|
+
}
|
|
181
|
+
if (state === "budget_exhausted") return "Budget-exhausted workflow runs must use workflow_resume({ runId, budget? })";
|
|
182
|
+
if (state === "completed") return "Completed workflow runs have no recovery action";
|
|
183
|
+
if (state === "stopped") return "Stopped workflow runs cannot be retried; launch a new workflow";
|
|
184
|
+
if (state === "interrupted") return "Interrupted workflow runs use /workflow resume, not workflow_retry";
|
|
185
|
+
return `Only failed workflow runs can be retried; source is ${state}`;
|
|
186
|
+
}
|
|
169
187
|
export const WORKFLOW_TOOL_PARAMETERS = Type.Object({
|
|
170
|
-
name: Type.Optional(Type.String({ description: "Required non-empty name for inline workflow
|
|
188
|
+
name: Type.Optional(Type.String({ description: "Required non-empty name for the default inline workflow path; invalid for registered function launches" })),
|
|
171
189
|
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: "
|
|
190
|
+
script: Type.Optional(Type.String({ description: "Immutable inline workflow source; default to a named script that fans out with parallel(...) and awaits results before passing them to a summarizing agent(...)" })),
|
|
191
|
+
workflow: Type.Optional(Type.String({ description: "Advanced: registered reusable function as an unqualified name" })),
|
|
174
192
|
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: "
|
|
193
|
+
foreground: Type.Optional(Type.Boolean({ description: "Wait for completion instead of the default background launch" })),
|
|
194
|
+
concurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: 16, description: "Advanced: optional per-run active-agent limit" })),
|
|
195
|
+
budget: Type.Optional(Type.Unknown({ description: "Advanced: optional aggregate soft and hard run budgets" })),
|
|
196
|
+
parentRunId: Type.Optional(Type.String({ description: "Advanced: terminal run whose named worktrees may be reused" })),
|
|
179
197
|
});
|
|
180
198
|
export const WORKFLOW_RETRY_PARAMETERS = Type.Object({ runId: Type.String({ description: "Explicit failed workflow run ID" }) });
|
|
181
199
|
|
|
182
200
|
type WorkflowToolUpdate = { content: [{ type: "text"; text: string }]; details: { runId: string; run: PersistedRun } };
|
|
183
201
|
export type WorkflowPhaseState = "not started" | "running" | "completed" | "failed" | "cancelled" | "interrupted" | "budget_exhausted";
|
|
184
202
|
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;
|
|
203
|
+
export interface WorkflowPhaseView { id: string; name: string; occurrence: number; state: WorkflowPhaseState; observed: boolean; afterAgent?: number; agents: readonly AgentRecord[]; counts: WorkflowPhaseAgentCounts }
|
|
186
204
|
export interface WorkflowPhaseModel { declaredPhases: readonly string[]; phases: readonly WorkflowPhaseView[]; currentPhaseIndex?: number; currentPhaseId?: string; counts: Readonly<Partial<Record<WorkflowPhaseState, number>>>; unassignedAgents?: readonly AgentRecord[] }
|
|
187
205
|
type WorkflowPhaseSource = readonly string[] | Pick<LaunchSnapshot, "phases"> | undefined;
|
|
188
206
|
function phaseNames(source: WorkflowPhaseSource): string[] {
|
|
@@ -244,7 +262,7 @@ export function buildWorkflowPhaseModel(run: Pick<PersistedRun, "state" | "phase
|
|
|
244
262
|
const agents = observation?.agents ?? [];
|
|
245
263
|
const counts = phaseAgentCounts(agents);
|
|
246
264
|
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,
|
|
265
|
+
return { id: `${entry.name}#${String(occurrence)}`, name: entry.name, occurrence, state, observed: observation !== undefined, ...(observation ? { afterAgent: observation.afterAgent } : {}), agents, counts };
|
|
248
266
|
});
|
|
249
267
|
let currentPhaseIndex: number | undefined;
|
|
250
268
|
for (let index = phases.length - 1; index >= 0; index -= 1) { if (phases[index]?.observed) { currentPhaseIndex = index; break; } }
|
|
@@ -258,12 +276,167 @@ export function buildWorkflowPhaseModel(run: Pick<PersistedRun, "state" | "phase
|
|
|
258
276
|
if (unassignedAgents.length) result.unassignedAgents = unassignedAgents;
|
|
259
277
|
return result;
|
|
260
278
|
}
|
|
261
|
-
export interface WorkflowPhaseSelection { phaseId?: string | undefined; agentId?: string | undefined }
|
|
279
|
+
export interface WorkflowPhaseSelection { phaseId?: string | undefined; agentId?: string | undefined; nodeId?: string | undefined; expandedNodeIds?: readonly string[] | undefined; treeOnly?: boolean | undefined; detailsOnly?: boolean | undefined; actions?: { title: string; options: readonly string[]; index: number } | undefined }
|
|
280
|
+
export type WorkflowPhaseTreeNodeKind = "phase" | "operation" | "agent";
|
|
281
|
+
export interface WorkflowPhaseTreeNode { id: string; kind: WorkflowPhaseTreeNodeKind; label: string; depth: number; phaseId: string; operationPath: readonly string[]; parentId?: string; children: readonly string[]; state: WorkflowPhaseState | AgentRecord["state"]; agentId?: string; agent?: AgentRecord; phase?: WorkflowPhaseView }
|
|
282
|
+
export interface WorkflowPhaseTree { roots: readonly string[]; nodes: readonly WorkflowPhaseTreeNode[]; byId: ReadonlyMap<string, WorkflowPhaseTreeNode> }
|
|
283
|
+
export interface WorkflowPhaseTreeSelection { nodeId?: string | undefined }
|
|
284
|
+
export type WorkflowPhaseTreeDirection = "up" | "down" | "left" | "right";
|
|
285
|
+
function workflowPhaseTreePath(kind: WorkflowPhaseTreeNodeKind, phaseId: string, operationPath: readonly string[], agentId?: string): string {
|
|
286
|
+
const root = `phase/${encodeURIComponent(phaseId)}`;
|
|
287
|
+
if (kind === "phase") return root;
|
|
288
|
+
const operation = operationPath.map((part) => encodeURIComponent(part)).join("/");
|
|
289
|
+
if (kind === "operation") return `${root}/operation/${operation}`;
|
|
290
|
+
return operation ? `${root}/operation/${operation}/agent/${encodeURIComponent(agentId ?? "")}` : `${root}/agent/${encodeURIComponent(agentId ?? "")}`;
|
|
291
|
+
}
|
|
292
|
+
function workflowPhaseTreeAggregateState(states: readonly AgentRecord["state"][]): AgentRecord["state"] {
|
|
293
|
+
if (!states.length || states.every((state) => state === "completed")) return "completed";
|
|
294
|
+
if (states.some((state) => state === "failed")) return "failed";
|
|
295
|
+
if (states.some((state) => state === "cancelled")) return "cancelled";
|
|
296
|
+
if (states.some((state) => state === "running")) return "running";
|
|
297
|
+
return "queued";
|
|
298
|
+
}
|
|
299
|
+
export function buildWorkflowPhaseTree(model: WorkflowPhaseModel): WorkflowPhaseTree {
|
|
300
|
+
type Draft = Omit<WorkflowPhaseTreeNode, "children"> & { children: string[] };
|
|
301
|
+
type AgentEntry = { agent: AgentRecord; node: Draft; path: readonly string[]; defaultParentId: string };
|
|
302
|
+
const drafts = new Map<string, Draft>();
|
|
303
|
+
const roots: string[] = [];
|
|
304
|
+
const add = (node: Omit<Draft, "children">, parentId?: string): Draft => {
|
|
305
|
+
const existing = drafts.get(node.id);
|
|
306
|
+
if (existing) return existing;
|
|
307
|
+
const draft: Draft = { ...node, ...(parentId === undefined ? {} : { parentId }), children: [] };
|
|
308
|
+
drafts.set(draft.id, draft);
|
|
309
|
+
if (parentId === undefined) roots.push(draft.id); else drafts.get(parentId)?.children.push(draft.id);
|
|
310
|
+
return draft;
|
|
311
|
+
};
|
|
312
|
+
const samePath = (left: readonly string[], right: readonly string[]): boolean => left.length === right.length && left.every((part, index) => part === right[index]);
|
|
313
|
+
const addPhase = (phaseId: string, label: string, agents: readonly AgentRecord[], phase?: WorkflowPhaseView): void => {
|
|
314
|
+
const phaseNode = add({ id: workflowPhaseTreePath("phase", phaseId, []), kind: "phase", label, depth: 0, phaseId, operationPath: [], state: phase?.state ?? workflowPhaseTreeAggregateState(agents.map((agent) => agent.state)), ...(phase ? { phase } : {}) });
|
|
315
|
+
const operationNodes = new Map<string, Draft>();
|
|
316
|
+
const entries: AgentEntry[] = agents.map((agent) => ({ agent, path: [...(agent.structuralPath ?? [])], node: undefined as unknown as Draft, defaultParentId: phaseNode.id }));
|
|
317
|
+
const agentEntries = new Map(entries.map((entry) => [entry.agent.id, entry]));
|
|
318
|
+
const acceptedParents = new Map<string, string>();
|
|
319
|
+
const wouldCycle = (childId: string, parentId: string): boolean => {
|
|
320
|
+
const seen = new Set<string>([childId]);
|
|
321
|
+
let current: string | undefined = parentId;
|
|
322
|
+
while (current) {
|
|
323
|
+
if (seen.has(current)) return true;
|
|
324
|
+
seen.add(current);
|
|
325
|
+
current = acceptedParents.get(current);
|
|
326
|
+
}
|
|
327
|
+
return false;
|
|
328
|
+
};
|
|
329
|
+
for (const entry of entries) {
|
|
330
|
+
const parent = entry.agent.parentId ? agentEntries.get(entry.agent.parentId) : undefined;
|
|
331
|
+
if (parent && !wouldCycle(entry.agent.id, parent.agent.id)) acceptedParents.set(entry.agent.id, parent.agent.id);
|
|
332
|
+
}
|
|
333
|
+
const operationChain = (path: readonly string[], owner: Draft, startIndex = 0): Draft => {
|
|
334
|
+
let parent = owner;
|
|
335
|
+
for (let index = startIndex; index < path.length; index += 1) {
|
|
336
|
+
const prefix = path.slice(0, index + 1);
|
|
337
|
+
const key = `${owner.id}:${JSON.stringify(prefix)}`;
|
|
338
|
+
const existing = operationNodes.get(key);
|
|
339
|
+
if (existing) { parent = existing; continue; }
|
|
340
|
+
const suffix = path.slice(startIndex, index + 1).map((part) => encodeURIComponent(part)).join("/");
|
|
341
|
+
const id = owner.id === phaseNode.id ? workflowPhaseTreePath("operation", phaseId, prefix) : `${owner.id}/operation/${suffix}`;
|
|
342
|
+
const operation = add({ id, kind: "operation", label: prefix.at(-1) ?? "", depth: 0, phaseId, operationPath: prefix, state: "queued", ...(phase ? { phase } : {}) }, parent.id);
|
|
343
|
+
operationNodes.set(key, operation);
|
|
344
|
+
parent = operation;
|
|
345
|
+
}
|
|
346
|
+
return parent;
|
|
347
|
+
};
|
|
348
|
+
for (const entry of entries) {
|
|
349
|
+
if (!acceptedParents.has(entry.agent.id)) entry.defaultParentId = operationChain(entry.path, phaseNode).id;
|
|
350
|
+
}
|
|
351
|
+
for (const entry of entries) {
|
|
352
|
+
entry.node = add({ id: workflowPhaseTreePath("agent", phaseId, entry.path, entry.agent.id), kind: "agent", label: entry.agent.label ?? entry.agent.name, depth: 0, phaseId, operationPath: entry.path, state: entry.agent.state, agentId: entry.agent.id, agent: entry.agent }, phaseNode.id);
|
|
353
|
+
}
|
|
354
|
+
const attach = (entry: AgentEntry, parentId: string): void => {
|
|
355
|
+
const previous = entry.node.parentId ? drafts.get(entry.node.parentId) : undefined;
|
|
356
|
+
if (previous) previous.children = previous.children.filter((childId) => childId !== entry.node.id);
|
|
357
|
+
entry.node.parentId = parentId;
|
|
358
|
+
const parent = drafts.get(parentId);
|
|
359
|
+
if (parent && !parent.children.includes(entry.node.id)) parent.children.push(entry.node.id);
|
|
360
|
+
};
|
|
361
|
+
for (const entry of entries) {
|
|
362
|
+
const parentId = acceptedParents.get(entry.agent.id);
|
|
363
|
+
const parent = parentId ? agentEntries.get(parentId) : undefined;
|
|
364
|
+
if (parent) {
|
|
365
|
+
if (samePath(entry.path, parent.path)) entry.defaultParentId = parent.node.id;
|
|
366
|
+
else {
|
|
367
|
+
const commonLength = entry.path.findIndex((part, index) => parent.path[index] !== part);
|
|
368
|
+
const startIndex = commonLength < 0 ? Math.min(entry.path.length, parent.path.length) : commonLength;
|
|
369
|
+
entry.defaultParentId = operationChain(entry.path, parent.node, startIndex).id;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
attach(entry, entry.defaultParentId);
|
|
373
|
+
}
|
|
374
|
+
const setDepth = (node: Draft, depth: number, seen = new Set<string>()): void => {
|
|
375
|
+
if (seen.has(node.id)) return;
|
|
376
|
+
seen.add(node.id);
|
|
377
|
+
node.depth = depth;
|
|
378
|
+
for (const childId of node.children) { const child = drafts.get(childId); if (child) setDepth(child, depth + 1, seen); }
|
|
379
|
+
};
|
|
380
|
+
setDepth(phaseNode, 0);
|
|
381
|
+
const statesFor = (node: Draft, seen = new Set<string>()): AgentRecord["state"][] => {
|
|
382
|
+
if (seen.has(node.id)) return [];
|
|
383
|
+
const nextSeen = new Set(seen).add(node.id);
|
|
384
|
+
return node.children.flatMap((childId) => {
|
|
385
|
+
const child = drafts.get(childId);
|
|
386
|
+
return child?.kind === "agent" ? [child.agent?.state ?? "queued", ...statesFor(child, nextSeen)] : child ? statesFor(child, nextSeen) : [];
|
|
387
|
+
});
|
|
388
|
+
};
|
|
389
|
+
for (const operation of operationNodes.values()) operation.state = workflowPhaseTreeAggregateState(statesFor(operation));
|
|
390
|
+
};
|
|
391
|
+
for (const phase of model.phases) addPhase(phase.id, `${phase.name}${phase.occurrence > 1 ? ` #${String(phase.occurrence)}` : ""}`, phase.agents, phase);
|
|
392
|
+
if (model.unassignedAgents?.length) addPhase("unassigned", "Unassigned", model.unassignedAgents);
|
|
393
|
+
const nodes = [...drafts.values()].map((node) => ({ ...node, children: [...node.children] }));
|
|
394
|
+
return { roots, nodes, byId: new Map(nodes.map((node) => [node.id, node])) };
|
|
395
|
+
}
|
|
396
|
+
export function workflowPhaseTreeVisibleNodes(tree: WorkflowPhaseTree, expanded: ReadonlySet<string> = new Set()): readonly WorkflowPhaseTreeNode[] {
|
|
397
|
+
const visible: WorkflowPhaseTreeNode[] = [];
|
|
398
|
+
const visit = (id: string): void => {
|
|
399
|
+
const node = tree.byId.get(id);
|
|
400
|
+
if (!node) return;
|
|
401
|
+
visible.push(node);
|
|
402
|
+
if (expanded.has(node.id)) for (const childId of node.children) visit(childId);
|
|
403
|
+
};
|
|
404
|
+
for (const root of tree.roots) visit(root);
|
|
405
|
+
return visible;
|
|
406
|
+
}
|
|
407
|
+
export function workflowPhaseTreeInitialExpanded(tree: WorkflowPhaseTree): ReadonlySet<string> {
|
|
408
|
+
return new Set(tree.nodes.filter((node) => node.children.length > 0).map((node) => node.id));
|
|
409
|
+
}
|
|
410
|
+
export function preserveWorkflowPhaseTreeSelection(tree: WorkflowPhaseTree, selection: WorkflowPhaseTreeSelection): WorkflowPhaseTreeSelection {
|
|
411
|
+
const node = (selection.nodeId ? tree.byId.get(selection.nodeId) : undefined) ?? tree.nodes[0];
|
|
412
|
+
return node ? { nodeId: node.id } : {};
|
|
413
|
+
}
|
|
414
|
+
export function navigateWorkflowPhaseTree(tree: WorkflowPhaseTree, selectedNodeId: string | undefined, expandedNodeIds: ReadonlySet<string>, direction: WorkflowPhaseTreeDirection): { nodeId?: string; expandedNodeIds: ReadonlySet<string> } {
|
|
415
|
+
const expanded = new Set(expandedNodeIds);
|
|
416
|
+
const current = (selectedNodeId ? tree.byId.get(selectedNodeId) : undefined) ?? tree.nodes[0];
|
|
417
|
+
if (!current) return { expandedNodeIds: expanded };
|
|
418
|
+
if (direction === "left") {
|
|
419
|
+
if (current.children.length && expanded.delete(current.id)) return { nodeId: current.id, expandedNodeIds: expanded };
|
|
420
|
+
return { nodeId: current.parentId ?? current.id, expandedNodeIds: expanded };
|
|
421
|
+
}
|
|
422
|
+
if (direction === "right") {
|
|
423
|
+
if (current.children.length && !expanded.has(current.id)) { expanded.add(current.id); return { nodeId: current.id, expandedNodeIds: expanded }; }
|
|
424
|
+
return { nodeId: current.children[0] ?? current.id, expandedNodeIds: expanded };
|
|
425
|
+
}
|
|
426
|
+
const visible = workflowPhaseTreeVisibleNodes(tree, expanded);
|
|
427
|
+
const index = Math.max(0, visible.findIndex((node) => node.id === current.id));
|
|
428
|
+
const next = visible[(index + (direction === "up" ? visible.length - 1 : 1)) % visible.length];
|
|
429
|
+
return { nodeId: next?.id ?? current.id, expandedNodeIds: expanded };
|
|
430
|
+
}
|
|
262
431
|
export function preserveWorkflowPhaseSelection(model: WorkflowPhaseModel, selection: WorkflowPhaseSelection): WorkflowPhaseSelection {
|
|
263
432
|
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
|
-
|
|
433
|
+
if (!phase) return model.unassignedAgents?.length ? { nodeId: workflowPhaseTreePath("phase", "unassigned", []) } : {};
|
|
434
|
+
const tree = buildWorkflowPhaseTree(model);
|
|
435
|
+
const selectedAgent = selection.agentId ? phase.agents.find((candidate) => candidate.id === selection.agentId) : undefined;
|
|
436
|
+
const selectedCandidate = selection.nodeId ? tree.byId.get(selection.nodeId) : undefined;
|
|
437
|
+
const selected = selectedCandidate?.phaseId === phase.id ? selectedCandidate : selectedAgent ? tree.byId.get(workflowPhaseTreePath("agent", phase.id, selectedAgent.structuralPath ?? [], selectedAgent.id)) : undefined;
|
|
438
|
+
const nodeId = selected?.id ?? tree.byId.get(workflowPhaseTreePath("phase", phase.id, []))?.id;
|
|
439
|
+
return { phaseId: phase.id, ...(selection.agentId && phase.agents.some((agent) => agent.id === selection.agentId) ? { agentId: selection.agentId } : phase.agents[0] ? { agentId: phase.agents[0].id } : {}), ...(nodeId ? { nodeId } : {}), ...(selection.expandedNodeIds ? { expandedNodeIds: selection.expandedNodeIds } : {}) };
|
|
267
440
|
}
|
|
268
441
|
type AgentGroup = { label: string; entries: readonly { agent: AgentRecord; index: number; depth: number }[] };
|
|
269
442
|
function agentGroupKey(agent: AgentRecord): string { return JSON.stringify([agent.structuralPath ?? [], agent.parentBreadcrumb ?? null]); }
|
|
@@ -294,23 +467,32 @@ function renderGroupedAgents(agents: readonly AgentRecord[], render: (entry: { a
|
|
|
294
467
|
...group.entries.map((entry) => render(entry, grouped)),
|
|
295
468
|
]);
|
|
296
469
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
}
|
|
470
|
+
const RUN_STATE_GLYPH: Record<string, string> = { completed: "✓", failed: "✗", stopped: "✗", budget_exhausted: "!", awaiting_input: "●" };
|
|
471
|
+
const AGENT_STATE_GLYPH: Record<string, string> = { completed: "✓", failed: "✗", cancelled: "✗" };
|
|
472
|
+
function runStateGlyph(state: string, running: string): string { return state === "running" ? running : RUN_STATE_GLYPH[state] ?? "◆"; }
|
|
473
|
+
function agentStateGlyph(state: string, running: string): string { return state === "running" ? running : AGENT_STATE_GLYPH[state] ?? "○"; }
|
|
474
|
+
type ProgressStyleKey = "success" | "error" | "warning" | "accent" | "muted";
|
|
475
|
+
const PROGRESS_STATE_STYLE: Record<string, ProgressStyleKey> = { completed: "success", failed: "error", cancelled: "error", running: "accent" };
|
|
476
|
+
const WORKFLOW_ICON_STYLE: Record<string, ProgressStyleKey> = { completed: "success", failed: "error", stopped: "error", budget_exhausted: "warning", running: "accent" };
|
|
477
|
+
const PHASE_STATE_STYLE: Record<string, ProgressStyleKey> = { completed: "success", failed: "error", cancelled: "error", running: "accent", interrupted: "warning", budget_exhausted: "warning" };
|
|
478
|
+
function styleForState(map: Record<string, ProgressStyleKey>, state: string, styles: WorkflowProgressStyles): (text: string) => string {
|
|
479
|
+
const key = map[state] ?? "muted";
|
|
480
|
+
return (text) => styles[key](text);
|
|
481
|
+
}
|
|
482
|
+
function progressStyleForState(state: string, styles: WorkflowProgressStyles): (text: string) => string { return styleForState(PROGRESS_STATE_STYLE, state, styles); }
|
|
483
|
+
function workflowIconStyle(state: string, styles: WorkflowProgressStyles): (text: string) => string { return styleForState(WORKFLOW_ICON_STYLE, state, styles); }
|
|
484
|
+
function phaseStyleForState(state: string, styles: WorkflowProgressStyles): (text: string) => string { return styleForState(PHASE_STATE_STYLE, state, styles); }
|
|
303
485
|
export function formatWorkflowProgress(run: PersistedRun, spinner = "◇", styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES): string {
|
|
304
486
|
const done = run.agents.filter((agent) => SETTLED_AGENT_STATES.has(agent.state)).length;
|
|
305
|
-
const workflowIcon = run.state
|
|
306
|
-
const
|
|
487
|
+
const workflowIcon = runStateGlyph(run.state, spinner);
|
|
488
|
+
const iconStyle = workflowIconStyle(run.state, styles);
|
|
307
489
|
const header = styles.bold(styles.accent(`Workflow: ${run.workflowName} (${String(done)}/${String(run.agents.length)} done)`));
|
|
308
|
-
const lines = [`${
|
|
490
|
+
const lines = [`${iconStyle(workflowIcon)} ${header}`];
|
|
309
491
|
const budgetWarning = run.state === "budget_exhausted" || (run.budgetEvents ?? []).some((event) => event.type === "hard_exhausted");
|
|
310
492
|
lines.push(...formatCompactBudgetStatus(run).map((line) => ` ${budgetWarning ? styles.warning(line) : line}`));
|
|
311
493
|
const byId = new Map(run.agents.map((agent) => [agent.id, agent]));
|
|
312
494
|
const renderAgents = (agents: readonly AgentRecord[], offset: number, nested: boolean) => renderGroupedAgents(agents, ({ agent, index, depth }, grouped) => {
|
|
313
|
-
const icon = agent.state
|
|
495
|
+
const icon = agentStateGlyph(agent.state, spinner);
|
|
314
496
|
const indent = " ".repeat((grouped ? 2 : 1) + depth);
|
|
315
497
|
const activity = SETTLED_AGENT_STATES.has(agent.state) ? "" : formatAgentActivity(agent, spinner, styles);
|
|
316
498
|
const name = grouped ? agent.label ?? agent.name : styledAgentBreadcrumb(agent, byId, styles);
|
|
@@ -364,6 +546,93 @@ function workflowCatalogBlock(text: string, expanded: boolean) {
|
|
|
364
546
|
};
|
|
365
547
|
}
|
|
366
548
|
|
|
549
|
+
type WorkflowControlResult = { details?: unknown; content?: readonly { type: string; text?: string }[] };
|
|
550
|
+
function controlString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; }
|
|
551
|
+
function controlValue(value: unknown): string {
|
|
552
|
+
if (value === null) return "removed";
|
|
553
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
|
|
554
|
+
const json = JSON.stringify(value);
|
|
555
|
+
return typeof json === "string" ? json : "unknown";
|
|
556
|
+
}
|
|
557
|
+
function controlTitle(name: string, theme: Theme): string { return theme.fg("toolTitle", theme.bold(name)); }
|
|
558
|
+
function controlState(state: string, theme: Theme): string {
|
|
559
|
+
const color = state === "completed" || state === "running" || state === "stopped" ? "success" : state === "failed" || state === "unknown" ? "error" : state === "budget_exhausted" || state === "awaiting_approval" ? "warning" : "accent";
|
|
560
|
+
return theme.fg(color, state);
|
|
561
|
+
}
|
|
562
|
+
function controlAction(action: string, theme: Theme): string {
|
|
563
|
+
const color = /approved|stopped|started|resumed/.test(action) ? "success" : /rejected|failed/.test(action) ? "error" : "warning";
|
|
564
|
+
return theme.fg(color, action);
|
|
565
|
+
}
|
|
566
|
+
function budgetPatchEntries(value: unknown): string[] {
|
|
567
|
+
if (!object(value)) return value === undefined ? [] : [controlValue(value)];
|
|
568
|
+
return Object.entries(value).map(([dimension, limits]) => {
|
|
569
|
+
if (limits === null) return `${dimension}=removed`;
|
|
570
|
+
if (!object(limits)) return `${dimension}=${controlValue(limits)}`;
|
|
571
|
+
const parts = ["soft", "hard"].filter((key) => Object.prototype.hasOwnProperty.call(limits, key)).map((key) => `${key}=${controlValue(limits[key])}`);
|
|
572
|
+
return `${dimension} ${parts.join(" ")}`;
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
function budgetPatchSummary(value: unknown): string {
|
|
576
|
+
const entries = budgetPatchEntries(value);
|
|
577
|
+
return entries.length ? entries.join(", ") : "unchanged";
|
|
578
|
+
}
|
|
579
|
+
function budgetPatchDetails(value: unknown, theme: Theme): string[] {
|
|
580
|
+
const entries = budgetPatchEntries(value);
|
|
581
|
+
return entries.length ? [theme.fg("accent", theme.bold("Budget patch")), ...entries.map((entry) => ` ${theme.fg("toolOutput", entry)}`)] : [];
|
|
582
|
+
}
|
|
583
|
+
function workflowControlValue(result: WorkflowControlResult): unknown { return catalogResultValue(result); }
|
|
584
|
+
function workflowControlCall(name: string, args: Record<string, unknown>, theme: Theme): string {
|
|
585
|
+
const runId = controlString(args.runId) ?? "(missing run ID)";
|
|
586
|
+
if (name === "workflow_respond") {
|
|
587
|
+
const proposalId = controlString(args.proposalId);
|
|
588
|
+
const target = proposalId ? `budget proposal ${proposalId}` : `checkpoint ${controlString(args.name) ?? "(missing name)"}`;
|
|
589
|
+
const decision = args.approved === true ? "approve" : "reject";
|
|
590
|
+
return [`${controlTitle(name, theme)} ${theme.fg("accent", runId)}`, `${theme.fg("muted", target)} · ${controlAction(decision, theme)}`].join("\n");
|
|
591
|
+
}
|
|
592
|
+
if (name === "workflow_resume") return args.budget === undefined ? `${controlTitle(name, theme)} ${theme.fg("accent", runId)}` : [`${controlTitle(name, theme)} ${theme.fg("accent", runId)}`, `${theme.fg("muted", "Budget")} ${theme.fg("toolOutput", budgetPatchSummary(args.budget))}`].join("\n");
|
|
593
|
+
if (name === "workflow_retry") return `${controlTitle(name, theme)} ${theme.fg("accent", runId)} ${theme.fg("muted", "failed run")}`;
|
|
594
|
+
return `${controlTitle(name, theme)} ${theme.fg("accent", runId)}`;
|
|
595
|
+
}
|
|
596
|
+
function workflowControlResult(name: string, args: Record<string, unknown>, result: WorkflowControlResult, expanded: boolean, theme: Theme, isError: boolean): string {
|
|
597
|
+
if (isError) {
|
|
598
|
+
const text = result.content?.filter(({ type }) => type === "text").map(({ text }) => text ?? "").join("\n").trim();
|
|
599
|
+
return theme.fg("error", text || `The ${name} tool failed.`);
|
|
600
|
+
}
|
|
601
|
+
const value = workflowControlValue(result);
|
|
602
|
+
if (!object(value)) return theme.fg("error", `The ${name} tool returned an invalid result.`);
|
|
603
|
+
const runId = controlString(args.runId) ?? controlString(value.runId) ?? "(unknown)";
|
|
604
|
+
const title = controlTitle(name, theme);
|
|
605
|
+
if (name === "workflow_stop") {
|
|
606
|
+
const state = controlString(value.state) ?? "unknown";
|
|
607
|
+
const action = value.stopped === true ? "stopped" : value.reason === "already_terminal" ? "already terminal" : value.reason === "unknown_run" ? "run not found" : "no change";
|
|
608
|
+
if (!expanded) return `${title}\nRun ${theme.fg("accent", runId)} · ${controlState(state, theme)} · ${controlAction(action, theme)}`;
|
|
609
|
+
return [title, `Run: ${theme.fg("accent", runId)}`, `State: ${controlState(state, theme)}`, `Action: ${controlAction(action, theme)}`, ...(controlString(value.reason) ? [`Reason: ${theme.fg("toolOutput", controlValue(value.reason))}`] : [])].join("\n");
|
|
610
|
+
}
|
|
611
|
+
if (name === "workflow_retry") {
|
|
612
|
+
const childRunId = controlString(value.runId) ?? "(unknown)";
|
|
613
|
+
const state = controlString(value.state) ?? "unknown";
|
|
614
|
+
if (!expanded) return [title, `Source ${theme.fg("accent", runId)}`, `Child ${theme.fg("accent", childRunId)} · ${controlState(state, theme)} · ${controlAction("started", theme)}`].join("\n");
|
|
615
|
+
return [title, `Source run: ${theme.fg("accent", runId)}`, `Retry run: ${theme.fg("accent", childRunId)}`, `State: ${controlState(state, theme)}`, `Action: ${controlAction("started; completed work will be replayed", theme)}`].join("\n");
|
|
616
|
+
}
|
|
617
|
+
if (name === "workflow_resume") {
|
|
618
|
+
const state = controlString(value.state) ?? "unknown";
|
|
619
|
+
const proposalId = controlString(value.proposalId);
|
|
620
|
+
const action = state === "awaiting_approval" ? "approval required" : state === "running" ? "resumed" : "no change";
|
|
621
|
+
if (!expanded) return [title, `Run ${theme.fg("accent", runId)} · ${controlState(state, theme)} · ${controlAction(action, theme)}`, ...(proposalId ? [`Proposal ${theme.fg("accent", proposalId)}`] : [])].join("\n");
|
|
622
|
+
return [title, `Run: ${theme.fg("accent", runId)}`, `State: ${controlState(state, theme)}`, `Action: ${controlAction(action, theme)}`, ...(proposalId ? [`Proposal: ${theme.fg("accent", proposalId)}`] : []), ...budgetPatchDetails(args.budget, theme)].join("\n");
|
|
623
|
+
}
|
|
624
|
+
const proposalId = controlString(args.proposalId);
|
|
625
|
+
const checkpointName = controlString(args.name);
|
|
626
|
+
const target = proposalId ? `Budget proposal ${theme.fg("accent", proposalId)}` : `Checkpoint ${theme.fg("accent", checkpointName ?? "(missing)")}`;
|
|
627
|
+
const accepted = value.accepted === true;
|
|
628
|
+
const approved = value.approved === true;
|
|
629
|
+
const reason = controlString(value.reason);
|
|
630
|
+
const action = reason === "proposal_not_pending" ? "not pending" : reason === "checkpoint" && !accepted ? "not pending" : approved ? "approved" : "rejected";
|
|
631
|
+
const state = controlString(value.state);
|
|
632
|
+
if (!expanded) return [title, target, `Run ${theme.fg("accent", runId)} · ${controlAction(action, theme)}${state ? ` · ${controlState(state, theme)}` : ""}`].join("\n");
|
|
633
|
+
return [title, `Run: ${theme.fg("accent", runId)}`, `Target: ${target}`, `Action: ${controlAction(action, theme)}`, ...(state ? [`State: ${controlState(state, theme)}`] : []), ...(reason ? [`Reason: ${theme.fg("toolOutput", reason)}`] : [])].join("\n");
|
|
634
|
+
}
|
|
635
|
+
|
|
367
636
|
function catalogText(value: string): string { return value.replace(/\s+/g, " ").trim(); }
|
|
368
637
|
|
|
369
638
|
type CatalogToolResult = { details?: unknown; content?: readonly { type: string; text?: string }[] };
|
|
@@ -439,7 +708,8 @@ function formatWorkflowCatalog(value: unknown, expanded: boolean, theme: Theme):
|
|
|
439
708
|
return theme.fg("error", "The workflow catalog returned an invalid result.");
|
|
440
709
|
}
|
|
441
710
|
|
|
442
|
-
const
|
|
711
|
+
const ANSI_SGR_SOURCE = `${String.fromCharCode(27)}\\[[0-9;]*m`;
|
|
712
|
+
const ANSI_SGR = new RegExp(ANSI_SGR_SOURCE);
|
|
443
713
|
export function truncateWorkflowProgress(text: string, width: number): string[] {
|
|
444
714
|
const safeWidth = Math.max(1, width);
|
|
445
715
|
return text.split("\n").flatMap((line) => {
|
|
@@ -506,7 +776,7 @@ function navigatorRunLabels(entries: readonly { store: RunStore; loaded: { run:
|
|
|
506
776
|
for (const { loaded: { run } } of entries) nameCount.set(run.workflowName, (nameCount.get(run.workflowName) ?? 0) + 1);
|
|
507
777
|
return entries.map(({ store, loaded: { run } }) => {
|
|
508
778
|
const done = run.agents.filter((a) => SETTLED_AGENT_STATES.has(a.state)).length;
|
|
509
|
-
const glyph = run.state
|
|
779
|
+
const glyph = runStateGlyph(run.state, "⠦");
|
|
510
780
|
const suffix = (nameCount.get(run.workflowName) ?? 0) > 1 ? ` ${store.runId.slice(0, 8)}` : "";
|
|
511
781
|
const cost = run.agents.reduce((sum, a) => sum + (a.accounting?.cost ?? 0), 0);
|
|
512
782
|
const costStr = cost > 0 ? ` $${cost.toFixed(2)}` : "";
|
|
@@ -555,7 +825,7 @@ export function formatNavigatorDashboard(run: PersistedRun, checkpoints: readonl
|
|
|
555
825
|
const done = run.agents.filter((a) => SETTLED_AGENT_STATES.has(a.state)).length;
|
|
556
826
|
const totalAccounting = run.agents.reduce((sum, a) => ({ input: sum.input + (a.accounting?.input ?? 0), output: sum.output + (a.accounting?.output ?? 0), cacheRead: sum.cacheRead + (a.accounting?.cacheRead ?? 0), cacheWrite: sum.cacheWrite + (a.accounting?.cacheWrite ?? 0), cost: sum.cost + (a.accounting?.cost ?? 0) }), { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 });
|
|
557
827
|
const hasAccounting = run.agents.some((a) => a.accounting);
|
|
558
|
-
const glyph = run.state
|
|
828
|
+
const glyph = runStateGlyph(run.state, "⠦");
|
|
559
829
|
const header = `${glyph} ${run.workflowName}`;
|
|
560
830
|
const meta = [run.state, run.phase ? `phase: ${run.phase}` : "", `${String(done)}/${String(run.agents.length)} agents`, hasAccounting ? formatAccounting(totalAccounting) : "", totalAccounting.cost > 0 ? `$${totalAccounting.cost.toFixed(2)}` : ""].filter(Boolean).join(" · ");
|
|
561
831
|
const lines = [header, meta, ...formatCompactBudgetStatus(run)];
|
|
@@ -564,7 +834,7 @@ export function formatNavigatorDashboard(run: PersistedRun, checkpoints: readonl
|
|
|
564
834
|
lines.push("");
|
|
565
835
|
const byId = new Map(run.agents.map((a) => [a.id, a]));
|
|
566
836
|
const render = ({ agent, depth }: { agent: AgentRecord; index: number; depth: number }, grouped: boolean) => {
|
|
567
|
-
const icon = agent.state
|
|
837
|
+
const icon = agentStateGlyph(agent.state, "⠦");
|
|
568
838
|
const breadcrumb = grouped ? agent.label ?? agent.name : agentBreadcrumb(agent, byId);
|
|
569
839
|
const tokens = agent.accounting ? formatAccounting(agent.accounting) : "";
|
|
570
840
|
const indent = " ".repeat((grouped ? 2 : 1) + depth);
|
|
@@ -582,7 +852,7 @@ export function formatNavigatorDashboard(run: PersistedRun, checkpoints: readonl
|
|
|
582
852
|
return lines.join("\n");
|
|
583
853
|
}
|
|
584
854
|
|
|
585
|
-
export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> }, checkpoints: readonly AwaitingCheckpoint[],
|
|
855
|
+
export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> }, checkpoints: readonly AwaitingCheckpoint[], worktrees: readonly WorktreeReference[]): string {
|
|
586
856
|
const { run, snapshot } = loaded;
|
|
587
857
|
const lines = [
|
|
588
858
|
`Workflow: ${run.workflowName}`,
|
|
@@ -616,62 +886,92 @@ export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readon
|
|
|
616
886
|
lines.push("Checkpoints:");
|
|
617
887
|
if (!checkpoints.length) lines.push(" (none)");
|
|
618
888
|
for (const checkpoint of checkpoints) lines.push(` ${checkpoint.name}: ${checkpoint.prompt} context=${JSON.stringify(checkpoint.context)}`);
|
|
619
|
-
lines.push(`Worktrees: ${String(
|
|
889
|
+
lines.push(`Worktrees: ${String(worktrees.length)}`);
|
|
620
890
|
lines.push(`Native Pi transcripts: ${String(run.nativeSessions.length)}`);
|
|
621
891
|
return lines.join("\n");
|
|
622
892
|
}
|
|
623
893
|
export function formatWorkflowPhaseDashboard(run: PersistedRun, snapshot: Readonly<LaunchSnapshot>, width: number, selection: WorkflowPhaseSelection = {}, styles: WorkflowProgressStyles = PLAIN_WORKFLOW_PROGRESS_STYLES): string[] {
|
|
624
894
|
const safeWidth = Math.max(1, width);
|
|
625
895
|
const model = buildWorkflowPhaseModel(run, snapshot);
|
|
896
|
+
const tree = buildWorkflowPhaseTree(model);
|
|
897
|
+
const expanded = selection.expandedNodeIds === undefined ? workflowPhaseTreeInitialExpanded(tree) : new Set(selection.expandedNodeIds);
|
|
626
898
|
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
|
|
899
|
+
// ponytail: ANSI-only width, good enough for the ASCII labels the tree renders
|
|
900
|
+
const ansiPattern = new RegExp(ANSI_SGR_SOURCE, "g");
|
|
901
|
+
const visibleLength = (text: string): number => text.replace(ansiPattern, "").length;
|
|
902
|
+
const padTo = (text: string, limit: number): string => `${text}${" ".repeat(Math.max(0, limit - visibleLength(text)))}`;
|
|
903
|
+
const phaseStyle = (state: WorkflowPhaseState | AgentRecord["state"]): ((text: string) => string) => phaseStyleForState(state, styles);
|
|
904
|
+
const phase = selection.phaseId ? model.phases.find((candidate) => candidate.id === selection.phaseId) : undefined;
|
|
905
|
+
const selectedByAgent = selection.agentId ? tree.nodes.find((node) => node.kind === "agent" && node.agentId === selection.agentId && (!selection.phaseId || node.phaseId === selection.phaseId)) : undefined;
|
|
906
|
+
const selectedNode = (selection.nodeId ? tree.byId.get(selection.nodeId) : undefined) ?? selectedByAgent ?? (phase ? tree.byId.get(workflowPhaseTreePath("phase", phase.id, [])) : undefined) ?? (model.currentPhaseId ? tree.byId.get(workflowPhaseTreePath("phase", model.currentPhaseId, [])) : undefined) ?? tree.nodes[0];
|
|
907
|
+
const selectedPhase = selectedNode?.phase ?? (selectedNode ? model.phases.find((candidate) => candidate.id === selectedNode.phaseId) : undefined);
|
|
908
|
+
const visibleNodes = workflowPhaseTreeVisibleNodes(tree, expanded);
|
|
909
|
+
const nodeAgents = (node: WorkflowPhaseTreeNode): AgentRecord[] => {
|
|
910
|
+
const agents: AgentRecord[] = [];
|
|
911
|
+
const visit = (id: string): void => {
|
|
912
|
+
const child = tree.byId.get(id);
|
|
913
|
+
if (!child) return;
|
|
914
|
+
if (child.agent) agents.push(child.agent); else for (const childId of child.children) visit(childId);
|
|
915
|
+
};
|
|
916
|
+
if (node.agent) agents.push(node.agent); else for (const childId of node.children) visit(childId);
|
|
917
|
+
return agents;
|
|
918
|
+
};
|
|
919
|
+
const nodeStatus = (node: WorkflowPhaseTreeNode): string => phaseStyle(node.state)(node.state);
|
|
920
|
+
const nodeIcon = (node: WorkflowPhaseTreeNode): string => node.children.length ? expanded.has(node.id) ? "▾" : "▸" : node.kind === "agent" ? "•" : " ";
|
|
921
|
+
const treeLine = (node: WorkflowPhaseTreeNode): string => {
|
|
922
|
+
const selected = node.id === selectedNode?.id;
|
|
923
|
+
const state = progressStyleForState(node.state, styles);
|
|
924
|
+
return `${selected ? "→" : " "} ${" ".repeat(node.depth)}${nodeIcon(node)} ${node.label} · ${state(node.state)}`;
|
|
925
|
+
};
|
|
926
|
+
const details = (node: WorkflowPhaseTreeNode | undefined): string[] => {
|
|
927
|
+
if (!node) return [styles.muted("No workflow node is selected")];
|
|
928
|
+
const agents = nodeAgents(node);
|
|
929
|
+
if (node.kind === "phase") {
|
|
930
|
+
const selected = node.phase;
|
|
931
|
+
const counts = selected?.counts ?? phaseAgentCounts(agents);
|
|
932
|
+
return [styles.bold(`Selected phase: ${node.label}`), `Status: ${nodeStatus(node)}`, `agents completed=${String(counts.completed)} running=${String(counts.running)} failed=${String(counts.failed)} cancelled=${String(counts.cancelled)} pending=${String(counts.pending)}`, `Agents: ${String(agents.length)}`];
|
|
933
|
+
}
|
|
934
|
+
if (node.kind === "operation") {
|
|
935
|
+
const states = phaseAgentCounts(agents);
|
|
936
|
+
return [styles.bold(`Selected operation: ${node.operationPath.join(" > ")}`), `Phase: ${node.phase?.name ?? node.phaseId}`, `Status: ${nodeStatus(node)}`, `agents completed=${String(states.completed)} running=${String(states.running)} failed=${String(states.failed)} cancelled=${String(states.cancelled)} pending=${String(states.pending)}`, `Agents: ${String(agents.length)}`];
|
|
937
|
+
}
|
|
938
|
+
const agent = node.agent;
|
|
939
|
+
if (!agent) return [styles.muted("Agent details are unavailable")];
|
|
940
|
+
const byId = new Map(run.agents.map((candidate) => [candidate.id, candidate]));
|
|
941
|
+
const result = [styles.bold(`Selected agent: ${agentBreadcrumb(agent, byId, true)}`), `State: ${phaseStyle(agent.state)(agent.state)}`, `Structural path: ${agent.structuralPath?.join(" > ") || "(root)"}`, `Model: ${agent.model.provider}/${agent.model.model}${agent.model.thinking ? `:${agent.model.thinking}` : ""}`, `Role: ${agent.role ?? "(none)"}`, `Tools: ${agent.tools.join(", ") || "(none)"}`, `Attempts: ${String(agent.attempts)}`, ...(selection.actions ? [] : [styles.muted("enter for agent actions")])];
|
|
942
|
+
const error = agent.attemptDetails?.at(-1)?.error;
|
|
943
|
+
if (error) result.push(styles.error(`Error: ${error.code}: ${error.message}`));
|
|
944
|
+
if (agent.activity) result.push(`Activity: ${agent.activity.text}`);
|
|
945
|
+
return result;
|
|
946
|
+
};
|
|
631
947
|
const stateNames: readonly WorkflowPhaseState[] = ["not started", "running", "completed", "failed", "cancelled", "interrupted", "budget_exhausted"];
|
|
632
948
|
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
949
|
const lines: string[] = [styles.bold(styles.accent(`Workflow: ${run.workflowName}`))];
|
|
637
950
|
if (run.error) lines.push(styles.error(`ERROR ${run.error.code}: ${run.error.message}`));
|
|
638
951
|
lines.push(`phase: ${run.phase ?? selectedPhase?.name ?? "none"}`, `Run state: ${run.state}`, `Phases: ${statusSummary}`);
|
|
639
952
|
for (const event of run.events ?? []) lines.push(styles.warning(`Warning: ${event.message}`));
|
|
640
953
|
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;
|
|
954
|
+
const renderTree = (limit: number): string[] => [styles.bold("Tree"), ...(visibleNodes.length ? visibleNodes.flatMap((node) => wrap(treeLine(node), limit)) : [styles.muted("(empty)")])];
|
|
955
|
+
const actionRows = (): string[] => {
|
|
956
|
+
const actions = selection.actions;
|
|
957
|
+
if (!actions) return [];
|
|
958
|
+
return ["", styles.bold(actions.title), ...actions.options.map((option, index) => `${index === actions.index ? "→ " : " "}${index === actions.index ? styles.accent(option) : option}`)];
|
|
656
959
|
};
|
|
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];
|
|
960
|
+
const detailRows = (): string[] => [...details(selectedNode), ...actionRows()];
|
|
662
961
|
if (safeWidth >= 80) {
|
|
663
|
-
const sidebarWidth = Math.min(
|
|
962
|
+
const sidebarWidth = Math.min(42, Math.max(24, Math.floor((safeWidth - 3) * 0.38)));
|
|
664
963
|
const detailWidth = Math.max(1, safeWidth - sidebarWidth - 3);
|
|
665
|
-
const sidebar =
|
|
666
|
-
const detail =
|
|
964
|
+
const sidebar = renderTree(sidebarWidth).flatMap((line) => wrap(line, sidebarWidth));
|
|
965
|
+
const detail = detailRows().flatMap((line) => wrap(line, detailWidth));
|
|
667
966
|
const rows = Math.max(sidebar.length, detail.length);
|
|
668
|
-
for (let index = 0; index < rows; index += 1) lines.push(`${sidebar[index] ?? ""} | ${detail[index] ?? ""}`);
|
|
967
|
+
for (let index = 0; index < rows; index += 1) lines.push(`${padTo(sidebar[index] ?? "", sidebarWidth)} | ${detail[index] ?? ""}`);
|
|
968
|
+
} else if (selection.detailsOnly) {
|
|
969
|
+
lines.push(...detailRows().flatMap((line) => wrap(line)));
|
|
669
970
|
} 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)));
|
|
971
|
+
lines.push(...renderTree(safeWidth));
|
|
972
|
+
if (!selection.treeOnly) lines.push("", ...detailRows().flatMap((line) => wrap(line)));
|
|
673
973
|
}
|
|
674
|
-
if (model.unassignedAgents?.length) lines.push(...wrap(styles.muted(`Unassigned agents: ${String(model.unassignedAgents.length)}`)));
|
|
974
|
+
if (model.unassignedAgents?.length && !tree.nodes.some((node) => node.phaseId === "unassigned")) lines.push(...wrap(styles.muted(`Unassigned agents: ${String(model.unassignedAgents.length)}`)));
|
|
675
975
|
return lines.flatMap((line) => wrap(line));
|
|
676
976
|
}
|
|
677
977
|
function formatCheckpointReview(checkpoint: AwaitingCheckpoint): string {
|
|
@@ -1094,26 +1394,25 @@ function projectTrusted(ctx: unknown): boolean {
|
|
|
1094
1394
|
const check = object(ctx) ? ctx.isProjectTrusted : undefined;
|
|
1095
1395
|
return typeof check === "function" ? Boolean(Reflect.apply(check, ctx, [])) : true;
|
|
1096
1396
|
}
|
|
1397
|
+
function asFn(value: unknown): ((...args: never[]) => unknown) | undefined { return typeof value === "function" ? value as (...args: never[]) => unknown : undefined; }
|
|
1097
1398
|
type PiHostCapabilities = { registerEntryRenderer?: ExtensionAPI["registerEntryRenderer"]; events?: WorkflowEventSink };
|
|
1098
|
-
function isEntryRenderer(value: unknown): value is NonNullable<PiHostCapabilities["registerEntryRenderer"]> { return typeof value === "function"; }
|
|
1099
1399
|
function isWorkflowEventSink(value: unknown): value is WorkflowEventSink { return object(value) && typeof value.emit === "function"; }
|
|
1100
1400
|
function piHostCapabilities(pi: unknown): PiHostCapabilities {
|
|
1101
1401
|
if (!object(pi)) return {};
|
|
1102
|
-
const registerEntryRenderer = pi.registerEntryRenderer;
|
|
1402
|
+
const registerEntryRenderer = asFn(pi.registerEntryRenderer) as NonNullable<PiHostCapabilities["registerEntryRenderer"]> | undefined;
|
|
1103
1403
|
const events = pi.events;
|
|
1104
|
-
return { ...(
|
|
1404
|
+
return { ...(registerEntryRenderer ? { registerEntryRenderer } : {}), ...(isWorkflowEventSink(events) ? { events } : {}) };
|
|
1105
1405
|
}
|
|
1106
1406
|
type ContextHostCapabilities = { modelRegistry?: ModelRegistryCapability };
|
|
1107
1407
|
type ModelSummary = { provider: string; id: string };
|
|
1108
1408
|
type ModelRegistryGetter = () => readonly ModelSummary[];
|
|
1109
1409
|
type ModelRegistryCapability = { getAll?: ModelRegistryGetter; getAvailable?: ModelRegistryGetter };
|
|
1110
|
-
function isModelRegistryGetter(value: unknown): value is ModelRegistryGetter { return typeof value === "function"; }
|
|
1111
1410
|
function contextHostCapabilities(ctx: unknown): ContextHostCapabilities {
|
|
1112
1411
|
if (!object(ctx) || !object(ctx.modelRegistry)) return {};
|
|
1113
1412
|
const registry = ctx.modelRegistry;
|
|
1114
|
-
const getAll = registry.getAll;
|
|
1115
|
-
const getAvailable = registry.getAvailable;
|
|
1116
|
-
return { modelRegistry: { ...(
|
|
1413
|
+
const getAll = (asFn(registry.getAll) as ModelRegistryGetter | undefined);
|
|
1414
|
+
const getAvailable = (asFn(registry.getAvailable) as ModelRegistryGetter | undefined);
|
|
1415
|
+
return { modelRegistry: { ...(getAll ? { getAll: () => getAll.call(registry) } : {}), ...(getAvailable ? { getAvailable: () => getAvailable.call(registry) } : {}) } };
|
|
1117
1416
|
}
|
|
1118
1417
|
function modelInventory(root: ModelSpec | undefined, registry: ModelRegistryCapability | undefined): { knownModels: ReadonlySet<string>; availableModels: ReadonlySet<string> } {
|
|
1119
1418
|
const all = registry?.getAll?.() ?? registry?.getAvailable?.() ?? [];
|
|
@@ -1146,23 +1445,20 @@ type UiSelect = (title: string, options: string[]) => Promise<string | undefined
|
|
|
1146
1445
|
type UiInput = (title: string, placeholder?: string) => Promise<string | undefined>;
|
|
1147
1446
|
type UiSetStatus = (key: string, text?: string) => void;
|
|
1148
1447
|
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
1448
|
function uiHostCapabilities(ui: unknown): UiHostCapabilities | undefined {
|
|
1153
1449
|
if (!object(ui)) return undefined;
|
|
1154
|
-
const select = ui.select;
|
|
1155
|
-
const input = ui.input;
|
|
1156
|
-
const setStatus = ui.setStatus;
|
|
1157
|
-
return { ...(
|
|
1450
|
+
const select = (asFn(ui.select) as UiSelect | undefined);
|
|
1451
|
+
const input = (asFn(ui.input) as UiInput | undefined);
|
|
1452
|
+
const setStatus = (asFn(ui.setStatus) as UiSetStatus | undefined);
|
|
1453
|
+
return { ...(select ? { select } : {}), ...(input ? { input } : {}), ...(setStatus ? { setStatus } : {}) };
|
|
1158
1454
|
}
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
return { terminal: { ...(tui.terminal.rows === undefined ? {} : { rows: tui.terminal.rows }) } };
|
|
1455
|
+
function tuiRows(tui: unknown): number {
|
|
1456
|
+
const rows = object(tui) && object(tui.terminal) ? tui.terminal.rows : undefined;
|
|
1457
|
+
return typeof rows === "number" && Number.isFinite(rows) ? rows : 24;
|
|
1163
1458
|
}
|
|
1164
|
-
function tuiRows(tui: unknown): number { const rows = tuiHostCapabilities(tui).terminal?.rows; return typeof rows === "number" && Number.isFinite(rows) ? rows : 24; }
|
|
1165
1459
|
const WORKFLOW_OVERLAY_BORDER_ROWS = 2;
|
|
1460
|
+
const WORKFLOW_OVERLAY_TOP_MARGIN = 1;
|
|
1461
|
+
const WORKFLOW_OVERLAY_OPTIONS = { anchor: "top-left", width: "100%", maxHeight: "100%", margin: { top: WORKFLOW_OVERLAY_TOP_MARGIN } } as const;
|
|
1166
1462
|
type WorkflowOverlayComponent = { render(width: number): string[]; invalidate(): void; handleInput?(data: string): void; dispose?(): void };
|
|
1167
1463
|
function borderWorkflowOverlay(component: WorkflowOverlayComponent, theme: { fg(color: "border", text: string): string }): WorkflowOverlayComponent {
|
|
1168
1464
|
return {
|
|
@@ -1174,12 +1470,10 @@ function borderWorkflowOverlay(component: WorkflowOverlayComponent, theme: { fg(
|
|
|
1174
1470
|
};
|
|
1175
1471
|
}
|
|
1176
1472
|
type KeybindingsHostCapabilities = { getKeys?: (name: string) => readonly string[] };
|
|
1177
|
-
function
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
return { getKeys: keybindings.getKeys };
|
|
1473
|
+
function keybindingKeys(keybindings: unknown, name: string): readonly string[] | undefined {
|
|
1474
|
+
const getKeys = object(keybindings) ? asFn(keybindings.getKeys) as NonNullable<KeybindingsHostCapabilities["getKeys"]> | undefined : undefined;
|
|
1475
|
+
return getKeys ? getKeys.call(keybindings, name) : undefined;
|
|
1181
1476
|
}
|
|
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
1477
|
|
|
1184
1478
|
export default function workflowExtension(pi: ExtensionAPI, home?: string, clipboard = copyToClipboard, createSession: SessionFactory = createNativeAgentSession, agentDir?: string) {
|
|
1185
1479
|
beginWorkflowExtensionLoading();
|
|
@@ -1392,7 +1686,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1392
1686
|
let effective: { model: ModelSpec; requestedModel?: string; tools: readonly string[] };
|
|
1393
1687
|
try { effective = run.executor.resolve(requested); }
|
|
1394
1688
|
catch { effective = previous ? { model: previous.model, ...(previous.requestedModel ? { requestedModel: previous.requestedModel } : {}), tools: previous.tools } : { model: node.options.model ? modelSpec(node.options.model, run.model) : { ...run.model, ...(node.options.thinking ? { thinking: node.options.thinking } : {}) }, ...(node.options.model ? { requestedModel: node.options.model } : {}), tools: node.options.tools }; }
|
|
1395
|
-
|
|
1689
|
+
const resultPath = !node.parentId && node.options.agentIdentity ? agentIdentityPath(node.options.agentIdentity) : undefined;
|
|
1690
|
+
return { id: node.id, name: node.label, ...(node.options.requestedLabel ? { label: node.options.requestedLabel } : {}), path: node.id, state: node.state, ...(node.parentId ? { parentId: node.parentId } : {}), structuralPath: [...(node.options.agentIdentity?.structuralPath ?? [])], ...(resultPath ? { resultPath } : {}), ...(node.options.parentBreadcrumb ? { parentBreadcrumb: node.options.parentBreadcrumb } : {}), ...(node.options.worktreeOwner ? { worktreeOwner: node.options.worktreeOwner } : {}), ...(node.options.role ? { role: node.options.role } : {}), ...(effective.requestedModel ? { requestedModel: effective.requestedModel } : {}), model: effective.model, tools: effective.tools, attempts: previous?.attempts ?? 0, ...(previous?.attemptDetails ? { attemptDetails: previous.attemptDetails } : {}), ...(previous?.accounting ? { accounting: previous.accounting } : {}), ...(previous?.toolCalls ? { toolCalls: previous.toolCalls } : {}), ...(previous?.activity ? { activity: previous.activity } : {}) };
|
|
1396
1691
|
});
|
|
1397
1692
|
return { ...current, agents };
|
|
1398
1693
|
});
|
|
@@ -1401,7 +1696,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1401
1696
|
});
|
|
1402
1697
|
const cleanupTerminalRun = async (runId: string): Promise<void> => {
|
|
1403
1698
|
const run = runs.get(runId);
|
|
1404
|
-
if (!run || !
|
|
1699
|
+
if (!run || !HARD_TERMINAL_RUN_STATES.has(run.lifecycle.state)) return;
|
|
1405
1700
|
await scheduler.cancelRun(runId);
|
|
1406
1701
|
await scheduler.flush();
|
|
1407
1702
|
if (runs.get(runId) !== run) return;
|
|
@@ -1514,6 +1809,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1514
1809
|
throw mainAgentError(error);
|
|
1515
1810
|
}
|
|
1516
1811
|
},
|
|
1812
|
+
renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_respond", args, theme)); },
|
|
1813
|
+
renderResult(result, options, theme, context) { return workflowCatalogBlock(workflowControlResult("workflow_respond", context.args, result, options.expanded, theme, context.isError), options.expanded); },
|
|
1517
1814
|
});
|
|
1518
1815
|
pi.registerTool({
|
|
1519
1816
|
name: "workflow_stop",
|
|
@@ -1528,6 +1825,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1528
1825
|
throw mainAgentError(error);
|
|
1529
1826
|
}
|
|
1530
1827
|
},
|
|
1828
|
+
renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_stop", args, theme)); },
|
|
1829
|
+
renderResult(result, options, theme, context) { return workflowCatalogBlock(workflowControlResult("workflow_stop", context.args, result, options.expanded, theme, context.isError), options.expanded); },
|
|
1531
1830
|
});
|
|
1532
1831
|
let catalogRegistered = false;
|
|
1533
1832
|
let sessionStarted = false;
|
|
@@ -1557,29 +1856,81 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1557
1856
|
});
|
|
1558
1857
|
catalogRegistered = true;
|
|
1559
1858
|
};
|
|
1560
|
-
const
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1859
|
+
const createAgentExecutor = (root: Omit<import("./agent-execution.js").AgentExecutionRoot, "agentDir" | "agentSetupHooks">) => new WorkflowAgentExecutor({ ...root, agentDir: extensionAgentDir, agentSetupHooks: registry.agentSetupHooks() }, createSession);
|
|
1860
|
+
const activeSnapshotTools = (tools: readonly string[], active: ReadonlySet<string> | "session") => active === "session"
|
|
1861
|
+
? new Set(tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog"))
|
|
1862
|
+
: new Set(tools.filter((tool) => active.has(tool) || tool === "workflow_catalog"));
|
|
1863
|
+
const resumeLaunchPrologue = async (input: {
|
|
1864
|
+
snapshot: Readonly<LaunchSnapshot>;
|
|
1865
|
+
cwd: string;
|
|
1866
|
+
trustedProject: boolean;
|
|
1867
|
+
rootModel: ModelSpec;
|
|
1868
|
+
modelRegistry?: ModelRegistryCapability | undefined;
|
|
1869
|
+
signal: AbortSignal;
|
|
1870
|
+
resolvedAliases?: Readonly<Record<string, string>>;
|
|
1871
|
+
blockedAliases?: ReadonlySet<string>;
|
|
1872
|
+
blockedAliasTargets?: Readonly<Record<string, string>>;
|
|
1873
|
+
withPreflight: boolean;
|
|
1874
|
+
}) => {
|
|
1875
|
+
const active = new Set(pi.getActiveTools().filter((tool) => !INTERNAL_WORKFLOW_TOOLS.includes(tool)));
|
|
1876
|
+
const missing = input.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
|
|
1564
1877
|
if (missing) throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
|
|
1565
1878
|
const settingsPath = workflowSettingsPath(extensionAgentDir);
|
|
1566
|
-
const
|
|
1567
|
-
const
|
|
1568
|
-
const currentPolicy = resolveAgentResourcePolicy(run.store.cwd, trustedProject, settingsPath);
|
|
1879
|
+
const resolution = resolveWorkflowSettings(input.cwd, input.trustedProject, settingsPath);
|
|
1880
|
+
const currentPolicy = resolveAgentResourcePolicy(input.cwd, input.trustedProject, settingsPath);
|
|
1569
1881
|
const staticAliases = resolution.effective.modelAliases ?? {};
|
|
1570
|
-
const previousAliases =
|
|
1882
|
+
const previousAliases = input.snapshot.modelAliases ?? input.snapshot.settings.modelAliases ?? {};
|
|
1883
|
+
const inventory = modelInventory(input.rootModel, input.modelRegistry);
|
|
1884
|
+
const knownModels = input.modelRegistry ? inventory.knownModels : new Set([...input.snapshot.models, ...inventory.knownModels]);
|
|
1885
|
+
const availableModels = input.modelRegistry ? inventory.availableModels : new Set([...input.snapshot.models, ...inventory.availableModels]);
|
|
1886
|
+
const currentAliases = input.resolvedAliases ?? (await resolveLaunchAliases(registry, staticAliases, { cwd: input.cwd, projectTrusted: input.trustedProject, rootModel: input.rootModel, knownModels, availableModels, signal: input.signal }, availableModels, knownModels, settingsPath)).aliases;
|
|
1887
|
+
const blockedAliases = input.blockedAliases ?? new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
1888
|
+
const blockedAliasTargets = input.blockedAliasTargets ?? Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
1889
|
+
let script: ReturnType<typeof launchScriptForSnapshot> | undefined;
|
|
1890
|
+
if (input.withPreflight) {
|
|
1891
|
+
const resumeAliases = { ...previousAliases, ...currentAliases };
|
|
1892
|
+
script = launchScriptForSnapshot(input.snapshot, registry);
|
|
1893
|
+
preflight(script, { models: availableModels, tools: active, agentTypes: new Set(input.snapshot.agentTypes), modelAliases: resumeAliases, knownModels, settingsPath, skipModelAvailability: true }, input.snapshot.schemas, input.snapshot.metadata, true);
|
|
1894
|
+
}
|
|
1895
|
+
const refreshed = resumedSnapshotSettings(input.snapshot, resolution, currentAliases);
|
|
1896
|
+
const snapshot = createLaunchSnapshot({ ...input.snapshot, settingsPath, ...refreshed, modelAliases: currentAliases });
|
|
1897
|
+
return { active, settingsPath, resolution, currentPolicy, previousAliases, knownModels, availableModels, currentAliases, blockedAliases, blockedAliasTargets, snapshot, script };
|
|
1898
|
+
};
|
|
1899
|
+
const workflowAgentHandler = (store: RunStore, metadata: WorkflowMetadata, lifecycle: RunLifecycle, executor: WorkflowAgentExecutor, cwd: string, runId: string) => async (prompt: string, options: Readonly<Record<string, JsonValue>>, agentSignal: AbortSignal, identity: import("./types.js").AgentIdentity) => {
|
|
1900
|
+
await lifecycle.enter();
|
|
1901
|
+
try {
|
|
1902
|
+
const path = agentIdentityPath(identity);
|
|
1903
|
+
const replayed = await store.replay(path);
|
|
1904
|
+
if (replayed) {
|
|
1905
|
+
return replayed.value;
|
|
1906
|
+
}
|
|
1907
|
+
const worktree = agentWorktree(identity);
|
|
1908
|
+
const agentCwd = worktree.worktreeOwner ? (await persistWorktree(store, metadata, worktree.worktreeOwner)).cwd : cwd;
|
|
1909
|
+
const role = typeof options.role === "string" ? options.role : undefined;
|
|
1910
|
+
const model = typeof options.model === "string" ? options.model : undefined;
|
|
1911
|
+
const thinking = parseThinking(options.thinking);
|
|
1912
|
+
const requestedLabel = typeof options.label === "string" ? options.label : undefined;
|
|
1913
|
+
const resolved = executor.resolve({ label: requestedLabel ?? role ?? "agent", workflowName: metadata.name, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(Array.isArray(options.tools) ? { tools: options.tools as string[] } : {}) });
|
|
1914
|
+
const label = displayAgentName(requestedLabel, role, resolved.model);
|
|
1915
|
+
const tools = resolved.tools;
|
|
1916
|
+
const schema = object(options.outputSchema) ? options.outputSchema : undefined;
|
|
1917
|
+
const spawned = scheduler.spawn(runId, prompt, { label, ...(requestedLabel ? { requestedLabel } : {}), ...(identity.parentBreadcrumb ? { parentBreadcrumb: identity.parentBreadcrumb } : {}), cwd: agentCwd, tools, ...worktree, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(schema ? { schema } : {}), ...(typeof options.retries === "number" ? { retries: options.retries } : {}), ...(positiveInteger(options.timeoutMs) || options.timeoutMs === null ? { timeoutMs: options.timeoutMs } : {}), agentOptions: options, agentIdentity: identity });
|
|
1918
|
+
const cancel = () => { scheduler.cancel(spawned.id); };
|
|
1919
|
+
if (agentSignal.aborted) cancel(); else agentSignal.addEventListener("abort", cancel, { once: true });
|
|
1920
|
+
const outcome = await spawned.result.finally(() => { agentSignal.removeEventListener("abort", cancel); });
|
|
1921
|
+
if (!outcome.ok) throw new WorkflowError(outcome.error.code as WorkflowErrorCode, outcome.error.message);
|
|
1922
|
+
await store.complete(path, outcome.value);
|
|
1923
|
+
return outcome.value;
|
|
1924
|
+
} finally { await lifecycle.leave(); }
|
|
1925
|
+
};
|
|
1926
|
+
const refreshPausedRunAliases = async (run: NonNullable<ReturnType<typeof runs.get>>, context?: { model: { provider: string; id: string } | undefined; modelRegistry: ModelRegistryCapability | undefined; projectTrusted?: boolean }) => {
|
|
1927
|
+
const loaded = await run.store.load();
|
|
1928
|
+
const trustedProject = context?.projectTrusted ?? run.projectTrusted();
|
|
1571
1929
|
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 });
|
|
1930
|
+
const { settingsPath, currentPolicy, previousAliases, knownModels, availableModels, currentAliases, blockedAliases, blockedAliasTargets, snapshot } = await resumeLaunchPrologue({ snapshot: loaded.snapshot, cwd: run.store.cwd, trustedProject, rootModel, ...(context?.modelRegistry ? { modelRegistry: context.modelRegistry } : {}), signal: run.abortController.signal, withPreflight: false });
|
|
1580
1931
|
await run.store.saveSnapshot(snapshot);
|
|
1581
1932
|
scheduler.updateRunLimit(run.store.runId, snapshot.settings.concurrency);
|
|
1582
|
-
run.executor =
|
|
1933
|
+
run.executor = createAgentExecutor({ cwd: run.store.cwd, model: run.model, tools: activeSnapshotTools(snapshot.tools, "session"), availableModels, knownModels, modelAliases: currentAliases, blockedAliases, blockedAliasTargets, settingsPath, agentDefinitions: snapshot.roles ?? {}, runStore: run.store, providerPause: async () => { deliver(pi, `Workflow ${snapshot.metadata.name} paused: provider limit.`); await run.lifecycle.providerPause(); }, agentResourcePolicy: frozenResourcePolicy(currentPolicy) });
|
|
1583
1934
|
run.executor.setRunContext(workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, run.abortController.signal));
|
|
1584
1935
|
const drift = aliasDrift(previousAliases, currentAliases);
|
|
1585
1936
|
if (drift.length) await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
|
|
@@ -1593,32 +1944,15 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1593
1944
|
if ((loaded.snapshot.projectRoles?.length ?? 0) > 0 && !trustedProject) throw new WorkflowError("RESUME_INCOMPATIBLE", "Cannot restore project roles in an untrusted project");
|
|
1594
1945
|
const missingRole = loaded.snapshot.agentTypes.find((role) => !loaded.snapshot.roles?.[role]);
|
|
1595
1946
|
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
1947
|
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
1948
|
const controller = new AbortController();
|
|
1609
1949
|
if (context?.signal?.aborted) controller.abort(); else { context?.signal?.addEventListener("abort", () => { controller.abort(); }, { once: true }); }
|
|
1610
1950
|
run.abortController = controller;
|
|
1611
|
-
const currentAliases
|
|
1612
|
-
|
|
1613
|
-
const blockedAliases = context?.blockedAliases ?? new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
1614
|
-
const blockedAliasTargets = context?.blockedAliasTargets ?? Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
1615
|
-
const script = launchScriptForSnapshot(loaded.snapshot, registry);
|
|
1616
|
-
preflight(script, { models: availableModels, tools: active, agentTypes: new Set(loaded.snapshot.agentTypes), modelAliases: resumeAliases, knownModels, settingsPath, skipModelAvailability: true }, loaded.snapshot.schemas, loaded.snapshot.metadata, true);
|
|
1617
|
-
const refreshed = resumedSnapshotSettings(loaded.snapshot, resolution, currentAliases);
|
|
1618
|
-
const snapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, ...refreshed, modelAliases: currentAliases });
|
|
1951
|
+
const { settingsPath, currentPolicy, previousAliases, knownModels, availableModels, currentAliases, blockedAliases, blockedAliasTargets, snapshot, script } = await resumeLaunchPrologue({ snapshot: loaded.snapshot, cwd: run.store.cwd, trustedProject, rootModel, ...(context?.modelRegistry ? { modelRegistry: context.modelRegistry } : {}), signal: controller.signal, ...(context?.resolvedAliases ? { resolvedAliases: context.resolvedAliases } : {}), ...(context?.blockedAliases ? { blockedAliases: context.blockedAliases } : {}), ...(context?.blockedAliasTargets ? { blockedAliasTargets: context.blockedAliasTargets } : {}), withPreflight: true });
|
|
1952
|
+
if (!script) throw new WorkflowError("INTERNAL_ERROR", "Resume preflight did not produce a launch script");
|
|
1619
1953
|
await run.store.saveSnapshot(snapshot);
|
|
1620
1954
|
scheduler.updateRunLimit(run.store.runId, snapshot.settings.concurrency);
|
|
1621
|
-
run.executor =
|
|
1955
|
+
run.executor = createAgentExecutor({ cwd: run.store.cwd, model: rootModel, tools: activeSnapshotTools(snapshot.tools, "session"), availableModels, knownModels, modelAliases: currentAliases, blockedAliases, blockedAliasTargets, settingsPath, agentDefinitions: snapshot.roles ?? {}, runStore: run.store, providerPause: async () => { deliver(pi, `Workflow ${snapshot.metadata.name} paused: provider limit.`); await run.lifecycle.providerPause(); }, agentResourcePolicy: frozenResourcePolicy(currentPolicy) });
|
|
1622
1956
|
const drift = aliasDrift(previousAliases, currentAliases);
|
|
1623
1957
|
if (drift.length) await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
|
|
1624
1958
|
const runContext = workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, controller.signal);
|
|
@@ -1627,39 +1961,13 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1627
1961
|
try { variables = await resolveWorkflowVariables(runContext, controller, registry); }
|
|
1628
1962
|
catch (error) {
|
|
1629
1963
|
const typed = asWorkflowError(error);
|
|
1630
|
-
if (!
|
|
1964
|
+
if (!HARD_TERMINAL_RUN_STATES.has(run.lifecycle.state)) { await run.lifecycle.terminal("failed", typed.code).catch(() => undefined); const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, error: { code: typed.code, message: typed.message } })); await eventPublisher.runFailed(run.store, run.metadata, typed, run.lifecycle.state === "interrupted" ? "interrupted" : "failed"); run.update?.(workflowToolUpdate(persisted)); if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state)) await createWorkflowFailureDiagnostics(run.store, run.metadata, typed, persisted).then((diagnostic) => { deliverFailure(pi, diagnostic); }).catch(() => undefined); }
|
|
1631
1965
|
await cleanupTerminalRun(run.store.runId);
|
|
1632
1966
|
throw typed;
|
|
1633
1967
|
}
|
|
1634
1968
|
await scheduler.cancelRun(run.store.runId);
|
|
1635
1969
|
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);
|
|
1970
|
+
const execution = runWorkflow(script, loaded.snapshot.args, withWorkflowFunctions({ shell: (command, options, signal, identity) => shellForRun(run.store, run.metadata, run.lifecycle, command, options, signal, identity), agent: workflowAgentHandler(run.store, run.metadata, run.lifecycle, run.executor, run.store.cwd, run.store.runId), worktree: async (owner) => resolveWorktree(run.store, run.metadata, owner), checkpoint: checkpointBridge(run.store.runId, run.store, run.metadata, false, hasUI ? ui : undefined), phase: phaseBridge(run.store, run.metadata, run.lifecycle), log: logBridge(run.lifecycle, run.metadata.name) }, run.store, runContext, variables, registry), controller.signal);
|
|
1663
1971
|
run.execution = execution;
|
|
1664
1972
|
const completion = execution.result.then(async (value) => {
|
|
1665
1973
|
await scheduler.flush();
|
|
@@ -1696,9 +2004,23 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1696
2004
|
};
|
|
1697
2005
|
const resumeWorkflowRun = async (runId: string, rawPatch?: unknown, context?: unknown, signal?: AbortSignal): Promise<Record<string, JsonValue>> => {
|
|
1698
2006
|
const run = runs.get(runId);
|
|
1699
|
-
if (!run)
|
|
2007
|
+
if (!run) {
|
|
2008
|
+
const host = object(context) ? context : {};
|
|
2009
|
+
const cwd = typeof host.cwd === "string" ? host.cwd : undefined;
|
|
2010
|
+
const sessionManager = object(host.sessionManager) ? host.sessionManager : undefined;
|
|
2011
|
+
const sessionId = typeof sessionManager?.getSessionId === "function" ? String(Reflect.apply(sessionManager.getSessionId, sessionManager, [])) : undefined;
|
|
2012
|
+
if (cwd && sessionId) {
|
|
2013
|
+
try {
|
|
2014
|
+
const state = (await new RunStore(cwd, sessionId, runId, home).load()).run.state;
|
|
2015
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", workflowRecoveryGuidance("resume", state));
|
|
2016
|
+
} catch (error) {
|
|
2017
|
+
if (error instanceof WorkflowError) throw error;
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run ${runId} in the current project and Pi session`);
|
|
2021
|
+
}
|
|
1700
2022
|
const loaded = await run.store.load();
|
|
1701
|
-
if (loaded.run.state !== "budget_exhausted") throw new WorkflowError("RESUME_INCOMPATIBLE", "
|
|
2023
|
+
if (loaded.run.state !== "budget_exhausted") throw new WorkflowError("RESUME_INCOMPATIBLE", workflowRecoveryGuidance("resume", loaded.run.state));
|
|
1702
2024
|
const currentBudget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
|
|
1703
2025
|
const patch = rawPatch === undefined ? {} : validateBudgetPatch(rawPatch);
|
|
1704
2026
|
const nextBudget = mergeBudget(currentBudget, patch);
|
|
@@ -1733,8 +2055,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1733
2055
|
await ensureSessionLease(cwd, sessionId);
|
|
1734
2056
|
const sourceStore = new RunStore(cwd, sessionId, runId, home);
|
|
1735
2057
|
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",
|
|
2058
|
+
try { loaded = await sourceStore.load(); } catch (error) { throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run ${runId} in the current project and Pi session: ${errorText(error)}`); }
|
|
2059
|
+
if (loaded.run.state !== "failed") throw new WorkflowError("RESUME_INCOMPATIBLE", workflowRecoveryGuidance("retry", loaded.run.state));
|
|
1738
2060
|
if (loaded.run.retry && (typeof loaded.run.retry.sourceRunId !== "string" || !loaded.run.retry.sourceRunId || typeof loaded.run.retry.lineageRootRunId !== "string" || !loaded.run.retry.lineageRootRunId || !Array.isArray(loaded.run.retry.completedPaths) || loaded.run.retry.completedPaths.some((path) => typeof path !== "string") || !Array.isArray(loaded.run.retry.incompletePaths) || loaded.run.retry.incompletePaths.some((path) => typeof path !== "string") || !Array.isArray(loaded.run.retry.namedWorktrees) || loaded.run.retry.namedWorktrees.some((name) => typeof name !== "string"))) throw new WorkflowError("RESUME_INCOMPATIBLE", "The source retry provenance is incomplete");
|
|
1739
2061
|
const lineageRootRunId = loaded.run.retry?.lineageRootRunId ?? loaded.run.id;
|
|
1740
2062
|
if (retryReservations.has(lineageRootRunId)) throw new WorkflowError("RESUME_INCOMPATIBLE", `An active retry already owns lineage ${lineageRootRunId}`);
|
|
@@ -1760,27 +2082,10 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1760
2082
|
if ((loaded.snapshot.projectRoles?.length ?? 0) > 0 && !trustedProject) throw new WorkflowError("RESUME_INCOMPATIBLE", "Cannot restore project roles in an untrusted project");
|
|
1761
2083
|
const missingRole = loaded.snapshot.agentTypes.find((role) => !loaded.snapshot.roles?.[role]);
|
|
1762
2084
|
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
2085
|
const modelRegistry = contextHostCapabilities(context).modelRegistry;
|
|
1772
2086
|
const hostModel = object(host.model) && typeof host.model.provider === "string" && typeof host.model.id === "string" ? { provider: host.model.provider, id: host.model.id } : { provider: "", id: "" };
|
|
1773
2087
|
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);
|
|
2088
|
+
const { active, settingsPath, currentPolicy, knownModels, availableModels, currentAliases, blockedAliases, blockedAliasTargets, snapshot: childBaseSnapshot } = await resumeLaunchPrologue({ snapshot: loaded.snapshot, cwd, trustedProject, rootModel, ...(modelRegistry ? { modelRegistry } : {}), signal: signal ?? new AbortController().signal, withPreflight: true });
|
|
1784
2089
|
await sourceStore.validateNamedWorktrees();
|
|
1785
2090
|
for (const name of loaded.run.retry?.namedWorktrees ?? []) await sourceStore.resolveNamedWorktree(name);
|
|
1786
2091
|
const completedPaths = (await sourceStore.replayableOperations()).map(({ path }) => path);
|
|
@@ -1789,8 +2094,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1789
2094
|
const budget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
|
|
1790
2095
|
const childRunId = randomUUID();
|
|
1791
2096
|
const childStore = new RunStore(cwd, sessionId, childRunId, home);
|
|
1792
|
-
const
|
|
1793
|
-
const childSnapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, ...refreshed, modelAliases: currentAliases });
|
|
2097
|
+
const childSnapshot = childBaseSnapshot;
|
|
1794
2098
|
const childBudget = new WorkflowBudgetRuntime(budget, loaded.run.budgetVersion ?? 1, loaded.run.usage, loaded.run.budgetEvents);
|
|
1795
2099
|
const childInitialBudget = childBudget.snapshot();
|
|
1796
2100
|
const retry: WorkflowRetryProvenance = { sourceRunId: loaded.run.id, lineageRootRunId, completedPaths, incompletePaths, namedWorktrees };
|
|
@@ -1801,7 +2105,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1801
2105
|
const abortController = new AbortController();
|
|
1802
2106
|
const providerErrorRecovery = createProviderErrorRecovery(context, availableModels, () => { abortController.abort(); });
|
|
1803
2107
|
const providerPause = async () => { deliver(pi, `Workflow ${loaded.snapshot.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
|
|
1804
|
-
const childRun = { executor:
|
|
2108
|
+
const childRun = { executor: createAgentExecutor({ cwd, model, tools: activeSnapshotTools(loaded.snapshot.tools, active), availableModels, knownModels, modelAliases: currentAliases, blockedAliases, blockedAliasTargets, settingsPath, agentDefinitions: loaded.snapshot.roles ?? {}, runStore: childStore, providerPause, agentResourcePolicy: frozenResourcePolicy(currentPolicy) }), store: childStore, metadata: loaded.snapshot.metadata, model, lifecycle, budget: childBudget, abortController, projectTrusted: () => projectTrusted(context), checkpointResolvers: new Map(), ...(providerErrorRecovery ? { providerErrorRecovery } : {}) };
|
|
1805
2109
|
runs.set(childRunId, childRun);
|
|
1806
2110
|
scheduler.addRun(childRunId, loaded.snapshot.settings.concurrency, () => { childBudget.checkAgentLaunch(); });
|
|
1807
2111
|
await eventPublisher.runStarted(childStore, loaded.snapshot.metadata);
|
|
@@ -1825,6 +2129,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1825
2129
|
try { const result = await retryWorkflowRun(params.runId, ctx, signal); return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result }; }
|
|
1826
2130
|
catch (error) { throw mainAgentError(error); }
|
|
1827
2131
|
},
|
|
2132
|
+
renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_retry", args, theme)); },
|
|
2133
|
+
renderResult(result, options, theme, context) { return workflowCatalogBlock(workflowControlResult("workflow_retry", context.args, result, options.expanded, theme, context.isError), options.expanded); },
|
|
1828
2134
|
});
|
|
1829
2135
|
pi.registerTool({
|
|
1830
2136
|
name: "workflow_resume",
|
|
@@ -1835,6 +2141,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1835
2141
|
try { const result = await resumeWorkflowRun(params.runId, params.budget, ctx, signal); return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result }; }
|
|
1836
2142
|
catch (error) { throw mainAgentError(error); }
|
|
1837
2143
|
},
|
|
2144
|
+
renderCall(args, theme) { return styledTextBlock(workflowControlCall("workflow_resume", args, theme)); },
|
|
2145
|
+
renderResult(result, options, theme, context) { return workflowCatalogBlock(workflowControlResult("workflow_resume", context.args, result, options.expanded, theme, context.isError), options.expanded); },
|
|
1838
2146
|
});
|
|
1839
2147
|
pi.on("session_start", async (_event, ctx) => {
|
|
1840
2148
|
if (sessionStarted) return;
|
|
@@ -1865,7 +2173,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1865
2173
|
const roleDefinitions = loaded.snapshot.roles ?? {};
|
|
1866
2174
|
const abortController = new AbortController();
|
|
1867
2175
|
const providerErrorRecovery = createProviderErrorRecovery(ctx, new Set(loaded.snapshot.models), () => { abortController.abort(); });
|
|
1868
|
-
runs.set(runId, { executor:
|
|
2176
|
+
runs.set(runId, { executor: createAgentExecutor({ cwd: ctx.cwd, model, tools: activeSnapshotTools(loaded.snapshot.tools, "session"), availableModels: new Set(loaded.snapshot.models), knownModels: new Set(loaded.snapshot.models), ...(loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ? { modelAliases: loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases } : {}), ...(loaded.snapshot.settingsSources?.modelAliases ? { settingsPath: loaded.snapshot.settingsSources.modelAliases } : loaded.snapshot.settingsPath ? { settingsPath: loaded.snapshot.settingsPath } : {}), agentDefinitions: roleDefinitions, runStore: store, providerPause, agentResourcePolicy: frozenResourcePolicy(snapshotResourcePolicy(loaded.snapshot, store.cwd, projectTrusted(ctx), workflowSettingsPath(extensionAgentDir))) }), store, metadata: loaded.snapshot.metadata, model, lifecycle, budget: budgetRuntime, abortController, projectTrusted: () => projectTrusted(ctx), checkpointResolvers: new Map(), ...(providerErrorRecovery ? { providerErrorRecovery } : {}) });
|
|
1869
2177
|
for (const checkpoint of await store.awaitingCheckpoints()) deliver(pi, `Workflow ${loaded.snapshot.metadata.name} checkpoint ${checkpoint.name}: ${checkpoint.prompt}\nContext: ${JSON.stringify(checkpoint.context)}\nRespond with workflow_respond.`);
|
|
1870
2178
|
for (const decision of await store.pendingWorkflowDecisions()) deliver(pi, budgetDecisionDelivery(loaded.snapshot.metadata, decision));
|
|
1871
2179
|
scheduler.restoreRun(runId, loaded.snapshot.settings.concurrency, loaded.snapshot.identityVersion === LAUNCH_SNAPSHOT_IDENTITY_VERSION ? await store.loadOwnership() : [], () => runs.get(runId)?.budget.checkAgentLaunch());
|
|
@@ -1913,7 +2221,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1913
2221
|
const inventory = modelInventory(rootModel, modelRegistry);
|
|
1914
2222
|
const knownModels = inventory.knownModels;
|
|
1915
2223
|
const availableModels = inventory.availableModels;
|
|
1916
|
-
const rootTools = pi.getActiveTools().filter((name) => !
|
|
2224
|
+
const rootTools = pi.getActiveTools().filter((name) => !INTERNAL_WORKFLOW_TOOLS.includes(name));
|
|
1917
2225
|
const trustedProject = projectTrusted(ctx);
|
|
1918
2226
|
const launchCwd = typeof ctx.cwd === "string" ? ctx.cwd : process.cwd();
|
|
1919
2227
|
const launch = workflowLaunchSettings(launchCwd, trustedProject, settingsPath, params.concurrency);
|
|
@@ -1945,37 +2253,11 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1945
2253
|
const background = !params.foreground;
|
|
1946
2254
|
const providerPause = async () => { if (background) deliver(pi, `Workflow ${checked.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
|
|
1947
2255
|
const providerErrorRecovery = createProviderErrorRecovery(ctx, availableModels, () => { runController.abort(); });
|
|
1948
|
-
const executor =
|
|
2256
|
+
const executor = createAgentExecutor({ cwd: ctx.cwd, model: rootModel, tools: new Set(rootTools), availableModels, knownModels, modelAliases, settingsPath, agentDefinitions, runStore: store, providerPause, agentResourcePolicy: frozenResourcePolicy(launch.resourcePolicy), runContext });
|
|
1949
2257
|
runs.set(runId, { executor, store, metadata: checked.metadata, model: rootModel, lifecycle, budget: budgetRuntime, abortController: runController, projectTrusted: () => projectTrusted(ctx), checkpointResolvers: new Map(), ...(providerErrorRecovery ? { providerErrorRecovery } : {}), ...(params.foreground && onUpdate ? { update: onUpdate } : {}) });
|
|
1950
2258
|
if (params.foreground && onUpdate) onUpdate(workflowToolUpdate((await store.load()).run));
|
|
1951
2259
|
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);
|
|
2260
|
+
const execution = runWorkflow(script, args, withWorkflowFunctions({ shell: (command, options, signal, identity) => shellForRun(store, checked.metadata, lifecycle, command, options, signal, identity), agent: workflowAgentHandler(store, checked.metadata, lifecycle, executor, ctx.cwd, runId), worktree: async (owner) => resolveWorktree(store, checked.metadata, owner), checkpoint: checkpointBridge(runId, store, checked.metadata, Boolean(params.foreground), params.foreground && ctx.hasUI ? ctx.ui : undefined, headless), phase: phaseBridge(store, checked.metadata, lifecycle), log: logBridge(lifecycle, checked.metadata.name) }, store, runContext, variables, registry), runController.signal);
|
|
1979
2261
|
(runs.get(runId) as NonNullable<ReturnType<typeof runs.get>>).execution = execution;
|
|
1980
2262
|
await eventPublisher.runStarted(store, checked.metadata);
|
|
1981
2263
|
const finish = execution.result.then(async (value) => {
|
|
@@ -2078,7 +2360,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2078
2360
|
return keepContext ? "dashboard" : "done";
|
|
2079
2361
|
}
|
|
2080
2362
|
if (action === "delete" && stored) {
|
|
2081
|
-
if (!
|
|
2363
|
+
if (!HARD_TERMINAL_RUN_STATES.has(stored.loaded.run.state)) { ctx.ui.notify("Stop the workflow before deleting it.", "warning"); return keepContext ? "dashboard" : "done"; }
|
|
2082
2364
|
if (!await ctx.ui.confirm("Delete workflow?", `Delete ${stored.loaded.run.workflowName} (${stored.store.runId}) and all owned artifacts? This cannot be undone.`)) return keepContext ? "dashboard" : "done";
|
|
2083
2365
|
await stored.store.delete(true); runs.delete(stored.store.runId); terminalRunStates.delete(stored.store.runId); ctx.ui.notify(`Deleted workflow ${stored.store.runId}.`, "info"); return keepContext ? "picker" : "done";
|
|
2084
2366
|
}
|
|
@@ -2201,7 +2483,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2201
2483
|
}
|
|
2202
2484
|
const sorted = navigatorAttentionSort(stores);
|
|
2203
2485
|
const labels = navigatorRunLabels(sorted);
|
|
2204
|
-
const terminalStates =
|
|
2486
|
+
const terminalStates = HARD_TERMINAL_RUN_STATES;
|
|
2205
2487
|
const hasCompleted = sorted.some(({ loaded: { run } }) => run.state === "completed");
|
|
2206
2488
|
const hasFailed = sorted.some(({ loaded: { run } }) => run.state === "failed");
|
|
2207
2489
|
const pickerOptions = [...labels, ...(herdrPaneId() ? ["Inspect session in pane"] : []), "Model aliases", "Close", ...(hasCompleted ? ["Delete all completed"] : []), ...(hasFailed ? ["Delete all failed"] : [])];
|
|
@@ -2248,6 +2530,13 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2248
2530
|
const loaded = await store.load();
|
|
2249
2531
|
const checkpoints = await store.awaitingCheckpoints();
|
|
2250
2532
|
const worktrees = await store.worktrees();
|
|
2533
|
+
const completedOperations = ctx.mode === "tui" ? await store.replayableOperations().catch(() => []) : [];
|
|
2534
|
+
const agentResults = new Map<string, JsonValue>();
|
|
2535
|
+
for (const agent of loaded.run.agents) {
|
|
2536
|
+
if (agent.state !== "completed" || agent.parentId || !agent.resultPath) continue;
|
|
2537
|
+
const operation = completedOperations.find((candidate) => candidate.path === agent.resultPath);
|
|
2538
|
+
if (operation) agentResults.set(agent.id, operation.value);
|
|
2539
|
+
}
|
|
2251
2540
|
const actions = new Map<string, string>();
|
|
2252
2541
|
const copies = new Map<string, { value: string; artifact: string }>();
|
|
2253
2542
|
const reviews = new Map<string, AwaitingCheckpoint>();
|
|
@@ -2273,96 +2562,135 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2273
2562
|
}
|
|
2274
2563
|
}
|
|
2275
2564
|
if (ctx.mode !== "tui") actions.set("Refresh", "refresh");
|
|
2276
|
-
else actions.set("
|
|
2277
|
-
if (loaded.run.agents.length) actions.set("Agents...", "agents");
|
|
2565
|
+
else actions.set("Open script in editor", "open-script");
|
|
2566
|
+
if (ctx.mode !== "tui" && loaded.run.agents.length) actions.set("Agents...", "agents");
|
|
2278
2567
|
if (terminalStates.has(loaded.run.state)) add("Delete", "delete");
|
|
2279
2568
|
if (ctx.mode === "tui") {
|
|
2280
2569
|
addCopy("Copy run path", store.directory, "run path");
|
|
2281
2570
|
addCopy("Copy run ID", store.runId, "run ID");
|
|
2282
2571
|
}
|
|
2283
|
-
return { dashboard: formatWorkflowPhaseDashboard(loaded.run, loaded.snapshot, process.stdout.columns || 80).join("\n"), phaseModel: buildWorkflowPhaseModel(loaded.run, loaded.snapshot), run: loaded.run, snapshot: loaded.snapshot, actions, copies, reviews,
|
|
2572
|
+
return { dashboard: formatWorkflowPhaseDashboard(loaded.run, loaded.snapshot, process.stdout.columns || 80).join("\n"), phaseModel: buildWorkflowPhaseModel(loaded.run, loaded.snapshot), run: loaded.run, snapshot: loaded.snapshot, actions, copies, reviews, agentResults, agents: loaded.run.agents, worktrees, cwd: loaded.run.cwd };
|
|
2284
2573
|
};
|
|
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"] : []),
|
|
2574
|
+
const agentWorktreeFor = (dashboard: Awaited<ReturnType<typeof loadDashboard>>, agent: AgentRecord): WorktreeReference | undefined => agent.worktreeOwner ? dashboard.worktrees.find((candidate) => candidate.owner === agent.worktreeOwner) : undefined;
|
|
2575
|
+
const agentActionLabels = (dashboard: Awaited<ReturnType<typeof loadDashboard>>, agent: AgentRecord): string[] => {
|
|
2576
|
+
const worktree = agentWorktreeFor(dashboard, agent);
|
|
2577
|
+
return [
|
|
2578
|
+
...(agent.attemptDetails?.length && herdrPaneId() ? ["Fork as Pi session in pane"] : []),
|
|
2297
2579
|
...(worktree ? ["Copy branch", "Copy worktree path"] : []),
|
|
2580
|
+
...(ctx.mode === "tui" && dashboard.agentResults.has(agent.id) ? ["Open result in editor"] : []),
|
|
2298
2581
|
"Copy agent ID",
|
|
2299
2582
|
"Back",
|
|
2300
2583
|
];
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2584
|
+
};
|
|
2585
|
+
const forkAgentSession = async (dashboard: Awaited<ReturnType<typeof loadDashboard>>, agent: AgentRecord): Promise<void> => {
|
|
2586
|
+
const attempts = [...(agent.attemptDetails ?? [])].sort((left, right) => left.attempt - right.attempt);
|
|
2587
|
+
const worktree = agentWorktreeFor(dashboard, agent);
|
|
2588
|
+
const choices = attempts.map((attempt) => `Attempt ${String(attempt.attempt)}`);
|
|
2589
|
+
const choice = choices.length === 1 ? choices[0] : await ctx.ui.select("Fork attempts", [...choices, "Back"]);
|
|
2590
|
+
const index = choice ? choices.indexOf(choice) : -1;
|
|
2591
|
+
const attempt = index >= 0 ? attempts[index] : undefined;
|
|
2592
|
+
if (!attempt) return;
|
|
2593
|
+
const running = !SETTLED_AGENT_STATES.has(agent.state) && attempt.attempt === attempts.at(-1)?.attempt && !attempt.error;
|
|
2594
|
+
if (running && !await ctx.ui.confirm("Fork running attempt?", "This attempt is still running. The snapshot may end mid-turn and will not receive later updates. It opens read-only to avoid concurrent changes to the workflow agent's working directory. Continue?")) return;
|
|
2595
|
+
try {
|
|
2596
|
+
await openHerdrPane({ action: "fork", cwd: worktree?.cwd ?? attempt.setup?.cwd ?? dashboard.cwd, original: attempt.sessionFile, ...(running ? { readOnly: true } : {}) });
|
|
2597
|
+
ctx.ui.notify("Forked Pi session in pane.", "info");
|
|
2598
|
+
} catch (error) {
|
|
2599
|
+
ctx.ui.notify(`Cannot fork Pi session in pane: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
2600
|
+
}
|
|
2601
|
+
};
|
|
2602
|
+
const selectAgent = async (dashboard: Awaited<ReturnType<typeof loadDashboard>>, requestedAgentId?: string): Promise<void> => {
|
|
2603
|
+
const byId = new Map(dashboard.agents.map((agent) => [agent.id, agent]));
|
|
2604
|
+
const title = (agent: AgentRecord): string => agentBreadcrumb(agent, byId, true);
|
|
2605
|
+
const labels = dashboard.agents.map((agent, index) => `#${String(index + 1)} ${title(agent)} [${agent.state}]`);
|
|
2606
|
+
let selected: AgentRecord | undefined;
|
|
2607
|
+
if (requestedAgentId) selected = dashboard.agents.find((agent) => agent.id === requestedAgentId);
|
|
2608
|
+
else {
|
|
2609
|
+
const selectedLabel = await ctx.ui.select("Agents", [...labels, "Back"]);
|
|
2610
|
+
const selectedIndex = selectedLabel ? labels.indexOf(selectedLabel) : -1;
|
|
2611
|
+
selected = selectedIndex >= 0 ? dashboard.agents[selectedIndex] : undefined;
|
|
2612
|
+
}
|
|
2613
|
+
if (!selected) return;
|
|
2614
|
+
const worktree = agentWorktreeFor(dashboard, selected);
|
|
2615
|
+
const actions = agentActionLabels(dashboard, selected);
|
|
2307
2616
|
for (;;) {
|
|
2308
2617
|
const action = await ctx.ui.select(title(selected), actions);
|
|
2309
2618
|
if (!action || action === "Back") return;
|
|
2310
2619
|
if (action === "Copy agent ID") { await copyArtifact(selected.id, "agent ID"); continue; }
|
|
2311
2620
|
if (action === "Copy branch" && worktree) { await copyArtifact(worktree.branch, "branch"); continue; }
|
|
2312
2621
|
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
|
-
}
|
|
2622
|
+
if (action === "Fork as Pi session in pane") await forkAgentSession(dashboard, selected);
|
|
2325
2623
|
}
|
|
2326
2624
|
};
|
|
2327
2625
|
for (;;) {
|
|
2328
2626
|
let view = await loadDashboard();
|
|
2329
2627
|
const actionChoice = ctx.mode === "tui"
|
|
2330
2628
|
? await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
|
|
2331
|
-
let options = [...view.actions.keys(), "Close"];
|
|
2332
|
-
let selectedIndex = 0;
|
|
2333
2629
|
let dashboardOffset = 0;
|
|
2334
2630
|
let refreshing = false;
|
|
2335
2631
|
let disposed = false;
|
|
2632
|
+
let detailsMode = false;
|
|
2633
|
+
let actionMode = false;
|
|
2634
|
+
let actionIndex = 0;
|
|
2336
2635
|
let stopRequested = false;
|
|
2337
2636
|
let stopStatus: string | undefined;
|
|
2637
|
+
let selectionNeedsScroll = true;
|
|
2638
|
+
let renderedWidth = 80;
|
|
2639
|
+
let refreshGeneration = 0;
|
|
2338
2640
|
const initialSelection = preserveWorkflowPhaseSelection(view.phaseModel, {});
|
|
2339
|
-
let
|
|
2340
|
-
let
|
|
2641
|
+
let tree = buildWorkflowPhaseTree(view.phaseModel);
|
|
2642
|
+
let selectedNodeId = initialSelection.nodeId ?? tree.nodes[0]?.id;
|
|
2643
|
+
let expandedNodeIds = new Set(initialSelection.expandedNodeIds ?? workflowPhaseTreeInitialExpanded(tree));
|
|
2341
2644
|
const terminalRows = () => Math.max(1, tuiRows(tui) - WORKFLOW_OVERLAY_BORDER_ROWS);
|
|
2342
|
-
const keyLabels: Record<string, string> = { up: "↑", down: "↓", left: "←", right: "→",
|
|
2645
|
+
const keyLabels: Record<string, string> = { up: "↑", down: "↓", left: "←", right: "→", pageUp: "pgup", pageDown: "pgdn" };
|
|
2343
2646
|
const keyLabel = (binding: string, fallback: string) => {
|
|
2344
2647
|
const keys = keybindingKeys(keybindings, binding);
|
|
2345
2648
|
return keys?.length ? keys.map((key) => keyLabels[key] ?? key).join("/") : fallback;
|
|
2346
2649
|
};
|
|
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 };
|
|
2650
|
+
const selectedAgentRecord = (): AgentRecord | undefined => {
|
|
2651
|
+
const node = selectedNodeId ? tree.byId.get(selectedNodeId) : tree.nodes[0];
|
|
2652
|
+
return node?.kind === "agent" && node.agentId ? view.agents.find((agent) => agent.id === node.agentId) : undefined;
|
|
2354
2653
|
};
|
|
2355
|
-
const
|
|
2356
|
-
const
|
|
2357
|
-
|
|
2654
|
+
const actionOptions = () => {
|
|
2655
|
+
const agent = selectedAgentRecord();
|
|
2656
|
+
return agent ? agentActionLabels(view, agent) : [...view.actions.keys(), "Back"];
|
|
2657
|
+
};
|
|
2658
|
+
let editorRunning = false;
|
|
2659
|
+
const openArtifact = async (artifact: Promise<WorkflowArtifact>, label: string): Promise<void> => {
|
|
2660
|
+
if (editorRunning) return;
|
|
2661
|
+
editorRunning = true;
|
|
2662
|
+
try {
|
|
2663
|
+
const command = SettingsManager.create(view.cwd, extensionAgentDir, { projectTrusted: projectTrusted(ctx) }).getExternalEditorCommand();
|
|
2664
|
+
if (!command) { ctx.ui.notify(`Cannot open ${label}: no external editor is configured.`, "warning"); return; }
|
|
2665
|
+
const exitCode = await openWorkflowArtifact(tui, command, await artifact);
|
|
2666
|
+
if (exitCode !== 0) {
|
|
2667
|
+
const detail = exitCode === null ? "could not be started" : `exited with code ${String(exitCode)}`;
|
|
2668
|
+
ctx.ui.notify(`Cannot open ${label}: external editor ${detail}.`, "warning");
|
|
2669
|
+
}
|
|
2670
|
+
} catch (error) {
|
|
2671
|
+
ctx.ui.notify(`Cannot open ${label}: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
2672
|
+
} finally {
|
|
2673
|
+
editorRunning = false;
|
|
2674
|
+
tui.requestRender(true);
|
|
2675
|
+
}
|
|
2676
|
+
};
|
|
2677
|
+
const updateDashboard = async () => {
|
|
2678
|
+
const generation = ++refreshGeneration;
|
|
2679
|
+
const hadExpandableNodes = tree.nodes.some((node) => node.children.length > 0);
|
|
2358
2680
|
const next = await loadDashboard();
|
|
2359
|
-
if (disposed) return;
|
|
2681
|
+
if (disposed || generation !== refreshGeneration) return;
|
|
2682
|
+
const previousNodeId = selectedNodeId;
|
|
2683
|
+
const previousExpanded = expandedNodeIds;
|
|
2684
|
+
const selectedAction = actionMode ? actionOptions()[actionIndex] : undefined;
|
|
2360
2685
|
view = next;
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2686
|
+
tree = buildWorkflowPhaseTree(view.phaseModel);
|
|
2687
|
+
selectedNodeId = preserveWorkflowPhaseTreeSelection(tree, { nodeId: previousNodeId }).nodeId;
|
|
2688
|
+
expandedNodeIds = new Set([...previousExpanded].filter((id) => tree.byId.has(id)));
|
|
2689
|
+
if (!hadExpandableNodes && !expandedNodeIds.size && tree.nodes.some((node) => node.children.length > 0)) expandedNodeIds = new Set(workflowPhaseTreeInitialExpanded(tree));
|
|
2690
|
+
const nextActions = actionOptions();
|
|
2691
|
+
const preservedActionIndex = selectedAction ? nextActions.indexOf(selectedAction) : -1;
|
|
2692
|
+
actionIndex = preservedActionIndex >= 0 ? preservedActionIndex : selectedAction ? nextActions.length - 1 : Math.min(actionIndex, Math.max(0, nextActions.length - 1));
|
|
2693
|
+
selectionNeedsScroll = true;
|
|
2366
2694
|
tui.requestRender();
|
|
2367
2695
|
};
|
|
2368
2696
|
const requestStop = () => {
|
|
@@ -2370,14 +2698,14 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2370
2698
|
stopRequested = true;
|
|
2371
2699
|
stopStatus = undefined;
|
|
2372
2700
|
setWorkflowStatus(undefined);
|
|
2373
|
-
const selectedOption = options[selectedIndex];
|
|
2374
2701
|
void runAction(`stop ${store.runId}`, true, (status) => {
|
|
2375
2702
|
stopStatus = status;
|
|
2376
2703
|
setWorkflowStatus(status);
|
|
2377
2704
|
if (!disposed) tui.requestRender();
|
|
2378
|
-
}).then(() => updateDashboard(
|
|
2705
|
+
}).then(() => updateDashboard()).catch((error: unknown) => {
|
|
2379
2706
|
if (disposed) return;
|
|
2380
2707
|
stopStatus = `Could not stop workflow ${store.runId}: ${error instanceof Error ? error.message : String(error)}`;
|
|
2708
|
+
setWorkflowStatus(stopStatus);
|
|
2381
2709
|
tui.requestRender();
|
|
2382
2710
|
}).finally(() => {
|
|
2383
2711
|
stopRequested = false;
|
|
@@ -2387,110 +2715,122 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2387
2715
|
const timer = setInterval(() => {
|
|
2388
2716
|
if (refreshing || stopRequested) return;
|
|
2389
2717
|
refreshing = true;
|
|
2390
|
-
|
|
2391
|
-
void updateDashboard(selectedOption).catch(() => undefined).finally(() => { refreshing = false; });
|
|
2718
|
+
void updateDashboard().catch(() => undefined).finally(() => { refreshing = false; });
|
|
2392
2719
|
}, 1000);
|
|
2393
2720
|
timer.unref();
|
|
2394
2721
|
return borderWorkflowOverlay({
|
|
2395
2722
|
render(width: number) {
|
|
2723
|
+
renderedWidth = width;
|
|
2724
|
+
const narrow = width < 80;
|
|
2396
2725
|
const styles = themeWorkflowProgressStyles(theme);
|
|
2397
|
-
const
|
|
2398
|
-
const
|
|
2399
|
-
const
|
|
2400
|
-
const
|
|
2401
|
-
const
|
|
2402
|
-
const
|
|
2403
|
-
|
|
2404
|
-
const
|
|
2726
|
+
const agent = selectedAgentRecord();
|
|
2727
|
+
const actions = actionMode ? { title: agent ? "Agent actions" : "Run actions", options: actionOptions(), index: actionIndex } : undefined;
|
|
2728
|
+
const phaseLines = formatWorkflowPhaseDashboard(view.run, view.snapshot, width, { nodeId: selectedNodeId, expandedNodeIds: [...expandedNodeIds], ...(narrow && !detailsMode ? { treeOnly: true } : {}), ...(narrow && detailsMode ? { detailsOnly: true } : {}), ...(actions ? { actions } : {}) }, styles);
|
|
2729
|
+
const statusLines = stopStatus ? truncateToVisualLines(styles.error(stopStatus), Number.MAX_SAFE_INTEGER, width, 0).visualLines.map((line) => line.trimEnd()) : [];
|
|
2730
|
+
const content = [...statusLines, ...phaseLines];
|
|
2731
|
+
const rows = terminalRows();
|
|
2732
|
+
const hintRows = rows >= 3 ? 1 : 0;
|
|
2733
|
+
const viewport = Math.max(1, rows - hintRows);
|
|
2734
|
+
const maxOffset = Math.max(0, content.length - viewport);
|
|
2735
|
+
dashboardOffset = Math.max(0, Math.min(maxOffset, dashboardOffset));
|
|
2736
|
+
if (actionMode) {
|
|
2737
|
+
const label = actions?.options[actionIndex];
|
|
2738
|
+
const actionRow = label ? content.findIndex((line) => line.includes(label)) : -1;
|
|
2739
|
+
if (actionRow >= 0) {
|
|
2740
|
+
if (actionRow < dashboardOffset) dashboardOffset = actionRow;
|
|
2741
|
+
else if (actionRow >= dashboardOffset + viewport) dashboardOffset = actionRow - viewport + 1;
|
|
2742
|
+
}
|
|
2743
|
+
} else if (!detailsMode && selectionNeedsScroll) {
|
|
2744
|
+
const selectedRow = content.findIndex((line) => line.startsWith("→"));
|
|
2745
|
+
if (selectedRow >= 0) {
|
|
2746
|
+
if (selectedRow < dashboardOffset) dashboardOffset = selectedRow;
|
|
2747
|
+
else if (selectedRow >= dashboardOffset + viewport) dashboardOffset = selectedRow - viewport + 1;
|
|
2748
|
+
}
|
|
2749
|
+
selectionNeedsScroll = false;
|
|
2750
|
+
}
|
|
2405
2751
|
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
|
-
];
|
|
2752
|
+
const hint = truncateToVisualLines(theme.fg("dim", actionMode ? `${keyLabel("tui.select.up", "↑")}/${keyLabel("tui.select.down", "↓")} actions · ${keyLabel("tui.select.confirm", "enter")} run · ${keyLabel("tui.select.cancel", "esc")} tree` : `${keyLabel("tui.select.up", "↑")}/${keyLabel("tui.select.down", "↓")} tree · ${keyLabel("tui.editor.cursorLeft", "←")}/${keyLabel("tui.editor.cursorRight", "→")} collapse/expand · ${keyLabel("tui.select.confirm", "enter")} inspect · a actions · ${keyLabel("tui.select.cancel", "esc")} ${narrow && detailsMode ? "tree" : "close"}${content.length > viewport ? ` · ${keyLabel("tui.select.pageUp", "pgup")}/${keyLabel("tui.select.pageDown", "pgdn")} scroll` : ""} · auto-refresh 1s`), Number.MAX_SAFE_INTEGER, width, 1).visualLines[0] ?? "";
|
|
2753
|
+
return [...content.slice(dashboardOffset, dashboardOffset + viewport), ...(hintRows ? [hint] : [])];
|
|
2413
2754
|
},
|
|
2414
2755
|
invalidate() {},
|
|
2415
2756
|
handleInput(data: string) {
|
|
2416
|
-
if (stopRequested) return;
|
|
2417
|
-
const
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
if (
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2757
|
+
if (stopRequested || editorRunning) return;
|
|
2758
|
+
const narrow = renderedWidth < 80;
|
|
2759
|
+
if (!actionMode && (data === "a" || data === "A")) { actionMode = true; actionIndex = 0; dashboardOffset = 0; tui.requestRender(); return; }
|
|
2760
|
+
if (actionMode) {
|
|
2761
|
+
const options = actionOptions();
|
|
2762
|
+
if (keybindings.matches(data, "tui.select.cancel")) { actionMode = false; dashboardOffset = 0; tui.requestRender(); return; }
|
|
2763
|
+
if (keybindings.matches(data, "tui.select.up")) actionIndex = (actionIndex + options.length - 1) % options.length;
|
|
2764
|
+
else if (keybindings.matches(data, "tui.select.down")) actionIndex = (actionIndex + 1) % options.length;
|
|
2765
|
+
else if (keybindings.matches(data, "tui.select.pageUp")) dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, terminalRows() - 1));
|
|
2766
|
+
else if (keybindings.matches(data, "tui.select.pageDown")) dashboardOffset += Math.max(1, terminalRows() - 1);
|
|
2767
|
+
else if (keybindings.matches(data, "tui.select.confirm")) {
|
|
2768
|
+
const action = options[actionIndex];
|
|
2769
|
+
const agent = selectedAgentRecord();
|
|
2770
|
+
if (!action || action === "Back") { actionMode = false; dashboardOffset = 0; }
|
|
2771
|
+
else if (agent) {
|
|
2772
|
+
const worktree = agentWorktreeFor(view, agent);
|
|
2773
|
+
if (action === "Open result in editor") {
|
|
2774
|
+
const result = view.agentResults.get(agent.id);
|
|
2775
|
+
if (result !== undefined) void openArtifact(Promise.resolve(workflowResultArtifact(result)), "agent result");
|
|
2776
|
+
}
|
|
2777
|
+
else if (action === "Copy agent ID") void copyArtifact(agent.id, "agent ID");
|
|
2778
|
+
else if (action === "Copy branch" && worktree) void copyArtifact(worktree.branch, "branch");
|
|
2779
|
+
else if (action === "Copy worktree path" && worktree) void copyArtifact(worktree.path, "worktree path");
|
|
2780
|
+
else done(`__workflow_fork__:${agent.id}`);
|
|
2781
|
+
}
|
|
2782
|
+
else if (action === "Open script in editor") void openArtifact(readFile(join(store.directory, "workflow.js"), "utf8").then(workflowScriptArtifact), "workflow script");
|
|
2783
|
+
else if (action === "Stop") requestStop();
|
|
2784
|
+
else done(action);
|
|
2428
2785
|
}
|
|
2786
|
+
tui.requestRender();
|
|
2787
|
+
return;
|
|
2429
2788
|
}
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
if (
|
|
2433
|
-
|
|
2434
|
-
|
|
2789
|
+
const current = selectedNodeId ? tree.byId.get(selectedNodeId) : tree.nodes[0];
|
|
2790
|
+
if (keybindings.matches(data, "tui.select.cancel")) {
|
|
2791
|
+
if (narrow && detailsMode) { detailsMode = false; selectionNeedsScroll = true; } else done(undefined);
|
|
2792
|
+
} else if (narrow && detailsMode) {
|
|
2793
|
+
if (keybindings.matches(data, "tui.select.pageUp")) dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, terminalRows() - 1));
|
|
2794
|
+
else if (keybindings.matches(data, "tui.select.pageDown")) dashboardOffset += Math.max(1, terminalRows() - 1);
|
|
2795
|
+
else if (keybindings.matches(data, "tui.select.confirm")) {
|
|
2796
|
+
if (current?.kind === "agent" && current.agentId) { actionMode = true; actionIndex = 0; }
|
|
2797
|
+
else if (current?.children.length) { if (expandedNodeIds.has(current.id)) expandedNodeIds.delete(current.id); else expandedNodeIds.add(current.id); }
|
|
2435
2798
|
}
|
|
2436
|
-
}
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
else if (keybindings.matches(data, "tui.
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
else if (keybindings.matches(data, "tui.select.
|
|
2443
|
-
|
|
2444
|
-
|
|
2799
|
+
} else if (keybindings.matches(data, "tui.editor.cursorLeft")) {
|
|
2800
|
+
const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "left");
|
|
2801
|
+
selectedNodeId = next.nodeId; expandedNodeIds = new Set(next.expandedNodeIds); selectionNeedsScroll = true;
|
|
2802
|
+
} else if (keybindings.matches(data, "tui.editor.cursorRight")) {
|
|
2803
|
+
const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "right");
|
|
2804
|
+
selectedNodeId = next.nodeId; expandedNodeIds = new Set(next.expandedNodeIds); selectionNeedsScroll = true;
|
|
2805
|
+
} else if (keybindings.matches(data, "tui.select.up")) {
|
|
2806
|
+
const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "up");
|
|
2807
|
+
selectedNodeId = next.nodeId; selectionNeedsScroll = true;
|
|
2808
|
+
} else if (keybindings.matches(data, "tui.select.down")) {
|
|
2809
|
+
const next = navigateWorkflowPhaseTree(tree, selectedNodeId, expandedNodeIds, "down");
|
|
2810
|
+
selectedNodeId = next.nodeId; selectionNeedsScroll = true;
|
|
2811
|
+
} else if (keybindings.matches(data, "tui.select.pageUp")) dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, terminalRows() - 1));
|
|
2812
|
+
else if (keybindings.matches(data, "tui.select.pageDown")) dashboardOffset += Math.max(1, terminalRows() - 1);
|
|
2445
2813
|
else if (keybindings.matches(data, "tui.select.confirm")) {
|
|
2446
|
-
if (
|
|
2447
|
-
else
|
|
2814
|
+
if (narrow) detailsMode = true;
|
|
2815
|
+
else if (current?.kind === "agent" && current.agentId) { actionMode = true; actionIndex = 0; }
|
|
2816
|
+
else if (current?.children.length) { if (expandedNodeIds.has(current.id)) expandedNodeIds.delete(current.id); else expandedNodeIds.add(current.id); }
|
|
2448
2817
|
}
|
|
2449
|
-
else if (keybindings.matches(data, "tui.select.cancel")) done(undefined);
|
|
2450
2818
|
tui.requestRender();
|
|
2451
2819
|
},
|
|
2452
2820
|
dispose() { disposed = true; clearInterval(timer); setWorkflowStatus(undefined); },
|
|
2453
2821
|
}, theme);
|
|
2454
|
-
}, { overlay: true })
|
|
2822
|
+
}, { overlay: true, overlayOptions: WORKFLOW_OVERLAY_OPTIONS })
|
|
2455
2823
|
: await ctx.ui.select(view.dashboard, [...view.actions.keys(), "Close"]);
|
|
2456
2824
|
if (!actionChoice || actionChoice === "Close") return;
|
|
2457
2825
|
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%" } });
|
|
2826
|
+
if (actionChoice.startsWith("__workflow_agent__:")) { await selectAgent(view, actionChoice.slice("__workflow_agent__:".length)); continue; }
|
|
2827
|
+
if (actionChoice.startsWith("__workflow_fork__:")) {
|
|
2828
|
+
const agentId = actionChoice.slice("__workflow_fork__:".length);
|
|
2829
|
+
const agent = view.agents.find((candidate) => candidate.id === agentId);
|
|
2830
|
+
if (agent) await forkAgentSession(view, agent);
|
|
2492
2831
|
continue;
|
|
2493
2832
|
}
|
|
2833
|
+
if (actionChoice === "Refresh") continue;
|
|
2494
2834
|
const copy = view.copies.get(actionChoice);
|
|
2495
2835
|
if (copy) { await copyArtifact(copy.value, copy.artifact); continue; }
|
|
2496
2836
|
if (actionChoice.startsWith("Review ")) {
|
|
@@ -2544,7 +2884,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2544
2884
|
tui.requestRender();
|
|
2545
2885
|
},
|
|
2546
2886
|
}, theme);
|
|
2547
|
-
}, { overlay: true, overlayOptions:
|
|
2887
|
+
}, { overlay: true, overlayOptions: WORKFLOW_OVERLAY_OPTIONS });
|
|
2548
2888
|
if (decision) {
|
|
2549
2889
|
const accepted = await answerCheckpoint(store.runId, checkpoint.name, decision === "Approve", true);
|
|
2550
2890
|
if (!accepted) ctx.ui.notify("Checkpoint is not awaiting a response.", "warning");
|
|
@@ -2564,9 +2904,9 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2564
2904
|
pi.on("session_shutdown", async () => {
|
|
2565
2905
|
try {
|
|
2566
2906
|
await Promise.all([...runs.entries()].map(async ([runId, run]) => {
|
|
2567
|
-
|
|
2568
|
-
if (!
|
|
2569
|
-
try { await run.lifecycle.terminal("interrupted"); } catch (error) { if (!
|
|
2907
|
+
const isTerminal = SHUTDOWN_TERMINAL_RUN_STATES.has(run.lifecycle.state);
|
|
2908
|
+
if (!isTerminal) {
|
|
2909
|
+
try { await run.lifecycle.terminal("interrupted"); } catch (error) { if (!SHUTDOWN_TERMINAL_RUN_STATES.has(run.lifecycle.state)) throw error; }
|
|
2570
2910
|
run.abortController.abort();
|
|
2571
2911
|
run.execution?.cancel();
|
|
2572
2912
|
await scheduler.cancelRun(runId);
|