pi-extensible-workflows 0.3.2 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/dist/src/agent-execution.d.ts +81 -8
- package/dist/src/agent-execution.js +421 -71
- package/dist/src/doctor.d.ts +4 -1
- package/dist/src/doctor.js +56 -11
- package/dist/src/index.d.ts +217 -22
- package/dist/src/index.js +1416 -337
- package/dist/src/persistence.d.ts +26 -1
- package/dist/src/persistence.js +100 -6
- package/dist/src/session-inspector.d.ts +13 -1
- package/dist/src/session-inspector.js +22 -4
- package/dist/src/workflow-evals.js +1 -1
- package/package.json +1 -1
- package/skills/pi-extensible-workflows/SKILL.md +9 -5
- package/src/agent-execution.ts +350 -83
- package/src/doctor.ts +59 -12
- package/src/index.ts +1231 -360
- package/src/persistence.ts +76 -7
- package/src/session-inspector.ts +25 -7
- package/src/workflow-evals.ts +1 -1
package/src/index.ts
CHANGED
|
@@ -1,47 +1,80 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import { fork, type ChildProcess } from "node:child_process";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
-
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
-
import { tmpdir } from "node:os";
|
|
6
|
-
import { basename, dirname, extname, join } from "node:path";
|
|
4
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { homedir, tmpdir } from "node:os";
|
|
6
|
+
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import * as acorn from "acorn";
|
|
9
9
|
import { Script } from "node:vm";
|
|
10
10
|
import { Type } from "@earendil-works/pi-ai";
|
|
11
11
|
import { Value } from "typebox/value";
|
|
12
12
|
import { copyToClipboard, getAgentDir, parseFrontmatter, highlightCode, SessionManager, truncateToVisualLines, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
|
-
import { createNativeAgentSession, FairAgentScheduler, WorkflowAgentExecutor, type AgentActivity, type AgentAttempt, type AgentDefinition, type AgentProgress, type SessionFactory } from "./agent-execution.js";
|
|
13
|
+
import { createNativeAgentSession, FairAgentScheduler, WorkflowAgentExecutor, type AgentActivity, type AgentAccounting, type AgentAttempt, type AgentBudgetHooks, type AgentDefinition, type AgentProgress, type AgentSetupHook, type RegisteredAgentSetupHook, type SessionFactory } from "./agent-execution.js";
|
|
14
14
|
import { transcriptLines } from "./session-inspector.js";
|
|
15
15
|
import { acquireSessionLease, listRunIds, RunStore, SessionLease, structuralPath as operationPath } from "./persistence.js";
|
|
16
16
|
import type { AwaitingCheckpoint, PersistedRun, WorktreeReference } from "./persistence.js";
|
|
17
17
|
|
|
18
|
-
export const RUN_STATES = ["queued", "running", "pausing", "paused", "awaiting_input", "completed", "failed", "stopped", "interrupted"] as const;
|
|
18
|
+
export const RUN_STATES = ["queued", "running", "pausing", "paused", "awaiting_input", "completed", "failed", "stopped", "interrupted", "budget_exhausted"] as const;
|
|
19
19
|
export const AGENT_STATES = ["queued", "running", "waiting_for_child", "paused", "retrying", "completed", "failed", "cancelled"] as const;
|
|
20
20
|
export const WORKFLOW_ASYNC_STARTED_EVENT = "workflow:async-started";
|
|
21
21
|
export const WORKFLOW_ASYNC_COMPLETE_EVENT = "workflow:async-complete";
|
|
22
|
+
export const WORKFLOW_RUN_STARTED_EVENT = "workflow:run-started";
|
|
23
|
+
export const WORKFLOW_RUN_RESUMED_EVENT = "workflow:run-resumed";
|
|
24
|
+
export const WORKFLOW_RUN_STATE_CHANGED_EVENT = "workflow:run-state-changed";
|
|
25
|
+
export const WORKFLOW_RUN_COMPLETED_EVENT = "workflow:run-completed";
|
|
26
|
+
export const WORKFLOW_RUN_FAILED_EVENT = "workflow:run-failed";
|
|
27
|
+
export const WORKFLOW_AGENT_STATE_CHANGED_EVENT = "workflow:agent-state-changed";
|
|
28
|
+
export const WORKFLOW_PHASE_CHANGED_EVENT = "workflow:phase-changed";
|
|
29
|
+
export const WORKFLOW_CHECKPOINT_STATE_CHANGED_EVENT = "workflow:checkpoint-state-changed";
|
|
30
|
+
export const WORKFLOW_BUDGET_EVENT = "workflow:budget-event";
|
|
31
|
+
export const WORKFLOW_WORKTREE_CREATED_EVENT = "workflow:worktree-created";
|
|
22
32
|
const SETTLED_AGENT_STATES: ReadonlySet<AgentState> = new Set(["completed", "failed", "cancelled"]);
|
|
23
33
|
export const ERROR_CODES = [
|
|
24
|
-
"INVALID_SETTINGS", "INVALID_SYNTAX", "INVALID_METADATA", "DUPLICATE_NAME", "INVALID_SCHEMA", "UNKNOWN_MODEL", "UNKNOWN_TOOL", "UNKNOWN_AGENT_TYPE",
|
|
25
|
-
"
|
|
26
|
-
"CANCELLED", "WORKER_UNRESPONSIVE", "WORKTREE_FAILED", "RESUME_INCOMPATIBLE", "INTERNAL_ERROR",
|
|
27
|
-
] as const;
|
|
34
|
+
"CONFIG_ERROR", "INVALID_SETTINGS", "INVALID_SYNTAX", "INVALID_METADATA", "DUPLICATE_NAME", "INVALID_SCHEMA", "UNKNOWN_MODEL", "UNKNOWN_TOOL", "UNKNOWN_AGENT_TYPE",
|
|
35
|
+
"RUN_OWNED", "REGISTRY_FROZEN", "GLOBAL_COLLISION", "MISSING_WORKFLOW", "RPC_LIMIT_EXCEEDED", "AGENT_TIMEOUT", "AGENT_FAILED", "RESULT_INVALID",
|
|
36
|
+
"CANCELLED", "WORKER_UNRESPONSIVE", "WORKTREE_FAILED", "RESUME_INCOMPATIBLE", "BUDGET_EXHAUSTED", "INTERNAL_ERROR",
|
|
37
|
+
] as const;
|
|
28
38
|
|
|
29
39
|
export type RunState = (typeof RUN_STATES)[number];
|
|
30
40
|
export type AgentState = (typeof AGENT_STATES)[number];
|
|
31
41
|
export type WorkflowErrorCode = (typeof ERROR_CODES)[number];
|
|
32
42
|
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
|
|
33
43
|
export type JsonSchema = { [key: string]: JsonValue };
|
|
34
|
-
|
|
44
|
+
export type BudgetDimension = "tokens" | "costUsd" | "durationMs" | "agentLaunches";
|
|
45
|
+
export interface BudgetLimits { soft?: number; hard?: number }
|
|
46
|
+
export type WorkflowBudget = Partial<Record<BudgetDimension, BudgetLimits>>;
|
|
47
|
+
export type WorkflowBudgetPatch = Partial<Record<BudgetDimension, BudgetLimits | { soft?: number | null; hard?: number | null } | null>>;
|
|
48
|
+
export interface WorkflowBudgetUsage { tokens: number; costUsd: number; durationMs: number; agentLaunches: number }
|
|
49
|
+
export type BudgetEventType = "soft_crossed" | "hard_overrun" | "hard_exhausted" | "adjustment_requested" | "adjustment_approved" | "adjustment_rejected";
|
|
50
|
+
export interface BudgetEvent { type: BudgetEventType; budgetVersion: number; dimensions: readonly BudgetDimension[]; usage: WorkflowBudgetUsage; limits: WorkflowBudget; at: number; proposalId?: string; previous?: WorkflowBudget; proposed?: WorkflowBudget }
|
|
51
|
+
export interface BudgetApprovalRequest { kind: "budget"; proposalId: string; runId: string; consumed: WorkflowBudgetUsage; previous: WorkflowBudget; proposed: WorkflowBudget; budgetVersion: number }
|
|
35
52
|
export interface WorkflowErrorShape { code: WorkflowErrorCode; message: string }
|
|
53
|
+
export interface WorkflowEventBase { runId: string; sessionId: string; workflowName: string; cwd: string; runDirectory: string; timestamp: number }
|
|
54
|
+
export type WorkflowRunStartedEvent = WorkflowEventBase;
|
|
55
|
+
export type WorkflowRunResumedEvent = WorkflowEventBase;
|
|
56
|
+
export interface WorkflowRunStateChangedEvent extends WorkflowEventBase { previousState: RunState; state: RunState; reason?: string; errorCode?: WorkflowErrorCode }
|
|
57
|
+
export interface WorkflowRunCompletedEvent extends WorkflowEventBase { resultPath: string }
|
|
58
|
+
export interface WorkflowRunFailedEvent extends WorkflowEventBase { error: WorkflowErrorShape }
|
|
59
|
+
export interface WorkflowAgentStateChangedEvent extends WorkflowEventBase { agentId: string; displayLabel: string; role?: string; structuralPath: readonly string[]; parentId?: string; parentBreadcrumb?: string; worktreeOwner?: string; previousState?: AgentState; state: AgentState; attempt: number }
|
|
60
|
+
export interface WorkflowPhaseChangedEvent extends WorkflowEventBase { previousPhase?: string; phase: string }
|
|
61
|
+
export type WorkflowCheckpointState = "awaiting" | "approved" | "rejected";
|
|
62
|
+
export interface WorkflowCheckpointStateChangedEvent extends WorkflowEventBase { name: string; state: WorkflowCheckpointState }
|
|
63
|
+
export interface WorkflowBudgetEvent extends WorkflowEventBase { type: BudgetEventType; budgetVersion: number; dimensions: readonly BudgetDimension[]; usage: WorkflowBudgetUsage; limits: WorkflowBudget; proposalId?: string; previous?: WorkflowBudget; proposed?: WorkflowBudget }
|
|
36
64
|
export interface ModelSpec { provider: string; model: string; thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" }
|
|
37
65
|
export interface WorkflowMetadata { name: string; description?: string }
|
|
38
|
-
export interface WorkflowSettings { concurrency: number;
|
|
39
|
-
export interface
|
|
40
|
-
export interface
|
|
41
|
-
export interface
|
|
42
|
-
|
|
43
|
-
export interface
|
|
44
|
-
export interface
|
|
66
|
+
export interface WorkflowSettings { concurrency: number; modelAliases?: Readonly<Record<string, string>>; disabledAgentResources?: Readonly<AgentResourceExclusions> }
|
|
67
|
+
export interface AgentResourceExclusions { skills: readonly string[]; extensions: readonly string[] }
|
|
68
|
+
export interface AgentResourcePolicy { globalSettingsPath: string; projectSettingsPath: string; projectTrusted: boolean; global: AgentResourceExclusions; project: AgentResourceExclusions; effective: AgentResourceExclusions; unmatchedSkills: string[]; unmatchedExtensions: string[] }
|
|
69
|
+
export interface AgentSetupSummary { hookNames: readonly string[]; model: ModelSpec; tools: readonly string[]; cwd: string; disabledAgentResources?: { skills: readonly string[]; extensions: readonly string[]; unmatchedSkills: readonly string[]; unmatchedExtensions: readonly string[] } }
|
|
70
|
+
export interface AgentAttemptSummary { attempt: number; sessionId: string; sessionFile: string; error?: { code: string; message: string }; accounting: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number }; setup?: AgentSetupSummary }
|
|
71
|
+
export interface WorkflowWorktreeCreatedEvent extends WorkflowEventBase { owner: string; branch: string; path: string; base: string }
|
|
72
|
+
export interface AgentRecord { id: string; name: string; label?: string; path: string; state: AgentState; parentId?: string; structuralPath?: readonly string[]; parentBreadcrumb?: string; worktreeOwner?: string; role?: string; requestedModel?: string; model: ModelSpec; tools: readonly string[]; attempts: number; attemptDetails?: readonly AgentAttemptSummary[]; accounting?: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number }; toolCalls?: readonly { id: string; name: string; state: "running" | "completed" | "failed" }[]; activity?: AgentActivity | undefined }
|
|
73
|
+
export interface WorkflowRunEvent { type: "warning"; message: string }
|
|
74
|
+
export interface RunRecord { id: string; workflowName: string; cwd: string; sessionId: string; state: RunState; phase?: string; agents: readonly AgentRecord[]; error?: WorkflowErrorShape; budget?: WorkflowBudget; budgetVersion?: number; usage?: WorkflowBudgetUsage; budgetEvents?: readonly BudgetEvent[]; events?: readonly WorkflowRunEvent[] }
|
|
75
|
+
export const LAUNCH_SNAPSHOT_IDENTITY_VERSION = 3;
|
|
76
|
+
export interface LaunchSnapshot { identityVersion?: number; script: string; args: JsonValue; metadata: WorkflowMetadata; settings: WorkflowSettings; budget?: WorkflowBudget; settingsPath?: string; modelAliases?: Readonly<Record<string, string>>; models: readonly string[]; tools: readonly string[]; agentTypes: readonly string[]; roles?: Readonly<Record<string, AgentDefinition>>; projectRoles?: readonly string[]; schemas: readonly JsonSchema[] }
|
|
77
|
+
export interface PreflightCapabilities { models: ReadonlySet<string>; tools: ReadonlySet<string>; agentTypes: ReadonlySet<string>; modelAliases?: Readonly<Record<string, string>>; knownModels?: ReadonlySet<string>; settingsPath?: string; skipModelAvailability?: boolean }
|
|
45
78
|
export interface PreflightResult { metadata: WorkflowMetadata; referenced: { phases: readonly string[]; models: readonly string[]; tools: readonly string[]; agentTypes: readonly string[] }; schemas: readonly JsonSchema[]; dynamicAgentRoles: boolean }
|
|
46
79
|
export interface WorkflowOrchestrationContext {
|
|
47
80
|
agent: (...args: readonly unknown[]) => Promise<JsonValue>;
|
|
@@ -58,7 +91,7 @@ export interface WorkflowFunctionContext extends WorkflowOrchestrationContext {
|
|
|
58
91
|
export interface WorkflowFunction { description: string; input: JsonSchema; output: JsonSchema; run: (input: Readonly<Record<string, JsonValue>>, context: Readonly<WorkflowFunctionContext>) => Promise<JsonValue> | JsonValue }
|
|
59
92
|
export interface WorkflowVariable { description: string; schema: JsonSchema; resolve: (run: Readonly<WorkflowRunContext>) => Promise<JsonValue> | JsonValue }
|
|
60
93
|
export interface WorkflowScriptDefinition { description: string; script: string }
|
|
61
|
-
export interface WorkflowExtension {
|
|
94
|
+
export interface WorkflowExtension { version: string; headline: string; description: string; functions?: Readonly<Record<string, WorkflowFunction>>; variables?: Readonly<Record<string, WorkflowVariable>>; workflows?: Readonly<Record<string, WorkflowScriptDefinition>>; agentSetupHooks?: Readonly<Record<string, AgentSetupHook>> }
|
|
62
95
|
export interface WorkflowJournal { get(path: string): JsonValue | undefined; put(path: string, value: JsonValue): void }
|
|
63
96
|
|
|
64
97
|
export class WorkflowError extends Error {
|
|
@@ -86,6 +119,7 @@ function workflowDetail(message: string): string {
|
|
|
86
119
|
}
|
|
87
120
|
|
|
88
121
|
const WORKFLOW_ERROR_PROSE: Record<WorkflowErrorCode, (detail: string) => string> = {
|
|
122
|
+
CONFIG_ERROR: (detail) => `The workflow configuration is invalid: ${detail}.`,
|
|
89
123
|
INVALID_SETTINGS: (detail) => `The workflow settings are invalid: ${detail}.`,
|
|
90
124
|
INVALID_SYNTAX: (detail) => `The workflow source is invalid: ${detail}.`,
|
|
91
125
|
INVALID_METADATA: (detail) => `The workflow metadata is invalid: ${detail}.`,
|
|
@@ -97,7 +131,6 @@ const WORKFLOW_ERROR_PROSE: Record<WorkflowErrorCode, (detail: string) => string
|
|
|
97
131
|
UNKNOWN_MODEL: (detail) => `The workflow requested the unavailable model ${detail.replace(/^(?:Unknown model(?: for role [^:]+)?|Invalid model spec):\s*/, "")}.`,
|
|
98
132
|
UNKNOWN_TOOL: (detail) => `The workflow requested the unavailable tool ${detail.replace(/^Unknown tool:\s*/, "")}.`,
|
|
99
133
|
UNKNOWN_AGENT_TYPE: (detail) => `The workflow requested the unavailable agent role ${detail.replace(/^Unknown agent role:\s*/, "")}.`,
|
|
100
|
-
RUN_LIMIT_EXCEEDED: (detail) => `The workflow exceeded its agent launch limit: ${detail.replace(/^Run\s+exceeded\s+/i, "")}.`,
|
|
101
134
|
RUN_OWNED: (detail) => /already owned|active ownership/.test(detail) ? "The workflow session is already in use." : `The workflow session is already in use: ${detail}.`,
|
|
102
135
|
RPC_LIMIT_EXCEEDED: (detail) => `The workflow communication data exceeded its size limit: ${detail}.`,
|
|
103
136
|
AGENT_TIMEOUT: (detail) => `The workflow agent timed out: ${detail}.`,
|
|
@@ -107,6 +140,7 @@ const WORKFLOW_ERROR_PROSE: Record<WorkflowErrorCode, (detail: string) => string
|
|
|
107
140
|
WORKER_UNRESPONSIVE: (detail) => `The workflow worker stopped responding: ${detail}.`,
|
|
108
141
|
WORKTREE_FAILED: (detail) => `The workflow worktree operation failed: ${detail}.`,
|
|
109
142
|
RESUME_INCOMPATIBLE: (detail) => `The workflow cannot resume this run: ${detail}.`,
|
|
143
|
+
BUDGET_EXHAUSTED: (detail) => `The workflow budget was exhausted: ${detail}.`,
|
|
110
144
|
INTERNAL_ERROR: (detail) => `The workflow encountered an internal error: ${detail}.`,
|
|
111
145
|
};
|
|
112
146
|
export function formatWorkflowFailure(error: unknown): string {
|
|
@@ -133,13 +167,12 @@ function mainAgentError(error: unknown): WorkflowError {
|
|
|
133
167
|
return presented;
|
|
134
168
|
}
|
|
135
169
|
|
|
136
|
-
|
|
137
170
|
export class RunLifecycle {
|
|
138
171
|
#state: RunState;
|
|
139
172
|
#active = 0;
|
|
140
173
|
#waiters: Array<() => void> = [];
|
|
141
174
|
|
|
142
|
-
constructor(state: RunState = "running", private readonly changed?: (state: RunState) => void | Promise<void>) { this.#state = state; }
|
|
175
|
+
constructor(state: RunState = "running", private readonly changed?: (state: RunState, previousState: RunState, reason?: string) => void | Promise<void>) { this.#state = state; }
|
|
143
176
|
get state(): RunState { return this.#state; }
|
|
144
177
|
|
|
145
178
|
async enter(): Promise<void> {
|
|
@@ -150,31 +183,31 @@ export class RunLifecycle {
|
|
|
150
183
|
|
|
151
184
|
async leave(): Promise<void> {
|
|
152
185
|
if (this.#active > 0) this.#active -= 1;
|
|
153
|
-
if (this.#state === "pausing" && this.#active === 0) await this.#set("paused");
|
|
186
|
+
if (this.#state === "pausing" && this.#active === 0) await this.#set("paused", "pause");
|
|
154
187
|
}
|
|
155
188
|
|
|
156
189
|
async enterAwaitingInput(): Promise<void> {
|
|
157
190
|
while (this.#state === "pausing" || this.#state === "paused") await new Promise<void>((resolve) => { this.#waiters.push(resolve); });
|
|
158
191
|
if (this.#state === "awaiting_input") return;
|
|
159
192
|
if (this.#state !== "running") throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot await input for ${this.#state} run`);
|
|
160
|
-
await this.#set("awaiting_input");
|
|
193
|
+
await this.#set("awaiting_input", "awaiting_input");
|
|
161
194
|
}
|
|
162
195
|
|
|
163
196
|
async resolveAwaitingInput(): Promise<void> {
|
|
164
197
|
if (this.#state !== "awaiting_input") return;
|
|
165
|
-
await this.#set("running");
|
|
198
|
+
await this.#set("running", "checkpoint_resolved");
|
|
166
199
|
for (const resolve of this.#waiters.splice(0)) resolve();
|
|
167
200
|
}
|
|
168
201
|
|
|
169
202
|
async pause(): Promise<void> {
|
|
170
203
|
if (this.#state !== "running") throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot pause ${this.#state} run`);
|
|
171
|
-
await this.#set("pausing");
|
|
172
|
-
if (this.#active === 0) await this.#set("paused");
|
|
204
|
+
await this.#set("pausing", "pause");
|
|
205
|
+
if (this.#active === 0) await this.#set("paused", "pause");
|
|
173
206
|
}
|
|
174
207
|
|
|
175
208
|
async resume(): Promise<void> {
|
|
176
|
-
if (this.#state !== "paused" && this.#state !== "interrupted") throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot resume ${this.#state} run`);
|
|
177
|
-
await this.#set("running");
|
|
209
|
+
if (this.#state !== "paused" && this.#state !== "interrupted" && this.#state !== "budget_exhausted") throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot resume ${this.#state} run`);
|
|
210
|
+
await this.#set("running", "resume");
|
|
178
211
|
for (const resolve of this.#waiters.splice(0)) resolve();
|
|
179
212
|
}
|
|
180
213
|
|
|
@@ -184,33 +217,209 @@ export class RunLifecycle {
|
|
|
184
217
|
await this.enter();
|
|
185
218
|
}
|
|
186
219
|
|
|
187
|
-
async terminal(state: "completed" | "failed" | "stopped" | "interrupted"): Promise<void> {
|
|
220
|
+
async terminal(state: "completed" | "failed" | "stopped" | "interrupted" | "budget_exhausted", reason?: string): Promise<void> {
|
|
188
221
|
if (["completed", "failed", "stopped"].includes(this.#state)) throw new WorkflowError("RESUME_INCOMPATIBLE", `${this.#state} runs are terminal`);
|
|
189
|
-
await this.#set(state);
|
|
222
|
+
await this.#set(state, reason ?? state);
|
|
190
223
|
for (const resolve of this.#waiters.splice(0)) resolve();
|
|
191
224
|
}
|
|
192
225
|
|
|
193
|
-
async #set(state: RunState): Promise<void> {
|
|
226
|
+
async #set(state: RunState, reason?: string): Promise<void> {
|
|
227
|
+
const previousState = this.#state;
|
|
228
|
+
this.#state = state;
|
|
229
|
+
await this.changed?.(state, previousState, reason);
|
|
230
|
+
}
|
|
194
231
|
}
|
|
195
232
|
|
|
196
|
-
export const DEFAULT_SETTINGS: Readonly<WorkflowSettings> = Object.freeze({ concurrency: 8
|
|
233
|
+
export const DEFAULT_SETTINGS: Readonly<WorkflowSettings> = Object.freeze({ concurrency: 8 });
|
|
197
234
|
|
|
198
235
|
function fail(code: WorkflowErrorCode, message: string): never { throw new WorkflowError(code, message); }
|
|
199
236
|
function object(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
|
237
|
+
function nonNegativeInteger(value: unknown): value is number { return Number.isInteger(value) && (value as number) >= 0; }
|
|
238
|
+
function nonNegativeFinite(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value >= 0; }
|
|
239
|
+
export function validateBudget(value: unknown): WorkflowBudget | undefined {
|
|
240
|
+
if (value === undefined) return undefined;
|
|
241
|
+
if (!object(value)) fail("INVALID_METADATA", "budget must be an object");
|
|
242
|
+
const result: WorkflowBudget = {};
|
|
243
|
+
for (const [dimension, raw] of Object.entries(value)) {
|
|
244
|
+
if (!["tokens", "costUsd", "durationMs", "agentLaunches"].includes(dimension)) fail("INVALID_METADATA", `Unknown budget dimension: ${dimension}`);
|
|
245
|
+
if (!object(raw)) fail("INVALID_METADATA", `${dimension} budget must be an object`);
|
|
246
|
+
if (Object.keys(raw).some((key) => key !== "soft" && key !== "hard")) fail("INVALID_METADATA", `${dimension} budget has an unknown limit`);
|
|
247
|
+
const isCost = dimension === "costUsd";
|
|
248
|
+
for (const key of ["soft", "hard"] as const) if (raw[key] !== undefined && !(isCost ? nonNegativeFinite(raw[key]) : nonNegativeInteger(raw[key]))) fail("INVALID_METADATA", `${dimension}.${key} must be a non-negative ${isCost ? "finite number" : "integer"}`);
|
|
249
|
+
if (raw.soft !== undefined && raw.soft !== null && raw.hard !== undefined && raw.hard !== null && raw.soft >= raw.hard) fail("INVALID_METADATA", `${dimension}.soft must be less than hard`);
|
|
250
|
+
const limits: BudgetLimits = {};
|
|
251
|
+
if (raw.soft !== undefined) limits.soft = raw.soft as number;
|
|
252
|
+
if (raw.hard !== undefined) limits.hard = raw.hard as number;
|
|
253
|
+
if (Object.keys(limits).length) (result as Record<string, BudgetLimits>)[dimension] = limits;
|
|
254
|
+
}
|
|
255
|
+
return Object.freeze(result);
|
|
256
|
+
}
|
|
257
|
+
export function validateBudgetPatch(value: unknown): WorkflowBudgetPatch {
|
|
258
|
+
if (!object(value)) fail("INVALID_METADATA", "budget patch must be an object");
|
|
259
|
+
const result: WorkflowBudgetPatch = {};
|
|
260
|
+
for (const [dimension, raw] of Object.entries(value)) {
|
|
261
|
+
if (!["tokens", "costUsd", "durationMs", "agentLaunches"].includes(dimension)) fail("INVALID_METADATA", `Unknown budget dimension: ${dimension}`);
|
|
262
|
+
if (raw === null) { (result as Record<string, null>)[dimension] = null; continue; }
|
|
263
|
+
if (!object(raw) || Object.keys(raw).some((key) => key !== "soft" && key !== "hard")) fail("INVALID_METADATA", `${dimension} budget patch must contain only soft and hard`);
|
|
264
|
+
const limits: { soft?: number | null; hard?: number | null } = {};
|
|
265
|
+
for (const key of ["soft", "hard"] as const) if (Object.prototype.hasOwnProperty.call(raw, key)) {
|
|
266
|
+
if (raw[key] === null) limits[key] = null;
|
|
267
|
+
else { const checked = validateBudget({ [dimension]: { [key]: raw[key] } })?.[dimension as BudgetDimension]; if (checked?.[key] !== undefined) limits[key] = checked[key]; }
|
|
268
|
+
}
|
|
269
|
+
if (limits.soft !== null && limits.hard !== null && limits.soft !== undefined && limits.hard !== undefined && limits.soft >= limits.hard) fail("INVALID_METADATA", `${dimension}.soft must be less than hard`);
|
|
270
|
+
(result as Record<string, { soft?: number | null; hard?: number | null }>)[dimension] = limits;
|
|
271
|
+
}
|
|
272
|
+
return result;
|
|
273
|
+
}
|
|
274
|
+
function budgetUsage(value?: Partial<WorkflowBudgetUsage>): WorkflowBudgetUsage { return { tokens: value?.tokens ?? 0, costUsd: value?.costUsd ?? 0, durationMs: value?.durationMs ?? 0, agentLaunches: value?.agentLaunches ?? 0 }; }
|
|
275
|
+
export class WorkflowBudgetRuntime {
|
|
276
|
+
readonly #now: () => number;
|
|
277
|
+
readonly #onChange: (() => void) | undefined;
|
|
278
|
+
readonly #injected = new Set<string>();
|
|
279
|
+
readonly #seen = new Set<string>();
|
|
280
|
+
#active: boolean;
|
|
281
|
+
#activeSince: number | undefined;
|
|
282
|
+
#usage: WorkflowBudgetUsage;
|
|
283
|
+
#events: BudgetEvent[];
|
|
284
|
+
#turnAccounting?: { input: number; output: number; cost: number };
|
|
285
|
+
constructor(readonly budget: WorkflowBudget | undefined, readonly version = 1, usage?: Partial<WorkflowBudgetUsage>, events: readonly BudgetEvent[] = [], options: { now?: () => number; onChange?: () => void; active?: boolean } = {}) {
|
|
286
|
+
this.#now = options.now ?? (() => Date.now());
|
|
287
|
+
this.#onChange = options.onChange;
|
|
288
|
+
this.#active = options.active ?? true;
|
|
289
|
+
this.#activeSince = this.#active ? this.#now() : undefined;
|
|
290
|
+
this.#usage = budgetUsage(usage);
|
|
291
|
+
this.#events = [...events];
|
|
292
|
+
for (const event of events) if (event.budgetVersion === version) this.#seen.add(event.type);
|
|
293
|
+
}
|
|
294
|
+
get usage(): WorkflowBudgetUsage { this.#syncDuration(); return { ...this.#usage }; }
|
|
295
|
+
get events(): readonly BudgetEvent[] { return this.#events; }
|
|
296
|
+
get hardExhausted(): boolean { return this.#events.some((event) => event.type === "hard_exhausted" && event.budgetVersion === this.version); }
|
|
297
|
+
checkAgentLaunch(): void { this.#checkHard(["agentLaunches"]); }
|
|
298
|
+
beforeAttempt(): void { this.#checkHard(["agentLaunches"]); this.#usage.agentLaunches += 1; this.#evaluate(); }
|
|
299
|
+
beforeTurn(): void { this.#syncDuration(); this.#evaluate(); this.#checkHard(["tokens", "costUsd", "durationMs"]); }
|
|
300
|
+
afterTurn(accounting: AgentAccounting, final: boolean): void { this.#syncDuration(); this.#applyTurn(accounting, final, this.#turnAccounting); this.#turnAccounting = { input: accounting.input, output: accounting.output, cost: accounting.cost }; }
|
|
301
|
+
#applyTurn(accounting: AgentAccounting, final: boolean, previous = { input: 0, output: 0, cost: 0 }): void {
|
|
302
|
+
this.#usage.tokens += Math.max(0, accounting.input - previous.input) + Math.max(0, accounting.output - previous.output);
|
|
303
|
+
this.#usage.costUsd += Math.max(0, accounting.cost - previous.cost);
|
|
304
|
+
this.#evaluate();
|
|
305
|
+
if (!final) this.#checkHard(["tokens", "costUsd", "durationMs"]);
|
|
306
|
+
}
|
|
307
|
+
instruction(agentId = "agent"): string | undefined {
|
|
308
|
+
if (!this.#hasSoftCrossed() || this.#injected.has(agentId)) return undefined;
|
|
309
|
+
this.#injected.add(agentId);
|
|
310
|
+
return `The workflow budget soft limit has been reached. Finish the requested output now, preserving any required output schema. Current usage: ${JSON.stringify(this.usage)}. Do not start additional model work unless it is required to produce the final requested result.`;
|
|
311
|
+
}
|
|
312
|
+
forAgent(agentId: string): AgentBudgetHooks {
|
|
313
|
+
let attempt = 0;
|
|
314
|
+
let previous: { input: number; output: number; cost: number } | undefined;
|
|
315
|
+
return {
|
|
316
|
+
beforeAttempt: () => { attempt += 1; previous = undefined; this.beforeAttempt(); },
|
|
317
|
+
beforeTurn: () => { this.beforeTurn(); },
|
|
318
|
+
afterTurn: (accounting, final) => { this.#applyTurn(accounting, final, previous); previous = { input: accounting.input, output: accounting.output, cost: accounting.cost }; },
|
|
319
|
+
instruction: () => this.instruction(`${agentId}:${String(attempt + 1)}`),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
transition(state: RunState): void {
|
|
323
|
+
const active = state === "running";
|
|
324
|
+
if (active === this.#active) return;
|
|
325
|
+
if (active) { this.#active = true; this.#activeSince = this.#now(); }
|
|
326
|
+
else { this.#syncDuration(); this.#evaluate(); this.#active = false; this.#activeSince = undefined; }
|
|
327
|
+
this.#onChange?.();
|
|
328
|
+
}
|
|
329
|
+
#syncDuration(): void { if (this.#active && this.#activeSince !== undefined) { const now = this.#now(); this.#usage.durationMs += Math.max(0, now - this.#activeSince); this.#activeSince = now; } }
|
|
330
|
+
#hasSoftCrossed(): boolean { return !!this.budget && (Object.entries(this.budget) as [BudgetDimension, BudgetLimits][]).some(([dimension, limits]) => limits.soft !== undefined && this.#usage[dimension] >= limits.soft); }
|
|
331
|
+
#checkHard(dimensions: readonly BudgetDimension[]): void {
|
|
332
|
+
const exhausted = dimensions.filter((dimension) => { const hard = this.budget?.[dimension]?.hard; return hard !== undefined && this.#usage[dimension] >= hard; });
|
|
333
|
+
if (!exhausted.length) return;
|
|
334
|
+
this.#record("hard_exhausted", exhausted);
|
|
335
|
+
const detail = exhausted.map((dimension) => `${dimension} usage=${String(this.#usage[dimension])} hard=${String(this.budget?.[dimension]?.hard)}`).join(", ");
|
|
336
|
+
throw new WorkflowError("BUDGET_EXHAUSTED", `Budget version ${String(this.version)} exhausted: ${detail}`);
|
|
337
|
+
}
|
|
338
|
+
#evaluate(): void {
|
|
339
|
+
const budget = this.budget;
|
|
340
|
+
if (!budget) return;
|
|
341
|
+
const soft = (Object.keys(budget) as BudgetDimension[]).filter((dimension) => { const limits = budget[dimension]; return limits !== undefined && limits.soft !== undefined && this.#usage[dimension] >= limits.soft; });
|
|
342
|
+
if (soft.length) this.#record("soft_crossed", soft);
|
|
343
|
+
const overrun = (Object.keys(budget) as BudgetDimension[]).filter((dimension) => { const limits = budget[dimension]; return limits !== undefined && limits.hard !== undefined && this.#usage[dimension] > limits.hard; });
|
|
344
|
+
if (overrun.length) this.#record("hard_overrun", overrun);
|
|
345
|
+
}
|
|
346
|
+
#record(type: BudgetEvent["type"], dimensions: readonly BudgetDimension[]): void { if (this.#seen.has(type)) return; this.#seen.add(type); this.#events.push({ type, budgetVersion: this.version, dimensions: [...dimensions], usage: this.usage, limits: structuredClone(this.budget ?? {}), at: this.#now() }); this.#onChange?.(); }
|
|
347
|
+
recordEvent(event: BudgetEvent): void { this.#events.push(structuredClone(event)); }
|
|
348
|
+
snapshot(): { usage: WorkflowBudgetUsage; budgetEvents: readonly BudgetEvent[] } { return { usage: this.usage, budgetEvents: [...this.#events] }; }
|
|
349
|
+
}
|
|
350
|
+
export function mergeBudget(budget: WorkflowBudget | undefined, patch: WorkflowBudgetPatch): WorkflowBudget | undefined {
|
|
351
|
+
const merged: WorkflowBudget = structuredClone(budget ?? {});
|
|
352
|
+
for (const dimension of ["tokens", "costUsd", "durationMs", "agentLaunches"] as const) if (Object.prototype.hasOwnProperty.call(patch, dimension)) {
|
|
353
|
+
const value = patch[dimension];
|
|
354
|
+
if (value === null) { Reflect.deleteProperty(merged, dimension); continue; }
|
|
355
|
+
const next: BudgetLimits = { ...(merged[dimension] ?? {}) };
|
|
356
|
+
for (const key of ["soft", "hard"] as const) if (value && Object.prototype.hasOwnProperty.call(value, key)) { const limit = value[key]; if (limit === null) Reflect.deleteProperty(next, key); else if (limit !== undefined) next[key] = limit; }
|
|
357
|
+
if (Object.keys(next).length) (merged as Record<string, BudgetLimits>)[dimension] = next; else Reflect.deleteProperty(merged, dimension);
|
|
358
|
+
}
|
|
359
|
+
return validateBudget(merged);
|
|
360
|
+
}
|
|
361
|
+
export function budgetRelaxed(previous: WorkflowBudget | undefined, next: WorkflowBudget | undefined): boolean { for (const dimension of ["tokens", "costUsd", "durationMs", "agentLaunches"] as const) { const oldLimit = previous?.[dimension]; const newLimit = next?.[dimension]; for (const key of ["soft", "hard"] as const) if ((oldLimit?.[key] !== undefined && newLimit?.[key] === undefined) || (oldLimit?.[key] !== undefined && newLimit?.[key] !== undefined && newLimit[key] > oldLimit[key])) return true; } return false; }
|
|
362
|
+
export function exhaustedBudgetDimensions(budget: WorkflowBudget | undefined, usage: WorkflowBudgetUsage): BudgetDimension[] {
|
|
363
|
+
if (!budget) return [];
|
|
364
|
+
return (Object.keys(budget) as BudgetDimension[]).filter((dimension) => { const limits = budget[dimension]; return limits !== undefined && limits.hard !== undefined && usage[dimension] >= limits.hard; });
|
|
365
|
+
}
|
|
366
|
+
export function resumeBudgetAllowed(budget: WorkflowBudget | undefined, usage: WorkflowBudgetUsage): boolean { return exhaustedBudgetDimensions(budget, usage).length === 0; }
|
|
200
367
|
function positiveInteger(value: unknown): value is number { return Number.isInteger(value) && (value as number) > 0; }
|
|
368
|
+
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
369
|
+
const MODEL_ALIAS_NAME = /^[A-Za-z][A-Za-z0-9_-]*$/;
|
|
201
370
|
export function parseModelReference(value: string): ModelSpec {
|
|
202
371
|
const match = /^([^/:\s]+)\/([^:\s]+)(?::([^:\s]+))?$/.exec(value);
|
|
203
372
|
if (!match?.[1] || !match[2]) fail("UNKNOWN_MODEL", `Invalid model spec: ${value}`);
|
|
204
373
|
const thinking = match[3];
|
|
205
|
-
if (thinking && !
|
|
374
|
+
if (thinking && !THINKING_LEVELS.includes(thinking as (typeof THINKING_LEVELS)[number])) fail("UNKNOWN_MODEL", `Invalid thinking level: ${thinking}`);
|
|
206
375
|
return { provider: match[1], model: match[2], ...(thinking ? { thinking: thinking as NonNullable<ModelSpec["thinking"]> } : {}) };
|
|
207
376
|
}
|
|
208
377
|
|
|
209
|
-
function
|
|
210
|
-
|
|
378
|
+
function aliasError(message: string, settingsPath: string): never { fail("CONFIG_ERROR", `${message} (settings: ${settingsPath})`); }
|
|
379
|
+
export function validateModelAliases(value: unknown, settingsPath = "workflow settings"): Readonly<Record<string, string>> {
|
|
380
|
+
if (!object(value)) aliasError("modelAliases must be an object", settingsPath);
|
|
381
|
+
const aliases: Record<string, string> = {};
|
|
382
|
+
for (const [name, target] of Object.entries(value)) {
|
|
383
|
+
if (!MODEL_ALIAS_NAME.test(name)) aliasError(`Invalid model alias name: ${name}`, settingsPath);
|
|
384
|
+
if (typeof target !== "string" || !target.trim()) aliasError(`Invalid model alias target for ${name}`, settingsPath);
|
|
385
|
+
try { parseModelReference(target); } catch (error) { aliasError(`Invalid model alias target for ${name}: ${errorText(error)}`, settingsPath); }
|
|
386
|
+
aliases[name] = target;
|
|
387
|
+
}
|
|
388
|
+
return Object.freeze(aliases);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function unknownModel(value: string, target: string | undefined, settingsPath?: string): never {
|
|
392
|
+
const resolved = target ? ` resolved to ${target}` : "";
|
|
393
|
+
const path = settingsPath ? ` (settings: ${settingsPath})` : "";
|
|
394
|
+
fail("UNKNOWN_MODEL", `Unknown model${target ? " alias" : ""} ${value}${resolved}${path}`);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function resolveModelReference(value: string, aliases: Readonly<Record<string, string>> = {}, knownModels?: ReadonlySet<string>, settingsPath?: string): ModelSpec {
|
|
398
|
+
const target = Object.prototype.hasOwnProperty.call(aliases, value) ? aliases[value] : undefined;
|
|
399
|
+
if (target !== undefined) {
|
|
400
|
+
try { return parseModelReference(target); } catch { unknownModel(value, target, settingsPath); }
|
|
401
|
+
}
|
|
402
|
+
if (value.includes("/")) return parseModelReference(value);
|
|
403
|
+
const match = /^([^:\s]+)(?::([^:\s]+))?$/.exec(value);
|
|
404
|
+
const thinking = match?.[2];
|
|
405
|
+
if (!match?.[1] || thinking && !THINKING_LEVELS.includes(thinking as (typeof THINKING_LEVELS)[number])) unknownModel(value, undefined, settingsPath);
|
|
406
|
+
const candidates = [...(knownModels ?? [])].filter((model) => model.slice(model.indexOf("/") + 1) === match[1]);
|
|
407
|
+
if (candidates.length === 1) {
|
|
408
|
+
const parsed = parseModelReference(candidates[0] as string);
|
|
409
|
+
return thinking ? { ...parsed, thinking: thinking as NonNullable<ModelSpec["thinking"]> } : parsed;
|
|
410
|
+
}
|
|
411
|
+
unknownModel(value, undefined, settingsPath);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function modelCapability(value: string, aliases?: Readonly<Record<string, string>>, knownModels?: ReadonlySet<string>, settingsPath?: string): string {
|
|
415
|
+
const parsed = resolveModelReference(value, aliases, knownModels, settingsPath);
|
|
211
416
|
return `${parsed.provider}/${parsed.model}`;
|
|
212
417
|
}
|
|
213
418
|
|
|
419
|
+
function aliasDrift(previous: Readonly<Record<string, string>>, current: Readonly<Record<string, string>>): string[] {
|
|
420
|
+
return [...new Set([...Object.keys(previous), ...Object.keys(current)])].sort().flatMap((name) => previous[name] === current[name] ? [] : [`${name}: ${previous[name] ?? "(missing)"} -> ${current[name] ?? "(missing)"}`]);
|
|
421
|
+
}
|
|
422
|
+
|
|
214
423
|
export interface CheckpointInput { name: string; prompt: string; context: JsonValue }
|
|
215
424
|
export function validateCheckpoint(value: unknown): CheckpointInput {
|
|
216
425
|
if (!object(value) || Object.keys(value).some((key) => !["name", "prompt", "context"].includes(key)) || typeof value.name !== "string" || value.name.trim() === "" || typeof value.prompt !== "string" || !jsonValue(value.context)) fail("INVALID_METADATA", "checkpoint requires only name, prompt, and JSON context");
|
|
@@ -220,25 +429,79 @@ export function validateCheckpoint(value: unknown): CheckpointInput {
|
|
|
220
429
|
}
|
|
221
430
|
|
|
222
431
|
export function workflowSettingsPath(agentDir = getAgentDir()): string { return join(agentDir, ROLE_DIRECTORY, "settings.json"); }
|
|
432
|
+
export function workflowProjectSettingsPath(cwd: string): string { return join(cwd, ".pi", ROLE_DIRECTORY, "settings.json"); }
|
|
433
|
+
const EMPTY_AGENT_RESOURCE_EXCLUSIONS: AgentResourceExclusions = Object.freeze({ skills: [], extensions: [] });
|
|
434
|
+
function normalizedResourcePath(value: string, settingsPath: string): string {
|
|
435
|
+
let expanded = value === "~" ? homedir() : value.startsWith("~/") || value.startsWith("~\\") ? join(homedir(), value.slice(2)) : value;
|
|
436
|
+
if (expanded.startsWith("file://")) expanded = fileURLToPath(expanded);
|
|
437
|
+
const resolved = resolve(dirname(settingsPath), expanded);
|
|
438
|
+
try { return realpathSync(resolved); } catch { return resolved; }
|
|
439
|
+
}
|
|
440
|
+
export function mergeAgentResourceExclusions(...values: (AgentResourceExclusions | undefined)[]): AgentResourceExclusions {
|
|
441
|
+
return { skills: [...new Set(values.flatMap((value) => value?.skills ?? []))], extensions: [...new Set(values.flatMap((value) => value?.extensions ?? []))] };
|
|
442
|
+
}
|
|
443
|
+
function validateAgentResourceExclusions(value: unknown, settingsPath: string, errorCode: "INVALID_SETTINGS" | "INVALID_METADATA" = "INVALID_SETTINGS"): AgentResourceExclusions | undefined {
|
|
444
|
+
if (value === undefined) return undefined;
|
|
445
|
+
const base = `${settingsPath}.disabledAgentResources`;
|
|
446
|
+
if (!object(value)) fail(errorCode, `${base} must be an object`);
|
|
447
|
+
for (const key of Object.keys(value)) if (key !== "skills" && key !== "extensions") fail(errorCode, `${base}.${key} is not supported`);
|
|
448
|
+
const normalized: { skills: string[]; extensions: string[] } = { skills: [], extensions: [] };
|
|
449
|
+
for (const kind of ["skills", "extensions"] as const) {
|
|
450
|
+
const entries = value[kind];
|
|
451
|
+
if (entries === undefined) continue;
|
|
452
|
+
if (!Array.isArray(entries)) fail(errorCode, `${base}.${kind} must be an array`);
|
|
453
|
+
const seen = new Set<string>();
|
|
454
|
+
for (const [index, entry] of entries.entries()) {
|
|
455
|
+
if (typeof entry !== "string" || !entry.trim()) fail(errorCode, `${base}.${kind}[${String(index)}] must be a non-empty string`);
|
|
456
|
+
let selector = entry.trim();
|
|
457
|
+
if (kind === "extensions") {
|
|
458
|
+
try { selector = normalizedResourcePath(selector, settingsPath); } catch (error) { fail(errorCode, `${base}.${kind}[${String(index)}] must be a valid path: ${errorText(error)}`); }
|
|
459
|
+
}
|
|
460
|
+
if (!seen.has(selector)) { seen.add(selector); normalized[kind].push(selector); }
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return Object.freeze({ skills: Object.freeze(normalized.skills), extensions: Object.freeze(normalized.extensions) });
|
|
464
|
+
}
|
|
223
465
|
export function loadSettings(path = workflowSettingsPath()): Readonly<WorkflowSettings> {
|
|
224
466
|
let parsed: unknown;
|
|
225
467
|
try { parsed = JSON.parse(readFileSync(path, "utf8")); }
|
|
226
468
|
catch (error) {
|
|
227
469
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return DEFAULT_SETTINGS;
|
|
228
|
-
fail("
|
|
470
|
+
fail("CONFIG_ERROR", `Invalid workflow settings JSON at ${path}: ${errorText(error)}`);
|
|
229
471
|
}
|
|
230
|
-
if (!object(parsed)) fail("INVALID_SETTINGS",
|
|
231
|
-
const allowed = new Set(["concurrency", "
|
|
472
|
+
if (!object(parsed)) fail("INVALID_SETTINGS", `Workflow settings at ${path} must be an object`);
|
|
473
|
+
const allowed = new Set(["concurrency", "modelAliases", "disabledAgentResources"]);
|
|
232
474
|
const unknown = Object.keys(parsed).find((key) => !allowed.has(key));
|
|
233
|
-
if (unknown) fail("INVALID_SETTINGS", `Unknown workflow setting: ${unknown}`);
|
|
475
|
+
if (unknown) fail("INVALID_SETTINGS", `Unknown workflow setting at ${path}: ${unknown}`);
|
|
234
476
|
const concurrency = parsed.concurrency === undefined ? DEFAULT_SETTINGS.concurrency : parsed.concurrency;
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
return Object.freeze({ concurrency,
|
|
477
|
+
if (!positiveInteger(concurrency) || concurrency > 16) fail("INVALID_SETTINGS", `${path}.concurrency must be an integer from 1 to 16`);
|
|
478
|
+
const modelAliases = parsed.modelAliases === undefined ? undefined : validateModelAliases(parsed.modelAliases, path);
|
|
479
|
+
const disabledAgentResources = validateAgentResourceExclusions(parsed.disabledAgentResources, path);
|
|
480
|
+
return Object.freeze({ concurrency, ...(modelAliases ? { modelAliases } : {}), ...(disabledAgentResources ? { disabledAgentResources } : {}) });
|
|
481
|
+
}
|
|
482
|
+
export function resolveAgentResourcePolicy(cwd: string, projectTrusted: boolean, globalSettingsPath = workflowSettingsPath()): AgentResourcePolicy {
|
|
483
|
+
const projectSettingsPath = workflowProjectSettingsPath(cwd);
|
|
484
|
+
const global = loadSettings(globalSettingsPath).disabledAgentResources ?? EMPTY_AGENT_RESOURCE_EXCLUSIONS;
|
|
485
|
+
const project = projectTrusted ? loadSettings(projectSettingsPath).disabledAgentResources ?? EMPTY_AGENT_RESOURCE_EXCLUSIONS : EMPTY_AGENT_RESOURCE_EXCLUSIONS;
|
|
486
|
+
const effective = mergeAgentResourceExclusions(global, project);
|
|
487
|
+
return { globalSettingsPath, projectSettingsPath, projectTrusted, global, project, effective, unmatchedSkills: [], unmatchedExtensions: [] };
|
|
488
|
+
}
|
|
489
|
+
export function saveModelAliases(path = workflowSettingsPath(), aliases: Readonly<Record<string, string>> = {}): void {
|
|
490
|
+
const normalized = validateModelAliases(aliases, path);
|
|
491
|
+
let parsed: Record<string, unknown> = {};
|
|
492
|
+
try {
|
|
493
|
+
loadSettings(path);
|
|
494
|
+
parsed = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
|
495
|
+
} catch (error) {
|
|
496
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
497
|
+
}
|
|
498
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
499
|
+
const temporary = `${path}.${String(process.pid)}.${randomUUID()}.tmp`;
|
|
500
|
+
writeFileSync(temporary, `${JSON.stringify({ ...parsed, modelAliases: normalized }, null, 2)}\n`, { mode: 0o600 });
|
|
501
|
+
renameSync(temporary, path);
|
|
239
502
|
}
|
|
240
503
|
|
|
241
|
-
export function parseRoleMarkdown(content: string, strict = false): AgentDefinition {
|
|
504
|
+
export function parseRoleMarkdown(content: string, strict = false, rolePath?: string): AgentDefinition {
|
|
242
505
|
if (!strict) {
|
|
243
506
|
if (!content.startsWith("---\n")) return { prompt: content };
|
|
244
507
|
const end = content.indexOf("\n---", 4);
|
|
@@ -264,12 +527,13 @@ export function parseRoleMarkdown(content: string, strict = false): AgentDefinit
|
|
|
264
527
|
try { parsed = parseFrontmatter(content); }
|
|
265
528
|
catch (error) { fail("INVALID_METADATA", `Invalid role frontmatter: ${errorText(error)}`); }
|
|
266
529
|
if (!object(parsed.frontmatter)) fail("INVALID_METADATA", "Role frontmatter must be an object");
|
|
267
|
-
const { model, thinking, tools, description } = parsed.frontmatter;
|
|
530
|
+
const { model, thinking, tools, description, disabledAgentResources } = parsed.frontmatter;
|
|
268
531
|
if (model !== undefined && (typeof model !== "string" || model.trim() === "")) fail("INVALID_METADATA", "Role model must be a non-empty string");
|
|
269
532
|
if (thinking !== undefined && (typeof thinking !== "string" || !["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(thinking))) fail("INVALID_METADATA", `Invalid role thinking level: ${typeof thinking === "string" ? thinking : typeof thinking}`);
|
|
270
533
|
if (description !== undefined && (typeof description !== "string" || description.trim() === "" || description.length > 1024 || /[\r\n]/.test(description))) fail("INVALID_METADATA", "Role description must be a non-empty single-line string of at most 1024 characters");
|
|
271
534
|
if (tools !== undefined && (!Array.isArray(tools) || tools.some((tool) => typeof tool !== "string" || tool.trim() === ""))) fail("INVALID_METADATA", "Role tools must be an array of non-empty strings");
|
|
272
|
-
|
|
535
|
+
const normalizedResources = validateAgentResourceExclusions(disabledAgentResources, rolePath ?? "<role>", "INVALID_METADATA");
|
|
536
|
+
return { prompt: parsed.body, ...(typeof description === "string" ? { description: description.trim() } : {}), ...(typeof model === "string" ? { model: model.trim() } : {}), ...(typeof thinking === "string" ? { thinking: thinking as NonNullable<AgentDefinition["thinking"]> } : {}), ...(Array.isArray(tools) ? { tools: tools.map((tool) => (tool as string).trim()) } : {}), ...(normalizedResources ? { disabledAgentResources: normalizedResources } : {}) };
|
|
273
537
|
}
|
|
274
538
|
|
|
275
539
|
const ROLE_DIRECTORY = "pi-extensible-workflows";
|
|
@@ -286,7 +550,7 @@ function readAgentDefinitions(dir: string): Record<string, AgentDefinition> {
|
|
|
286
550
|
try {
|
|
287
551
|
return Object.fromEntries(readdirSync(dir, { withFileTypes: true })
|
|
288
552
|
.filter((entry) => entry.isFile() && extname(entry.name) === ".md")
|
|
289
|
-
.map((entry) => [basename(entry.name, ".md"), parseRoleMarkdown(readFileSync(
|
|
553
|
+
.map((entry) => { const path = join(dir, entry.name); return [basename(entry.name, ".md"), parseRoleMarkdown(readFileSync(path, "utf8"), true, path)]; }));
|
|
290
554
|
} catch (error) {
|
|
291
555
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
292
556
|
throw error;
|
|
@@ -300,11 +564,17 @@ function readRoleDefinitions(dirs: readonly string[]): Record<string, AgentDefin
|
|
|
300
564
|
export function loadAgentDefinitions(cwd: string, agentDir = getAgentDir(), projectTrusted = true): Readonly<Record<string, AgentDefinition>> {
|
|
301
565
|
return deepFreeze({ ...readRoleDefinitions(workflowRoleDirectories(agentDir)), ...(projectTrusted ? readRoleDefinitions(projectRoleDirectories(join(cwd, ".pi"))) : {}) });
|
|
302
566
|
}
|
|
303
|
-
function validateRolePolicies(definitions: Readonly<Record<string, AgentDefinition>>, roles: readonly string[], availableModels: ReadonlySet<string>, rootTools: ReadonlySet<string
|
|
567
|
+
function validateRolePolicies(definitions: Readonly<Record<string, AgentDefinition>>, roles: readonly string[], availableModels: ReadonlySet<string>, rootTools: ReadonlySet<string>, aliases: Readonly<Record<string, string>> = {}, knownModels = availableModels, settingsPath?: string): void {
|
|
304
568
|
for (const role of roles) {
|
|
305
569
|
const definition = definitions[role];
|
|
306
570
|
if (!definition) continue;
|
|
307
|
-
if (definition.model !== undefined
|
|
571
|
+
if (definition.model !== undefined) {
|
|
572
|
+
const resolved = modelCapability(definition.model, aliases, knownModels, settingsPath);
|
|
573
|
+
if (!availableModels.has(resolved)) {
|
|
574
|
+
if (Object.prototype.hasOwnProperty.call(aliases, definition.model)) unknownModel(definition.model, resolved, settingsPath);
|
|
575
|
+
fail("UNKNOWN_MODEL", `Unknown model for role ${role}: ${resolved}`);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
308
578
|
const missingTool = (definition.tools ?? [...rootTools]).find((tool) => !rootTools.has(tool));
|
|
309
579
|
if (missingTool) fail("UNKNOWN_TOOL", `Unknown tool for role ${role}: ${missingTool}`);
|
|
310
580
|
}
|
|
@@ -346,7 +616,7 @@ function astNode(value: unknown): value is acorn.AnyNode {
|
|
|
346
616
|
function workflowCalls(program: acorn.Program): WorkflowCall[] {
|
|
347
617
|
const calls: WorkflowCall[] = [];
|
|
348
618
|
const visit = (node: acorn.AnyNode): void => {
|
|
349
|
-
if (node.type === "CallExpression" && node.callee.type === "Identifier" && ["agent", "parallel", "pipeline", "checkpoint", "phase", "withWorktree"].includes(node.callee.name)) calls.push(node as WorkflowCall);
|
|
619
|
+
if (node.type === "CallExpression" && node.callee.type === "Identifier" && ["agent", "conversation", "parallel", "pipeline", "checkpoint", "phase", "withWorktree"].includes(node.callee.name)) calls.push(node as WorkflowCall);
|
|
350
620
|
for (const value of Object.values(node) as unknown[]) {
|
|
351
621
|
if (Array.isArray(value)) {
|
|
352
622
|
for (const child of value as unknown[]) if (astNode(child)) visit(child);
|
|
@@ -368,7 +638,7 @@ function workflowCallsWithStructure(program: acorn.Program): Array<{ call: Workf
|
|
|
368
638
|
const key = node.key.type === "Identifier" ? node.key.name : node.key.type === "Literal" ? String(node.key.value) : undefined;
|
|
369
639
|
if (scope?.key === null && key) current = { ...current, structure: [...current.structure.slice(0, -1), { ...scope, key }] };
|
|
370
640
|
}
|
|
371
|
-
if (node.type === "CallExpression" && node.callee.type === "Identifier" && ["agent", "parallel", "pipeline", "checkpoint", "phase", "withWorktree"].includes(node.callee.name)) {
|
|
641
|
+
if (node.type === "CallExpression" && node.callee.type === "Identifier" && ["agent", "conversation", "parallel", "pipeline", "checkpoint", "phase", "withWorktree"].includes(node.callee.name)) {
|
|
372
642
|
const call = node as WorkflowCall;
|
|
373
643
|
const operation = call.callee.name as StaticWorkflowCall["kind"];
|
|
374
644
|
const execution = operation === "parallel" ? "parallel" : operation === "pipeline" ? "sequential" : current.execution;
|
|
@@ -412,6 +682,7 @@ function hasIdentifier(node: acorn.AnyNode, name: string): boolean {
|
|
|
412
682
|
}
|
|
413
683
|
|
|
414
684
|
const INTERNAL_AGENT_NAME = "__pi_extensible_workflows_agent";
|
|
685
|
+
const INTERNAL_CONVERSATION_NAME = "__pi_extensible_workflows_conversation";
|
|
415
686
|
const INTERNAL_WORKTREE_NAME = "__pi_extensible_workflows_withWorktree";
|
|
416
687
|
|
|
417
688
|
function callHasTrailingComma(source: string, call: WorkflowCall): boolean {
|
|
@@ -429,13 +700,14 @@ function instrumentWorkflow(script: string): string {
|
|
|
429
700
|
if (!body.trim()) return body;
|
|
430
701
|
const program = parseWorkflow(body);
|
|
431
702
|
if (hasIdentifier(program, INTERNAL_AGENT_NAME)) fail("INVALID_METADATA", `${INTERNAL_AGENT_NAME} is reserved for workflow agent instrumentation`);
|
|
703
|
+
if (hasIdentifier(program, INTERNAL_CONVERSATION_NAME)) fail("INVALID_METADATA", `${INTERNAL_CONVERSATION_NAME} is reserved for workflow conversation instrumentation`);
|
|
432
704
|
if (hasIdentifier(program, INTERNAL_WORKTREE_NAME)) fail("INVALID_METADATA", `${INTERNAL_WORKTREE_NAME} is reserved for workflow withWorktree instrumentation`);
|
|
433
|
-
const calls = workflowCalls(program).filter((call) => ["agent", "withWorktree"].includes(call.callee.name));
|
|
705
|
+
const calls = workflowCalls(program).filter((call) => ["agent", "conversation", "withWorktree"].includes(call.callee.name));
|
|
434
706
|
const edits = calls.flatMap((call) => {
|
|
435
707
|
const callSite = `${String(call.start)}:${String(call.end)}`;
|
|
436
708
|
const hiddenArgument = call.arguments.length === 0 || callHasTrailingComma(body, call) ? "" : ", ";
|
|
437
709
|
return [
|
|
438
|
-
{ start: call.callee.start, end: call.callee.end, text: call.callee.name === "agent" ? INTERNAL_AGENT_NAME : INTERNAL_WORKTREE_NAME },
|
|
710
|
+
{ start: call.callee.start, end: call.callee.end, text: call.callee.name === "agent" ? INTERNAL_AGENT_NAME : call.callee.name === "conversation" ? INTERNAL_CONVERSATION_NAME : INTERNAL_WORKTREE_NAME },
|
|
439
711
|
{ start: call.end - 1, end: call.end - 1, text: `${hiddenArgument}${JSON.stringify(callSite)}` },
|
|
440
712
|
];
|
|
441
713
|
}).sort((left, right) => right.start - left.start);
|
|
@@ -512,14 +784,14 @@ function validateSchema(schema: unknown, at = "schema"): asserts schema is JsonS
|
|
|
512
784
|
}
|
|
513
785
|
|
|
514
786
|
const AGENT_OPTION_KEYS = new Set(["label", "model", "thinking", "tools", "role", "outputSchema", "retries", "timeoutMs"]);
|
|
515
|
-
function validateAgentOption(key: string, value: unknown): void {
|
|
787
|
+
function validateAgentOption(key: string, value: unknown, aliases?: Readonly<Record<string, string>>, knownModels?: ReadonlySet<string>, settingsPath?: string): void {
|
|
516
788
|
switch (key) {
|
|
517
789
|
case "label":
|
|
518
790
|
if (typeof value !== "string" || !value.trim()) fail("INVALID_METADATA", "agent label must be a non-empty string");
|
|
519
791
|
break;
|
|
520
792
|
case "model":
|
|
521
|
-
if (typeof value !== "string") fail("INVALID_METADATA", "agent model must be a string");
|
|
522
|
-
|
|
793
|
+
if (typeof value !== "string" || !value.trim()) fail("INVALID_METADATA", "agent model must be a non-empty string");
|
|
794
|
+
if (aliases !== undefined) resolveModelReference(value, aliases, knownModels, settingsPath);
|
|
523
795
|
break;
|
|
524
796
|
case "thinking":
|
|
525
797
|
if (typeof value !== "string" || !parseThinking(value)) fail("INVALID_METADATA", "agent thinking must be off, minimal, low, medium, high, xhigh, or max");
|
|
@@ -541,12 +813,9 @@ function validateAgentOption(key: string, value: unknown): void {
|
|
|
541
813
|
break;
|
|
542
814
|
}
|
|
543
815
|
}
|
|
544
|
-
|
|
545
816
|
function validateAgentOptions(value: unknown): Readonly<Record<string, JsonValue>> {
|
|
546
817
|
if (!object(value) || !jsonValue(value)) fail("INVALID_METADATA", "agent options must be a JSON object");
|
|
547
|
-
const
|
|
548
|
-
if (unknown) fail("INVALID_METADATA", `Unknown agent option: ${unknown}`);
|
|
549
|
-
for (const [key, option] of Object.entries(value)) validateAgentOption(key, option);
|
|
818
|
+
for (const [key, option] of Object.entries(value)) if (AGENT_OPTION_KEYS.has(key)) validateAgentOption(key, option);
|
|
550
819
|
if (typeof value.role === "string" && ["model", "thinking", "tools"].some((key) => Object.prototype.hasOwnProperty.call(value, key))) fail("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
|
|
551
820
|
return value;
|
|
552
821
|
}
|
|
@@ -585,7 +854,7 @@ function staticValue(node: acorn.AnyNode | undefined): StaticValue {
|
|
|
585
854
|
}
|
|
586
855
|
|
|
587
856
|
export interface StaticWorkflowCall {
|
|
588
|
-
kind: "agent" | "parallel" | "pipeline" | "checkpoint" | "phase" | "withWorktree";
|
|
857
|
+
kind: "agent" | "conversation" | "parallel" | "pipeline" | "checkpoint" | "phase" | "withWorktree";
|
|
589
858
|
start: number;
|
|
590
859
|
end: number;
|
|
591
860
|
name: string | null;
|
|
@@ -617,7 +886,7 @@ export function inspectWorkflowScript(script: string): StaticWorkflowCall[] {
|
|
|
617
886
|
const first = callArgument(call, 0);
|
|
618
887
|
const options = callArgument(call, 1);
|
|
619
888
|
const placement = { execution, structure };
|
|
620
|
-
if (kind === "agent") {
|
|
889
|
+
if (kind === "agent" || kind === "conversation") {
|
|
621
890
|
const retries = staticValue(propertyNode(options, "retries"));
|
|
622
891
|
const outputSchema = staticValue(propertyNode(options, "outputSchema"));
|
|
623
892
|
const optionKeys = options?.type === "ObjectExpression" ? options.properties.flatMap((property) => {
|
|
@@ -626,7 +895,7 @@ export function inspectWorkflowScript(script: string): StaticWorkflowCall[] {
|
|
|
626
895
|
return key ? [key] : [];
|
|
627
896
|
}) : [];
|
|
628
897
|
const knownOptions = Object.fromEntries(optionKeys.flatMap((key) => { const value = staticValue(propertyNode(options, key)); return value.known && jsonValue(value.value) ? [[key, value.value]] : []; })) as Record<string, JsonValue>;
|
|
629
|
-
const base = { ...placement, kind, start: call.start, end: call.end, name: null, prompt: staticString(first), model: staticString(propertyNode(options, "model")), label: staticString(propertyNode(options, "label")), role: staticString(propertyNode(options, "role")) };
|
|
898
|
+
const base = { ...placement, kind, start: call.start, end: call.end, name: kind === "conversation" ? staticString(first) : null, prompt: kind === "conversation" ? null : staticString(first), model: staticString(propertyNode(options, "model")), label: staticString(propertyNode(options, "label")), role: staticString(propertyNode(options, "role")) };
|
|
630
899
|
return { ...base, ...(retries.known && typeof retries.value === "number" ? { retries: retries.value } : {}), ...(outputSchema.known && object(outputSchema.value) ? { outputSchema: outputSchema.value as JsonSchema } : {}), ...(optionKeys.length ? { options: knownOptions, optionKeys } : {}) };
|
|
631
900
|
}
|
|
632
901
|
if (kind === "checkpoint") return { ...placement, kind, start: call.start, end: call.end, name: staticString(propertyNode(first, "name")), prompt: staticString(propertyNode(first, "prompt")), model: null, role: null };
|
|
@@ -634,21 +903,14 @@ export function inspectWorkflowScript(script: string): StaticWorkflowCall[] {
|
|
|
634
903
|
});
|
|
635
904
|
}
|
|
636
905
|
|
|
637
|
-
function validateStaticAgentOptions(node: acorn.AnyNode | undefined): void {
|
|
906
|
+
function validateStaticAgentOptions(node: acorn.AnyNode | undefined, aliases: Readonly<Record<string, string>> = {}, knownModels?: ReadonlySet<string>, settingsPath?: string): void {
|
|
638
907
|
if (node?.type !== "ObjectExpression") return;
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
const key = property.key.type === "Identifier" ? property.key.name : property.key.type === "Literal" ? String(property.key.value) : undefined;
|
|
642
|
-
if (key && !AGENT_OPTION_KEYS.has(key)) fail("INVALID_METADATA", `Unknown agent option: ${key}`);
|
|
643
|
-
}
|
|
644
|
-
const role = propertyNode(node, "role");
|
|
645
|
-
if (role && ["model", "thinking", "tools"].some((key) => propertyNode(node, key) !== undefined)) fail("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
|
|
908
|
+
const options = staticValue(node);
|
|
909
|
+
if (options.known && object(options.value) && typeof options.value.role === "string" && ["model", "thinking", "tools"].some((key) => Object.prototype.hasOwnProperty.call(options.value as Record<string, unknown>, key))) fail("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
|
|
646
910
|
for (const key of AGENT_OPTION_KEYS) {
|
|
647
911
|
const value = staticValue(propertyNode(node, key));
|
|
648
|
-
if (value.known) validateAgentOption(key, value.value);
|
|
912
|
+
if (value.known) validateAgentOption(key, value.value, aliases, knownModels, settingsPath);
|
|
649
913
|
}
|
|
650
|
-
const options = staticValue(node);
|
|
651
|
-
if (options.known && object(options.value) && typeof options.value.role === "string" && ["model", "thinking", "tools"].some((key) => Object.prototype.hasOwnProperty.call(options.value as Record<string, unknown>, key))) fail("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
|
|
652
914
|
}
|
|
653
915
|
|
|
654
916
|
function validateStaticWithWorktree(call: WorkflowCall): void {
|
|
@@ -662,17 +924,19 @@ function validateStaticWithWorktree(call: WorkflowCall): void {
|
|
|
662
924
|
}
|
|
663
925
|
}
|
|
664
926
|
|
|
665
|
-
export interface WorkflowCatalogFunction { name: string;
|
|
666
|
-
export interface WorkflowCatalogVariable { name: string;
|
|
667
|
-
export interface WorkflowCatalogWorkflow { name: string;
|
|
668
|
-
export interface WorkflowCatalog { functions: readonly WorkflowCatalogFunction[]; variables: readonly WorkflowCatalogVariable[]; workflows: readonly WorkflowCatalogWorkflow[] }
|
|
669
|
-
const RESERVED_GLOBALS = new Set(["agent", "prompt", "checkpoint", "parallel", "pipeline", "phase", "withWorktree", "log", "args", "Promise", "JSON", "Math", "Date", "eval", "Function", "WebAssembly", "process", "require", "module", "exports", "console", "fetch", "XMLHttpRequest", "WebSocket", "performance", "crypto", "setTimeout", "setInterval", "setImmediate", "queueMicrotask", "Intl", "SharedArrayBuffer", "Atomics", "globalThis", "global", "undefined", "NaN", "Infinity", "extensions", "workflow_catalog"]);
|
|
927
|
+
export interface WorkflowCatalogFunction { name: string; version: string; headline: string; extensionDescription: string; description: string; input: JsonSchema; output: JsonSchema }
|
|
928
|
+
export interface WorkflowCatalogVariable { name: string; version: string; headline: string; extensionDescription: string; description: string; schema: JsonSchema }
|
|
929
|
+
export interface WorkflowCatalogWorkflow { name: string; version: string; headline: string; extensionDescription: string; description: string }
|
|
930
|
+
export interface WorkflowCatalog { functions: readonly WorkflowCatalogFunction[]; variables: readonly WorkflowCatalogVariable[]; workflows: readonly WorkflowCatalogWorkflow[]; modelAliases?: Readonly<Record<string, string>> }
|
|
931
|
+
const RESERVED_GLOBALS = new Set(["agent", "conversation", "prompt", "checkpoint", "parallel", "pipeline", "phase", "withWorktree", "log", "args", "Promise", "JSON", "Math", "Date", "eval", "Function", "WebAssembly", "process", "require", "module", "exports", "console", "fetch", "XMLHttpRequest", "WebSocket", "performance", "crypto", "setTimeout", "setInterval", "setImmediate", "queueMicrotask", "Intl", "SharedArrayBuffer", "Atomics", "globalThis", "global", "undefined", "NaN", "Infinity", "extensions", "workflow_catalog"]);
|
|
670
932
|
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
|
671
933
|
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
672
934
|
|
|
673
935
|
export class WorkflowRegistry {
|
|
674
|
-
readonly #extensions = new
|
|
936
|
+
readonly #extensions = new Set<Readonly<WorkflowExtension>>();
|
|
675
937
|
readonly #globals = new Map<string, string>();
|
|
938
|
+
readonly #workflows = new Map<string, WorkflowScriptDefinition>();
|
|
939
|
+
readonly #hooks = new Map<string, RegisteredAgentSetupHook>();
|
|
676
940
|
#frozen = false;
|
|
677
941
|
|
|
678
942
|
get frozen(): boolean { return this.#frozen; }
|
|
@@ -680,89 +944,99 @@ export class WorkflowRegistry {
|
|
|
680
944
|
|
|
681
945
|
register(extension: WorkflowExtension): void {
|
|
682
946
|
if (this.#frozen) fail("REGISTRY_FROZEN", "Workflow extension registration is closed after session_start");
|
|
683
|
-
if (!object(extension) || Object.keys(extension).some((key) => !["
|
|
684
|
-
if (this.#extensions.has(extension.namespace)) fail("DUPLICATE_NAME", `Workflow extension already registered: ${extension.namespace}`);
|
|
947
|
+
if (!object(extension) || Object.keys(extension).some((key) => !["version", "headline", "description", "functions", "variables", "workflows", "agentSetupHooks"].includes(key)) || typeof extension.version !== "string" || !SEMVER.test(extension.version) || typeof extension.headline !== "string" || !extension.headline.trim() || typeof extension.description !== "string" || !extension.description.trim()) fail("INVALID_METADATA", "Workflow extensions require a semantic version, headline, and description");
|
|
685
948
|
const functions = extension.functions ?? {};
|
|
686
949
|
const variables = extension.variables ?? {};
|
|
687
950
|
const workflows = extension.workflows ?? {};
|
|
688
|
-
|
|
951
|
+
const agentSetupHooks = extension.agentSetupHooks ?? {};
|
|
952
|
+
if (!object(functions) || !object(variables) || !object(workflows) || !object(agentSetupHooks) || (Object.keys(functions).length === 0 && Object.keys(variables).length === 0 && Object.keys(workflows).length === 0 && Object.keys(agentSetupHooks).length === 0)) fail("INVALID_METADATA", "Workflow extensions require functions, variables, workflows, or agent setup hooks");
|
|
689
953
|
const names = [...Object.keys(functions), ...Object.keys(variables)];
|
|
690
|
-
if (new Set(names).size !== names.length) fail("GLOBAL_COLLISION",
|
|
954
|
+
if (new Set(names).size !== names.length) fail("GLOBAL_COLLISION", "Global name collision inside extension");
|
|
691
955
|
for (const name of names) {
|
|
692
|
-
if (!IDENTIFIER.test(name) || name.startsWith("__pi_extensible_workflows_")) fail("INVALID_METADATA", `Invalid global name: ${
|
|
956
|
+
if (!IDENTIFIER.test(name) || name.startsWith("__pi_extensible_workflows_")) fail("INVALID_METADATA", `Invalid global name: ${name}`);
|
|
693
957
|
if (RESERVED_GLOBALS.has(name)) fail("GLOBAL_COLLISION", `Global name is reserved: ${name}`);
|
|
694
|
-
|
|
695
|
-
if (owner) fail("GLOBAL_COLLISION", `Global name ${name} is already owned by ${owner}; cannot register ${extension.namespace}.${name}`);
|
|
958
|
+
if (this.#globals.has(name)) fail("GLOBAL_COLLISION", `Global name is already registered: ${name}`);
|
|
696
959
|
}
|
|
697
960
|
for (const [name, fn] of Object.entries(functions)) {
|
|
698
|
-
if (!object(fn) || Object.keys(fn).some((key) => !["description", "input", "output", "run"].includes(key)) || typeof fn.description !== "string" || !fn.description.trim() || typeof fn.run !== "function") fail("INVALID_METADATA", `Invalid workflow function: ${
|
|
699
|
-
validateSchema(fn.input, `${
|
|
700
|
-
validateSchema(fn.output, `${
|
|
701
|
-
if (fn.input.type !== "object") fail("INVALID_SCHEMA", `${
|
|
961
|
+
if (!object(fn) || Object.keys(fn).some((key) => !["description", "input", "output", "run"].includes(key)) || typeof fn.description !== "string" || !fn.description.trim() || typeof fn.run !== "function") fail("INVALID_METADATA", `Invalid workflow function: ${name}`);
|
|
962
|
+
validateSchema(fn.input, `${name} input`);
|
|
963
|
+
validateSchema(fn.output, `${name} output`);
|
|
964
|
+
if (fn.input.type !== "object") fail("INVALID_SCHEMA", `${name} input must describe one object`);
|
|
702
965
|
}
|
|
703
966
|
for (const [name, variable] of Object.entries(variables)) {
|
|
704
|
-
if (!object(variable) || Object.keys(variable).some((key) => !["description", "schema", "resolve"].includes(key)) || typeof variable.description !== "string" || !variable.description.trim() || typeof variable.resolve !== "function") fail("INVALID_METADATA", `Invalid workflow variable: ${
|
|
705
|
-
validateSchema(variable.schema, `${
|
|
967
|
+
if (!object(variable) || Object.keys(variable).some((key) => !["description", "schema", "resolve"].includes(key)) || typeof variable.description !== "string" || !variable.description.trim() || typeof variable.resolve !== "function") fail("INVALID_METADATA", `Invalid workflow variable: ${name}`);
|
|
968
|
+
validateSchema(variable.schema, `${name} schema`);
|
|
706
969
|
}
|
|
707
970
|
for (const [name, workflow] of Object.entries(workflows)) {
|
|
708
|
-
if (!IDENTIFIER.test(name) || !object(workflow) || Object.keys(workflow).some((key) => !["description", "script"].includes(key)) || typeof workflow.description !== "string" || !workflow.description.trim() || typeof workflow.script !== "string" || !workflow.script.trim()) fail("INVALID_METADATA", `Invalid workflow script: ${
|
|
971
|
+
if (!IDENTIFIER.test(name) || !object(workflow) || Object.keys(workflow).some((key) => !["description", "script"].includes(key)) || typeof workflow.description !== "string" || !workflow.description.trim() || typeof workflow.script !== "string" || !workflow.script.trim()) fail("INVALID_METADATA", `Invalid workflow script: ${name}`);
|
|
972
|
+
if (this.#workflows.has(name)) fail("DUPLICATE_NAME", `Reusable workflow already registered: ${name}`);
|
|
709
973
|
parseWorkflow(workflow.script);
|
|
710
974
|
}
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
975
|
+
for (const [name, hook] of Object.entries(agentSetupHooks)) {
|
|
976
|
+
if (!IDENTIFIER.test(name) || !object(hook) || Object.keys(hook).some((key) => !["priority", "setup"].includes(key)) || typeof hook.setup !== "function" || hook.priority !== undefined && (typeof hook.priority !== "number" || !Number.isFinite(hook.priority))) fail("INVALID_METADATA", `Invalid agent setup hook: ${name}`);
|
|
977
|
+
if (this.#hooks.has(name)) fail("DUPLICATE_NAME", `Agent setup hook already registered: ${name}`);
|
|
978
|
+
}
|
|
979
|
+
const stored = deepFreeze({ ...extension, functions, variables, workflows, agentSetupHooks });
|
|
980
|
+
this.#extensions.add(stored);
|
|
981
|
+
for (const name of names) this.#globals.set(name, name);
|
|
982
|
+
for (const [name, workflow] of Object.entries(workflows)) this.#workflows.set(name, workflow);
|
|
983
|
+
for (const [name, hook] of Object.entries(agentSetupHooks)) this.#hooks.set(name, { name, priority: hook.priority ?? 10, setup: hook.setup });
|
|
714
984
|
}
|
|
715
985
|
|
|
716
986
|
workflow(name: string): WorkflowScriptDefinition {
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
const workflow = this.#extensions.get(name.slice(0, separator))?.workflows?.[name.slice(separator + 1)];
|
|
987
|
+
if (!IDENTIFIER.test(name)) fail("MISSING_WORKFLOW", `Registered workflows require an unqualified name: ${name}`);
|
|
988
|
+
const workflow = this.#workflows.get(name);
|
|
720
989
|
if (!workflow) fail("MISSING_WORKFLOW", `Workflow is unavailable: ${name}`);
|
|
721
990
|
return workflow;
|
|
722
991
|
}
|
|
723
992
|
|
|
724
993
|
workflows(): Readonly<Record<string, WorkflowScriptDefinition>> {
|
|
725
|
-
return Object.freeze(Object.fromEntries(
|
|
994
|
+
return Object.freeze(Object.fromEntries(this.#workflows));
|
|
726
995
|
}
|
|
727
996
|
|
|
728
997
|
catalog(): WorkflowCatalog {
|
|
729
998
|
const functions: WorkflowCatalogFunction[] = [];
|
|
730
999
|
const variables: WorkflowCatalogVariable[] = [];
|
|
731
1000
|
const workflows: WorkflowCatalogWorkflow[] = [];
|
|
732
|
-
for (const
|
|
733
|
-
for (const [name, fn] of Object.entries(extension.functions ?? {})) functions.push({ name,
|
|
734
|
-
for (const [name, variable] of Object.entries(extension.variables ?? {})) variables.push({ name,
|
|
735
|
-
for (const [name, workflow] of Object.entries(extension.workflows ?? {})) workflows.push({ name
|
|
1001
|
+
for (const extension of this.#extensions) {
|
|
1002
|
+
for (const [name, fn] of Object.entries(extension.functions ?? {})) functions.push({ name, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: fn.description, input: structuredClone(fn.input), output: structuredClone(fn.output) });
|
|
1003
|
+
for (const [name, variable] of Object.entries(extension.variables ?? {})) variables.push({ name, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: variable.description, schema: structuredClone(variable.schema) });
|
|
1004
|
+
for (const [name, workflow] of Object.entries(extension.workflows ?? {})) workflows.push({ name, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: workflow.description });
|
|
736
1005
|
}
|
|
737
|
-
|
|
738
|
-
|
|
1006
|
+
let aliases: Readonly<Record<string, string>> | undefined;
|
|
1007
|
+
try { aliases = loadSettings().modelAliases; } catch { aliases = undefined; }
|
|
1008
|
+
const sort = (left: { name: string }, right: { name: string }) => left.name.localeCompare(right.name);
|
|
1009
|
+
return deepFreeze({ functions: functions.sort(sort), variables: variables.sort(sort), workflows: workflows.sort(sort), ...(aliases ? { modelAliases: structuredClone(aliases) } : {}) });
|
|
739
1010
|
}
|
|
740
1011
|
|
|
741
|
-
globals(): Readonly<Record<string, {
|
|
742
|
-
return Object.freeze(Object.fromEntries([...this.#extensions].flatMap((
|
|
1012
|
+
globals(): Readonly<Record<string, { name: string }>> {
|
|
1013
|
+
return Object.freeze(Object.fromEntries([...this.#extensions].flatMap((extension) => Object.keys(extension.functions ?? {}).map((name) => [name, { name }]))));
|
|
743
1014
|
}
|
|
744
1015
|
|
|
745
|
-
async invokeFunction(
|
|
746
|
-
const fn = this.#extensions.
|
|
747
|
-
if (!fn) fail("MISSING_WORKFLOW", `Workflow function is unavailable: ${
|
|
748
|
-
if (!object(input) || !jsonValue(input) || !Value.Check(fn.input, input)) fail("RESULT_INVALID", `Invalid input for ${
|
|
1016
|
+
async invokeFunction(name: string, input: unknown, context: Readonly<WorkflowFunctionContext>, path: string, journal: WorkflowJournal): Promise<JsonValue> {
|
|
1017
|
+
const fn = [...this.#extensions].find((extension) => extension.functions?.[name])?.functions?.[name];
|
|
1018
|
+
if (!fn) fail("MISSING_WORKFLOW", `Workflow function is unavailable: ${name}`);
|
|
1019
|
+
if (!object(input) || !jsonValue(input) || !Value.Check(fn.input, input)) fail("RESULT_INVALID", `Invalid input for ${name}`);
|
|
749
1020
|
const replayed = journal.get(path);
|
|
750
1021
|
if (replayed !== undefined) {
|
|
751
|
-
if (!jsonValue(replayed) || !Value.Check(fn.output, replayed)) fail("RESULT_INVALID", `Invalid replay for ${
|
|
1022
|
+
if (!jsonValue(replayed) || !Value.Check(fn.output, replayed)) fail("RESULT_INVALID", `Invalid replay for ${name}`);
|
|
752
1023
|
return structuredClone(replayed);
|
|
753
1024
|
}
|
|
754
1025
|
const result: unknown = await fn.run(deepFreeze(structuredClone(input)), Object.freeze({ run: context.run, agent: context.agent, prompt: context.prompt, parallel: context.parallel, pipeline: context.pipeline, withWorktree: context.withWorktree, checkpoint: context.checkpoint, phase: context.phase, log: context.log }));
|
|
755
|
-
if (!jsonValue(result) || !Value.Check(fn.output, result)) fail("RESULT_INVALID", `Invalid output from ${
|
|
1026
|
+
if (!jsonValue(result) || !Value.Check(fn.output, result)) fail("RESULT_INVALID", `Invalid output from ${name}`);
|
|
756
1027
|
const stored = structuredClone(result);
|
|
757
1028
|
journal.put(path, stored);
|
|
758
1029
|
return structuredClone(stored);
|
|
759
1030
|
}
|
|
760
1031
|
|
|
761
|
-
variables(): readonly {
|
|
762
|
-
return [...this.#extensions].flatMap((
|
|
1032
|
+
variables(): readonly { name: string; variable: WorkflowVariable }[] {
|
|
1033
|
+
return [...this.#extensions].flatMap((extension) => Object.entries(extension.variables ?? {}).map(([name, variable]) => ({ name, variable })));
|
|
1034
|
+
}
|
|
1035
|
+
agentSetupHooks(): readonly RegisteredAgentSetupHook[] {
|
|
1036
|
+
return [...this.#hooks.values()].sort((left, right) => left.priority - right.priority || (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
|
|
763
1037
|
}
|
|
764
1038
|
}
|
|
765
|
-
type WorkflowRegistryApi = Pick<WorkflowRegistry, "frozen" | "freeze" | "register" | "workflow" | "workflows" | "catalog" | "globals" | "invokeFunction" | "variables">;
|
|
1039
|
+
type WorkflowRegistryApi = Pick<WorkflowRegistry, "frozen" | "freeze" | "register" | "workflow" | "workflows" | "catalog" | "globals" | "invokeFunction" | "variables" | "agentSetupHooks">;
|
|
766
1040
|
interface WorkflowRegistryHost { api: WorkflowRegistryApi }
|
|
767
1041
|
const WORKFLOW_REGISTRY_KEY = Symbol.for("pi-extensible-workflows.workflow-registry");
|
|
768
1042
|
const globalRegistry = globalThis as typeof globalThis & Record<symbol, WorkflowRegistryHost | undefined>;
|
|
@@ -777,6 +1051,7 @@ function createWorkflowRegistryApi(registry: WorkflowRegistry): WorkflowRegistry
|
|
|
777
1051
|
globals: () => registry.globals(),
|
|
778
1052
|
invokeFunction: (...args) => registry.invokeFunction(...args),
|
|
779
1053
|
variables: () => registry.variables(),
|
|
1054
|
+
agentSetupHooks: () => registry.agentSetupHooks(),
|
|
780
1055
|
};
|
|
781
1056
|
}
|
|
782
1057
|
function workflowRegistryHost(): WorkflowRegistryHost {
|
|
@@ -807,11 +1082,11 @@ export const WORKFLOW_TOOL_PARAMETERS = Type.Object({
|
|
|
807
1082
|
name: Type.Optional(Type.String({ description: "Workflow name for inline scripts" })),
|
|
808
1083
|
description: Type.Optional(Type.String({ description: "Optional human-readable workflow description" })),
|
|
809
1084
|
script: Type.Optional(Type.String({ description: "Immutable workflow source without metadata" })),
|
|
810
|
-
workflow: Type.Optional(Type.String({ description: "Registered reusable workflow as
|
|
1085
|
+
workflow: Type.Optional(Type.String({ description: "Registered reusable workflow as an unqualified name" })),
|
|
811
1086
|
args: Type.Optional(Type.Unknown({ description: "JSON-compatible workflow arguments" })),
|
|
812
1087
|
foreground: Type.Optional(Type.Boolean({ description: "Wait for completion instead of running in the background" })),
|
|
813
1088
|
concurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: 16 })),
|
|
814
|
-
|
|
1089
|
+
budget: Type.Optional(Type.Unknown({ description: "Optional aggregate soft and hard run budgets" })),
|
|
815
1090
|
});
|
|
816
1091
|
|
|
817
1092
|
function hasDynamicAgentRole(node: acorn.AnyNode | undefined): boolean {
|
|
@@ -830,33 +1105,42 @@ export function preflight(script: string, capabilities: PreflightCapabilities, s
|
|
|
830
1105
|
const checkedMetadata = validateWorkflowMetadata(metadata);
|
|
831
1106
|
const program = parseWorkflow(script);
|
|
832
1107
|
if (hasIdentifier(program, INTERNAL_AGENT_NAME)) fail("INVALID_METADATA", `${INTERNAL_AGENT_NAME} is reserved for workflow agent instrumentation`);
|
|
1108
|
+
if (hasIdentifier(program, INTERNAL_CONVERSATION_NAME)) fail("INVALID_METADATA", `${INTERNAL_CONVERSATION_NAME} is reserved for workflow conversation instrumentation`);
|
|
833
1109
|
if (hasIdentifier(program, INTERNAL_WORKTREE_NAME)) fail("INVALID_METADATA", `${INTERNAL_WORKTREE_NAME} is reserved for workflow withWorktree instrumentation`);
|
|
834
1110
|
validateDirectPrimitiveReferences(program, "withWorktree");
|
|
1111
|
+
validateDirectPrimitiveReferences(program, "conversation");
|
|
835
1112
|
for (const [index, schema] of schemas.entries()) validateSchema(schema, `schema[${String(index)}]`);
|
|
836
1113
|
const calls = workflowCalls(program);
|
|
837
1114
|
const phases = calls.filter((call) => call.callee.name === "phase").map((call) => literalString(call.arguments[0])).filter((phase): phase is string => phase !== undefined);
|
|
838
1115
|
for (const call of calls) {
|
|
839
1116
|
const operation = call.callee.name;
|
|
840
|
-
if (operation === "agent")
|
|
1117
|
+
if (operation === "agent" || operation === "conversation") {
|
|
1118
|
+
if (operation === "conversation" && (!literalString(call.arguments[0])?.trim() || call.arguments.length > 2)) fail("INVALID_METADATA", "conversation requires a stable name and optional options object");
|
|
1119
|
+
validateStaticAgentOptions(call.arguments[1], capabilities.modelAliases ?? {}, capabilities.knownModels ?? capabilities.models, capabilities.settingsPath);
|
|
1120
|
+
}
|
|
841
1121
|
if (operation === "withWorktree") validateStaticWithWorktree(call);
|
|
842
1122
|
if ((operation === "parallel" || operation === "pipeline") && call.arguments.some((argument) => argument.type === "SpreadElement")) continue;
|
|
843
1123
|
if (operation === "checkpoint" && stableName(call.arguments[0]) === false) fail("INVALID_METADATA", `${operation} requires a stable explicit name`);
|
|
844
1124
|
if (operation === "parallel" && (call.arguments.length !== 2 || !literalString(call.arguments[0])?.trim() || call.arguments[1]?.type !== "ObjectExpression")) fail("INVALID_METADATA", "parallel requires an operation name string and tasks record");
|
|
845
1125
|
if (operation === "pipeline" && (call.arguments.length !== 3 || !literalString(call.arguments[0])?.trim() || call.arguments[1]?.type !== "ObjectExpression" || call.arguments[2]?.type !== "ObjectExpression")) fail("INVALID_METADATA", "pipeline requires an operation name string, items record, and stages record");
|
|
846
1126
|
}
|
|
847
|
-
const agentCalls = calls.filter((call) => call.callee.name === "agent");
|
|
1127
|
+
const agentCalls = calls.filter((call) => call.callee.name === "agent" || call.callee.name === "conversation");
|
|
848
1128
|
const dynamicAgentRoles = agentCalls.some((call) => hasDynamicAgentRole(call.arguments[1]));
|
|
849
1129
|
const staticSchemas = agentCalls.flatMap((call) => { const value = staticValue(propertyNode(call.arguments[1], "outputSchema")); return value.known ? [value.value] : []; });
|
|
850
1130
|
for (const [index, schema] of staticSchemas.entries()) validateSchema(schema, `agent outputSchema[${String(index)}]`);
|
|
851
1131
|
const checkedSchemas = [...schemas, ...staticSchemas];
|
|
852
|
-
const
|
|
1132
|
+
const modelRefs = agentCalls.flatMap((call) => { const requested = literalString(propertyNode(call.arguments[1], "model")); return requested === undefined ? [] : [{ requested, resolved: modelCapability(requested, capabilities.modelAliases, capabilities.knownModels ?? capabilities.models, capabilities.settingsPath) }]; });
|
|
1133
|
+
const models = modelRefs.map(({ resolved }) => resolved);
|
|
853
1134
|
const tools = agentCalls.flatMap((call) => {
|
|
854
1135
|
const value = propertyNode(call.arguments[1], "tools");
|
|
855
1136
|
return value?.type === "ArrayExpression" ? value.elements.flatMap((element) => { const tool = element && element.type !== "SpreadElement" ? literalString(element) : undefined; return tool === undefined ? [] : [tool]; }) : [];
|
|
856
1137
|
});
|
|
857
1138
|
const agentTypes = agentCalls.flatMap((call) => { const value = literalString(propertyNode(call.arguments[1], "role")); return value === undefined ? [] : [value]; });
|
|
858
|
-
const missingModel =
|
|
859
|
-
if (missingModel)
|
|
1139
|
+
const missingModel = capabilities.skipModelAvailability ? undefined : modelRefs.find(({ resolved }) => !capabilities.models.has(resolved));
|
|
1140
|
+
if (missingModel) {
|
|
1141
|
+
if (Object.prototype.hasOwnProperty.call(capabilities.modelAliases ?? {}, missingModel.requested)) unknownModel(missingModel.requested, missingModel.resolved, capabilities.settingsPath);
|
|
1142
|
+
fail("UNKNOWN_MODEL", `Unknown model: ${missingModel.resolved}`);
|
|
1143
|
+
}
|
|
860
1144
|
const missingTool = tools.find((tool) => !capabilities.tools.has(tool));
|
|
861
1145
|
if (missingTool) fail("UNKNOWN_TOOL", `Unknown tool: ${missingTool}`);
|
|
862
1146
|
const missingType = agentTypes.find((type) => !capabilities.agentTypes.has(type));
|
|
@@ -876,6 +1160,9 @@ export interface WorkflowValidationContext {
|
|
|
876
1160
|
projectTrusted: boolean;
|
|
877
1161
|
availableModels: ReadonlySet<string>;
|
|
878
1162
|
rootTools: ReadonlySet<string>;
|
|
1163
|
+
modelAliases?: Readonly<Record<string, string>>;
|
|
1164
|
+
knownModels?: ReadonlySet<string>;
|
|
1165
|
+
settingsPath?: string;
|
|
879
1166
|
}
|
|
880
1167
|
|
|
881
1168
|
|
|
@@ -891,6 +1178,7 @@ export function validateWorkflowLaunch(params: WorkflowValidationParameters, con
|
|
|
891
1178
|
return validateWorkflowLaunchWithRegistry(params, context, loadingRegistry());
|
|
892
1179
|
}
|
|
893
1180
|
function validateWorkflowLaunchWithRegistry(params: WorkflowValidationParameters, context: WorkflowValidationContext, registry: WorkflowRegistryApi): ValidatedWorkflowLaunch {
|
|
1181
|
+
if (Object.prototype.hasOwnProperty.call(params, "maxAgentLaunches")) fail("INVALID_METADATA", "maxAgentLaunches has been removed; use budget.agentLaunches");
|
|
894
1182
|
if (params.script !== undefined && params.workflow !== undefined) fail("INVALID_METADATA", "Provide either script or workflow, not both");
|
|
895
1183
|
const definition = typeof params.workflow === "string" ? registry.workflow(params.workflow) : undefined;
|
|
896
1184
|
const script = typeof params.script === "string" && params.script.trim() ? params.script : definition?.script ?? "";
|
|
@@ -901,9 +1189,11 @@ function validateWorkflowLaunchWithRegistry(params: WorkflowValidationParameters
|
|
|
901
1189
|
const globalAgentDefinitions = loadAgentDefinitions(context.cwd, undefined, false);
|
|
902
1190
|
const projectAgentDefinitions = context.projectTrusted ? readRoleDefinitions(projectRoleDirectories(join(context.cwd, ".pi"))) : {};
|
|
903
1191
|
const agentDefinitions = deepFreeze({ ...globalAgentDefinitions, ...projectAgentDefinitions });
|
|
904
|
-
const
|
|
1192
|
+
const aliases = context.modelAliases ?? {};
|
|
1193
|
+
const knownModels = context.knownModels ?? context.availableModels;
|
|
1194
|
+
const checked = preflight(script, { models: context.availableModels, tools: context.rootTools, agentTypes: new Set(Object.keys(agentDefinitions)), modelAliases: aliases, knownModels, ...(context.settingsPath ? { settingsPath: context.settingsPath } : {}) }, [], metadata);
|
|
905
1195
|
const roleNames = checked.dynamicAgentRoles ? Object.keys(agentDefinitions) : checked.referenced.agentTypes;
|
|
906
|
-
validateRolePolicies(agentDefinitions, roleNames, context.availableModels, context.rootTools);
|
|
1196
|
+
validateRolePolicies(agentDefinitions, roleNames, context.availableModels, context.rootTools, aliases, knownModels, context.settingsPath);
|
|
907
1197
|
return { script, checked, agentDefinitions, projectAgentDefinitions, roleNames };
|
|
908
1198
|
}
|
|
909
1199
|
|
|
@@ -922,18 +1212,19 @@ export function createLaunchSnapshot(input: LaunchSnapshotInput): Readonly<Launc
|
|
|
922
1212
|
}
|
|
923
1213
|
|
|
924
1214
|
export function loadLaunchSnapshot(input: LaunchSnapshot): Readonly<LaunchSnapshot> {
|
|
1215
|
+
if (object(input.settings) && Object.prototype.hasOwnProperty.call(input.settings, "maxAgentLaunches")) fail("RESUME_INCOMPATIBLE", "Persisted workflow settings contain removed maxAgentLaunches");
|
|
925
1216
|
return deepFreeze(structuredClone(input));
|
|
926
1217
|
}
|
|
927
1218
|
|
|
928
1219
|
export const RPC_LIMIT_BYTES = 10 * 1024 * 1024;
|
|
929
1220
|
export const HEARTBEAT_TIMEOUT_MS = 5000;
|
|
930
1221
|
|
|
931
|
-
export interface AgentIdentity { structuralPath: readonly string[]; callSite: string; occurrence: number; parentBreadcrumb?: string; worktreeOwner?: string }
|
|
1222
|
+
export interface AgentIdentity { structuralPath: readonly string[]; callSite: string; occurrence: number; parentBreadcrumb?: string; worktreeOwner?: string; conversation?: { name: string; turn: number } }
|
|
932
1223
|
export interface WorkflowBridge {
|
|
933
1224
|
agent?: (prompt: string, options: Readonly<Record<string, JsonValue>>, signal: AbortSignal, identity: AgentIdentity) => Promise<JsonValue>;
|
|
934
1225
|
checkpoint?: (input: Readonly<Record<string, JsonValue>>, signal: AbortSignal) => boolean | Promise<boolean>;
|
|
935
|
-
function?: (
|
|
936
|
-
functions?: Readonly<Record<string, {
|
|
1226
|
+
function?: (name: string, input: Readonly<Record<string, JsonValue>>, path: string, signal: AbortSignal, worktreeOwner?: string, structuralPath?: readonly string[]) => Promise<JsonValue>;
|
|
1227
|
+
functions?: Readonly<Record<string, { name: string }>>;
|
|
937
1228
|
variables?: Readonly<Record<string, JsonValue>>;
|
|
938
1229
|
phase?: (name: string) => void | Promise<void>;
|
|
939
1230
|
log?: (message: string) => void | Promise<void>;
|
|
@@ -1005,6 +1296,7 @@ const named = (value, kind) => { if (typeof value !== "string" || !value.trim())
|
|
|
1005
1296
|
const path = (...names) => names.map(encodeURIComponent).join("/");
|
|
1006
1297
|
const inheritedAgentPath = new AsyncLocalStorage();
|
|
1007
1298
|
const agentOccurrences = new Map();
|
|
1299
|
+
const conversationOccurrences = new Map();
|
|
1008
1300
|
const worktreeOwners = new AsyncLocalStorage();
|
|
1009
1301
|
const worktreeOccurrences = new Map();
|
|
1010
1302
|
const rejectAgent = () => { throw workError("INVALID_METADATA", "Workflow agent calls must use a direct agent(...) call; aliases and indirect calls are unsupported"); };
|
|
@@ -1028,6 +1320,44 @@ const internalWithWorktree = async (...values) => {
|
|
|
1028
1320
|
}
|
|
1029
1321
|
return await worktreeOwners.run(owner, callback);
|
|
1030
1322
|
};
|
|
1323
|
+
const internalConversation = (...values) => {
|
|
1324
|
+
const callSite = values.pop();
|
|
1325
|
+
if (typeof callSite !== "string") throw workError("INTERNAL_ERROR", "Missing workflow conversation call-site identity");
|
|
1326
|
+
const name = values[0];
|
|
1327
|
+
if (typeof name !== "string" || !name.trim()) throw workError("INVALID_METADATA", "conversation requires a non-empty name");
|
|
1328
|
+
const conversationOptions = values.length < 2 || values[1] === undefined ? {} : values[1];
|
|
1329
|
+
if (!conversationOptions || typeof conversationOptions !== "object" || Array.isArray(conversationOptions)) throw workError("INVALID_METADATA", "conversation options must be a JSON object");
|
|
1330
|
+
const inherited = inheritedAgentPath.getStore() || [];
|
|
1331
|
+
const occurrenceKey = JSON.stringify([inherited, callSite, name]);
|
|
1332
|
+
const occurrence = (conversationOccurrences.get(occurrenceKey) || 0) + 1;
|
|
1333
|
+
conversationOccurrences.set(occurrenceKey, occurrence);
|
|
1334
|
+
const fixedOptions = structuredClone(conversationOptions);
|
|
1335
|
+
const defaultTimeout = fixedOptions.timeoutMs;
|
|
1336
|
+
const defaultRetries = fixedOptions.retries;
|
|
1337
|
+
delete fixedOptions.timeoutMs;
|
|
1338
|
+
delete fixedOptions.retries;
|
|
1339
|
+
const worktreeOwner = worktreeOwners.getStore();
|
|
1340
|
+
let turn = 0;
|
|
1341
|
+
let active = false;
|
|
1342
|
+
return Object.freeze({
|
|
1343
|
+
run(prompt, turnOptions = {}) {
|
|
1344
|
+
if (typeof prompt !== "string") throw workError("INVALID_METADATA", "conversation.run prompt must be a string");
|
|
1345
|
+
if (!turnOptions || typeof turnOptions !== "object" || Array.isArray(turnOptions) || Object.keys(turnOptions).some(key => key !== "timeoutMs" && key !== "retries")) throw workError("INVALID_METADATA", "conversation.run options only support timeoutMs and retries");
|
|
1346
|
+
if (active) throw workError("RESUME_INCOMPATIBLE", "Conversation turns cannot overlap");
|
|
1347
|
+
active = true;
|
|
1348
|
+
const turnNumber = turn + 1;
|
|
1349
|
+
const options = { ...fixedOptions, ...(defaultTimeout !== undefined && turnOptions.timeoutMs === undefined ? { timeoutMs: defaultTimeout } : {}), ...(defaultRetries !== undefined && turnOptions.retries === undefined ? { retries: defaultRetries } : {}), ...turnOptions };
|
|
1350
|
+
const identity = { structuralPath: [...inherited], callSite, occurrence, ...(worktreeOwner ? { worktreeOwner } : {}), conversation: { name: name.trim(), turn: turnNumber } };
|
|
1351
|
+
const result = rpc("agent", [prompt, options, identity]).then(value => { const unwrapped = unwrap(value); turn = turnNumber; return unwrapped; }).finally(() => { active = false; });
|
|
1352
|
+
Object.defineProperties(result, {
|
|
1353
|
+
toJSON: { value() { throw workError("INVALID_METADATA", "Workflow conversation result is a Promise; await it before serialization"); } },
|
|
1354
|
+
toString: { value() { throw workError("INVALID_METADATA", "Workflow conversation result is a Promise; await it before interpolation"); } },
|
|
1355
|
+
[Symbol.toPrimitive]: { value() { throw workError("INVALID_METADATA", "Workflow conversation result is a Promise; await it before interpolation"); } },
|
|
1356
|
+
});
|
|
1357
|
+
return result;
|
|
1358
|
+
},
|
|
1359
|
+
});
|
|
1360
|
+
};
|
|
1031
1361
|
const internalAgent = (...values) => {
|
|
1032
1362
|
const callSite = values.pop();
|
|
1033
1363
|
if (typeof callSite !== "string") throw workError("INTERNAL_ERROR", "Missing workflow agent call-site identity");
|
|
@@ -1097,16 +1427,17 @@ const checkpoint = input => rpc("checkpoint", [input]).then(unwrap);
|
|
|
1097
1427
|
const phase = name => rpc("phase", [name]);
|
|
1098
1428
|
const log = message => rpc("log", [message]);
|
|
1099
1429
|
const functionOccurrences = new Map();
|
|
1100
|
-
const functionPath =
|
|
1430
|
+
const functionPath = name => {
|
|
1101
1431
|
const inherited = inheritedAgentPath.getStore() || [];
|
|
1102
|
-
const key = JSON.stringify([inherited,
|
|
1432
|
+
const key = JSON.stringify([inherited, name]);
|
|
1103
1433
|
const occurrence = (functionOccurrences.get(key) || 0) + 1;
|
|
1104
1434
|
functionOccurrences.set(key, occurrence);
|
|
1105
|
-
return path("function", ...inherited,
|
|
1435
|
+
return path("function", ...inherited, name, String(occurrence));
|
|
1106
1436
|
};
|
|
1107
1437
|
const functions = Object.freeze(Object.fromEntries(Object.entries(config.functions || {}).map(([local, target]) => [local, (...values) => {
|
|
1108
1438
|
if (values.length !== 1 || !values[0] || typeof values[0] !== "object" || Array.isArray(values[0])) throw workError("RESULT_INVALID", local + " requires exactly one JSON object argument");
|
|
1109
|
-
const
|
|
1439
|
+
const inherited = inheritedAgentPath.getStore() || [];
|
|
1440
|
+
const result = rpc("function", [target.name, values[0], functionPath(target.name), worktreeOwners.getStore() || null, inherited]).then(unwrap);
|
|
1110
1441
|
Object.defineProperty(result, "toJSON", { value() { throw workError("INVALID_METADATA", "Workflow function result is a Promise; await it before serialization"); } });
|
|
1111
1442
|
return result;
|
|
1112
1443
|
}])));
|
|
@@ -1167,13 +1498,13 @@ const pipeline = async (operationName, items, stages) => {
|
|
|
1167
1498
|
return Object.fromEntries(results.map(result => [result.name, result.value]));
|
|
1168
1499
|
};
|
|
1169
1500
|
const safeMath = Object.fromEntries(Object.getOwnPropertyNames(Math).filter(name => name !== "random").map(name => [name, Math[name]]));
|
|
1170
|
-
const sandbox = { agent, withWorktree: rejectWorktree, prompt, checkpoint, parallel, pipeline, phase, log, args: config.args, Promise, JSON, Math: Object.freeze(safeMath) };
|
|
1501
|
+
const sandbox = { agent, conversation: internalConversation, withWorktree: rejectWorktree, prompt, checkpoint, parallel, pipeline, phase, log, args: config.args, Promise, JSON, Math: Object.freeze(safeMath) };
|
|
1171
1502
|
for (const [name, fn] of Object.entries(functions)) Object.defineProperty(sandbox, name, { value: fn, writable: false, configurable: false });
|
|
1172
1503
|
for (const [name, value] of Object.entries(config.variables || {})) Object.defineProperty(sandbox, name, { value: freeze(value), writable: false, configurable: false });
|
|
1173
1504
|
for (const name of ["Date","eval","Function","WebAssembly","process","require","module","exports","console","fetch","XMLHttpRequest","WebSocket","performance","crypto","setTimeout","setInterval","setImmediate","queueMicrotask","Intl","SharedArrayBuffer","Atomics"]) sandbox[name] = undefined;
|
|
1174
1505
|
const context = vm.createContext(sandbox, { codeGeneration: { strings: false, wasm: false } });
|
|
1175
1506
|
const body = config.script;
|
|
1176
|
-
Promise.resolve().then(() => new vm.Script("(async(__pi_extensible_workflows_agent,__pi_extensible_workflows_withWorktree)=>{" + body + "\n})", { filename: "workflow.js" }).runInContext(context)(internalAgent, internalWithWorktree))
|
|
1507
|
+
Promise.resolve().then(() => new vm.Script("(async(__pi_extensible_workflows_agent,__pi_extensible_workflows_conversation,__pi_extensible_workflows_withWorktree)=>{" + body + "\n})", { filename: "workflow.js" }).runInContext(context)(internalAgent, internalConversation, internalWithWorktree))
|
|
1177
1508
|
.then(async value => { await Promise.all(inflight); send({ type: "result", value: value === undefined ? null : value }); })
|
|
1178
1509
|
.catch(error => send({ type: "error", error: workerError(error) }))
|
|
1179
1510
|
.finally(() => clearInterval(heartbeat));
|
|
@@ -1193,13 +1524,23 @@ function readAgentIdentity(value: unknown): AgentIdentity {
|
|
|
1193
1524
|
const occurrence = value.occurrence;
|
|
1194
1525
|
const worktreeOwner = value.worktreeOwner;
|
|
1195
1526
|
const parentBreadcrumb = value.parentBreadcrumb;
|
|
1196
|
-
|
|
1197
|
-
|
|
1527
|
+
const conversation = value.conversation;
|
|
1528
|
+
const parsedConversation = object(conversation) && typeof conversation.name === "string" && Boolean(conversation.name.trim()) && positiveInteger(conversation.turn) ? { name: conversation.name, turn: conversation.turn } : undefined;
|
|
1529
|
+
if (!Array.isArray(structuralPath) || !structuralPath.every((part): part is string => typeof part === "string" && Boolean(part.trim())) || typeof callSite !== "string" || !callSite || !positiveInteger(occurrence) || parentBreadcrumb !== undefined && (typeof parentBreadcrumb !== "string" || !parentBreadcrumb.trim()) || worktreeOwner !== undefined && (typeof worktreeOwner !== "string" || !worktreeOwner) || conversation !== undefined && !parsedConversation) fail("INTERNAL_ERROR", "Invalid workflow agent identity");
|
|
1530
|
+
return { structuralPath: [...structuralPath], callSite, occurrence, ...(typeof parentBreadcrumb === "string" ? { parentBreadcrumb } : {}), ...(typeof worktreeOwner === "string" ? { worktreeOwner } : {}), ...(parsedConversation ? { conversation: parsedConversation } : {}) };
|
|
1198
1531
|
}
|
|
1199
1532
|
|
|
1200
1533
|
function agentIdentityPath(identity: AgentIdentity): string {
|
|
1201
1534
|
return operationPath("agent", ...identity.structuralPath, `callsite:${identity.callSite}`, `occurrence:${String(identity.occurrence)}`);
|
|
1202
1535
|
}
|
|
1536
|
+
function conversationIdentityPath(identity: AgentIdentity): string {
|
|
1537
|
+
if (!identity.conversation) throw new WorkflowError("INTERNAL_ERROR", "Missing conversation identity");
|
|
1538
|
+
return operationPath("conversation", identity.conversation.name, ...identity.structuralPath, `callsite:${identity.callSite}`, `occurrence:${String(identity.occurrence)}`);
|
|
1539
|
+
}
|
|
1540
|
+
function conversationTurnPath(identity: AgentIdentity): string {
|
|
1541
|
+
if (!identity.conversation) throw new WorkflowError("INTERNAL_ERROR", "Missing conversation identity");
|
|
1542
|
+
return operationPath(conversationIdentityPath(identity), `turn:${String(identity.conversation.turn)}`);
|
|
1543
|
+
}
|
|
1203
1544
|
function agentWorktree(identity: AgentIdentity): { worktreeOwner?: string } {
|
|
1204
1545
|
return identity.worktreeOwner ? { worktreeOwner: identity.worktreeOwner } : {};
|
|
1205
1546
|
}
|
|
@@ -1285,11 +1626,13 @@ export function runWorkflow(script: string, args: JsonValue = null, bridge: Work
|
|
|
1285
1626
|
value = branded({ name, ok: false, failedAt: name, error: { code: typed.code, message: typed.message, ...(isWorkflowAuthored(typed) ? { authored: true } : {}) } });
|
|
1286
1627
|
}
|
|
1287
1628
|
} else if (method === "function") {
|
|
1288
|
-
const worktreeOwner = values[
|
|
1289
|
-
|
|
1290
|
-
|
|
1629
|
+
const worktreeOwner = values[3] === undefined || values[3] === null ? undefined : typeof values[3] === "string" && values[3] ? values[3] : fail("INTERNAL_ERROR", "function worktree scope is invalid");
|
|
1630
|
+
const structuralPath = values[4] === undefined ? [] : values[4];
|
|
1631
|
+
if (!Array.isArray(structuralPath) || !structuralPath.every((part): part is string => typeof part === "string" && Boolean(part.trim()))) fail("INTERNAL_ERROR", "function structural scope is invalid");
|
|
1632
|
+
if (!bridge.function || typeof values[0] !== "string" || !object(values[1]) || typeof values[2] !== "string") fail("INTERNAL_ERROR", "function requires an available bridge, name, object input, and path");
|
|
1633
|
+
const name = values[0];
|
|
1291
1634
|
try {
|
|
1292
|
-
const result = await bridge.function(values[0], values[1], values[2],
|
|
1635
|
+
const result = await bridge.function(values[0], values[1], values[2], controller.signal, worktreeOwner, structuralPath);
|
|
1293
1636
|
value = branded({ name, ok: true, value: result ?? null });
|
|
1294
1637
|
} catch (error) {
|
|
1295
1638
|
const typed = asWorkflowError(error);
|
|
@@ -1324,13 +1667,14 @@ function nativeSessionReference(attempt: Pick<AgentAttempt, "sessionId" | "sessi
|
|
|
1324
1667
|
return { sessionId: attempt.sessionId, sessionFile: attempt.sessionFile };
|
|
1325
1668
|
}
|
|
1326
1669
|
|
|
1327
|
-
export async function persistActiveAgentAttempt(store: RunStore, id: string, active: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile">): Promise<void> {
|
|
1670
|
+
export async function persistActiveAgentAttempt(store: RunStore, id: string, active: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile" | "setup">): Promise<void> {
|
|
1328
1671
|
await store.updateState((run) => {
|
|
1329
1672
|
const agent = run.agents.find((candidate) => candidate.id === id);
|
|
1330
1673
|
if (!agent) throw new WorkflowError("INTERNAL_ERROR", `Missing production ownership record: ${id}`);
|
|
1331
1674
|
const accounting = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
1675
|
+
const details = [...(agent.attemptDetails ?? []).filter((candidate) => candidate.attempt !== active.attempt), { ...active, accounting }];
|
|
1332
1676
|
const nativeSessions = run.nativeSessions.some(({ sessionId }) => sessionId === active.sessionId) ? run.nativeSessions : [...run.nativeSessions, nativeSessionReference(active)];
|
|
1333
|
-
return { ...run, agents: run.agents.map((candidate) => candidate.id === id ? { ...candidate, attempts: active.attempt, attemptDetails:
|
|
1677
|
+
return { ...run, agents: run.agents.map((candidate) => candidate.id === id ? { ...candidate, attempts: Math.max(candidate.attempts, active.attempt), attemptDetails: details } : candidate), nativeSessions };
|
|
1334
1678
|
});
|
|
1335
1679
|
}
|
|
1336
1680
|
|
|
@@ -1339,7 +1683,7 @@ export async function persistAgentAttempts(store: RunStore, id: string, attempts
|
|
|
1339
1683
|
const agent = run.agents.find((candidate) => candidate.id === id);
|
|
1340
1684
|
if (!agent) throw new WorkflowError("INTERNAL_ERROR", `Missing production ownership record: ${id}`);
|
|
1341
1685
|
const total = attempts.reduce((sum, attempt) => ({ input: sum.input + attempt.accounting.input, output: sum.output + attempt.accounting.output, cacheRead: sum.cacheRead + attempt.accounting.cacheRead, cacheWrite: sum.cacheWrite + attempt.accounting.cacheWrite, cost: sum.cost + attempt.accounting.cost }), { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 });
|
|
1342
|
-
const attemptDetails = attempts.map(({ attempt, sessionId, sessionFile, error, accounting }) => ({ attempt, sessionId, sessionFile, ...(error ? { error } : {}), accounting }));
|
|
1686
|
+
const attemptDetails = attempts.map(({ attempt, sessionId, sessionFile, error, accounting, setup }) => ({ attempt, sessionId, sessionFile, ...(error ? { error } : {}), accounting, ...(setup ? { setup } : {}) }));
|
|
1343
1687
|
const sessionIds = new Set(attempts.map(({ sessionId }) => sessionId));
|
|
1344
1688
|
return { ...run, agents: run.agents.map((candidate) => candidate.id === id ? { ...candidate, attempts: attempts.length, attemptDetails, accounting: total } : candidate), nativeSessions: [...run.nativeSessions.filter(({ sessionId }) => !sessionIds.has(sessionId)), ...attempts.map((attempt) => nativeSessionReference(attempt))] };
|
|
1345
1689
|
});
|
|
@@ -1347,19 +1691,47 @@ export async function persistAgentAttempts(store: RunStore, id: string, attempts
|
|
|
1347
1691
|
|
|
1348
1692
|
type WorkflowToolUpdate = { content: [{ type: "text"; text: string }]; details: { runId: string; run: PersistedRun } };
|
|
1349
1693
|
|
|
1694
|
+
type AgentGroup = { label: string; entries: readonly { agent: AgentRecord; index: number; depth: number }[] };
|
|
1695
|
+
function agentGroupKey(agent: AgentRecord): string { return JSON.stringify([agent.structuralPath ?? [], agent.parentBreadcrumb ?? null]); }
|
|
1696
|
+
function agentGroupLabel(agents: readonly AgentRecord[]): string {
|
|
1697
|
+
const structural = agents[0]?.structuralPath ?? [];
|
|
1698
|
+
const breadcrumbs = [...new Set(agents.map((agent) => agent.parentBreadcrumb).filter((value): value is string => Boolean(value)))];
|
|
1699
|
+
return [...(structural.length ? [structural.join(" > ")] : []), ...(breadcrumbs.length === 1 ? breadcrumbs : breadcrumbs.length ? [breadcrumbs.join(" | ")] : [])].join(" > ") || "Agents";
|
|
1700
|
+
}
|
|
1701
|
+
function agentGroups(agents: readonly AgentRecord[]): AgentGroup[] {
|
|
1702
|
+
const byId = new Map(agents.map((agent) => [agent.id, agent]));
|
|
1703
|
+
const groups = new Map<string, { agents: Array<{ agent: AgentRecord; index: number; depth: number }> }>();
|
|
1704
|
+
for (const [index, agent] of agents.entries()) {
|
|
1705
|
+
let depth = 0;
|
|
1706
|
+
for (let parent = agent.parentId; parent && byId.has(parent); parent = byId.get(parent)?.parentId) depth += 1;
|
|
1707
|
+
const key = agentGroupKey(agent);
|
|
1708
|
+
const group = groups.get(key) ?? { agents: [] };
|
|
1709
|
+
group.agents.push({ agent, index, depth });
|
|
1710
|
+
groups.set(key, group);
|
|
1711
|
+
}
|
|
1712
|
+
return [...groups].map(([, group]) => ({ label: agentGroupLabel(group.agents.map(({ agent }) => agent)), entries: group.agents }));
|
|
1713
|
+
}
|
|
1714
|
+
function renderGroupedAgents(agents: readonly AgentRecord[], render: (entry: { agent: AgentRecord; index: number; depth: number }, grouped: boolean) => string): string[] {
|
|
1715
|
+
const groups = agentGroups(agents);
|
|
1716
|
+
const grouped = groups.length > 1 || groups.some(({ label }) => label !== "Agents");
|
|
1717
|
+
return groups.flatMap((group) => [
|
|
1718
|
+
...(grouped ? [` ${group.label}`] : []),
|
|
1719
|
+
...group.entries.map((entry) => render(entry, grouped)),
|
|
1720
|
+
]);
|
|
1721
|
+
}
|
|
1350
1722
|
export function formatWorkflowProgress(run: PersistedRun, spinner = "◇"): string {
|
|
1351
1723
|
const done = run.agents.filter((agent) => SETTLED_AGENT_STATES.has(agent.state)).length;
|
|
1352
|
-
const lines = [`${run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "running" ? spinner : "◆"} Workflow: ${run.workflowName} (${String(done)}/${String(run.agents.length)} done)`];
|
|
1724
|
+
const lines = [`${run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "budget_exhausted" ? "!" : run.state === "running" ? spinner : "◆"} Workflow: ${run.workflowName} (${String(done)}/${String(run.agents.length)} done)`];
|
|
1353
1725
|
if (run.phase) lines.push(` Phase: ${run.phase}`);
|
|
1726
|
+
lines.push(...formatCompactBudgetStatus(run).map((line) => ` ${line}`));
|
|
1354
1727
|
const byId = new Map(run.agents.map((agent) => [agent.id, agent]));
|
|
1355
|
-
|
|
1356
|
-
let depth = 0;
|
|
1357
|
-
for (let parent = agent.parentId; parent && byId.has(parent); parent = byId.get(parent)?.parentId) depth += 1;
|
|
1728
|
+
lines.push(...renderGroupedAgents(run.agents, ({ agent, index, depth }, grouped) => {
|
|
1358
1729
|
const icon = agent.state === "completed" ? "✓" : agent.state === "failed" || agent.state === "cancelled" ? "✗" : agent.state === "running" ? spinner : "○";
|
|
1359
|
-
const indent = " ".repeat(
|
|
1730
|
+
const indent = " ".repeat((grouped ? 2 : 1) + depth);
|
|
1360
1731
|
const activity = SETTLED_AGENT_STATES.has(agent.state) ? "" : formatAgentActivity(agent, spinner);
|
|
1361
|
-
|
|
1362
|
-
|
|
1732
|
+
const name = grouped ? agent.label ?? agent.name : agentBreadcrumb(agent, byId);
|
|
1733
|
+
return `${indent}#${String(index + 1)} ${icon} ${name} [${agent.state}]${activity ? ` ${activity}` : ""}`;
|
|
1734
|
+
}));
|
|
1363
1735
|
return lines.join("\n");
|
|
1364
1736
|
}
|
|
1365
1737
|
|
|
@@ -1387,8 +1759,29 @@ function workflowProgressBlock(run: PersistedRun) {
|
|
|
1387
1759
|
invalidate() {},
|
|
1388
1760
|
};
|
|
1389
1761
|
}
|
|
1762
|
+
export function formatBudgetStatus(run: Pick<PersistedRun, "budget" | "budgetVersion" | "usage" | "budgetEvents">): string[] {
|
|
1763
|
+
const usage = budgetUsage(run.usage);
|
|
1764
|
+
if (!run.budget || !Object.keys(run.budget).length) return ["Budget: unlimited"];
|
|
1765
|
+
const lines = [`Budget version ${String(run.budgetVersion ?? 1)}`];
|
|
1766
|
+
for (const dimension of ["tokens", "costUsd", "durationMs", "agentLaunches"] as const) {
|
|
1767
|
+
const limits = run.budget[dimension];
|
|
1768
|
+
if (!limits || (limits.soft === undefined && limits.hard === undefined)) continue;
|
|
1769
|
+
const limit = limits.hard ?? limits.soft;
|
|
1770
|
+
const percent = limit === undefined ? "" : ` ${limit === 0 ? "100.0" : ((usage[dimension] / limit) * 100).toFixed(1)}%`;
|
|
1771
|
+
const state = (run.budgetEvents ?? []).filter((event) => event.dimensions.includes(dimension)).at(-1)?.type;
|
|
1772
|
+
lines.push(` ${dimension}: ${String(usage[dimension])}${limits.soft !== undefined ? ` soft=${String(limits.soft)}` : ""}${limits.hard !== undefined ? ` hard=${String(limits.hard)}` : ""}${percent}${state ? ` state=${state}` : ""}`);
|
|
1773
|
+
}
|
|
1774
|
+
const events = run.budgetEvents ?? [];
|
|
1775
|
+
if (events.length) lines.push(` events: ${events.map((event) => `${event.type}@v${String(event.budgetVersion)}`).join(", ")}`);
|
|
1776
|
+
return lines;
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
function formatCompactBudgetStatus(run: Pick<PersistedRun, "budget" | "budgetVersion" | "usage" | "budgetEvents">): string[] {
|
|
1780
|
+
if (!Object.values(run.budget ?? {}).some((limits) => limits.soft !== undefined || limits.hard !== undefined)) return [];
|
|
1781
|
+
return formatBudgetStatus(run);
|
|
1782
|
+
}
|
|
1390
1783
|
|
|
1391
|
-
const ATTENTION_ORDER: Record<string, number> = { awaiting_input: 0,
|
|
1784
|
+
const ATTENTION_ORDER: Record<string, number> = { awaiting_input: 0, budget_exhausted: 1, running: 2, pausing: 3, paused: 4, interrupted: 5, failed: 6, queued: 7, stopped: 8, completed: 9 };
|
|
1392
1785
|
|
|
1393
1786
|
function navigatorAttentionSort<T extends { loaded: { run: PersistedRun } }>(entries: readonly T[]): T[] {
|
|
1394
1787
|
return [...entries].sort((a, b) => (ATTENTION_ORDER[a.loaded.run.state] ?? 9) - (ATTENTION_ORDER[b.loaded.run.state] ?? 9));
|
|
@@ -1399,7 +1792,7 @@ function navigatorRunLabels(entries: readonly { store: RunStore; loaded: { run:
|
|
|
1399
1792
|
for (const { loaded: { run } } of entries) nameCount.set(run.workflowName, (nameCount.get(run.workflowName) ?? 0) + 1);
|
|
1400
1793
|
return entries.map(({ store, loaded: { run } }) => {
|
|
1401
1794
|
const done = run.agents.filter((a) => SETTLED_AGENT_STATES.has(a.state)).length;
|
|
1402
|
-
const glyph = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "running" ? "⠦" : run.state === "awaiting_input" ? "●" : "◆";
|
|
1795
|
+
const glyph = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "budget_exhausted" ? "!" : run.state === "running" ? "⠦" : run.state === "awaiting_input" ? "●" : "◆";
|
|
1403
1796
|
const suffix = (nameCount.get(run.workflowName) ?? 0) > 1 ? ` ${store.runId.slice(0, 8)}` : "";
|
|
1404
1797
|
const cost = run.agents.reduce((sum, a) => sum + (a.accounting?.cost ?? 0), 0);
|
|
1405
1798
|
const costStr = cost > 0 ? ` $${cost.toFixed(2)}` : "";
|
|
@@ -1409,15 +1802,16 @@ function navigatorRunLabels(entries: readonly { store: RunStore; loaded: { run:
|
|
|
1409
1802
|
|
|
1410
1803
|
function agentBreadcrumb(agent: AgentRecord, byId: Map<string, AgentRecord>): string {
|
|
1411
1804
|
const name = agent.label ?? agent.name;
|
|
1412
|
-
const parts: string[] = [
|
|
1805
|
+
const parts: string[] = agent.parentBreadcrumb ? [agent.parentBreadcrumb] : [];
|
|
1413
1806
|
const seen = new Set<string>([agent.id]);
|
|
1414
1807
|
for (let parentId = agent.parentId; parentId; parentId = byId.get(parentId)?.parentId) {
|
|
1415
1808
|
if (seen.has(parentId)) break; // ponytail: cycle guard for corrupt data
|
|
1416
1809
|
seen.add(parentId);
|
|
1417
1810
|
const parent = byId.get(parentId);
|
|
1418
|
-
if (parent) parts.
|
|
1811
|
+
if (parent) parts.push(parent.label ?? parent.name);
|
|
1419
1812
|
else break;
|
|
1420
1813
|
}
|
|
1814
|
+
parts.push(name);
|
|
1421
1815
|
return parts.length > 1 ? parts.join(" > ") : name;
|
|
1422
1816
|
}
|
|
1423
1817
|
|
|
@@ -1434,44 +1828,44 @@ function formatAccounting(accounting: NonNullable<AgentRecord["accounting"]>): s
|
|
|
1434
1828
|
return `${new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(total).toLowerCase()} tok`;
|
|
1435
1829
|
}
|
|
1436
1830
|
|
|
1831
|
+
|
|
1437
1832
|
export function formatNavigatorDashboard(run: PersistedRun, checkpoints: readonly AwaitingCheckpoint[], worktrees: readonly WorktreeReference[]): string {
|
|
1833
|
+
void worktrees;
|
|
1438
1834
|
const done = run.agents.filter((a) => SETTLED_AGENT_STATES.has(a.state)).length;
|
|
1439
1835
|
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 });
|
|
1440
1836
|
const hasAccounting = run.agents.some((a) => a.accounting);
|
|
1441
|
-
const glyph = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "running" ? "⠦" : run.state === "awaiting_input" ? "●" : "◆";
|
|
1837
|
+
const glyph = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "budget_exhausted" ? "!" : run.state === "running" ? "⠦" : run.state === "awaiting_input" ? "●" : "◆";
|
|
1442
1838
|
const header = `${glyph} ${run.workflowName}`;
|
|
1443
1839
|
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(" · ");
|
|
1444
|
-
const lines = [header, meta];
|
|
1840
|
+
const lines = [header, meta, ...formatCompactBudgetStatus(run)];
|
|
1445
1841
|
if (run.error) lines.push(`Error: ${run.error.code}: ${run.error.message}`);
|
|
1842
|
+
if (run.events?.length) lines.push(...run.events.map((event) => `Warning: ${event.message}`));
|
|
1446
1843
|
lines.push("");
|
|
1447
1844
|
const byId = new Map(run.agents.map((a) => [a.id, a]));
|
|
1448
|
-
const
|
|
1449
|
-
const prioritised = [...run.agents].sort((a, b) => {
|
|
1450
|
-
const aActive = activeStates.has(a.state) || a.state === "failed" ? 0 : 1;
|
|
1451
|
-
const bActive = activeStates.has(b.state) || b.state === "failed" ? 0 : 1;
|
|
1452
|
-
return aActive - bActive;
|
|
1453
|
-
});
|
|
1454
|
-
for (const agent of prioritised) {
|
|
1845
|
+
const render = ({ agent, depth }: { agent: AgentRecord; index: number; depth: number }, grouped: boolean) => {
|
|
1455
1846
|
const icon = agent.state === "completed" ? "✓" : agent.state === "failed" || agent.state === "cancelled" ? "✗" : agent.state === "running" ? "⠦" : "○";
|
|
1456
|
-
const breadcrumb = agentBreadcrumb(agent, byId);
|
|
1457
|
-
const
|
|
1458
|
-
const
|
|
1847
|
+
const breadcrumb = grouped ? agent.label ?? agent.name : agentBreadcrumb(agent, byId);
|
|
1848
|
+
const setup = agent.attemptDetails?.at(-1)?.setup;
|
|
1849
|
+
const thinking = setup?.model.thinking ?? agent.model.thinking;
|
|
1850
|
+
const model = `${setup?.model.provider ?? agent.model.provider}/${setup?.model.model ?? agent.model.model}${thinking ? `:${thinking}` : ""}`;
|
|
1851
|
+
const policy = [`model=${model}`, agent.requestedModel ? `requested=${agent.requestedModel}` : "", `tools=${(setup?.tools ?? agent.tools).join(",") || "(none)"}`, agent.role ? `role=${agent.role}` : ""];
|
|
1459
1852
|
const tokens = agent.accounting ? formatAccounting(agent.accounting) : "";
|
|
1460
|
-
const
|
|
1461
|
-
|
|
1853
|
+
const indent = " ".repeat((grouped ? 2 : 1) + depth);
|
|
1854
|
+
const result = [`${indent}${icon} ${breadcrumb} · ${agent.state} · ${policy.filter(Boolean).join(" · ")}${tokens ? ` · ${tokens}` : ""}`];
|
|
1462
1855
|
if (agent.state === "failed" && agent.attemptDetails?.length) {
|
|
1463
1856
|
const last = agent.attemptDetails[agent.attemptDetails.length - 1];
|
|
1464
|
-
if (last?.error)
|
|
1857
|
+
if (last?.error) result.push(`${indent} error: ${last.error.code}: ${last.error.message}`);
|
|
1465
1858
|
}
|
|
1466
1859
|
const activity = !SETTLED_AGENT_STATES.has(agent.state) ? formatAgentActivity(agent, "⠦") : "";
|
|
1467
|
-
if (activity)
|
|
1468
|
-
|
|
1860
|
+
if (activity) result.push(`${indent} ${activity}`);
|
|
1861
|
+
return result.join("\n");
|
|
1862
|
+
};
|
|
1863
|
+
lines.push(...renderGroupedAgents(run.agents, render));
|
|
1469
1864
|
if (checkpoints.length) { lines.push(""); for (const cp of checkpoints) lines.push(`● checkpoint ${cp.name}: ${cp.prompt}`); }
|
|
1470
|
-
if (worktrees.length) { lines.push(""); for (const wt of worktrees) lines.push(`branch ${wt.branch} (${wt.path})`); }
|
|
1471
1865
|
return lines.join("\n");
|
|
1472
1866
|
}
|
|
1473
1867
|
|
|
1474
|
-
export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> }, checkpoints: readonly AwaitingCheckpoint[],
|
|
1868
|
+
export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> }, checkpoints: readonly AwaitingCheckpoint[], _worktrees: readonly WorktreeReference[]): string {
|
|
1475
1869
|
const { run, snapshot } = loaded;
|
|
1476
1870
|
const lines = [
|
|
1477
1871
|
`Workflow: ${run.workflowName}`,
|
|
@@ -1479,29 +1873,32 @@ export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readon
|
|
|
1479
1873
|
`Status: ${run.state}`,
|
|
1480
1874
|
`Phase: ${run.phase ?? "(none)"}`,
|
|
1481
1875
|
`Launch cwd: ${run.cwd}`,
|
|
1876
|
+
...formatCompactBudgetStatus(run),
|
|
1482
1877
|
`Launch models: ${snapshot.models.join(", ") || "(none)"}`,
|
|
1483
1878
|
];
|
|
1484
1879
|
if (run.error) lines.push(`Run error: ${run.error.code}: ${run.error.message}`);
|
|
1880
|
+
if (run.events?.length) lines.push(...run.events.map((event) => `Warning: ${event.message}`));
|
|
1881
|
+
const aliases = snapshot.modelAliases ?? snapshot.settings.modelAliases;
|
|
1882
|
+
if (aliases && Object.keys(aliases).length) lines.push(`Model aliases: ${Object.entries(aliases).map(([name, target]) => `${name}=${target}`).join(", ")}`);
|
|
1485
1883
|
lines.push("Agents / ownership:");
|
|
1486
1884
|
if (!run.agents.length) lines.push(" (none)");
|
|
1487
|
-
|
|
1885
|
+
const byId = new Map(run.agents.map((agent) => [agent.id, agent]));
|
|
1886
|
+
lines.push(...renderGroupedAgents(run.agents, ({ agent, index, depth }, grouped) => {
|
|
1488
1887
|
const model = `${agent.model.provider}/${agent.model.model}${agent.model.thinking ? `:${agent.model.thinking}` : ""}`;
|
|
1489
1888
|
const role = agent.role ? ` role=${agent.role}` : "";
|
|
1490
1889
|
const tools = ` tools=${agent.tools.join(",") || "(none)"}`;
|
|
1491
1890
|
const accounting = agent.accounting ? ` input=${String(agent.accounting.input)} output=${String(agent.accounting.output)} cache-read=${String(agent.accounting.cacheRead)} cache-write=${String(agent.accounting.cacheWrite)} cost=${String(agent.accounting.cost)}` : "";
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
for (const
|
|
1495
|
-
}
|
|
1891
|
+
const indent = " ".repeat((grouped ? 2 : 1) + depth);
|
|
1892
|
+
const result = [`${indent}#${String(index + 1)} ${grouped ? agent.label ?? agent.name : agentBreadcrumb(agent, byId)} state=${agent.state} model=${model}${agent.requestedModel ? ` requested=${agent.requestedModel}` : ""}${role}${tools} attempts=${String(agent.attempts)} retries=${String(Math.max(0, agent.attempts - 1))}${accounting}`];
|
|
1893
|
+
for (const attempt of agent.attemptDetails ?? []) result.push(`${indent} attempt ${String(attempt.attempt)}${attempt.error ? ` error=${attempt.error.code}: ${attempt.error.message}` : ""}`);
|
|
1894
|
+
for (const call of agent.toolCalls ?? []) result.push(`${indent} tool ${call.name} state=${call.state}`);
|
|
1895
|
+
return result.join("\n");
|
|
1896
|
+
}));
|
|
1496
1897
|
lines.push("Checkpoints:");
|
|
1497
1898
|
if (!checkpoints.length) lines.push(" (none)");
|
|
1498
1899
|
for (const checkpoint of checkpoints) lines.push(` ${checkpoint.name}: ${checkpoint.prompt} context=${JSON.stringify(checkpoint.context)}`);
|
|
1499
|
-
lines.push(
|
|
1500
|
-
|
|
1501
|
-
for (const worktree of worktrees) lines.push(` ${worktree.owner}: branch=${worktree.branch} path=${worktree.path} cwd=${worktree.cwd}`);
|
|
1502
|
-
lines.push("Native Pi transcript paths:");
|
|
1503
|
-
if (!run.nativeSessions.length) lines.push(" (none)");
|
|
1504
|
-
for (const session of run.nativeSessions) lines.push(` ${session.sessionId}: ${session.sessionFile}`);
|
|
1900
|
+
lines.push(`Worktrees: ${String(_worktrees.length)}`);
|
|
1901
|
+
lines.push(`Native Pi transcripts: ${String(run.nativeSessions.length)}`);
|
|
1505
1902
|
return lines.join("\n");
|
|
1506
1903
|
}
|
|
1507
1904
|
function formatCheckpointReview(checkpoint: AwaitingCheckpoint): string {
|
|
@@ -1535,6 +1932,103 @@ function deliver(pi: ExtensionAPI, content: string): void {
|
|
|
1535
1932
|
function deliverFailure(pi: ExtensionAPI, name: string, error: unknown): void {
|
|
1536
1933
|
deliver(pi, `Workflow ${name} failed: ${formatWorkflowFailure(error)}`);
|
|
1537
1934
|
}
|
|
1935
|
+
|
|
1936
|
+
type WorkflowEventSink = { emit: (name: string, payload: unknown) => unknown };
|
|
1937
|
+
|
|
1938
|
+
function safeEventError(error: unknown): WorkflowErrorShape {
|
|
1939
|
+
const code = errorCode(error) ?? "INTERNAL_ERROR";
|
|
1940
|
+
return { code, message: `Workflow execution failed (${code})` };
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
class WorkflowEventPublisher {
|
|
1944
|
+
#queues = new Map<string, Promise<void>>();
|
|
1945
|
+
#budgetEvents = new Map<string, Set<string>>();
|
|
1946
|
+
#worktrees = new Map<string, Set<string>>();
|
|
1947
|
+
|
|
1948
|
+
constructor(private readonly sink: WorkflowEventSink | undefined) {}
|
|
1949
|
+
|
|
1950
|
+
seedBudget(runId: string, events: readonly BudgetEvent[] | undefined): void {
|
|
1951
|
+
const seen = this.#budgetEvents.get(runId) ?? new Set<string>();
|
|
1952
|
+
for (const event of events ?? []) seen.add(this.budgetKey(event));
|
|
1953
|
+
this.#budgetEvents.set(runId, seen);
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
async runStarted(store: RunStore, metadata: WorkflowMetadata, background: boolean): Promise<void> {
|
|
1957
|
+
await this.#publish(store, metadata, WORKFLOW_RUN_STARTED_EVENT, {});
|
|
1958
|
+
if (background) await this.legacyStarted(store, metadata);
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
async legacyStarted(store: RunStore, metadata: WorkflowMetadata): Promise<void> { await this.#publish(store, metadata, WORKFLOW_ASYNC_STARTED_EVENT, { id: store.runId, runId: store.runId, pid: process.pid, sessionId: store.sessionId, asyncDir: store.directory, agent: metadata.name }); }
|
|
1962
|
+
|
|
1963
|
+
async runResumed(store: RunStore, metadata: WorkflowMetadata): Promise<void> { await this.#publish(store, metadata, WORKFLOW_RUN_RESUMED_EVENT, {}); }
|
|
1964
|
+
|
|
1965
|
+
async runState(store: RunStore, metadata: WorkflowMetadata, previousState: RunState, state: RunState, reason?: string): Promise<void> {
|
|
1966
|
+
await this.#publish(store, metadata, WORKFLOW_RUN_STATE_CHANGED_EVENT, { previousState, state, ...(reason ? { reason } : {}), ...(ERROR_CODES.includes(reason as WorkflowErrorCode) ? { errorCode: reason } : {}) });
|
|
1967
|
+
if ((previousState === "paused" || previousState === "interrupted" || previousState === "budget_exhausted") && state === "running") await this.runResumed(store, metadata);
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
async runCompleted(store: RunStore, metadata: WorkflowMetadata, resultPath: string, background: boolean): Promise<void> {
|
|
1971
|
+
await this.#publish(store, metadata, WORKFLOW_RUN_COMPLETED_EVENT, { resultPath });
|
|
1972
|
+
if (background) await this.#publish(store, metadata, WORKFLOW_ASYNC_COMPLETE_EVENT, { id: store.runId, runId: store.runId, sessionId: store.sessionId, asyncDir: store.directory, success: true, state: "complete" });
|
|
1973
|
+
}
|
|
1974
|
+
async runFailed(store: RunStore, metadata: WorkflowMetadata, error: unknown, background: boolean, state: "failed" | "stopped" | "interrupted" | "budget_exhausted"): Promise<void> {
|
|
1975
|
+
if (state === "failed") await this.#publish(store, metadata, WORKFLOW_RUN_FAILED_EVENT, { error: safeEventError(error) });
|
|
1976
|
+
if (background) {
|
|
1977
|
+
const legacyState = state === "interrupted" ? "failed" : state;
|
|
1978
|
+
await this.#publish(store, metadata, WORKFLOW_ASYNC_COMPLETE_EVENT, { id: store.runId, runId: store.runId, sessionId: store.sessionId, asyncDir: store.directory, success: false, state: legacyState, ...(state === "stopped" ? { stopped: true } : {}), ...(legacyState === "budget_exhausted" || legacyState === "failed" ? { error: safeEventError(error).message } : {}) });
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
async agentState(store: RunStore, metadata: WorkflowMetadata, previous: AgentRecord | undefined, agent: AgentRecord): Promise<void> {
|
|
1983
|
+
await this.#publish(store, metadata, WORKFLOW_AGENT_STATE_CHANGED_EVENT, { agentId: agent.id, displayLabel: agent.label ?? agent.name, ...(agent.role ? { role: agent.role } : {}), structuralPath: [...(agent.structuralPath ?? [])], ...(agent.parentId ? { parentId: agent.parentId } : {}), ...(agent.parentBreadcrumb ? { parentBreadcrumb: agent.parentBreadcrumb } : {}), ...(agent.worktreeOwner ? { worktreeOwner: agent.worktreeOwner } : {}), ...(previous ? { previousState: previous.state } : {}), state: agent.state, attempt: agent.attempts });
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
async agentStates(store: RunStore, metadata: WorkflowMetadata, previous: readonly AgentRecord[], current: readonly AgentRecord[]): Promise<void> {
|
|
1987
|
+
const previousById = new Map(previous.map((agent) => [agent.id, agent]));
|
|
1988
|
+
for (const agent of current) {
|
|
1989
|
+
const old = previousById.get(agent.id);
|
|
1990
|
+
if (!old || old.state !== agent.state || old.attempts !== agent.attempts) await this.agentState(store, metadata, old, agent);
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
async phase(store: RunStore, metadata: WorkflowMetadata, previousPhase: string | undefined, phase: string): Promise<void> {
|
|
1995
|
+
if (previousPhase !== phase) await this.#publish(store, metadata, WORKFLOW_PHASE_CHANGED_EVENT, { ...(previousPhase !== undefined ? { previousPhase } : {}), phase });
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
async checkpoint(store: RunStore, metadata: WorkflowMetadata, name: string, state: WorkflowCheckpointState): Promise<void> { await this.#publish(store, metadata, WORKFLOW_CHECKPOINT_STATE_CHANGED_EVENT, { name, state }); }
|
|
1999
|
+
|
|
2000
|
+
async budget(store: RunStore, metadata: WorkflowMetadata, run: Pick<PersistedRun, "budgetEvents">): Promise<void> {
|
|
2001
|
+
const seen = this.#budgetEvents.get(store.runId) ?? new Set<string>();
|
|
2002
|
+
this.#budgetEvents.set(store.runId, seen);
|
|
2003
|
+
for (const event of run.budgetEvents ?? []) {
|
|
2004
|
+
const key = this.budgetKey(event);
|
|
2005
|
+
if (seen.has(key)) continue;
|
|
2006
|
+
seen.add(key);
|
|
2007
|
+
await this.#publish(store, metadata, WORKFLOW_BUDGET_EVENT, { ...event, timestamp: event.at });
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
async worktree(store: RunStore, metadata: WorkflowMetadata, worktree: WorktreeReference): Promise<void> {
|
|
2012
|
+
const seen = this.#worktrees.get(store.runId) ?? new Set<string>();
|
|
2013
|
+
this.#worktrees.set(store.runId, seen);
|
|
2014
|
+
if (seen.has(worktree.owner)) return;
|
|
2015
|
+
seen.add(worktree.owner);
|
|
2016
|
+
await this.#publish(store, metadata, WORKFLOW_WORKTREE_CREATED_EVENT, { owner: worktree.owner, branch: worktree.branch, path: worktree.path, base: worktree.base });
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
async #publish(store: RunStore, metadata: WorkflowMetadata, name: string, payload: Record<string, unknown>): Promise<void> {
|
|
2020
|
+
const base: WorkflowEventBase = { runId: store.runId, sessionId: store.sessionId, workflowName: metadata.name, cwd: store.cwd, runDirectory: store.directory, timestamp: Date.now() };
|
|
2021
|
+
const previous = this.#queues.get(store.runId) ?? Promise.resolve();
|
|
2022
|
+
const next = previous.then(() => {
|
|
2023
|
+
try { void Promise.resolve(this.sink?.emit(name, { ...base, ...payload })).catch(() => undefined); } catch { /* Best effort: listeners cannot affect a run. */ }
|
|
2024
|
+
});
|
|
2025
|
+
this.#queues.set(store.runId, next.catch(() => undefined));
|
|
2026
|
+
await next;
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
private budgetKey(event: BudgetEvent): string { return `${String(event.budgetVersion)}:${event.type}:${event.proposalId ?? ""}`; }
|
|
2030
|
+
}
|
|
2031
|
+
|
|
1538
2032
|
const inheritedHostAgentPath = new AsyncLocalStorage<readonly string[]>();
|
|
1539
2033
|
const inheritedHostWorktreeOwner = new AsyncLocalStorage<string>();
|
|
1540
2034
|
|
|
@@ -1566,13 +2060,13 @@ function workflowRunContext(cwd: string, sessionId: string, runId: string, workf
|
|
|
1566
2060
|
|
|
1567
2061
|
async function resolveWorkflowVariables(run: Readonly<WorkflowRunContext>, controller: AbortController, registry: WorkflowRegistryApi): Promise<Readonly<Record<string, JsonValue>>> {
|
|
1568
2062
|
let first: WorkflowError | undefined;
|
|
1569
|
-
const tasks = registry.variables().map(async ({
|
|
2063
|
+
const tasks = registry.variables().map(async ({ name, variable }) => {
|
|
1570
2064
|
try {
|
|
1571
2065
|
const result: unknown = await variable.resolve(run);
|
|
1572
|
-
if (!jsonValue(result) || !Value.Check(variable.schema, result)) fail("RESULT_INVALID", `Invalid output from ${
|
|
2066
|
+
if (!jsonValue(result) || !Value.Check(variable.schema, result)) fail("RESULT_INVALID", `Invalid output from ${name}`);
|
|
1573
2067
|
return [name, deepFreeze(structuredClone(result))] as const;
|
|
1574
2068
|
} catch (error) {
|
|
1575
|
-
const typed = errorCode(error) ? new WorkflowError(errorCode(error) as WorkflowErrorCode, `${
|
|
2069
|
+
const typed = errorCode(error) ? new WorkflowError(errorCode(error) as WorkflowErrorCode, `${name}: ${errorText(error)}`) : new WorkflowError("INTERNAL_ERROR", `${name}: ${errorText(error)}`);
|
|
1576
2070
|
if (!first) { first = typed; controller.abort(); }
|
|
1577
2071
|
throw typed;
|
|
1578
2072
|
}
|
|
@@ -1643,7 +2137,7 @@ function nextNamedOccurrence(counters: Map<string, number>, label: string): stri
|
|
|
1643
2137
|
function withWorkflowFunctions(bridge: WorkflowBridge, store: RunStore, runContext: Readonly<WorkflowRunContext>, variables: Readonly<Record<string, JsonValue>>, registry: WorkflowRegistryApi): WorkflowBridge {
|
|
1644
2138
|
const functionAgentOccurrences = new Map<string, number>();
|
|
1645
2139
|
const functionWorktreeOccurrences = new Map<string, number>();
|
|
1646
|
-
return { ...bridge, functions: registry.globals(), variables, function: async (
|
|
2140
|
+
return { ...bridge, functions: registry.globals(), variables, function: async (name, input, path, signal, worktreeOwner, structuralPath = []) => {
|
|
1647
2141
|
const replayed = await store.replay(path);
|
|
1648
2142
|
let stored: JsonValue | undefined;
|
|
1649
2143
|
const sideEffects: Promise<void>[] = [];
|
|
@@ -1653,11 +2147,11 @@ function withWorkflowFunctions(bridge: WorkflowBridge, store: RunStore, runConte
|
|
|
1653
2147
|
if (!bridge.agent || typeof args[0] !== "string") fail("AGENT_FAILED", "No agent bridge is available");
|
|
1654
2148
|
const options = validateAgentOptions(args[1] === undefined ? {} : args[1]);
|
|
1655
2149
|
const scopedWorktreeOwner = inheritedHostWorktreeOwner.getStore() ?? worktreeOwner;
|
|
1656
|
-
const
|
|
1657
|
-
const key = `${path}\0${JSON.stringify(
|
|
2150
|
+
const inherited = inheritedHostAgentPath.getStore() ?? [];
|
|
2151
|
+
const key = `${path}\0${JSON.stringify(inherited)}`;
|
|
1658
2152
|
const occurrence = (functionAgentOccurrences.get(key) ?? 0) + 1;
|
|
1659
2153
|
functionAgentOccurrences.set(key, occurrence);
|
|
1660
|
-
return bridge.agent(args[0], options, signal, { structuralPath: [...
|
|
2154
|
+
return bridge.agent(args[0], options, signal, { structuralPath: [...inherited], callSite: `function:${path}`, occurrence, parentBreadcrumb: name, ...(scopedWorktreeOwner ? { worktreeOwner: scopedWorktreeOwner } : {}) });
|
|
1661
2155
|
},
|
|
1662
2156
|
prompt: workflowPrompt,
|
|
1663
2157
|
parallel: (...args: readonly unknown[]) => hostParallel(args[0], args[1]),
|
|
@@ -1670,7 +2164,7 @@ function withWorkflowFunctions(bridge: WorkflowBridge, store: RunStore, runConte
|
|
|
1670
2164
|
phase: (name: string) => { sideEffects.push(Promise.resolve(bridge.phase?.(name))); },
|
|
1671
2165
|
log: (message: string) => { sideEffects.push(Promise.resolve(bridge.log?.(message))); },
|
|
1672
2166
|
};
|
|
1673
|
-
const result = await registry.invokeFunction(
|
|
2167
|
+
const result = await inheritedHostAgentPath.run([...structuralPath], async () => registry.invokeFunction(name, input, context, path, { get: () => replayed?.value, put: (_path, value) => { stored = value; } }));
|
|
1674
2168
|
await Promise.all(sideEffects);
|
|
1675
2169
|
if (!replayed) await store.complete(path, stored ?? result);
|
|
1676
2170
|
return result;
|
|
@@ -1702,20 +2196,17 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1702
2196
|
try { pi.appendEntry<WorkflowLogEntry>(WORKFLOW_LOG_ENTRY, { workflowName, message: utf8Prefix(message, DELIVERY_LIMIT_BYTES) }); }
|
|
1703
2197
|
finally { await lifecycle.leave(); }
|
|
1704
2198
|
};
|
|
1705
|
-
const
|
|
2199
|
+
const eventPublisher = new WorkflowEventPublisher((pi as unknown as { events?: WorkflowEventSink }).events);
|
|
1706
2200
|
pi.on("resources_discover", () => {
|
|
1707
2201
|
if (!pi.getActiveTools().includes("workflow")) return;
|
|
1708
2202
|
const extensionDir = dirname(fileURLToPath(import.meta.url));
|
|
1709
2203
|
const skillPath = [join(extensionDir, "../skills"), join(extensionDir, "../../skills")].find((path) => existsSync(path));
|
|
1710
2204
|
return skillPath ? { skillPaths: [skillPath] } : undefined;
|
|
1711
2205
|
});
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
const
|
|
1716
|
-
events?.emit(WORKFLOW_ASYNC_COMPLETE_EVENT, { id: store.runId, runId: store.runId, sessionId: store.sessionId, asyncDir: store.directory, success: state === "complete", state, ...(state === "stopped" ? { stopped: true } : {}), ...(error ? { error: error.message } : {}) });
|
|
1717
|
-
};
|
|
1718
|
-
const runs = new Map<string, { executor: WorkflowAgentExecutor; store: RunStore; metadata: WorkflowMetadata; model: ModelSpec; lifecycle: RunLifecycle; abortController: AbortController; execution?: WorkflowExecution; completion?: Promise<unknown>; checkpointResolvers: Map<string, (value: boolean) => void>; update?: (result: WorkflowToolUpdate) => void }>();
|
|
2206
|
+
type BudgetDecisionResult = { state: "running" | "budget_exhausted"; approved: boolean };
|
|
2207
|
+
const runs = new Map<string, { executor: WorkflowAgentExecutor; store: RunStore; metadata: WorkflowMetadata; model: ModelSpec; lifecycle: RunLifecycle; budget: WorkflowBudgetRuntime; abortController: AbortController; projectTrusted: () => boolean; execution?: WorkflowExecution; completion?: Promise<unknown>; checkpointResolvers: Map<string, (value: boolean) => void>; budgetResolvers: Map<string, (result: BudgetDecisionResult) => void>; update?: (result: WorkflowToolUpdate) => void }>();
|
|
2208
|
+
const conversationLocks = new Set<string>();
|
|
2209
|
+
const terminalRunStates = new Map<string, "completed" | "failed" | "stopped">();
|
|
1719
2210
|
let sessionLease: SessionLease | undefined;
|
|
1720
2211
|
let sessionLeasePromise: Promise<SessionLease> | undefined;
|
|
1721
2212
|
const ensureSessionLease = async (cwd: string, sessionId: string) => {
|
|
@@ -1730,121 +2221,208 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1730
2221
|
sessionLeasePromise = undefined;
|
|
1731
2222
|
await lease?.release();
|
|
1732
2223
|
};
|
|
1733
|
-
const
|
|
1734
|
-
const
|
|
1735
|
-
|
|
2224
|
+
const persistRunState = async (store: RunStore, metadata: WorkflowMetadata, update: (run: PersistedRun) => PersistedRun | Promise<PersistedRun>): Promise<PersistedRun> => {
|
|
2225
|
+
const persisted = await store.updateState(update);
|
|
2226
|
+
await eventPublisher.budget(store, metadata, persisted);
|
|
2227
|
+
return persisted;
|
|
2228
|
+
};
|
|
2229
|
+
const persistWorktree = async (store: RunStore, metadata: WorkflowMetadata, owner: string): Promise<WorktreeReference> => {
|
|
2230
|
+
const existing = (await store.worktrees()).some((worktree) => worktree.owner === owner);
|
|
2231
|
+
const worktree = await store.worktree(owner);
|
|
2232
|
+
if (!existing) await eventPublisher.worktree(store, metadata, worktree);
|
|
2233
|
+
return worktree;
|
|
2234
|
+
};
|
|
2235
|
+
const lifecycleFor = (store: RunStore, state: RunState, budget: WorkflowBudgetRuntime, metadata: WorkflowMetadata) => new RunLifecycle(state, async (next, previous, reason) => {
|
|
2236
|
+
if (next !== "pausing") budget.transition(next);
|
|
2237
|
+
const persisted = await persistRunState(store, metadata, (current) => {
|
|
2238
|
+
const nextRun = { ...current, state: next, ...budget.snapshot() };
|
|
1736
2239
|
if (next === "running" || next === "completed") delete nextRun.error;
|
|
1737
2240
|
return nextRun;
|
|
1738
2241
|
});
|
|
1739
|
-
|
|
2242
|
+
await eventPublisher.runState(store, metadata, previous, next, reason);
|
|
2243
|
+
runs.get(store.runId)?.update?.(workflowToolUpdate(persisted));
|
|
1740
2244
|
});
|
|
1741
2245
|
const scheduler = new FairAgentScheduler(async ({ id, runId, parentId, prompt, options, signal, setSteer }) => {
|
|
1742
2246
|
const run = runs.get(runId);
|
|
1743
2247
|
if (!run) throw new WorkflowError("INTERNAL_ERROR", `Unknown production run: ${runId}`);
|
|
1744
2248
|
try {
|
|
2249
|
+
const budget = run.budget.forAgent(id);
|
|
1745
2250
|
const onProgress = async (progress: AgentProgress) => {
|
|
1746
2251
|
let runState: PersistedRun;
|
|
1747
2252
|
if (progress.persist) {
|
|
1748
|
-
runState = await run.store.
|
|
2253
|
+
runState = await persistRunState(run.store, run.metadata, (current) => current.agents.some((agent) => agent.id === id) ? { ...current, ...run.budget.snapshot(), agents: current.agents.map((agent) => agent.id === id ? { ...agent, accounting: progress.accounting, toolCalls: progress.toolCalls, activity: progress.activity } : agent) } : current);
|
|
1749
2254
|
} else {
|
|
1750
2255
|
const loaded = await run.store.load();
|
|
1751
2256
|
if (!loaded.run.agents.some((agent) => agent.id === id)) return;
|
|
1752
|
-
runState = { ...loaded.run, agents: loaded.run.agents.map((agent) => agent.id === id ? { ...agent, accounting: progress.accounting, toolCalls: progress.toolCalls, activity: progress.activity } : agent) };
|
|
2257
|
+
runState = { ...loaded.run, ...run.budget.snapshot(), agents: loaded.run.agents.map((agent) => agent.id === id ? { ...agent, accounting: progress.accounting, toolCalls: progress.toolCalls, activity: progress.activity } : agent) };
|
|
1753
2258
|
}
|
|
1754
2259
|
if (!runState.agents.some((agent) => agent.id === id)) return;
|
|
1755
2260
|
run.update?.(workflowToolUpdate(runState));
|
|
1756
2261
|
};
|
|
1757
|
-
const onAttempt = async (attempt: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile">) => {
|
|
2262
|
+
const onAttempt = async (attempt: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile" | "setup">) => {
|
|
1758
2263
|
await scheduler.flush();
|
|
2264
|
+
scheduler.attemptStarted(id);
|
|
2265
|
+
await scheduler.flush();
|
|
2266
|
+
const before = (await run.store.load()).run;
|
|
1759
2267
|
await persistActiveAgentAttempt(run.store, id, attempt);
|
|
1760
|
-
|
|
2268
|
+
const active = (await run.store.load()).run;
|
|
2269
|
+
await eventPublisher.agentStates(run.store, run.metadata, before.agents, active.agents);
|
|
2270
|
+
const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
|
|
2271
|
+
run.update?.(workflowToolUpdate(persisted));
|
|
1761
2272
|
};
|
|
1762
|
-
const result = await run.executor.execute(prompt, { label: options.label, workflowName: run.metadata.name, onProgress, onAttempt, ...(parentId ? { parent: parentId, cwd: options.cwd, ...(options.worktreeOwner ? { worktreeOwner: options.worktreeOwner } : {}) } : options.worktreeOwner ? { worktreeOwner: options.worktreeOwner } : {}), ...(options.model ? { model: options.model } : {}), ...(options.thinking ? { thinking: options.thinking } : {}), ...(options.role ? { role: options.role } : {}), ...(options.role ? {} : { tools: options.tools }), effectiveTools: options.tools, ...(options.schema ? { schema: options.schema } : {}), ...(options.retries === undefined ? {} : { retries: options.retries }), ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }) }, signal, scheduler.toolsFor(id, (role, tools, model, inheritedTools, thinking) => run.executor.resolve({ label: "child", workflowName: run.metadata.name, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(tools !== undefined ? { tools } : {}) }, inheritedTools).tools), setSteer, () => { scheduler.cancelChildren(id); });
|
|
2273
|
+
const result = await run.executor.execute(prompt, { label: options.label, workflowName: run.metadata.name, onProgress, onAttempt, budget, ...(parentId ? { parent: parentId, cwd: options.cwd, ...(options.worktreeOwner ? { worktreeOwner: options.worktreeOwner } : {}) } : options.worktreeOwner ? { worktreeOwner: options.worktreeOwner } : {}), ...(options.model ? { model: options.model } : {}), ...(options.thinking ? { thinking: options.thinking } : {}), ...(options.role ? { role: options.role } : {}), ...(options.role ? {} : { tools: options.tools }), effectiveTools: options.tools, ...(options.schema ? { schema: options.schema } : {}), ...(options.retries === undefined ? {} : { retries: options.retries }), ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }), ...(options.agentOptions ? { agentOptions: options.agentOptions } : {}), ...(options.agentIdentity ? { agentIdentity: options.agentIdentity } : {}), ...(options.conversation ? { conversation: options.conversation } : {}) }, signal, scheduler.toolsFor(id, (role, tools, model, inheritedTools, thinking) => run.executor.resolve({ label: "child", workflowName: run.metadata.name, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(tools !== undefined ? { tools } : {}) }, inheritedTools).tools), setSteer, () => { scheduler.cancelChildren(id); scheduler.retry(id); });
|
|
2274
|
+
const before = (await run.store.load()).run;
|
|
1763
2275
|
await persistAgentAttempts(run.store, id, result.attempts);
|
|
2276
|
+
const completed = (await run.store.load()).run;
|
|
2277
|
+
await eventPublisher.agentStates(run.store, run.metadata, before.agents, completed.agents);
|
|
2278
|
+
const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
|
|
2279
|
+
run.update?.(workflowToolUpdate(persisted));
|
|
1764
2280
|
return result.value;
|
|
1765
2281
|
} catch (error) {
|
|
1766
2282
|
const attempts = (error as WorkflowError & { attempts?: readonly AgentAttempt[] }).attempts;
|
|
1767
|
-
if (attempts?.length)
|
|
2283
|
+
if (attempts?.length) {
|
|
2284
|
+
const before = (await run.store.load()).run;
|
|
2285
|
+
await persistAgentAttempts(run.store, id, attempts);
|
|
2286
|
+
const failed = (await run.store.load()).run;
|
|
2287
|
+
await eventPublisher.agentStates(run.store, run.metadata, before.agents, failed.agents);
|
|
2288
|
+
}
|
|
2289
|
+
const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
|
|
2290
|
+
run.update?.(workflowToolUpdate(persisted));
|
|
1768
2291
|
throw error;
|
|
1769
2292
|
}
|
|
1770
2293
|
}, 16, async (runId, ownership) => {
|
|
1771
2294
|
const run = runs.get(runId);
|
|
1772
2295
|
if (!run) return;
|
|
1773
2296
|
await run.store.saveOwnership(ownership);
|
|
1774
|
-
|
|
2297
|
+
let previousAgents: readonly AgentRecord[] = [];
|
|
2298
|
+
const runState = await persistRunState(run.store, run.metadata, (current) => {
|
|
2299
|
+
previousAgents = current.agents;
|
|
1775
2300
|
const existing = new Map(current.agents.map((agent) => [agent.id, agent]));
|
|
1776
2301
|
const agents = ownership.map((node) => {
|
|
1777
2302
|
const previous = existing.get(node.id);
|
|
1778
2303
|
const requested = { label: node.options.label, workflowName: run.metadata.name, ...(node.options.model ? { model: node.options.model } : {}), ...(node.options.thinking ? { thinking: node.options.thinking } : {}), ...(node.options.role ? { role: node.options.role } : {}), effectiveTools: node.options.tools };
|
|
1779
|
-
let effective: { model: ModelSpec; tools: readonly string[] };
|
|
2304
|
+
let effective: { model: ModelSpec; requestedModel?: string; tools: readonly string[] };
|
|
1780
2305
|
try { effective = run.executor.resolve(requested); }
|
|
1781
|
-
catch { effective = previous ? { model: previous.model, tools: previous.tools } : { model: node.options.model ? modelSpec(node.options.model, run.model) : { ...run.model, ...(node.options.thinking ? { thinking: node.options.thinking } : {}) }, tools: node.options.tools }; }
|
|
1782
|
-
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 } : {}), ...(node.options.parentBreadcrumb ? { parentBreadcrumb: node.options.parentBreadcrumb } : {}), ...(node.options.role ? { role: node.options.role } : {}), 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 } : {}) };
|
|
2306
|
+
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 }; }
|
|
2307
|
+
return { id: node.id, name: node.label, ...(node.options.requestedLabel ? { label: node.options.requestedLabel } : {}), path: node.id, state: node.state, ...(node.parentId ? { parentId: node.parentId } : {}), structuralPath: [...(node.options.agentIdentity?.structuralPath ?? [])], ...(node.options.parentBreadcrumb ? { parentBreadcrumb: node.options.parentBreadcrumb } : {}), ...(node.options.worktreeOwner ? { worktreeOwner: node.options.worktreeOwner } : {}), ...(node.options.role ? { role: node.options.role } : {}), ...(effective.requestedModel ? { requestedModel: effective.requestedModel } : {}), model: effective.model, tools: effective.tools, attempts: previous?.attempts ?? 0, ...(previous?.attemptDetails ? { attemptDetails: previous.attemptDetails } : {}), ...(previous?.accounting ? { accounting: previous.accounting } : {}), ...(previous?.toolCalls ? { toolCalls: previous.toolCalls } : {}), ...(previous?.activity ? { activity: previous.activity } : {}) };
|
|
1783
2308
|
});
|
|
1784
2309
|
return { ...current, agents };
|
|
1785
2310
|
});
|
|
2311
|
+
await eventPublisher.agentStates(run.store, run.metadata, previousAgents, runState.agents);
|
|
1786
2312
|
run.update?.(workflowToolUpdate(runState));
|
|
1787
2313
|
});
|
|
2314
|
+
type WorkflowStopResult = { runId: string; state: RunState | "unknown"; stopped: boolean; reason?: "unknown_run" | "already_terminal" };
|
|
2315
|
+
const stopWorkflowRun = async (runId: string): Promise<WorkflowStopResult> => {
|
|
2316
|
+
const run = runs.get(runId);
|
|
2317
|
+
const terminalState = terminalRunStates.get(runId);
|
|
2318
|
+
if (!run) return terminalState ? { runId, state: terminalState, stopped: false, reason: "already_terminal" } : { runId, state: "unknown", stopped: false, reason: "unknown_run" };
|
|
2319
|
+
const state = run.lifecycle.state;
|
|
2320
|
+
if (state === "completed" || state === "failed" || state === "stopped") return { runId, state, stopped: false, reason: "already_terminal" };
|
|
2321
|
+
await run.lifecycle.terminal("stopped");
|
|
2322
|
+
run.abortController.abort();
|
|
2323
|
+
run.execution?.cancel();
|
|
2324
|
+
await scheduler.cancelRun(run.store.runId);
|
|
2325
|
+
await scheduler.flush();
|
|
2326
|
+
return { runId, state: "stopped", stopped: true };
|
|
2327
|
+
};
|
|
1788
2328
|
const answerCheckpoint = async (runId: string, name: string, approved: boolean, silent = false) => {
|
|
1789
2329
|
const run = runs.get(runId);
|
|
1790
2330
|
if (!run) return false;
|
|
1791
2331
|
const checkpoint = await run.store.answerCheckpoint(name, approved);
|
|
1792
2332
|
if (!checkpoint) return false;
|
|
2333
|
+
await eventPublisher.checkpoint(run.store, run.metadata, checkpoint.name, approved ? "approved" : "rejected");
|
|
1793
2334
|
if ((await run.store.awaitingCheckpoints()).length === 0) await run.lifecycle.resolveAwaitingInput();
|
|
1794
2335
|
run.checkpointResolvers.get(checkpoint.path)?.(approved);
|
|
1795
2336
|
run.checkpointResolvers.delete(checkpoint.path);
|
|
1796
2337
|
if (!silent) deliver(pi, `Workflow ${run.metadata.name} checkpoint ${name}: ${approved ? "Approved" : "Rejected"}.`);
|
|
1797
2338
|
return true;
|
|
1798
2339
|
};
|
|
2340
|
+
const budgetDecisionDelivery = (metadata: WorkflowMetadata, request: BudgetApprovalRequest) => `Workflow ${metadata.name} budget adjustment ${request.proposalId} for run ${request.runId} requires approval. Consumed usage: ${JSON.stringify(request.consumed)}. Previous limits: ${JSON.stringify(request.previous)}. Proposed limits: ${JSON.stringify(request.proposed)}. Respond with workflow_respond using proposalId ${request.proposalId}.`;
|
|
2341
|
+
const appendBudgetDecisionEvent = async (run: NonNullable<ReturnType<typeof runs.get>>, request: BudgetApprovalRequest, type: "adjustment_requested" | "adjustment_approved" | "adjustment_rejected") => {
|
|
2342
|
+
run.budget.recordEvent({ type, budgetVersion: request.budgetVersion, dimensions: [], usage: structuredClone(request.consumed), limits: structuredClone(request.proposed), at: Date.now(), proposalId: request.proposalId, previous: structuredClone(request.previous), proposed: structuredClone(request.proposed) });
|
|
2343
|
+
await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
|
|
2344
|
+
};
|
|
2345
|
+
const answerBudgetDecision = async (runId: string, proposalId: string, approved: boolean, silent = false): Promise<BudgetDecisionResult | undefined> => {
|
|
2346
|
+
const run = runs.get(runId);
|
|
2347
|
+
if (!run) return undefined;
|
|
2348
|
+
const request = await run.store.answerWorkflowDecision(proposalId, approved);
|
|
2349
|
+
if (!request) return undefined;
|
|
2350
|
+
await appendBudgetDecisionEvent(run, request, approved ? "adjustment_approved" : "adjustment_rejected");
|
|
2351
|
+
const result = await applyBudgetDecision(request, approved);
|
|
2352
|
+
run.budgetResolvers.get(proposalId)?.(result);
|
|
2353
|
+
run.budgetResolvers.delete(proposalId);
|
|
2354
|
+
if (!silent) deliver(pi, `Workflow ${run.metadata.name} budget adjustment ${proposalId}: ${approved ? "Approved" : "Rejected"}.`);
|
|
2355
|
+
return result;
|
|
2356
|
+
};
|
|
1799
2357
|
const checkpointBridge = (runId: string, store: RunStore, metadata: WorkflowMetadata, foreground: boolean, ui?: { select?: (prompt: string, options: string[]) => Promise<string | undefined> }) => {
|
|
1800
2358
|
const checkpointCounters = new Map<string, number>();
|
|
1801
2359
|
return async (raw: Readonly<Record<string, JsonValue>>, signal: AbortSignal): Promise<boolean> => {
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
if (
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
if (ui?.select) void (async () => {
|
|
1824
|
-
while (!signal.aborted && run?.checkpointResolvers.has(path)) {
|
|
1825
|
-
const choice = await ui.select?.(input.prompt, ["Approve", "Reject"]);
|
|
1826
|
-
if (!choice) {
|
|
1827
|
-
if (foreground) continue; // foreground: retry until answered
|
|
1828
|
-
// Background resume: user dismissed UI, fall back to LLM
|
|
1829
|
-
deliver(pi, `Workflow ${metadata.name} checkpoint ${label}: ${input.prompt}\nContext: ${JSON.stringify(input.context)}\nRespond with workflow_respond.`);
|
|
1830
|
-
return;
|
|
1831
|
-
}
|
|
1832
|
-
if (await answerCheckpoint(runId, label, choice === "Approve", true)) return;
|
|
2360
|
+
const input = validateCheckpoint(raw);
|
|
2361
|
+
const label = nextNamedOccurrence(checkpointCounters, input.name);
|
|
2362
|
+
const path = operationPath("checkpoint", label);
|
|
2363
|
+
if (foreground && !ui?.select) fail("RESUME_INCOMPATIBLE", "Foreground checkpoints require UI");
|
|
2364
|
+
const alreadyAwaiting = (await store.awaitingCheckpoints()).some((checkpoint) => checkpoint.path === path);
|
|
2365
|
+
const replayed = await store.awaitCheckpoint({ ...input, name: label, path });
|
|
2366
|
+
if (replayed !== undefined) return replayed;
|
|
2367
|
+
if (!alreadyAwaiting) await eventPublisher.checkpoint(store, metadata, label, "awaiting");
|
|
2368
|
+
const run = runs.get(runId);
|
|
2369
|
+
await run?.lifecycle.enterAwaitingInput();
|
|
2370
|
+
if (!alreadyAwaiting && !ui?.select) deliver(pi, `Workflow ${metadata.name} checkpoint ${label}: ${input.prompt}\nContext: ${JSON.stringify(input.context)}\nRespond with workflow_respond.`);
|
|
2371
|
+
const decision = new Promise<boolean>((resolve, reject) => {
|
|
2372
|
+
run?.checkpointResolvers.set(path, resolve);
|
|
2373
|
+
if (signal.aborted) reject(new WorkflowError("CANCELLED", "Workflow cancelled"));
|
|
2374
|
+
else signal.addEventListener("abort", () => { run?.checkpointResolvers.delete(path); reject(new WorkflowError("CANCELLED", "Workflow cancelled")); }, { once: true });
|
|
2375
|
+
});
|
|
2376
|
+
const answered = await store.awaitCheckpoint({ ...input, name: label, path });
|
|
2377
|
+
if (answered !== undefined) {
|
|
2378
|
+
if ((await store.awaitingCheckpoints()).length === 0) await run?.lifecycle.resolveAwaitingInput();
|
|
2379
|
+
run?.checkpointResolvers.get(path)?.(answered);
|
|
2380
|
+
run?.checkpointResolvers.delete(path);
|
|
1833
2381
|
}
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
2382
|
+
if (ui?.select) void (async () => {
|
|
2383
|
+
while (!signal.aborted && run?.checkpointResolvers.has(path)) {
|
|
2384
|
+
const choice = await ui.select?.(input.prompt, ["Approve", "Reject"]);
|
|
2385
|
+
if (!choice) {
|
|
2386
|
+
if (foreground) continue; // foreground: retry until answered
|
|
2387
|
+
deliver(pi, `Workflow ${metadata.name} checkpoint ${label}: ${input.prompt}\nContext: ${JSON.stringify(input.context)}\nRespond with workflow_respond.`);
|
|
2388
|
+
return;
|
|
2389
|
+
}
|
|
2390
|
+
if (await answerCheckpoint(runId, label, choice === "Approve", true)) return;
|
|
2391
|
+
}
|
|
2392
|
+
})().catch(() => undefined);
|
|
2393
|
+
return decision;
|
|
2394
|
+
};
|
|
1837
2395
|
};
|
|
1838
2396
|
|
|
1839
2397
|
pi.registerTool({
|
|
1840
2398
|
name: "workflow_respond",
|
|
1841
2399
|
label: "Workflow Respond",
|
|
1842
|
-
description: "Approve or reject one pending workflow checkpoint",
|
|
1843
|
-
parameters: Type.Object({ runId: Type.String(), name: Type.String(), approved: Type.Boolean() }, { additionalProperties: false }),
|
|
2400
|
+
description: "Approve or reject one pending workflow checkpoint or budget decision",
|
|
2401
|
+
parameters: Type.Object({ runId: Type.String(), name: Type.Optional(Type.String()), proposalId: Type.Optional(Type.String()), approved: Type.Boolean() }, { additionalProperties: false }),
|
|
1844
2402
|
async execute(_id, params) {
|
|
1845
2403
|
try {
|
|
2404
|
+
if (params.proposalId) {
|
|
2405
|
+
const result = await answerBudgetDecision(params.runId, params.proposalId, params.approved);
|
|
2406
|
+
if (!result) { const denied = { state: "budget_exhausted" as const, approved: false, reason: "proposal_not_pending" }; return { content: [{ type: "text" as const, text: JSON.stringify(denied) }], details: denied }; }
|
|
2407
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: { ...result, reason: params.approved ? "approved" : "rejected" } };
|
|
2408
|
+
}
|
|
2409
|
+
if (!params.name) throw new WorkflowError("INVALID_METADATA", "workflow_respond requires name or proposalId");
|
|
1846
2410
|
const accepted = await answerCheckpoint(params.runId, params.name, params.approved);
|
|
1847
|
-
return { content: [{ type: "text" as const, text: accepted ? "Checkpoint response accepted." : "Checkpoint is not awaiting a response." }], details: { accepted } };
|
|
2411
|
+
return { content: [{ type: "text" as const, text: accepted ? "Checkpoint response accepted." : "Checkpoint is not awaiting a response." }], details: { accepted, state: accepted ? "checkpoint_answered" : "not_pending", approved: params.approved, reason: "checkpoint" } as never };
|
|
2412
|
+
} catch (error) {
|
|
2413
|
+
throw mainAgentError(error);
|
|
2414
|
+
}
|
|
2415
|
+
},
|
|
2416
|
+
});
|
|
2417
|
+
pi.registerTool({
|
|
2418
|
+
name: "workflow_stop",
|
|
2419
|
+
label: "Workflow Stop",
|
|
2420
|
+
description: "Stop an active workflow run by ID",
|
|
2421
|
+
parameters: Type.Object({ runId: Type.String() }, { additionalProperties: false }),
|
|
2422
|
+
async execute(_id, params) {
|
|
2423
|
+
try {
|
|
2424
|
+
const result = await stopWorkflowRun(params.runId);
|
|
2425
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result };
|
|
1848
2426
|
} catch (error) {
|
|
1849
2427
|
throw mainAgentError(error);
|
|
1850
2428
|
}
|
|
@@ -1855,7 +2433,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1855
2433
|
const registerCatalog = () => {
|
|
1856
2434
|
if (catalogRegistered || !pi.getActiveTools().includes("workflow")) return;
|
|
1857
2435
|
const catalog = registry.catalog();
|
|
1858
|
-
|
|
2436
|
+
const hasAliases = Object.keys(catalog.modelAliases ?? {}).length > 0;
|
|
2437
|
+
if (!catalog.functions.length && !catalog.variables.length && !catalog.workflows.length && !hasAliases) return;
|
|
1859
2438
|
pi.registerTool({
|
|
1860
2439
|
name: "workflow_catalog",
|
|
1861
2440
|
label: "Workflow Catalog",
|
|
@@ -1865,37 +2444,92 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1865
2444
|
});
|
|
1866
2445
|
catalogRegistered = true;
|
|
1867
2446
|
};
|
|
1868
|
-
const
|
|
2447
|
+
const refreshPausedRunAliases = async (run: NonNullable<ReturnType<typeof runs.get>>, context?: { model: { provider: string; id: string } | undefined; modelRegistry: { getAll?: () => Array<{ provider: string; id: string }>; getAvailable?: () => Array<{ provider: string; id: string }> } | undefined }) => {
|
|
2448
|
+
const loaded = await run.store.load();
|
|
2449
|
+
const active = new Set(pi.getActiveTools().filter((tool) => tool !== "workflow" && tool !== "workflow_respond" && tool !== "workflow_stop" && tool !== "workflow_catalog"));
|
|
2450
|
+
const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
|
|
2451
|
+
if (missing) throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
|
|
2452
|
+
const settingsPath = workflowSettingsPath();
|
|
2453
|
+
const currentSettings = loadSettings(settingsPath);
|
|
2454
|
+
resolveAgentResourcePolicy(run.store.cwd, run.projectTrusted(), settingsPath);
|
|
2455
|
+
const currentAliases = currentSettings.modelAliases ?? {};
|
|
2456
|
+
const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
|
|
2457
|
+
const modelRegistry = context?.modelRegistry;
|
|
2458
|
+
const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`));
|
|
2459
|
+
if (context?.model) knownModels.add(`${context.model.provider}/${context.model.id}`);
|
|
2460
|
+
const resumeModels = modelRegistry ? knownModels : new Set([...loaded.snapshot.models, ...knownModels]);
|
|
2461
|
+
const blockedAliases = new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
2462
|
+
const blockedAliasTargets = Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
2463
|
+
const snapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, settings: { ...loaded.snapshot.settings, modelAliases: currentAliases }, modelAliases: currentAliases });
|
|
2464
|
+
await run.store.saveSnapshot(snapshot);
|
|
2465
|
+
run.executor = new WorkflowAgentExecutor({ cwd: run.store.cwd, model: run.model, tools: new Set(snapshot.tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog")), availableModels: resumeModels, knownModels: resumeModels, modelAliases: currentAliases, blockedAliases, blockedAliasTargets, settingsPath, agentDefinitions: snapshot.roles ?? {}, runStore: run.store, providerPause: async () => { deliver(pi, `Workflow ${snapshot.metadata.name} paused: provider limit.`); await run.lifecycle.providerPause(); }, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: () => resolveAgentResourcePolicy(run.store.cwd, run.projectTrusted(), settingsPath) }, createSession);
|
|
2466
|
+
run.executor.setRunContext(workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, run.abortController.signal));
|
|
2467
|
+
const drift = aliasDrift(previousAliases, currentAliases);
|
|
2468
|
+
if (drift.length) await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
|
|
2469
|
+
};
|
|
2470
|
+
const coldResumeRun = async (run: NonNullable<ReturnType<typeof runs.get>>, hasUI: boolean, ui: { select?: (prompt: string, options: string[]) => Promise<string | undefined> }, trustedProject: boolean, context?: { model: { provider: string; id: string } | undefined; modelRegistry: { getAll?: () => Array<{ provider: string; id: string }>; getAvailable?: () => Array<{ provider: string; id: string }> } | undefined }) => {
|
|
1869
2471
|
const loaded = await run.store.load();
|
|
1870
2472
|
if (loaded.snapshot.identityVersion !== LAUNCH_SNAPSHOT_IDENTITY_VERSION) throw new WorkflowError("RESUME_INCOMPATIBLE", "Workflow launch snapshot identity version is incompatible");
|
|
1871
2473
|
if (loaded.snapshot.roles === undefined) throw new WorkflowError("RESUME_INCOMPATIBLE", "Workflow role definitions are missing from the launch snapshot");
|
|
1872
2474
|
if ((loaded.snapshot.projectRoles?.length ?? 0) > 0 && !trustedProject) throw new WorkflowError("RESUME_INCOMPATIBLE", "Cannot restore project roles in an untrusted project");
|
|
1873
2475
|
const missingRole = loaded.snapshot.agentTypes.find((role) => !loaded.snapshot.roles?.[role]);
|
|
1874
2476
|
if (missingRole) throw new WorkflowError("RESUME_INCOMPATIBLE", `Role definition is missing from the launch snapshot: ${missingRole}`);
|
|
1875
|
-
const active = new Set(pi.getActiveTools().filter((tool) => tool !== "workflow" && tool !== "workflow_respond" && tool !== "workflow_catalog"));
|
|
2477
|
+
const active = new Set(pi.getActiveTools().filter((tool) => tool !== "workflow" && tool !== "workflow_respond" && tool !== "workflow_stop" && tool !== "workflow_catalog"));
|
|
1876
2478
|
const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
|
|
1877
2479
|
if (missing) throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
|
|
1878
|
-
|
|
2480
|
+
const settingsPath = workflowSettingsPath();
|
|
2481
|
+
const currentSettings = loadSettings(settingsPath);
|
|
2482
|
+
resolveAgentResourcePolicy(run.store.cwd, trustedProject, settingsPath);
|
|
2483
|
+
const currentAliases = currentSettings.modelAliases ?? {};
|
|
2484
|
+
const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
|
|
2485
|
+
const modelRegistry = context?.modelRegistry;
|
|
2486
|
+
const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`));
|
|
2487
|
+
if (context?.model) knownModels.add(`${context.model.provider}/${context.model.id}`);
|
|
2488
|
+
const resumeModels = modelRegistry ? knownModels : new Set([...loaded.snapshot.models, ...knownModels]);
|
|
2489
|
+
const resumeAliases = { ...previousAliases, ...currentAliases };
|
|
2490
|
+
const blockedAliases = new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
2491
|
+
const blockedAliasTargets = Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
2492
|
+
preflight(loaded.snapshot.script, { models: resumeModels, tools: active, agentTypes: new Set(loaded.snapshot.agentTypes), modelAliases: resumeAliases, knownModels: resumeModels, settingsPath, skipModelAvailability: true }, loaded.snapshot.schemas, loaded.snapshot.metadata);
|
|
2493
|
+
const snapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, settings: { ...loaded.snapshot.settings, modelAliases: currentAliases }, modelAliases: currentAliases });
|
|
2494
|
+
await run.store.saveSnapshot(snapshot);
|
|
2495
|
+
run.executor = new WorkflowAgentExecutor({ cwd: run.store.cwd, model: run.model, tools: new Set(snapshot.tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog")), availableModels: resumeModels, knownModels: resumeModels, modelAliases: currentAliases, blockedAliases, blockedAliasTargets, settingsPath, agentDefinitions: snapshot.roles ?? {}, runStore: run.store, providerPause: async () => { deliver(pi, `Workflow ${snapshot.metadata.name} paused: provider limit.`); await run.lifecycle.providerPause(); }, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: () => resolveAgentResourcePolicy(run.store.cwd, run.projectTrusted(), settingsPath) }, createSession);
|
|
2496
|
+
const drift = aliasDrift(previousAliases, currentAliases);
|
|
2497
|
+
if (drift.length) await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
|
|
1879
2498
|
const controller = new AbortController();
|
|
1880
2499
|
run.abortController = controller;
|
|
1881
2500
|
const runContext = workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, controller.signal);
|
|
2501
|
+
run.executor.setRunContext(runContext);
|
|
1882
2502
|
let variables: Readonly<Record<string, JsonValue>>;
|
|
1883
2503
|
try { variables = await resolveWorkflowVariables(runContext, controller, registry); }
|
|
1884
2504
|
catch (error) {
|
|
1885
2505
|
const typed = asWorkflowError(error);
|
|
1886
|
-
if (!["completed", "failed", "stopped"].includes(run.lifecycle.state)) { await run.lifecycle.terminal("failed").catch(() => undefined); await run.store.
|
|
2506
|
+
if (!["completed", "failed", "stopped"].includes(run.lifecycle.state)) { await run.lifecycle.terminal("failed", typed.code).catch(() => undefined); const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, error: { code: typed.code, message: typed.message } })); await eventPublisher.runFailed(run.store, run.metadata, typed, true, run.lifecycle.state === "interrupted" ? "interrupted" : "failed"); run.update?.(workflowToolUpdate(persisted)); }
|
|
1887
2507
|
throw typed;
|
|
1888
2508
|
}
|
|
1889
2509
|
await scheduler.cancelRun(run.store.runId);
|
|
1890
2510
|
await run.lifecycle.resume();
|
|
1891
2511
|
const execution = runWorkflow(loaded.snapshot.script, loaded.snapshot.args, withWorkflowFunctions({ agent: async (prompt, options, signal, identity) => {
|
|
1892
2512
|
await run.lifecycle.enter();
|
|
2513
|
+
const conversationId = identity.conversation ? conversationIdentityPath(identity) : undefined;
|
|
2514
|
+
const conversationLock = conversationId ? `${run.store.runId}:${conversationId}` : "";
|
|
1893
2515
|
try {
|
|
1894
|
-
const path = agentIdentityPath(identity);
|
|
2516
|
+
const path = conversationId ? conversationTurnPath(identity) : agentIdentityPath(identity);
|
|
1895
2517
|
const replayed = await run.store.replay(path);
|
|
1896
|
-
if (replayed)
|
|
2518
|
+
if (replayed) {
|
|
2519
|
+
if (conversationId) {
|
|
2520
|
+
const conversation = await run.store.conversation(conversationId);
|
|
2521
|
+
if (!conversation || conversation.head.turn < (identity.conversation?.turn ?? 0)) throw new WorkflowError("RESUME_INCOMPATIBLE", "Completed conversation turn has no persisted head");
|
|
2522
|
+
}
|
|
2523
|
+
return replayed.value;
|
|
2524
|
+
}
|
|
2525
|
+
if (conversationId) {
|
|
2526
|
+
if (conversationLocks.has(conversationLock)) throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation turns cannot overlap");
|
|
2527
|
+
const conversation = await run.store.conversation(conversationId);
|
|
2528
|
+
if (conversation ? conversation.head.turn + 1 !== identity.conversation?.turn : identity.conversation?.turn !== 1) throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation turn does not continue its persisted head");
|
|
2529
|
+
conversationLocks.add(conversationLock);
|
|
2530
|
+
}
|
|
1897
2531
|
const worktree = agentWorktree(identity);
|
|
1898
|
-
const cwd = worktree.worktreeOwner ? (await run.store.worktree
|
|
2532
|
+
const cwd = worktree.worktreeOwner ? (await persistWorktree(run.store, run.metadata, worktree.worktreeOwner)).cwd : run.store.cwd;
|
|
1899
2533
|
const role = typeof options.role === "string" ? options.role : undefined;
|
|
1900
2534
|
const model = typeof options.model === "string" ? options.model : undefined;
|
|
1901
2535
|
const thinking = parseThinking(options.thinking);
|
|
@@ -1904,21 +2538,86 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1904
2538
|
const label = displayAgentName(requestedLabel, role, resolved.model);
|
|
1905
2539
|
const tools = resolved.tools;
|
|
1906
2540
|
const schema = object(options.outputSchema) ? options.outputSchema : undefined;
|
|
1907
|
-
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 } : {}) });
|
|
2541
|
+
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 } : {}), ...(conversationId ? { conversation: { id: conversationId, turn: identity.conversation?.turn ?? 0 } } : {}), agentOptions: options, agentIdentity: identity });
|
|
1908
2542
|
const cancel = () => { scheduler.cancel(spawned.id); };
|
|
1909
2543
|
signal.addEventListener("abort", cancel, { once: true });
|
|
1910
2544
|
const outcome = await spawned.result.finally(() => { signal.removeEventListener("abort", cancel); });
|
|
1911
2545
|
if (!outcome.ok) throw new WorkflowError(outcome.error.code as WorkflowErrorCode, outcome.error.message);
|
|
1912
2546
|
await run.store.complete(path, outcome.value);
|
|
1913
2547
|
return outcome.value;
|
|
1914
|
-
} finally { await run.lifecycle.leave(); }
|
|
1915
|
-
}, checkpoint: checkpointBridge(run.store.runId, run.store, run.metadata, false, hasUI ? ui : undefined), phase: async (phase) => { await run.lifecycle.enter(); try { await run.store.
|
|
2548
|
+
} finally { if (conversationLock) conversationLocks.delete(conversationLock); await run.lifecycle.leave(); }
|
|
2549
|
+
}, checkpoint: checkpointBridge(run.store.runId, run.store, run.metadata, false, hasUI ? ui : undefined), phase: async (phase) => { await run.lifecycle.enter(); try { let previousPhase: string | undefined; const persisted = await persistRunState(run.store, run.metadata, (current) => { previousPhase = current.phase; return { ...current, phase }; }); await eventPublisher.phase(run.store, run.metadata, previousPhase, phase); runs.get(run.store.runId)?.update?.(workflowToolUpdate(persisted)); } finally { await run.lifecycle.leave(); } }, log: logBridge(run.lifecycle, run.metadata.name) }, run.store, runContext, variables, registry), controller.signal);
|
|
1916
2550
|
run.execution = execution;
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
2551
|
+
const completion = execution.result.then(async (value) => {
|
|
2552
|
+
await scheduler.flush();
|
|
2553
|
+
if (run.budget.hardExhausted) throw new WorkflowError("BUDGET_EXHAUSTED", "Budgeted work was attempted after hard exhaustion");
|
|
2554
|
+
const resultPath = await run.store.saveResult(value);
|
|
2555
|
+
await run.lifecycle.terminal("completed", "completed");
|
|
2556
|
+
await eventPublisher.runCompleted(run.store, run.metadata, resultPath, true);
|
|
2557
|
+
return { value, resultPath };
|
|
2558
|
+
}).catch(async (error: unknown) => {
|
|
2559
|
+
await scheduler.flush();
|
|
2560
|
+
const typed = error instanceof WorkflowError ? error : new WorkflowError(errorCode(error) ?? "INTERNAL_ERROR", errorText(error));
|
|
2561
|
+
if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state)) await run.lifecycle.terminal(typed.code === "BUDGET_EXHAUSTED" ? "budget_exhausted" : "failed", typed.code);
|
|
2562
|
+
const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot(), error: { code: typed.code, message: typed.message } }));
|
|
2563
|
+
const state = run.lifecycle.state === "stopped" || run.lifecycle.state === "interrupted" || run.lifecycle.state === "budget_exhausted" ? run.lifecycle.state : "failed";
|
|
2564
|
+
await eventPublisher.runFailed(run.store, run.metadata, typed, true, state);
|
|
2565
|
+
run.update?.(workflowToolUpdate(persisted));
|
|
2566
|
+
if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state)) deliverFailure(pi, run.metadata.name, error);
|
|
2567
|
+
});
|
|
1920
2568
|
void completion;
|
|
1921
2569
|
};
|
|
2570
|
+
const applyBudgetDecision = async (request: BudgetApprovalRequest, approved: boolean): Promise<BudgetDecisionResult> => {
|
|
2571
|
+
const run = runs.get(request.runId);
|
|
2572
|
+
if (!run) throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run: ${request.runId}`);
|
|
2573
|
+
if (!approved) return { state: "budget_exhausted", approved: false };
|
|
2574
|
+
const nextBudget = validateBudget(request.proposed);
|
|
2575
|
+
const nextVersion = request.budgetVersion + 1;
|
|
2576
|
+
const runtime = new WorkflowBudgetRuntime(nextBudget, nextVersion, request.consumed, run.budget.events, { active: false });
|
|
2577
|
+
run.budget = runtime;
|
|
2578
|
+
await persistRunState(run.store, run.metadata, (current) => { const next = { ...current, ...runtime.snapshot(), budgetVersion: nextVersion }; if (nextBudget) next.budget = nextBudget; else delete next.budget; return next; });
|
|
2579
|
+
await coldResumeRun(run, false, {}, true);
|
|
2580
|
+
return { state: "running", approved: true };
|
|
2581
|
+
};
|
|
2582
|
+
const resumeWorkflowRun = async (runId: string, rawPatch?: unknown): Promise<Record<string, JsonValue>> => {
|
|
2583
|
+
const run = runs.get(runId);
|
|
2584
|
+
if (!run) throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run: ${runId}`);
|
|
2585
|
+
const loaded = await run.store.load();
|
|
2586
|
+
if (loaded.run.state !== "budget_exhausted") throw new WorkflowError("RESUME_INCOMPATIBLE", "Only budget-exhausted runs can be resumed with workflow_resume");
|
|
2587
|
+
const currentBudget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
|
|
2588
|
+
const patch = rawPatch === undefined ? {} : validateBudgetPatch(rawPatch);
|
|
2589
|
+
const nextBudget = mergeBudget(currentBudget, patch);
|
|
2590
|
+
const usage = budgetUsage(loaded.run.usage);
|
|
2591
|
+
if (!resumeBudgetAllowed(nextBudget, usage)) throw new WorkflowError("RESUME_INCOMPATIBLE", "Every exhausted hard budget must be raised above retained usage or removed");
|
|
2592
|
+
if (budgetRelaxed(currentBudget, nextBudget)) {
|
|
2593
|
+
const proposalId = randomUUID();
|
|
2594
|
+
const request: BudgetApprovalRequest = { kind: "budget", proposalId, runId, consumed: usage, previous: currentBudget ?? {}, proposed: nextBudget ?? {}, budgetVersion: loaded.run.budgetVersion ?? 1 };
|
|
2595
|
+
const decision = new Promise<BudgetDecisionResult>((resolve) => { run.budgetResolvers.set(proposalId, resolve); });
|
|
2596
|
+
try { await run.store.requestWorkflowDecision(request); await appendBudgetDecisionEvent(run, request, "adjustment_requested"); } catch (error) { run.budgetResolvers.delete(proposalId); throw error; }
|
|
2597
|
+
deliver(pi, budgetDecisionDelivery(run.metadata, request));
|
|
2598
|
+
const decisionResult = await decision;
|
|
2599
|
+
return { state: decisionResult.state };
|
|
2600
|
+
}
|
|
2601
|
+
const changed = JSON.stringify(currentBudget ?? {}) !== JSON.stringify(nextBudget ?? {});
|
|
2602
|
+
if (changed) {
|
|
2603
|
+
const nextVersion = (loaded.run.budgetVersion ?? 1) + 1;
|
|
2604
|
+
const runtime = new WorkflowBudgetRuntime(nextBudget, nextVersion, usage, loaded.run.budgetEvents, { active: false });
|
|
2605
|
+
run.budget = runtime;
|
|
2606
|
+
await persistRunState(run.store, run.metadata, (current) => { const next = { ...current, ...runtime.snapshot(), budgetVersion: nextVersion }; if (nextBudget) next.budget = nextBudget; else delete next.budget; return next; });
|
|
2607
|
+
}
|
|
2608
|
+
await coldResumeRun(run, false, {}, true);
|
|
2609
|
+
return { state: "running" };
|
|
2610
|
+
};
|
|
2611
|
+
pi.registerTool({
|
|
2612
|
+
name: "workflow_resume",
|
|
2613
|
+
label: "Workflow Resume",
|
|
2614
|
+
description: "Resume an exhausted workflow with unchanged or patched aggregate budgets",
|
|
2615
|
+
parameters: Type.Object({ runId: Type.String(), budget: Type.Optional(Type.Unknown()) }, { additionalProperties: false }),
|
|
2616
|
+
async execute(_id, params) {
|
|
2617
|
+
try { const result = await resumeWorkflowRun(params.runId, params.budget); return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result }; }
|
|
2618
|
+
catch (error) { throw mainAgentError(error); }
|
|
2619
|
+
},
|
|
2620
|
+
});
|
|
1922
2621
|
pi.on("session_start", async (_event, ctx) => {
|
|
1923
2622
|
if (sessionStarted) return;
|
|
1924
2623
|
sessionStarted = true;
|
|
@@ -1931,18 +2630,25 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1931
2630
|
const store = new RunStore(ctx.cwd, ctx.sessionManager.getSessionId(), runId, home);
|
|
1932
2631
|
let loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> };
|
|
1933
2632
|
try { loaded = await store.load(); } catch { if (!await store.isComplete()) await store.delete(true).catch(() => undefined); continue; }
|
|
1934
|
-
if (
|
|
1935
|
-
if (loaded.run.state !== "interrupted") {
|
|
1936
|
-
|
|
2633
|
+
if (loaded.run.state === "completed" || loaded.run.state === "failed" || loaded.run.state === "stopped") { terminalRunStates.set(runId, loaded.run.state); continue; }
|
|
2634
|
+
if (loaded.run.state !== "interrupted" && loaded.run.state !== "budget_exhausted") {
|
|
2635
|
+
const previousState = loaded.run.state;
|
|
2636
|
+
await store.updateState((current) => ["completed", "failed", "stopped", "interrupted", "budget_exhausted"].includes(current.state) ? current : { ...current, state: "interrupted" });
|
|
2637
|
+
loaded = { ...loaded, run: (await store.load()).run };
|
|
2638
|
+
await eventPublisher.runState(store, loaded.snapshot.metadata, previousState, "interrupted", "session_shutdown");
|
|
1937
2639
|
loaded = { ...loaded, run: (await store.load()).run };
|
|
1938
2640
|
}
|
|
1939
2641
|
const model = modelSpec(loaded.snapshot.models[0] ?? "", { provider: ctx.model?.provider ?? "", model: ctx.model?.id ?? "", thinking: pi.getThinkingLevel() });
|
|
1940
|
-
const
|
|
2642
|
+
const budget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
|
|
2643
|
+
eventPublisher.seedBudget(runId, loaded.run.budgetEvents);
|
|
2644
|
+
const budgetRuntime = new WorkflowBudgetRuntime(budget, loaded.run.budgetVersion ?? 1, loaded.run.usage, loaded.run.budgetEvents, { active: loaded.run.state === "running" });
|
|
2645
|
+
const lifecycle = lifecycleFor(store, loaded.run.state, budgetRuntime, loaded.snapshot.metadata);
|
|
1941
2646
|
const providerPause = async () => { deliver(pi, `Workflow ${loaded.snapshot.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
|
|
1942
2647
|
const roleDefinitions = loaded.snapshot.roles ?? {};
|
|
1943
|
-
runs.set(runId, { executor: new WorkflowAgentExecutor({ cwd: ctx.cwd, model, tools: new Set(loaded.snapshot.tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog")), availableModels: new Set(loaded.snapshot.models), agentDefinitions: roleDefinitions, runStore: store, providerPause }, createSession), store, metadata: loaded.snapshot.metadata, model, lifecycle, abortController: new AbortController(), checkpointResolvers: new Map() });
|
|
2648
|
+
runs.set(runId, { executor: new WorkflowAgentExecutor({ cwd: ctx.cwd, model, tools: new Set(loaded.snapshot.tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog")), availableModels: new Set(loaded.snapshot.models), knownModels: new Set(loaded.snapshot.models), ...(loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ? { modelAliases: loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases } : {}), ...(loaded.snapshot.settingsPath ? { settingsPath: loaded.snapshot.settingsPath } : {}), agentDefinitions: roleDefinitions, runStore: store, providerPause, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: () => resolveAgentResourcePolicy(store.cwd, projectTrusted(ctx)) }, createSession), store, metadata: loaded.snapshot.metadata, model, lifecycle, budget: budgetRuntime, abortController: new AbortController(), projectTrusted: () => projectTrusted(ctx), checkpointResolvers: new Map(), budgetResolvers: new Map() });
|
|
1944
2649
|
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.`);
|
|
1945
|
-
|
|
2650
|
+
for (const decision of await store.pendingWorkflowDecisions()) deliver(pi, budgetDecisionDelivery(loaded.snapshot.metadata, decision));
|
|
2651
|
+
scheduler.restoreRun(runId, loaded.snapshot.settings.concurrency, loaded.snapshot.identityVersion === LAUNCH_SNAPSHOT_IDENTITY_VERSION ? await store.loadOwnership() : [], () => runs.get(runId)?.budget.checkAgentLaunch());
|
|
1946
2652
|
}
|
|
1947
2653
|
const resumeSelect = (ctx.ui as unknown as { select?: (prompt: string, options: string[]) => Promise<string | undefined> } | undefined)?.select;
|
|
1948
2654
|
if (ctx.hasUI && resumeSelect) {
|
|
@@ -1954,7 +2660,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1954
2660
|
if (choice && choice !== "Skip") {
|
|
1955
2661
|
const toResume = choice === "Resume all" ? interrupted : interrupted.filter((_, i) => labels[i] === choice);
|
|
1956
2662
|
for (const run of toResume) {
|
|
1957
|
-
try { await coldResumeRun(run, true, ctx.ui, projectTrusted(ctx)); ctx.ui.notify(`Resumed workflow ${run.metadata.name}.`, "info"); }
|
|
2663
|
+
try { await coldResumeRun(run, true, ctx.ui, projectTrusted(ctx), ctx); ctx.ui.notify(`Resumed workflow ${run.metadata.name}.`, "info"); }
|
|
1958
2664
|
catch (err) { ctx.ui.notify(`Cannot resume ${run.metadata.name}: ${err instanceof Error ? err.message : String(err)}`, "warning"); }
|
|
1959
2665
|
}
|
|
1960
2666
|
}
|
|
@@ -1977,17 +2683,21 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1977
2683
|
parameters: WORKFLOW_TOOL_PARAMETERS,
|
|
1978
2684
|
async execute(_id, params, signal, onUpdate, ctx) {
|
|
1979
2685
|
try {
|
|
2686
|
+
const settingsPath = workflowSettingsPath();
|
|
2687
|
+
const defaults = loadSettings(settingsPath);
|
|
1980
2688
|
if (!ctx.model) throw new WorkflowError("UNKNOWN_MODEL", "A launching model is required");
|
|
1981
|
-
const
|
|
1982
|
-
const
|
|
2689
|
+
const settings = Object.freeze({ concurrency: params.concurrency ?? defaults.concurrency, ...(defaults.modelAliases ? { modelAliases: defaults.modelAliases } : {}) });
|
|
2690
|
+
const budget = validateBudget(params.budget);
|
|
1983
2691
|
const rootModel: ModelSpec = { provider: ctx.model.provider, model: ctx.model.id, thinking: pi.getThinkingLevel() };
|
|
1984
2692
|
const rootModelName = `${rootModel.provider}/${rootModel.model}`;
|
|
1985
|
-
const modelRegistry = (ctx as unknown as { modelRegistry?: {
|
|
1986
|
-
const
|
|
1987
|
-
|
|
1988
|
-
const
|
|
2693
|
+
const modelRegistry = (ctx as unknown as { modelRegistry?: { getAll?: () => Array<{ provider: string; id: string }>; getAvailable?: () => Array<{ provider: string; id: string }> } }).modelRegistry;
|
|
2694
|
+
const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? [ctx.model]).map((model) => `${model.provider}/${model.id}`));
|
|
2695
|
+
knownModels.add(rootModelName);
|
|
2696
|
+
const availableModels = knownModels;
|
|
2697
|
+
const rootTools = pi.getActiveTools().filter((name) => name !== "workflow" && name !== "workflow_respond" && name !== "workflow_stop" && name !== "workflow_catalog");
|
|
1989
2698
|
const trustedProject = projectTrusted(ctx);
|
|
1990
|
-
|
|
2699
|
+
if (typeof ctx.cwd === "string") resolveAgentResourcePolicy(ctx.cwd, trustedProject, settingsPath);
|
|
2700
|
+
const validated = validateWorkflowLaunchWithRegistry(params, { cwd: ctx.cwd, projectTrusted: trustedProject, availableModels, rootTools: new Set(rootTools), modelAliases: defaults.modelAliases ?? {}, knownModels, settingsPath }, registry);
|
|
1991
2701
|
const { script, checked, agentDefinitions, projectAgentDefinitions, roleNames } = validated;
|
|
1992
2702
|
await ensureSessionLease(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
1993
2703
|
const runId = randomUUID();
|
|
@@ -2000,25 +2710,41 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2000
2710
|
const store = new RunStore(ctx.cwd, ctx.sessionManager.getSessionId(), runId, home);
|
|
2001
2711
|
const roles = Object.fromEntries(roleNames.map((role) => [role, agentDefinitions[role]])) as Record<string, AgentDefinition>;
|
|
2002
2712
|
const projectRoles = roleNames.filter((role) => projectAgentDefinitions[role] !== undefined);
|
|
2003
|
-
const roleModels = roleNames.flatMap((role) => { const model = agentDefinitions[role]?.model; return model ? [modelCapability(model)] : []; });
|
|
2713
|
+
const roleModels = roleNames.flatMap((role) => { const model = agentDefinitions[role]?.model; return model ? [modelCapability(model, defaults.modelAliases, knownModels, settingsPath)] : []; });
|
|
2004
2714
|
const snapshotModels = [...new Set([rootModelName, ...checked.referenced.models, ...roleModels])];
|
|
2005
|
-
const snapshot = createLaunchSnapshot({ script, args, metadata: checked.metadata, settings, models: snapshotModels, tools: rootTools, agentTypes: checked.referenced.agentTypes, roles, projectRoles, schemas: checked.schemas });
|
|
2006
|
-
|
|
2007
|
-
const
|
|
2715
|
+
const snapshot = createLaunchSnapshot({ script, args, metadata: checked.metadata, settings, settingsPath, ...(defaults.modelAliases ? { modelAliases: defaults.modelAliases } : {}), ...(budget ? { budget } : {}), models: snapshotModels, tools: rootTools, agentTypes: checked.referenced.agentTypes, roles, projectRoles, schemas: checked.schemas });
|
|
2716
|
+
const budgetRuntime = new WorkflowBudgetRuntime(budget);
|
|
2717
|
+
const initialBudget = budgetRuntime.snapshot();
|
|
2718
|
+
await store.create({ id: runId, workflowName: checked.metadata.name, cwd: ctx.cwd, sessionId: ctx.sessionManager.getSessionId(), state: "running", agents: [], nativeSessions: [], ...(budget ? { budget } : {}), budgetVersion: 1, ...initialBudget }, snapshot);
|
|
2719
|
+
const lifecycle = lifecycleFor(store, "running", budgetRuntime, checked.metadata);
|
|
2008
2720
|
const background = !params.foreground;
|
|
2009
2721
|
const providerPause = async () => { if (background) deliver(pi, `Workflow ${checked.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
|
|
2010
|
-
const executor = new WorkflowAgentExecutor({ cwd: ctx.cwd, model: rootModel, tools: new Set(rootTools), availableModels, agentDefinitions, runStore: store, providerPause }, createSession);
|
|
2011
|
-
runs.set(runId, { executor, store, metadata: checked.metadata, model: rootModel, lifecycle, abortController: runController, checkpointResolvers: new Map(), ...(params.foreground && onUpdate ? { update: onUpdate } : {}) });
|
|
2722
|
+
const executor = new WorkflowAgentExecutor({ cwd: ctx.cwd, model: rootModel, tools: new Set(rootTools), availableModels, knownModels, modelAliases: defaults.modelAliases ?? {}, settingsPath, agentDefinitions, runStore: store, providerPause, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: () => resolveAgentResourcePolicy(ctx.cwd, projectTrusted(ctx)), runContext }, createSession);
|
|
2723
|
+
runs.set(runId, { executor, store, metadata: checked.metadata, model: rootModel, lifecycle, budget: budgetRuntime, abortController: runController, projectTrusted: () => projectTrusted(ctx), checkpointResolvers: new Map(), budgetResolvers: new Map(), ...(params.foreground && onUpdate ? { update: onUpdate } : {}) });
|
|
2012
2724
|
if (params.foreground && onUpdate) onUpdate(workflowToolUpdate((await store.load()).run));
|
|
2013
|
-
scheduler.addRun(runId, settings.concurrency,
|
|
2725
|
+
scheduler.addRun(runId, settings.concurrency, () => runs.get(runId)?.budget.checkAgentLaunch());
|
|
2014
2726
|
const execution = runWorkflow(script, args, withWorkflowFunctions({ agent: async (prompt, options, agentSignal, identity) => {
|
|
2015
2727
|
await lifecycle.enter();
|
|
2728
|
+
const conversationId = identity.conversation ? conversationIdentityPath(identity) : undefined;
|
|
2729
|
+
const conversationLock = conversationId ? `${runId}:${conversationId}` : "";
|
|
2016
2730
|
try {
|
|
2017
|
-
const path = agentIdentityPath(identity);
|
|
2731
|
+
const path = conversationId ? conversationTurnPath(identity) : agentIdentityPath(identity);
|
|
2018
2732
|
const replayed = await store.replay(path);
|
|
2019
|
-
if (replayed)
|
|
2733
|
+
if (replayed) {
|
|
2734
|
+
if (conversationId) {
|
|
2735
|
+
const conversation = await store.conversation(conversationId);
|
|
2736
|
+
if (!conversation || conversation.head.turn < (identity.conversation?.turn ?? 0)) throw new WorkflowError("RESUME_INCOMPATIBLE", "Completed conversation turn has no persisted head");
|
|
2737
|
+
}
|
|
2738
|
+
return replayed.value;
|
|
2739
|
+
}
|
|
2740
|
+
if (conversationId) {
|
|
2741
|
+
if (conversationLocks.has(conversationLock)) throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation turns cannot overlap");
|
|
2742
|
+
const conversation = await store.conversation(conversationId);
|
|
2743
|
+
if (conversation ? conversation.head.turn + 1 !== identity.conversation?.turn : identity.conversation?.turn !== 1) throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation turn does not continue its persisted head");
|
|
2744
|
+
conversationLocks.add(conversationLock);
|
|
2745
|
+
}
|
|
2020
2746
|
const worktree = agentWorktree(identity);
|
|
2021
|
-
const cwd = worktree.worktreeOwner ? (await store.worktree
|
|
2747
|
+
const cwd = worktree.worktreeOwner ? (await persistWorktree(store, checked.metadata, worktree.worktreeOwner)).cwd : ctx.cwd;
|
|
2022
2748
|
const role = typeof options.role === "string" ? options.role : undefined;
|
|
2023
2749
|
const model = typeof options.model === "string" ? options.model : undefined;
|
|
2024
2750
|
const thinking = parseThinking(options.thinking);
|
|
@@ -2027,30 +2753,46 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2027
2753
|
const label = displayAgentName(requestedLabel, role, resolved.model);
|
|
2028
2754
|
const tools = resolved.tools;
|
|
2029
2755
|
const schema = object(options.outputSchema) ? options.outputSchema : undefined;
|
|
2030
|
-
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"
|
|
2756
|
+
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 } : {}), ...(conversationId ? { conversation: { id: conversationId, turn: identity.conversation?.turn ?? 0 } } : {}), agentOptions: options, agentIdentity: identity });
|
|
2031
2757
|
const cancel = () => { scheduler.cancel(spawned.id); };
|
|
2032
2758
|
if (agentSignal.aborted) cancel(); else agentSignal.addEventListener("abort", cancel, { once: true });
|
|
2033
2759
|
const outcome = await spawned.result.finally(() => { agentSignal.removeEventListener("abort", cancel); });
|
|
2034
2760
|
if (!outcome.ok) throw new WorkflowError(outcome.error.code as WorkflowErrorCode, outcome.error.message);
|
|
2035
2761
|
await store.complete(path, outcome.value);
|
|
2036
2762
|
return outcome.value;
|
|
2037
|
-
} finally { await lifecycle.leave(); }
|
|
2763
|
+
} finally { if (conversationLock) conversationLocks.delete(conversationLock); await lifecycle.leave(); }
|
|
2038
2764
|
}, checkpoint: checkpointBridge(runId, store, checked.metadata, Boolean(params.foreground), params.foreground && ctx.hasUI ? ctx.ui : undefined), phase: async (phase) => {
|
|
2039
2765
|
await lifecycle.enter();
|
|
2040
2766
|
try {
|
|
2041
|
-
|
|
2042
|
-
|
|
2767
|
+
let previousPhase: string | undefined;
|
|
2768
|
+
const persisted = await persistRunState(store, checked.metadata, (current) => { previousPhase = current.phase; return { ...current, phase }; });
|
|
2769
|
+
await eventPublisher.phase(store, checked.metadata, previousPhase, phase);
|
|
2770
|
+
runs.get(runId)?.update?.(workflowToolUpdate(persisted));
|
|
2043
2771
|
} finally { await lifecycle.leave(); }
|
|
2044
2772
|
}, log: logBridge(lifecycle, checked.metadata.name) }, store, runContext, variables, registry), runController.signal);
|
|
2045
2773
|
(runs.get(runId) as NonNullable<ReturnType<typeof runs.get>>).execution = execution;
|
|
2046
|
-
|
|
2047
|
-
const finish = execution.result.then(async (value) => {
|
|
2774
|
+
await eventPublisher.runStarted(store, checked.metadata, background);
|
|
2775
|
+
const finish = execution.result.then(async (value) => {
|
|
2776
|
+
await scheduler.flush();
|
|
2777
|
+
if (budgetRuntime.hardExhausted) throw new WorkflowError("BUDGET_EXHAUSTED", "Budgeted work was attempted after hard exhaustion");
|
|
2778
|
+
const resultPath = await store.saveResult(value);
|
|
2779
|
+
await lifecycle.terminal("completed", "completed");
|
|
2780
|
+
await eventPublisher.runCompleted(store, checked.metadata, resultPath, background);
|
|
2781
|
+
return { value, resultPath };
|
|
2782
|
+
}).catch(async (error: unknown) => {
|
|
2783
|
+
await scheduler.flush();
|
|
2784
|
+
const typed = error instanceof WorkflowError ? error : new WorkflowError("INTERNAL_ERROR", String(error));
|
|
2785
|
+
if (!["stopped", "interrupted", "budget_exhausted"].includes(lifecycle.state)) await lifecycle.terminal(typed.code === "CANCELLED" ? "stopped" : typed.code === "BUDGET_EXHAUSTED" ? "budget_exhausted" : "failed", typed.code);
|
|
2786
|
+
await persistRunState(store, checked.metadata, (current) => ({ ...current, ...budgetRuntime.snapshot(), error: { code: typed.code, message: typed.message } }));
|
|
2787
|
+
const state = lifecycle.state === "stopped" || lifecycle.state === "interrupted" || lifecycle.state === "budget_exhausted" ? lifecycle.state : "failed";
|
|
2788
|
+
await eventPublisher.runFailed(store, checked.metadata, typed, background, state);
|
|
2789
|
+
throw typed;
|
|
2790
|
+
});
|
|
2048
2791
|
(runs.get(runId) as NonNullable<ReturnType<typeof runs.get>>).completion = finish;
|
|
2049
2792
|
if (background) {
|
|
2050
2793
|
void finish.then(async ({ value, resultPath }) => {
|
|
2051
|
-
emitAsyncComplete(store, "complete");
|
|
2052
2794
|
deliver(pi, completionDelivery(checked.metadata.name, value, resultPath, await store.changedWorktrees()));
|
|
2053
|
-
}, (error: unknown) => {
|
|
2795
|
+
}, (error: unknown) => { deliverFailure(pi, checked.metadata.name, error); });
|
|
2054
2796
|
return { content: [{ type: "text" as const, text: JSON.stringify({ runId, state: "running" }) }], details: { runId, preview: `Started workflow ${runId}.` } };
|
|
2055
2797
|
}
|
|
2056
2798
|
const { value } = await finish;
|
|
@@ -2098,7 +2840,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2098
2840
|
return entries.filter((entry): entry is { store: RunStore; loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> } } => entry !== undefined);
|
|
2099
2841
|
};
|
|
2100
2842
|
let stores = await loadStores();
|
|
2101
|
-
const usage = "Usage: /workflow [doctor], or /workflow pause|resume|stop|approve|reject|delete <run-id> [checkpoint]"
|
|
2843
|
+
const usage = "Usage: /workflow [doctor|model-aliases], or /workflow pause|resume|stop|approve|reject|delete <run-id> [checkpoint or proposal-id]. Use workflow_resume for budget patches."
|
|
2102
2844
|
const setWorkflowStatus = (text: string | undefined) => {
|
|
2103
2845
|
const setStatus = (ctx.ui as unknown as { setStatus?: (key: string, text?: string) => void }).setStatus;
|
|
2104
2846
|
setStatus?.call(ctx.ui, "workflow-stop", text);
|
|
@@ -2114,22 +2856,44 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2114
2856
|
ctx.ui.notify(accepted ? `${action === "approve" ? "Approved" : "Rejected"} checkpoint ${rest.join(" ")}.` : "Checkpoint is not awaiting a response.", accepted ? "info" : "warning");
|
|
2115
2857
|
return keepContext ? "dashboard" : "done";
|
|
2116
2858
|
}
|
|
2859
|
+
if ((action === "budget-approve" || action === "budget-reject") && runId && rest[0]) {
|
|
2860
|
+
const result = await answerBudgetDecision(runId, rest[0], action === "budget-approve", true);
|
|
2861
|
+
ctx.ui.notify(result ? `Budget adjustment ${rest[0]} ${result.approved ? "approved" : "rejected"}.` : "Budget proposal is not pending.", result ? "info" : "warning");
|
|
2862
|
+
return keepContext ? "dashboard" : "done";
|
|
2863
|
+
}
|
|
2117
2864
|
if (action === "delete" && stored) {
|
|
2118
2865
|
if (!["completed", "failed", "stopped"].includes(stored.loaded.run.state)) { ctx.ui.notify("Stop the workflow before deleting it.", "warning"); return keepContext ? "dashboard" : "done"; }
|
|
2119
2866
|
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";
|
|
2120
|
-
await stored.store.delete(true); runs.delete(stored.store.runId); ctx.ui.notify(`Deleted workflow ${stored.store.runId}.`, "info"); return keepContext ? "picker" : "done";
|
|
2867
|
+
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";
|
|
2121
2868
|
}
|
|
2122
2869
|
if (action === "pause" && run) { await run.lifecycle.pause(); ctx.ui.notify(`Paused workflow ${run.store.runId}.`, "info"); return keepContext ? "dashboard" : "done"; }
|
|
2123
2870
|
if (action === "resume" && run) {
|
|
2124
|
-
if (run.lifecycle.state === "
|
|
2125
|
-
|
|
2126
|
-
|
|
2871
|
+
if (run.lifecycle.state === "budget_exhausted") {
|
|
2872
|
+
const patch: unknown = rest.length ? JSON.parse(rest.join(" ")) as unknown : undefined;
|
|
2873
|
+
const result = await resumeWorkflowRun(run.store.runId, patch);
|
|
2874
|
+
ctx.ui.notify(result.state === "running" ? `Resumed workflow ${run.store.runId}.` : `Budget adjustment for ${run.store.runId} is awaiting approval.`, result.state === "running" ? "info" : "warning");
|
|
2875
|
+
} else {
|
|
2876
|
+
if (run.lifecycle.state === "interrupted") await coldResumeRun(run, ctx.hasUI, ctx.ui, projectTrusted(ctx), ctx);
|
|
2877
|
+
else {
|
|
2878
|
+
if (run.lifecycle.state === "paused") await refreshPausedRunAliases(run, ctx);
|
|
2879
|
+
await run.lifecycle.resume();
|
|
2880
|
+
}
|
|
2881
|
+
ctx.ui.notify(`Resumed workflow ${run.store.runId}.`, "info");
|
|
2882
|
+
}
|
|
2883
|
+
return keepContext ? "dashboard" : "done";
|
|
2884
|
+
}
|
|
2885
|
+
if (action === "adjust" && run?.lifecycle.state === "budget_exhausted") {
|
|
2886
|
+
const input = await (ctx.ui as unknown as { input?: (title: string, placeholder?: string) => Promise<string | undefined> }).input?.("Budget patch (JSON)", "{\"tokens\":{\"hard\":null}}" );
|
|
2887
|
+
if (input === undefined) return keepContext ? "dashboard" : "done";
|
|
2888
|
+
const result = await resumeWorkflowRun(run.store.runId, JSON.parse(input));
|
|
2889
|
+
ctx.ui.notify(result.state === "running" ? `Resumed workflow ${run.store.runId}.` : `Budget adjustment for ${run.store.runId} is awaiting approval.`, result.state === "running" ? "info" : "warning");
|
|
2890
|
+
return keepContext ? "dashboard" : "done";
|
|
2127
2891
|
}
|
|
2128
2892
|
if (action === "stop" && run) {
|
|
2129
2893
|
const workflowName = stored?.loaded.run.workflowName ?? run.metadata.name;
|
|
2130
2894
|
if (keepContext && !await ctx.ui.confirm("Stop workflow?", `Stop workflow ${workflowName} (${run.store.runId})? This cannot be undone.`)) return "dashboard";
|
|
2131
2895
|
if (keepContext) status(`Stopping workflow ${workflowName}...`);
|
|
2132
|
-
await
|
|
2896
|
+
await stopWorkflowRun(run.store.runId);
|
|
2133
2897
|
if (keepContext) status(`Workflow ${run.store.runId} stopped.`);
|
|
2134
2898
|
ctx.ui.notify(`Stopped workflow ${run.store.runId}.`, "info"); return keepContext ? "dashboard" : "done";
|
|
2135
2899
|
}
|
|
@@ -2144,10 +2908,76 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2144
2908
|
return "dashboard";
|
|
2145
2909
|
}
|
|
2146
2910
|
};
|
|
2911
|
+
const manageAliases = async (): Promise<void> => {
|
|
2912
|
+
const settingsPath = workflowSettingsPath();
|
|
2913
|
+
const modelRegistry = (ctx as unknown as { modelRegistry?: { getAvailable?: () => Array<{ provider: string; id: string }> } }).modelRegistry;
|
|
2914
|
+
const available = () => [...new Set((modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`))].sort();
|
|
2915
|
+
const selectTarget = async (): Promise<string | undefined> => {
|
|
2916
|
+
const models = available();
|
|
2917
|
+
const choice = await ctx.ui.select("Model alias target", [...models, "Manual model ID", "Back"]);
|
|
2918
|
+
if (!choice || choice === "Back") return undefined;
|
|
2919
|
+
if (choice !== "Manual model ID") return choice;
|
|
2920
|
+
return (await ctx.ui.input("Manual model ID", "provider/model[:thinking]"))?.trim() || undefined;
|
|
2921
|
+
};
|
|
2922
|
+
const save = (aliases: Readonly<Record<string, string>>): boolean => {
|
|
2923
|
+
try { saveModelAliases(settingsPath, aliases); ctx.ui.notify(`Saved model aliases to ${settingsPath}.`, "info"); return true; }
|
|
2924
|
+
catch (error) { ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error"); return false; }
|
|
2925
|
+
};
|
|
2926
|
+
for (;;) {
|
|
2927
|
+
let aliases: Readonly<Record<string, string>>;
|
|
2928
|
+
try { aliases = loadSettings(settingsPath).modelAliases ?? {}; }
|
|
2929
|
+
catch (error) { ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error"); return; }
|
|
2930
|
+
const names = Object.keys(aliases).sort();
|
|
2931
|
+
const listing = names.length ? names.map((name) => `${name} = ${aliases[name] ?? ""}`).join("\n") : "(none)";
|
|
2932
|
+
const options = ["Add alias", ...names.map((name) => `Edit ${name}`), ...names.map((name) => `Delete ${name}`), "Back"];
|
|
2933
|
+
const choice = await ctx.ui.select(`Model aliases\n${listing}`, options);
|
|
2934
|
+
if (!choice || choice === "Back") return;
|
|
2935
|
+
if (choice === "Add alias") {
|
|
2936
|
+
const name = (await ctx.ui.input("Alias name", "reviewer-model"))?.trim();
|
|
2937
|
+
if (!name) continue;
|
|
2938
|
+
if (Object.prototype.hasOwnProperty.call(aliases, name)) { ctx.ui.notify(`Alias ${name} already exists; choose Edit ${name}.`, "warning"); continue; }
|
|
2939
|
+
const target = await selectTarget();
|
|
2940
|
+
if (!target) continue;
|
|
2941
|
+
const next = { ...aliases, [name]: target };
|
|
2942
|
+
try { validateModelAliases(next, settingsPath); } catch (error) { ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error"); continue; }
|
|
2943
|
+
const parsed = parseModelReference(target);
|
|
2944
|
+
if (!available().includes(`${parsed.provider}/${parsed.model}`)) {
|
|
2945
|
+
ctx.ui.notify(`Warning: ${target} is not currently available in Pi.`, "warning");
|
|
2946
|
+
if (!await ctx.ui.confirm("Save unknown model?", "Save this target for cross-machine portability?")) continue;
|
|
2947
|
+
}
|
|
2948
|
+
save(next);
|
|
2949
|
+
continue;
|
|
2950
|
+
}
|
|
2951
|
+
const edit = /^Edit (.+)$/.exec(choice);
|
|
2952
|
+
if (edit?.[1]) {
|
|
2953
|
+
const target = await selectTarget();
|
|
2954
|
+
if (!target) continue;
|
|
2955
|
+
const next = { ...aliases, [edit[1]]: target };
|
|
2956
|
+
try { validateModelAliases(next, settingsPath); } catch (error) { ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error"); continue; }
|
|
2957
|
+
const parsed = parseModelReference(target);
|
|
2958
|
+
if (!available().includes(`${parsed.provider}/${parsed.model}`)) {
|
|
2959
|
+
ctx.ui.notify(`Warning: ${target} is not currently available in Pi.`, "warning");
|
|
2960
|
+
if (!await ctx.ui.confirm("Save unknown model?", "Save this target for cross-machine portability?")) continue;
|
|
2961
|
+
}
|
|
2962
|
+
save(next);
|
|
2963
|
+
continue;
|
|
2964
|
+
}
|
|
2965
|
+
const deletion = /^Delete (.+)$/.exec(choice);
|
|
2966
|
+
if (deletion?.[1] && await ctx.ui.confirm("Delete model alias?", `Delete ${deletion[1]}? Future workflow resumes using this alias may fail.`)) {
|
|
2967
|
+
const next = Object.fromEntries(Object.entries(aliases).filter(([name]) => name !== deletion[1]));
|
|
2968
|
+
save(next);
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2971
|
+
};
|
|
2972
|
+
if (command === "model-aliases") {
|
|
2973
|
+
if (!ctx.hasUI) { ctx.ui.notify("Model alias management requires UI.", "warning"); return; }
|
|
2974
|
+
await manageAliases();
|
|
2975
|
+
return;
|
|
2976
|
+
}
|
|
2147
2977
|
if (!command) {
|
|
2148
2978
|
for (;;) {
|
|
2149
|
-
if (!stores.length) { ctx.ui.notify("No workflow runs in this session.", "info"); return; }
|
|
2150
2979
|
if (!ctx.hasUI) {
|
|
2980
|
+
if (!stores.length) { ctx.ui.notify("No workflow runs in this session.", "info"); return; }
|
|
2151
2981
|
const details = await Promise.all(stores.map(async ({ store, loaded }) => formatNavigatorRun(loaded, await store.awaitingCheckpoints(), await store.worktrees())));
|
|
2152
2982
|
ctx.ui.notify(details.join("\n\n"), "info"); return;
|
|
2153
2983
|
}
|
|
@@ -2155,13 +2985,14 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2155
2985
|
const labels = navigatorRunLabels(sorted);
|
|
2156
2986
|
const terminalStates = new Set(["completed", "failed", "stopped"]);
|
|
2157
2987
|
const hasCompleted = sorted.some(({ loaded: { run } }) => run.state === "completed");
|
|
2158
|
-
const pickerOptions = [...labels, ...(hasCompleted ? ["Delete all completed"] : [])
|
|
2988
|
+
const pickerOptions = [...labels, "Model aliases", "Close", ...(hasCompleted ? ["Delete all completed"] : [])];
|
|
2159
2989
|
const runChoice = await ctx.ui.select("Workflows\n", pickerOptions);
|
|
2160
2990
|
if (!runChoice || runChoice === "Close") return;
|
|
2991
|
+
if (runChoice === "Model aliases") { await manageAliases(); stores = await loadStores(); continue; }
|
|
2161
2992
|
if (runChoice === "Delete all completed") {
|
|
2162
2993
|
if (!await ctx.ui.confirm("Delete completed runs?", "Delete all completed workflow runs and their artifacts? This cannot be undone.")) continue;
|
|
2163
2994
|
for (const entry of sorted) {
|
|
2164
|
-
if (entry.loaded.run.state === "completed") { await entry.store.delete(true); runs.delete(entry.store.runId); }
|
|
2995
|
+
if (entry.loaded.run.state === "completed") { await entry.store.delete(true); runs.delete(entry.store.runId); terminalRunStates.delete(entry.store.runId); }
|
|
2165
2996
|
}
|
|
2166
2997
|
ctx.ui.notify("Deleted all completed workflow runs.", "info"); stores = await loadStores(); continue;
|
|
2167
2998
|
}
|
|
@@ -2178,6 +3009,38 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2178
3009
|
ctx.ui.notify(`Failed to copy ${artifact}: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
2179
3010
|
}
|
|
2180
3011
|
};
|
|
3012
|
+
const openTranscript = async (transcript: string): Promise<void> => {
|
|
3013
|
+
try {
|
|
3014
|
+
const entries = SessionManager.open(transcript).buildContextEntries();
|
|
3015
|
+
if (ctx.mode !== "tui") { ctx.ui.notify(`Transcript: ${transcript}`, "info"); return; }
|
|
3016
|
+
await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
|
|
3017
|
+
let offset = 0;
|
|
3018
|
+
let renderedLines: string[] = [];
|
|
3019
|
+
const viewport = () => Math.max(1, (((tui as unknown as { terminal?: { rows?: number } }).terminal?.rows) ?? 24) - 3);
|
|
3020
|
+
const move = (delta: number) => { offset = Math.max(0, Math.min(Math.max(0, renderedLines.length - viewport()), offset + delta)); };
|
|
3021
|
+
return {
|
|
3022
|
+
render(width: number) {
|
|
3023
|
+
renderedLines = transcriptLines(entries).flatMap((line) => line ? truncateToVisualLines(line, Number.MAX_SAFE_INTEGER, width, 0).visualLines : [""]);
|
|
3024
|
+
offset = Math.min(offset, Math.max(0, renderedLines.length - viewport()));
|
|
3025
|
+
return [theme.fg("accent", "Native Pi transcript"), ...renderedLines.slice(offset, offset + viewport()), "", theme.fg("dim", "↑↓/pgup/pgdn scroll · esc close")];
|
|
3026
|
+
},
|
|
3027
|
+
invalidate() {},
|
|
3028
|
+
handleInput(data: string) {
|
|
3029
|
+
if (keybindings.matches(data, "tui.select.up")) move(-1);
|
|
3030
|
+
else if (keybindings.matches(data, "tui.select.down")) move(1);
|
|
3031
|
+
else if (keybindings.matches(data, "tui.select.pageUp")) move(-viewport());
|
|
3032
|
+
else if (keybindings.matches(data, "tui.select.pageDown")) move(viewport());
|
|
3033
|
+
else if (keybindings.matches(data, "tui.editor.cursorLineStart")) offset = 0;
|
|
3034
|
+
else if (keybindings.matches(data, "tui.editor.cursorLineEnd")) offset = Math.max(0, renderedLines.length - viewport());
|
|
3035
|
+
else if (keybindings.matches(data, "tui.select.cancel")) done(undefined);
|
|
3036
|
+
tui.requestRender();
|
|
3037
|
+
},
|
|
3038
|
+
};
|
|
3039
|
+
}, { overlay: true });
|
|
3040
|
+
} catch (error) {
|
|
3041
|
+
ctx.ui.notify(`Cannot open transcript: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
3042
|
+
}
|
|
3043
|
+
};
|
|
2181
3044
|
const loadDashboard = async () => {
|
|
2182
3045
|
const loaded = await store.load();
|
|
2183
3046
|
const checkpoints = await store.awaitingCheckpoints();
|
|
@@ -2189,6 +3052,12 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2189
3052
|
const addCopy = (label: string, value: string, artifact: string) => { actions.set(label, "copy"); copies.set(label, { value, artifact }); };
|
|
2190
3053
|
if (loaded.run.state === "running") add("Pause", "pause");
|
|
2191
3054
|
if (["paused", "interrupted"].includes(loaded.run.state)) add("Resume", "resume");
|
|
3055
|
+
if (loaded.run.state === "budget_exhausted") { actions.set("Resume unchanged", `resume ${store.runId}`); actions.set("Adjust budget", `adjust ${store.runId}`); }
|
|
3056
|
+
for (const decision of await store.pendingWorkflowDecisions()) {
|
|
3057
|
+
const id = decision.proposalId.slice(0, 8);
|
|
3058
|
+
actions.set(`Approve budget ${id}`, `budget-approve ${store.runId} ${decision.proposalId}`);
|
|
3059
|
+
actions.set(`Reject budget ${id}`, `budget-reject ${store.runId} ${decision.proposalId}`);
|
|
3060
|
+
}
|
|
2192
3061
|
if (!terminalStates.has(loaded.run.state)) add("Stop", "stop");
|
|
2193
3062
|
for (const cp of checkpoints) {
|
|
2194
3063
|
if (ctx.mode === "tui") {
|
|
@@ -2203,18 +3072,59 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2203
3072
|
if (ctx.mode !== "tui") actions.set("Refresh", "refresh");
|
|
2204
3073
|
else actions.set("View script", "view-script");
|
|
2205
3074
|
const transcripts = [...new Set([...loaded.run.agents.flatMap((agent) => (agent.attemptDetails ?? []).map((attempt) => attempt.sessionFile)), ...loaded.run.nativeSessions.map(({ sessionFile }) => sessionFile)])];
|
|
2206
|
-
if (
|
|
2207
|
-
if (transcripts.length) actions.set("
|
|
3075
|
+
if (loaded.run.agents.length) actions.set("Agents...", "agents");
|
|
3076
|
+
if (!loaded.run.agents.length && ctx.mode === "tui" && transcripts.length) actions.set("View transcript", "view-transcript");
|
|
3077
|
+
if (!loaded.run.agents.length && transcripts.length) actions.set("Transcript paths", "transcripts");
|
|
2208
3078
|
if (terminalStates.has(loaded.run.state)) add("Delete", "delete");
|
|
2209
3079
|
if (ctx.mode === "tui") {
|
|
2210
3080
|
addCopy("Copy run path", store.directory, "run path");
|
|
2211
3081
|
addCopy("Copy run ID", store.runId, "run ID");
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
3082
|
+
}
|
|
3083
|
+
return { dashboard: formatNavigatorDashboard(loaded.run, checkpoints, worktrees), actions, copies, reviews, transcripts, script: loaded.snapshot.script, agents: loaded.run.agents, worktrees };
|
|
3084
|
+
};
|
|
3085
|
+
const selectAgent = async (dashboard: Awaited<ReturnType<typeof loadDashboard>>): Promise<void> => {
|
|
3086
|
+
const byId = new Map(dashboard.agents.map((agent) => [agent.id, agent]));
|
|
3087
|
+
const title = (agent: AgentRecord): string => {
|
|
3088
|
+
const parents: string[] = [];
|
|
3089
|
+
for (let parentId = agent.parentId; parentId; parentId = byId.get(parentId)?.parentId) {
|
|
3090
|
+
const parent = byId.get(parentId);
|
|
3091
|
+
if (!parent) break;
|
|
3092
|
+
parents.unshift(parent.label ?? parent.name);
|
|
3093
|
+
}
|
|
3094
|
+
return [...((agent.structuralPath ?? []).length ? [(agent.structuralPath ?? []).join(" > ")] : []), ...(agent.parentBreadcrumb ? [agent.parentBreadcrumb] : []), ...parents, agent.label ?? agent.name].join(" > ");
|
|
3095
|
+
};
|
|
3096
|
+
const labels = dashboard.agents.map((agent, index) => `#${String(index + 1)} ${title(agent)} [${agent.state}]`);
|
|
3097
|
+
const selectedLabel = await ctx.ui.select("Agents", [...labels, "Back"]);
|
|
3098
|
+
const selectedIndex = selectedLabel ? labels.indexOf(selectedLabel) : -1;
|
|
3099
|
+
const selected = selectedIndex >= 0 ? dashboard.agents[selectedIndex] : undefined;
|
|
3100
|
+
if (!selected) return;
|
|
3101
|
+
const attempts = [...(selected.attemptDetails ?? [])].sort((left, right) => left.attempt - right.attempt);
|
|
3102
|
+
const worktree = selected.worktreeOwner ? dashboard.worktrees.find((candidate) => candidate.owner === selected.worktreeOwner) : undefined;
|
|
3103
|
+
const actions = [
|
|
3104
|
+
...(attempts.length ? ["View transcript", "Copy transcript path"] : []),
|
|
3105
|
+
...(worktree ? ["Copy branch", "Copy worktree path"] : []),
|
|
3106
|
+
"Copy agent ID",
|
|
3107
|
+
"Back",
|
|
3108
|
+
];
|
|
3109
|
+
const chooseAttempt = async (): Promise<AgentAttemptSummary | undefined> => {
|
|
3110
|
+
const choices = attempts.map((attempt) => `Attempt ${String(attempt.attempt)}`);
|
|
3111
|
+
const choice = choices.length === 1 ? choices[0] : await ctx.ui.select("Transcript attempts", [...choices, "Back"]);
|
|
3112
|
+
const index = choice ? choices.indexOf(choice) : -1;
|
|
3113
|
+
return index >= 0 ? attempts[index] : undefined;
|
|
3114
|
+
};
|
|
3115
|
+
for (;;) {
|
|
3116
|
+
const action = await ctx.ui.select(title(selected), actions);
|
|
3117
|
+
if (!action || action === "Back") return;
|
|
3118
|
+
if (action === "Copy agent ID") { await copyArtifact(selected.id, "agent ID"); continue; }
|
|
3119
|
+
if (action === "Copy branch" && worktree) { await copyArtifact(worktree.branch, "branch"); continue; }
|
|
3120
|
+
if (action === "Copy worktree path" && worktree) { await copyArtifact(worktree.path, "worktree path"); continue; }
|
|
3121
|
+
if (action === "View transcript" || action === "Copy transcript path") {
|
|
3122
|
+
const attempt = await chooseAttempt();
|
|
3123
|
+
if (!attempt) continue;
|
|
3124
|
+
if (action === "Copy transcript path") await copyArtifact(attempt.sessionFile, "transcript path");
|
|
3125
|
+
else await openTranscript(attempt.sessionFile);
|
|
2215
3126
|
}
|
|
2216
3127
|
}
|
|
2217
|
-
return { dashboard: formatNavigatorDashboard(loaded.run, checkpoints, worktrees), actions, copies, reviews, transcripts, script: loaded.snapshot.script };
|
|
2218
3128
|
};
|
|
2219
3129
|
for (;;) {
|
|
2220
3130
|
let view = await loadDashboard();
|
|
@@ -2318,6 +3228,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2318
3228
|
}, { overlay: true })
|
|
2319
3229
|
: await ctx.ui.select(view.dashboard, [...view.actions.keys(), "Close"]);
|
|
2320
3230
|
if (!actionChoice || actionChoice === "Close") return;
|
|
3231
|
+
if (actionChoice === "Agents...") { await selectAgent(view); continue; }
|
|
2321
3232
|
if (actionChoice === "Refresh") continue;
|
|
2322
3233
|
if (actionChoice === "View script") {
|
|
2323
3234
|
await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
|
|
@@ -2356,47 +3267,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2356
3267
|
}
|
|
2357
3268
|
if (actionChoice === "View transcript") {
|
|
2358
3269
|
const transcript = await ctx.ui.select("Native Pi transcripts", [...view.transcripts, "Back"]);
|
|
2359
|
-
if (transcript && transcript !== "Back")
|
|
2360
|
-
try {
|
|
2361
|
-
const entries = SessionManager.open(transcript).buildContextEntries();
|
|
2362
|
-
await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
|
|
2363
|
-
let offset: number | undefined;
|
|
2364
|
-
let renderedLines: string[] = [];
|
|
2365
|
-
const terminalRows = () => Math.max(1, ((tui as unknown as { terminal?: { rows?: number } }).terminal?.rows) ?? 24);
|
|
2366
|
-
const viewport = () => Math.max(1, terminalRows() - 3);
|
|
2367
|
-
const move = (delta: number) => {
|
|
2368
|
-
const maxOffset = Math.max(0, renderedLines.length - viewport());
|
|
2369
|
-
offset = Math.max(0, Math.min(maxOffset, (offset ?? maxOffset) + delta));
|
|
2370
|
-
};
|
|
2371
|
-
return {
|
|
2372
|
-
render(width: number) {
|
|
2373
|
-
renderedLines = transcriptLines(entries).flatMap((line) => line ? truncateToVisualLines(line, Number.MAX_SAFE_INTEGER, width, 0).visualLines : [""]);
|
|
2374
|
-
const maxOffset = Math.max(0, renderedLines.length - viewport());
|
|
2375
|
-
offset = Math.min(offset ?? maxOffset, maxOffset);
|
|
2376
|
-
return [
|
|
2377
|
-
theme.fg("accent", "Native Pi transcript"),
|
|
2378
|
-
...renderedLines.slice(offset, offset + viewport()),
|
|
2379
|
-
"",
|
|
2380
|
-
theme.fg("dim", "↑↓/pgup/pgdn scroll · Home/End jump · esc close"),
|
|
2381
|
-
];
|
|
2382
|
-
},
|
|
2383
|
-
invalidate() {},
|
|
2384
|
-
handleInput(data: string) {
|
|
2385
|
-
if (keybindings.matches(data, "tui.select.up")) move(-1);
|
|
2386
|
-
else if (keybindings.matches(data, "tui.select.down")) move(1);
|
|
2387
|
-
else if (keybindings.matches(data, "tui.select.pageUp")) move(-viewport());
|
|
2388
|
-
else if (keybindings.matches(data, "tui.select.pageDown")) move(viewport());
|
|
2389
|
-
else if (keybindings.matches(data, "tui.editor.cursorLineStart")) offset = 0;
|
|
2390
|
-
else if (keybindings.matches(data, "tui.editor.cursorLineEnd")) offset = Math.max(0, renderedLines.length - viewport());
|
|
2391
|
-
else if (keybindings.matches(data, "tui.select.cancel")) done(undefined);
|
|
2392
|
-
tui.requestRender();
|
|
2393
|
-
},
|
|
2394
|
-
};
|
|
2395
|
-
}, { overlay: true });
|
|
2396
|
-
} catch (error) {
|
|
2397
|
-
ctx.ui.notify(`Cannot open transcript ${transcript}: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
2398
|
-
}
|
|
2399
|
-
}
|
|
3270
|
+
if (transcript && transcript !== "Back") await openTranscript(transcript);
|
|
2400
3271
|
continue;
|
|
2401
3272
|
}
|
|
2402
3273
|
if (actionChoice === "Transcript paths") {
|
|
@@ -2481,9 +3352,9 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2481
3352
|
pi.on("session_shutdown", async () => {
|
|
2482
3353
|
try {
|
|
2483
3354
|
await Promise.all([...runs.entries()].map(async ([runId, run]) => {
|
|
2484
|
-
if (["completed", "failed", "stopped"].includes(run.lifecycle.state)) { await run.completion?.catch(() => undefined); return; }
|
|
2485
|
-
if (!["completed", "failed", "stopped"].includes(run.lifecycle.state)) {
|
|
2486
|
-
try { await run.lifecycle.terminal("interrupted"); } catch (error) { if (!["completed", "failed", "stopped"].includes(run.lifecycle.state)) throw error; }
|
|
3355
|
+
if (["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state)) { await run.completion?.catch(() => undefined); return; }
|
|
3356
|
+
if (!["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state)) {
|
|
3357
|
+
try { await run.lifecycle.terminal("interrupted"); } catch (error) { if (!["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state)) throw error; }
|
|
2487
3358
|
run.abortController.abort();
|
|
2488
3359
|
run.execution?.cancel();
|
|
2489
3360
|
await scheduler.cancelRun(runId);
|
|
@@ -2512,8 +3383,8 @@ function modelSpec(value: string, fallback: ModelSpec): ModelSpec {
|
|
|
2512
3383
|
}
|
|
2513
3384
|
|
|
2514
3385
|
export { acquireSessionLease, projectStorageKey, RunStore, runsDirectory, SessionLease, structuralPath } from "./persistence.js";
|
|
2515
|
-
export type { AwaitingCheckpoint, CompletedOperation, NativeSessionReference, PersistedOwnershipNode, PersistedRun, WorktreeReference } from "./persistence.js";
|
|
3386
|
+
export type { AwaitingCheckpoint, CompletedOperation, ConversationHead, NativeSessionReference, PendingWorkflowDecision, PersistedConversation, PersistedOwnershipNode, PersistedRun, WorktreeReference } from "./persistence.js";
|
|
2516
3387
|
export { FairAgentScheduler, WorkflowAgentExecutor } from "./agent-execution.js";
|
|
2517
|
-
export type { AgentAccounting, AgentAttempt, AgentDefinition, AgentExecutionOptions, AgentExecutionResult, AgentExecutionRoot, AgentProgress, AgentToolCallProgress } from "./agent-execution.js";
|
|
3388
|
+
export type { AgentAccounting, AgentAttempt, AgentBudgetHooks, AgentDefinition, AgentExecutionOptions, AgentExecutionResult, AgentExecutionRoot, AgentProgress, AgentSetup, AgentSetupContext, AgentSetupHook, AgentToolCallProgress, RegisteredAgentSetupHook, SessionInput } from "./agent-execution.js";
|
|
2518
3389
|
export { doctor, doctorExitCode, formatDoctorReport } from "./doctor.js";
|
|
2519
3390
|
export type { DoctorDiagnostic, DoctorOptions, DoctorPiState, DoctorReport, DoctorRole, DoctorSeverity, DoctorTrust, DoctorWorkflow } from "./doctor.js";
|