pi-extensible-workflows 1.0.0 → 1.0.1
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/dist/src/agent-execution.js +47 -7
- package/dist/src/ambient-workflow-evals.js +14 -33
- package/dist/src/doctor.js +3 -5
- package/dist/src/index.d.ts +7 -4
- package/dist/src/index.js +176 -117
- package/dist/src/persistence.d.ts +2 -0
- package/dist/src/persistence.js +27 -4
- package/dist/src/workflow-evals.d.ts +1 -0
- package/dist/src/workflow-evals.js +23 -25
- package/package.json +5 -2
- package/skills/pi-extensible-workflows/SKILL.md +6 -2
- package/src/agent-execution.ts +45 -7
- package/src/ambient-workflow-evals.ts +18 -28
- package/src/doctor.ts +2 -5
- package/src/index.ts +145 -79
- package/src/persistence.ts +23 -4
- package/src/workflow-evals.ts +13 -16
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync,
|
|
4
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { homedir, tmpdir } from "node:os";
|
|
6
6
|
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
@@ -12,13 +12,13 @@ import { Value } from "typebox/value";
|
|
|
12
12
|
import { copyToClipboard, getAgentDir, parseFrontmatter, highlightCode, SessionManager, truncateToVisualLines, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
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
|
-
import { acquireSessionLease, listRunIds, RunStore, SessionLease, structuralPath as operationPath } from "./persistence.js";
|
|
15
|
+
import { acquireSessionLease, atomicWriteFile, listRunIds, RunStore, SessionLease, structuralPath as operationPath } from "./persistence.js";
|
|
16
16
|
import type { AwaitingCheckpoint, PersistedRun, WorktreeReference } from "./persistence.js";
|
|
17
17
|
|
|
18
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
|
-
export const
|
|
21
|
-
export
|
|
20
|
+
export const WORKFLOW_CALL_KINDS = ["agent", "conversation", "parallel", "pipeline", "checkpoint", "phase", "withWorktree"] as const;
|
|
21
|
+
export type WorkflowCallKind = (typeof WORKFLOW_CALL_KINDS)[number];
|
|
22
22
|
export const WORKFLOW_RUN_STARTED_EVENT = "workflow:run-started";
|
|
23
23
|
export const WORKFLOW_RUN_RESUMED_EVENT = "workflow:run-resumed";
|
|
24
24
|
export const WORKFLOW_RUN_STATE_CHANGED_EVENT = "workflow:run-state-changed";
|
|
@@ -70,7 +70,7 @@ export interface AgentSetupSummary { hookNames: readonly string[]; model: ModelS
|
|
|
70
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
71
|
export interface WorkflowWorktreeCreatedEvent extends WorkflowEventBase { owner: string; branch: string; path: string; base: string }
|
|
72
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:
|
|
73
|
+
export interface WorkflowRunEvent { type: string; message: string }
|
|
74
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
75
|
export const LAUNCH_SNAPSHOT_IDENTITY_VERSION = 3;
|
|
76
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[] }
|
|
@@ -87,7 +87,10 @@ export interface WorkflowOrchestrationContext {
|
|
|
87
87
|
log: (message: string) => void;
|
|
88
88
|
}
|
|
89
89
|
export interface WorkflowRunContext { cwd: string; sessionId: string; runId: string; workflow: Readonly<WorkflowMetadata>; args: JsonValue; signal: AbortSignal }
|
|
90
|
-
export interface WorkflowFunctionContext extends WorkflowOrchestrationContext {
|
|
90
|
+
export interface WorkflowFunctionContext extends WorkflowOrchestrationContext {
|
|
91
|
+
run: Readonly<WorkflowRunContext>;
|
|
92
|
+
invoke: (name: string, input: Readonly<Record<string, JsonValue>>) => Promise<JsonValue>;
|
|
93
|
+
}
|
|
91
94
|
export interface WorkflowFunction { description: string; input: JsonSchema; output: JsonSchema; run: (input: Readonly<Record<string, JsonValue>>, context: Readonly<WorkflowFunctionContext>) => Promise<JsonValue> | JsonValue }
|
|
92
95
|
export interface WorkflowVariable { description: string; schema: JsonSchema; resolve: (run: Readonly<WorkflowRunContext>) => Promise<JsonValue> | JsonValue }
|
|
93
96
|
export interface WorkflowScriptDefinition { description: string; script: string }
|
|
@@ -234,6 +237,7 @@ export const DEFAULT_SETTINGS: Readonly<WorkflowSettings> = Object.freeze({ conc
|
|
|
234
237
|
|
|
235
238
|
function fail(code: WorkflowErrorCode, message: string): never { throw new WorkflowError(code, message); }
|
|
236
239
|
function object(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
|
240
|
+
export { object as isObject };
|
|
237
241
|
function nonNegativeInteger(value: unknown): value is number { return Number.isInteger(value) && (value as number) >= 0; }
|
|
238
242
|
function nonNegativeFinite(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value >= 0; }
|
|
239
243
|
export function validateBudget(value: unknown): WorkflowBudget | undefined {
|
|
@@ -496,9 +500,7 @@ export function saveModelAliases(path = workflowSettingsPath(), aliases: Readonl
|
|
|
496
500
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
497
501
|
}
|
|
498
502
|
mkdirSync(dirname(path), { recursive: true });
|
|
499
|
-
|
|
500
|
-
writeFileSync(temporary, `${JSON.stringify({ ...parsed, modelAliases: normalized }, null, 2)}\n`, { mode: 0o600 });
|
|
501
|
-
renameSync(temporary, path);
|
|
503
|
+
atomicWriteFile(path, `${JSON.stringify({ ...parsed, modelAliases: normalized }, null, 2)}\n`, true);
|
|
502
504
|
}
|
|
503
505
|
|
|
504
506
|
export function parseRoleMarkdown(content: string, strict = false, rolePath?: string): AgentDefinition {
|
|
@@ -613,15 +615,25 @@ type WorkflowCall = acorn.CallExpression & { callee: acorn.Identifier };
|
|
|
613
615
|
function astNode(value: unknown): value is acorn.AnyNode {
|
|
614
616
|
return typeof value === "object" && value !== null && "type" in value && typeof value.type === "string";
|
|
615
617
|
}
|
|
618
|
+
function astChildren(node: acorn.AnyNode): acorn.AnyNode[] {
|
|
619
|
+
const children: acorn.AnyNode[] = [];
|
|
620
|
+
for (const value of Object.values(node) as unknown[]) {
|
|
621
|
+
if (Array.isArray(value)) {
|
|
622
|
+
for (const child of value) if (astNode(child)) children.push(child);
|
|
623
|
+
} else if (astNode(value)) children.push(value);
|
|
624
|
+
}
|
|
625
|
+
return children;
|
|
626
|
+
}
|
|
627
|
+
function workflowCallKind(node: acorn.AnyNode): WorkflowCallKind | undefined {
|
|
628
|
+
if (node.type !== "CallExpression" || node.callee.type !== "Identifier") return undefined;
|
|
629
|
+
const kind = node.callee.name as WorkflowCallKind;
|
|
630
|
+
return WORKFLOW_CALL_KINDS.includes(kind) ? kind : undefined;
|
|
631
|
+
}
|
|
616
632
|
function workflowCalls(program: acorn.Program): WorkflowCall[] {
|
|
617
633
|
const calls: WorkflowCall[] = [];
|
|
618
634
|
const visit = (node: acorn.AnyNode): void => {
|
|
619
|
-
if (
|
|
620
|
-
for (const
|
|
621
|
-
if (Array.isArray(value)) {
|
|
622
|
-
for (const child of value as unknown[]) if (astNode(child)) visit(child);
|
|
623
|
-
} else if (astNode(value)) visit(value);
|
|
624
|
-
}
|
|
635
|
+
if (workflowCallKind(node)) calls.push(node as WorkflowCall);
|
|
636
|
+
for (const child of astChildren(node)) visit(child);
|
|
625
637
|
};
|
|
626
638
|
visit(program);
|
|
627
639
|
return calls.sort((left, right) => left.start - right.start);
|
|
@@ -638,9 +650,9 @@ function workflowCallsWithStructure(program: acorn.Program): Array<{ call: Workf
|
|
|
638
650
|
const key = node.key.type === "Identifier" ? node.key.name : node.key.type === "Literal" ? String(node.key.value) : undefined;
|
|
639
651
|
if (scope?.key === null && key) current = { ...current, structure: [...current.structure.slice(0, -1), { ...scope, key }] };
|
|
640
652
|
}
|
|
641
|
-
|
|
653
|
+
const operation = workflowCallKind(node);
|
|
654
|
+
if (operation) {
|
|
642
655
|
const call = node as WorkflowCall;
|
|
643
|
-
const operation = call.callee.name as StaticWorkflowCall["kind"];
|
|
644
656
|
const execution = operation === "parallel" ? "parallel" : operation === "pipeline" ? "sequential" : current.execution;
|
|
645
657
|
calls.push({ call, execution, structure: current.structure });
|
|
646
658
|
for (const [index, argument] of call.arguments.entries()) {
|
|
@@ -650,16 +662,11 @@ function workflowCallsWithStructure(program: acorn.Program): Array<{ call: Workf
|
|
|
650
662
|
}
|
|
651
663
|
return;
|
|
652
664
|
}
|
|
653
|
-
for (const
|
|
654
|
-
if (Array.isArray(value)) {
|
|
655
|
-
for (const child of value as unknown[]) if (astNode(child)) visit(child, current);
|
|
656
|
-
} else if (astNode(value)) visit(value, current);
|
|
657
|
-
}
|
|
665
|
+
for (const child of astChildren(node)) visit(child, current);
|
|
658
666
|
};
|
|
659
667
|
visit(program, { execution: "sequential", structure: [] });
|
|
660
668
|
return calls.sort((left, right) => left.call.start - right.call.start);
|
|
661
669
|
}
|
|
662
|
-
|
|
663
670
|
function validateDirectPrimitiveReferences(program: acorn.AnyNode, name: string): void {
|
|
664
671
|
const visit = (node: acorn.AnyNode, parent?: acorn.AnyNode): void => {
|
|
665
672
|
if (node.type === "Identifier" && node.name === name) {
|
|
@@ -667,18 +674,13 @@ function validateDirectPrimitiveReferences(program: acorn.AnyNode, name: string)
|
|
|
667
674
|
const propertyKey = parent?.type === "Property" && parent.key === node && !parent.computed && !parent.shorthand;
|
|
668
675
|
if (!directCall && !propertyKey) fail("INVALID_METADATA", `${name} calls must use a direct ${name}(...) call; aliases and indirect calls are unsupported`);
|
|
669
676
|
}
|
|
670
|
-
for (const
|
|
671
|
-
if (Array.isArray(value)) {
|
|
672
|
-
for (const child of value as unknown[]) if (astNode(child)) visit(child, node);
|
|
673
|
-
} else if (astNode(value)) visit(value, node);
|
|
674
|
-
}
|
|
677
|
+
for (const child of astChildren(node)) visit(child, node);
|
|
675
678
|
};
|
|
676
679
|
visit(program);
|
|
677
680
|
}
|
|
678
|
-
|
|
679
681
|
function hasIdentifier(node: acorn.AnyNode, name: string): boolean {
|
|
680
682
|
if (node.type === "Identifier" && node.name === name) return true;
|
|
681
|
-
return
|
|
683
|
+
return astChildren(node).some((child) => hasIdentifier(child, name));
|
|
682
684
|
}
|
|
683
685
|
|
|
684
686
|
const INTERNAL_AGENT_NAME = "__pi_extensible_workflows_agent";
|
|
@@ -854,7 +856,7 @@ function staticValue(node: acorn.AnyNode | undefined): StaticValue {
|
|
|
854
856
|
}
|
|
855
857
|
|
|
856
858
|
export interface StaticWorkflowCall {
|
|
857
|
-
kind:
|
|
859
|
+
kind: WorkflowCallKind;
|
|
858
860
|
start: number;
|
|
859
861
|
end: number;
|
|
860
862
|
name: string | null;
|
|
@@ -1022,7 +1024,7 @@ export class WorkflowRegistry {
|
|
|
1022
1024
|
if (!jsonValue(replayed) || !Value.Check(fn.output, replayed)) fail("RESULT_INVALID", `Invalid replay for ${name}`);
|
|
1023
1025
|
return structuredClone(replayed);
|
|
1024
1026
|
}
|
|
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 }));
|
|
1027
|
+
const result: unknown = await fn.run(deepFreeze(structuredClone(input)), Object.freeze({ run: context.run, invoke: context.invoke, agent: context.agent, prompt: context.prompt, parallel: context.parallel, pipeline: context.pipeline, withWorktree: context.withWorktree, checkpoint: context.checkpoint, phase: context.phase, log: context.log }));
|
|
1026
1028
|
if (!jsonValue(result) || !Value.Check(fn.output, result)) fail("RESULT_INVALID", `Invalid output from ${name}`);
|
|
1027
1029
|
const stored = structuredClone(result);
|
|
1028
1030
|
journal.put(path, stored);
|
|
@@ -1212,7 +1214,6 @@ export function createLaunchSnapshot(input: LaunchSnapshotInput): Readonly<Launc
|
|
|
1212
1214
|
}
|
|
1213
1215
|
|
|
1214
1216
|
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");
|
|
1216
1217
|
return deepFreeze(structuredClone(input));
|
|
1217
1218
|
}
|
|
1218
1219
|
|
|
@@ -1953,13 +1954,7 @@ class WorkflowEventPublisher {
|
|
|
1953
1954
|
this.#budgetEvents.set(runId, seen);
|
|
1954
1955
|
}
|
|
1955
1956
|
|
|
1956
|
-
async runStarted(store: RunStore, metadata: WorkflowMetadata
|
|
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
|
-
|
|
1957
|
+
async runStarted(store: RunStore, metadata: WorkflowMetadata): Promise<void> { await this.#publish(store, metadata, WORKFLOW_RUN_STARTED_EVENT, {}); }
|
|
1963
1958
|
async runResumed(store: RunStore, metadata: WorkflowMetadata): Promise<void> { await this.#publish(store, metadata, WORKFLOW_RUN_RESUMED_EVENT, {}); }
|
|
1964
1959
|
|
|
1965
1960
|
async runState(store: RunStore, metadata: WorkflowMetadata, previousState: RunState, state: RunState, reason?: string): Promise<void> {
|
|
@@ -1967,16 +1962,9 @@ class WorkflowEventPublisher {
|
|
|
1967
1962
|
if ((previousState === "paused" || previousState === "interrupted" || previousState === "budget_exhausted") && state === "running") await this.runResumed(store, metadata);
|
|
1968
1963
|
}
|
|
1969
1964
|
|
|
1970
|
-
async runCompleted(store: RunStore, metadata: WorkflowMetadata, resultPath: string
|
|
1971
|
-
|
|
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> {
|
|
1965
|
+
async runCompleted(store: RunStore, metadata: WorkflowMetadata, resultPath: string): Promise<void> { await this.#publish(store, metadata, WORKFLOW_RUN_COMPLETED_EVENT, { resultPath }); }
|
|
1966
|
+
async runFailed(store: RunStore, metadata: WorkflowMetadata, error: unknown, state: "failed" | "stopped" | "interrupted" | "budget_exhausted"): Promise<void> {
|
|
1975
1967
|
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
1968
|
}
|
|
1981
1969
|
|
|
1982
1970
|
async agentState(store: RunStore, metadata: WorkflowMetadata, previous: AgentRecord | undefined, agent: AgentRecord): Promise<void> {
|
|
@@ -2133,16 +2121,26 @@ function nextNamedOccurrence(counters: Map<string, number>, label: string): stri
|
|
|
2133
2121
|
return count === 1 ? label : `${label}#${String(count)}`;
|
|
2134
2122
|
}
|
|
2135
2123
|
|
|
2136
|
-
|
|
2137
2124
|
function withWorkflowFunctions(bridge: WorkflowBridge, store: RunStore, runContext: Readonly<WorkflowRunContext>, variables: Readonly<Record<string, JsonValue>>, registry: WorkflowRegistryApi): WorkflowBridge {
|
|
2138
2125
|
const functionAgentOccurrences = new Map<string, number>();
|
|
2139
2126
|
const functionWorktreeOccurrences = new Map<string, number>();
|
|
2140
|
-
|
|
2127
|
+
const functionInvokeOccurrences = new Map<string, number>();
|
|
2128
|
+
const invokeFunction = async (name: string, input: Readonly<Record<string, JsonValue>>, path: string, signal: AbortSignal, worktreeOwner?: string, structuralPath: readonly string[] = [], breadcrumb?: string): Promise<JsonValue> => {
|
|
2141
2129
|
const replayed = await store.replay(path);
|
|
2142
2130
|
let stored: JsonValue | undefined;
|
|
2143
2131
|
const sideEffects: Promise<void>[] = [];
|
|
2132
|
+
const functionBreadcrumb = breadcrumb ?? name;
|
|
2144
2133
|
const context: WorkflowFunctionContext = {
|
|
2145
2134
|
run: runContext,
|
|
2135
|
+
invoke: async (targetName, targetInput) => {
|
|
2136
|
+
const inherited = inheritedHostAgentPath.getStore() ?? structuralPath;
|
|
2137
|
+
const scopedWorktreeOwner = inheritedHostWorktreeOwner.getStore() ?? worktreeOwner;
|
|
2138
|
+
const key = JSON.stringify([path, inherited, targetName]);
|
|
2139
|
+
const occurrence = (functionInvokeOccurrences.get(key) ?? 0) + 1;
|
|
2140
|
+
functionInvokeOccurrences.set(key, occurrence);
|
|
2141
|
+
const nestedPath = operationPath("function", "nested", path, ...inherited, targetName, `occurrence:${String(occurrence)}`);
|
|
2142
|
+
return invokeFunction(targetName, targetInput, nestedPath, signal, scopedWorktreeOwner, inherited, `${functionBreadcrumb} > ${targetName}`);
|
|
2143
|
+
},
|
|
2146
2144
|
agent: async (...args: readonly unknown[]) => {
|
|
2147
2145
|
if (!bridge.agent || typeof args[0] !== "string") fail("AGENT_FAILED", "No agent bridge is available");
|
|
2148
2146
|
const options = validateAgentOptions(args[1] === undefined ? {} : args[1]);
|
|
@@ -2151,7 +2149,7 @@ function withWorkflowFunctions(bridge: WorkflowBridge, store: RunStore, runConte
|
|
|
2151
2149
|
const key = `${path}\0${JSON.stringify(inherited)}`;
|
|
2152
2150
|
const occurrence = (functionAgentOccurrences.get(key) ?? 0) + 1;
|
|
2153
2151
|
functionAgentOccurrences.set(key, occurrence);
|
|
2154
|
-
return bridge.agent(args[0], options, signal, { structuralPath: [...inherited], callSite: `function:${path}`, occurrence, parentBreadcrumb:
|
|
2152
|
+
return bridge.agent(args[0], options, signal, { structuralPath: [...inherited], callSite: `function:${path}`, occurrence, parentBreadcrumb: functionBreadcrumb, ...(scopedWorktreeOwner ? { worktreeOwner: scopedWorktreeOwner } : {}) });
|
|
2155
2153
|
},
|
|
2156
2154
|
prompt: workflowPrompt,
|
|
2157
2155
|
parallel: (...args: readonly unknown[]) => hostParallel(args[0], args[1]),
|
|
@@ -2168,14 +2166,62 @@ function withWorkflowFunctions(bridge: WorkflowBridge, store: RunStore, runConte
|
|
|
2168
2166
|
await Promise.all(sideEffects);
|
|
2169
2167
|
if (!replayed) await store.complete(path, stored ?? result);
|
|
2170
2168
|
return result;
|
|
2171
|
-
}
|
|
2169
|
+
};
|
|
2170
|
+
return { ...bridge, functions: registry.globals(), variables, function: invokeFunction };
|
|
2172
2171
|
}
|
|
2173
2172
|
|
|
2174
2173
|
function projectTrusted(ctx: unknown): boolean {
|
|
2175
2174
|
const check = object(ctx) ? ctx.isProjectTrusted : undefined;
|
|
2176
2175
|
return typeof check === "function" ? Boolean(Reflect.apply(check, ctx, [])) : true;
|
|
2177
2176
|
}
|
|
2178
|
-
|
|
2177
|
+
type PiHostCapabilities = { registerEntryRenderer?: ExtensionAPI["registerEntryRenderer"]; events?: WorkflowEventSink };
|
|
2178
|
+
function isEntryRenderer(value: unknown): value is NonNullable<PiHostCapabilities["registerEntryRenderer"]> { return typeof value === "function"; }
|
|
2179
|
+
function isWorkflowEventSink(value: unknown): value is WorkflowEventSink { return object(value) && typeof value.emit === "function"; }
|
|
2180
|
+
function piHostCapabilities(pi: unknown): PiHostCapabilities {
|
|
2181
|
+
if (!object(pi)) return {};
|
|
2182
|
+
const registerEntryRenderer = pi.registerEntryRenderer;
|
|
2183
|
+
const events = pi.events;
|
|
2184
|
+
return { ...(isEntryRenderer(registerEntryRenderer) ? { registerEntryRenderer } : {}), ...(isWorkflowEventSink(events) ? { events } : {}) };
|
|
2185
|
+
}
|
|
2186
|
+
type ContextHostCapabilities = { modelRegistry?: ModelRegistryCapability };
|
|
2187
|
+
type ModelSummary = { provider: string; id: string };
|
|
2188
|
+
type ModelRegistryGetter = () => readonly ModelSummary[];
|
|
2189
|
+
type ModelRegistryCapability = { getAll?: ModelRegistryGetter; getAvailable?: ModelRegistryGetter };
|
|
2190
|
+
function isModelRegistryGetter(value: unknown): value is ModelRegistryGetter { return typeof value === "function"; }
|
|
2191
|
+
function contextHostCapabilities(ctx: unknown): ContextHostCapabilities {
|
|
2192
|
+
if (!object(ctx) || !object(ctx.modelRegistry)) return {};
|
|
2193
|
+
const registry = ctx.modelRegistry;
|
|
2194
|
+
const getAll = registry.getAll;
|
|
2195
|
+
const getAvailable = registry.getAvailable;
|
|
2196
|
+
return { modelRegistry: { ...(isModelRegistryGetter(getAll) ? { getAll: () => getAll.call(registry) } : {}), ...(isModelRegistryGetter(getAvailable) ? { getAvailable: () => getAvailable.call(registry) } : {}) } };
|
|
2197
|
+
}
|
|
2198
|
+
type UiSelect = (title: string, options: string[]) => Promise<string | undefined>;
|
|
2199
|
+
type UiInput = (title: string, placeholder?: string) => Promise<string | undefined>;
|
|
2200
|
+
type UiSetStatus = (key: string, text?: string) => void;
|
|
2201
|
+
type UiHostCapabilities = { select?: UiSelect; input?: UiInput; setStatus?: UiSetStatus };
|
|
2202
|
+
function isUiSelect(value: unknown): value is UiSelect { return typeof value === "function"; }
|
|
2203
|
+
function isUiInput(value: unknown): value is UiInput { return typeof value === "function"; }
|
|
2204
|
+
function isUiSetStatus(value: unknown): value is UiSetStatus { return typeof value === "function"; }
|
|
2205
|
+
function uiHostCapabilities(ui: unknown): UiHostCapabilities | undefined {
|
|
2206
|
+
if (!object(ui)) return undefined;
|
|
2207
|
+
const select = ui.select;
|
|
2208
|
+
const input = ui.input;
|
|
2209
|
+
const setStatus = ui.setStatus;
|
|
2210
|
+
return { ...(isUiSelect(select) ? { select } : {}), ...(isUiInput(input) ? { input } : {}), ...(isUiSetStatus(setStatus) ? { setStatus } : {}) };
|
|
2211
|
+
}
|
|
2212
|
+
type TuiHostCapabilities = { terminal?: { rows?: unknown } };
|
|
2213
|
+
function tuiHostCapabilities(tui: unknown): TuiHostCapabilities {
|
|
2214
|
+
if (!object(tui) || !object(tui.terminal)) return {};
|
|
2215
|
+
return { terminal: { ...(tui.terminal.rows === undefined ? {} : { rows: tui.terminal.rows }) } };
|
|
2216
|
+
}
|
|
2217
|
+
function tuiRows(tui: unknown): number { const rows = tuiHostCapabilities(tui).terminal?.rows; return typeof rows === "number" && Number.isFinite(rows) ? rows : 24; }
|
|
2218
|
+
type KeybindingsHostCapabilities = { getKeys?: (name: string) => readonly string[] };
|
|
2219
|
+
function isKeybindingGetter(value: unknown): value is NonNullable<KeybindingsHostCapabilities["getKeys"]> { return typeof value === "function"; }
|
|
2220
|
+
function keybindingsHostCapabilities(keybindings: unknown): KeybindingsHostCapabilities {
|
|
2221
|
+
if (!object(keybindings) || !isKeybindingGetter(keybindings.getKeys)) return {};
|
|
2222
|
+
return { getKeys: keybindings.getKeys };
|
|
2223
|
+
}
|
|
2224
|
+
function keybindingKeys(keybindings: unknown, name: string): readonly string[] | undefined { const getKeys = keybindingsHostCapabilities(keybindings).getKeys; return typeof getKeys === "function" ? getKeys.call(keybindings, name) : undefined; }
|
|
2179
2225
|
function parseThinking(value: unknown): ModelSpec["thinking"] | undefined {
|
|
2180
2226
|
switch (value) {
|
|
2181
2227
|
case "off": case "minimal": case "low": case "medium": case "high": case "xhigh": case "max": return value;
|
|
@@ -2186,7 +2232,7 @@ function parseThinking(value: unknown): ModelSpec["thinking"] | undefined {
|
|
|
2186
2232
|
export default function workflowExtension(pi: ExtensionAPI, home?: string, clipboard = copyToClipboard, createSession: SessionFactory = createNativeAgentSession) {
|
|
2187
2233
|
beginWorkflowExtensionLoading();
|
|
2188
2234
|
const registry = loadingRegistry();
|
|
2189
|
-
const registerEntryRenderer = (pi
|
|
2235
|
+
const registerEntryRenderer = piHostCapabilities(pi).registerEntryRenderer;
|
|
2190
2236
|
registerEntryRenderer?.<WorkflowLogEntry>(WORKFLOW_LOG_ENTRY, (entry) => {
|
|
2191
2237
|
const data = entry.data;
|
|
2192
2238
|
return textBlock(data ? `Workflow ${data.workflowName}: ${data.message}` : "");
|
|
@@ -2196,7 +2242,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2196
2242
|
try { pi.appendEntry<WorkflowLogEntry>(WORKFLOW_LOG_ENTRY, { workflowName, message: utf8Prefix(message, DELIVERY_LIMIT_BYTES) }); }
|
|
2197
2243
|
finally { await lifecycle.leave(); }
|
|
2198
2244
|
};
|
|
2199
|
-
const eventPublisher = new WorkflowEventPublisher((pi
|
|
2245
|
+
const eventPublisher = new WorkflowEventPublisher(piHostCapabilities(pi).events);
|
|
2200
2246
|
pi.on("resources_discover", () => {
|
|
2201
2247
|
if (!pi.getActiveTools().includes("workflow")) return;
|
|
2202
2248
|
const extensionDir = dirname(fileURLToPath(import.meta.url));
|
|
@@ -2205,6 +2251,24 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2205
2251
|
});
|
|
2206
2252
|
type BudgetDecisionResult = { state: "running" | "budget_exhausted"; approved: boolean };
|
|
2207
2253
|
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 }>();
|
|
2254
|
+
const liveActivities = new Map<string, Map<string, AgentActivity>>();
|
|
2255
|
+
const setLiveActivity = (runId: string, agentId: string, activity?: AgentActivity) => {
|
|
2256
|
+
const activities = liveActivities.get(runId);
|
|
2257
|
+
if (activity) {
|
|
2258
|
+
if (activities) activities.set(agentId, activity);
|
|
2259
|
+
else liveActivities.set(runId, new Map([[agentId, activity]]));
|
|
2260
|
+
} else {
|
|
2261
|
+
activities?.delete(agentId);
|
|
2262
|
+
if (activities?.size === 0) liveActivities.delete(runId);
|
|
2263
|
+
}
|
|
2264
|
+
};
|
|
2265
|
+
const withLiveActivities = (run: PersistedRun): PersistedRun => {
|
|
2266
|
+
const activities = liveActivities.get(run.id);
|
|
2267
|
+
return activities?.size ? { ...run, agents: run.agents.map((agent) => {
|
|
2268
|
+
const activity = activities.get(agent.id);
|
|
2269
|
+
return activity ? { ...agent, activity } : agent;
|
|
2270
|
+
}) } : run;
|
|
2271
|
+
};
|
|
2208
2272
|
const conversationLocks = new Set<string>();
|
|
2209
2273
|
const terminalRunStates = new Map<string, "completed" | "failed" | "stopped">();
|
|
2210
2274
|
let sessionLease: SessionLease | undefined;
|
|
@@ -2240,7 +2304,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2240
2304
|
return nextRun;
|
|
2241
2305
|
});
|
|
2242
2306
|
await eventPublisher.runState(store, metadata, previous, next, reason);
|
|
2243
|
-
runs.get(store.runId)?.update?.(workflowToolUpdate(persisted));
|
|
2307
|
+
runs.get(store.runId)?.update?.(workflowToolUpdate(withLiveActivities(persisted)));
|
|
2244
2308
|
});
|
|
2245
2309
|
const scheduler = new FairAgentScheduler(async ({ id, runId, parentId, prompt, options, signal, setSteer }) => {
|
|
2246
2310
|
const run = runs.get(runId);
|
|
@@ -2257,7 +2321,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2257
2321
|
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) };
|
|
2258
2322
|
}
|
|
2259
2323
|
if (!runState.agents.some((agent) => agent.id === id)) return;
|
|
2260
|
-
|
|
2324
|
+
setLiveActivity(runId, id, progress.activity);
|
|
2325
|
+
run.update?.(workflowToolUpdate(withLiveActivities(runState)));
|
|
2261
2326
|
};
|
|
2262
2327
|
const onAttempt = async (attempt: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile" | "setup">) => {
|
|
2263
2328
|
await scheduler.flush();
|
|
@@ -2268,7 +2333,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2268
2333
|
const active = (await run.store.load()).run;
|
|
2269
2334
|
await eventPublisher.agentStates(run.store, run.metadata, before.agents, active.agents);
|
|
2270
2335
|
const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
|
|
2271
|
-
run.update?.(workflowToolUpdate(persisted));
|
|
2336
|
+
run.update?.(workflowToolUpdate(withLiveActivities(persisted)));
|
|
2272
2337
|
};
|
|
2273
2338
|
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
2339
|
const before = (await run.store.load()).run;
|
|
@@ -2276,7 +2341,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2276
2341
|
const completed = (await run.store.load()).run;
|
|
2277
2342
|
await eventPublisher.agentStates(run.store, run.metadata, before.agents, completed.agents);
|
|
2278
2343
|
const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
|
|
2279
|
-
|
|
2344
|
+
setLiveActivity(runId, id);
|
|
2345
|
+
run.update?.(workflowToolUpdate(withLiveActivities(persisted)));
|
|
2280
2346
|
return result.value;
|
|
2281
2347
|
} catch (error) {
|
|
2282
2348
|
const attempts = (error as WorkflowError & { attempts?: readonly AgentAttempt[] }).attempts;
|
|
@@ -2287,7 +2353,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2287
2353
|
await eventPublisher.agentStates(run.store, run.metadata, before.agents, failed.agents);
|
|
2288
2354
|
}
|
|
2289
2355
|
const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
|
|
2290
|
-
|
|
2356
|
+
setLiveActivity(runId, id);
|
|
2357
|
+
run.update?.(workflowToolUpdate(withLiveActivities(persisted)));
|
|
2291
2358
|
throw error;
|
|
2292
2359
|
}
|
|
2293
2360
|
}, 16, async (runId, ownership) => {
|
|
@@ -2309,7 +2376,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2309
2376
|
return { ...current, agents };
|
|
2310
2377
|
});
|
|
2311
2378
|
await eventPublisher.agentStates(run.store, run.metadata, previousAgents, runState.agents);
|
|
2312
|
-
run.update?.(workflowToolUpdate(runState));
|
|
2379
|
+
run.update?.(workflowToolUpdate(withLiveActivities(runState)));
|
|
2313
2380
|
});
|
|
2314
2381
|
type WorkflowStopResult = { runId: string; state: RunState | "unknown"; stopped: boolean; reason?: "unknown_run" | "already_terminal" };
|
|
2315
2382
|
const stopWorkflowRun = async (runId: string): Promise<WorkflowStopResult> => {
|
|
@@ -2503,7 +2570,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2503
2570
|
try { variables = await resolveWorkflowVariables(runContext, controller, registry); }
|
|
2504
2571
|
catch (error) {
|
|
2505
2572
|
const typed = asWorkflowError(error);
|
|
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,
|
|
2573
|
+
if (!["completed", "failed", "stopped"].includes(run.lifecycle.state)) { await run.lifecycle.terminal("failed", typed.code).catch(() => undefined); const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, error: { code: typed.code, message: typed.message } })); await eventPublisher.runFailed(run.store, run.metadata, typed, run.lifecycle.state === "interrupted" ? "interrupted" : "failed"); run.update?.(workflowToolUpdate(persisted)); }
|
|
2507
2574
|
throw typed;
|
|
2508
2575
|
}
|
|
2509
2576
|
await scheduler.cancelRun(run.store.runId);
|
|
@@ -2553,7 +2620,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2553
2620
|
if (run.budget.hardExhausted) throw new WorkflowError("BUDGET_EXHAUSTED", "Budgeted work was attempted after hard exhaustion");
|
|
2554
2621
|
const resultPath = await run.store.saveResult(value);
|
|
2555
2622
|
await run.lifecycle.terminal("completed", "completed");
|
|
2556
|
-
await eventPublisher.runCompleted(run.store, run.metadata, resultPath
|
|
2623
|
+
await eventPublisher.runCompleted(run.store, run.metadata, resultPath);
|
|
2557
2624
|
return { value, resultPath };
|
|
2558
2625
|
}).catch(async (error: unknown) => {
|
|
2559
2626
|
await scheduler.flush();
|
|
@@ -2561,7 +2628,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2561
2628
|
if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state)) await run.lifecycle.terminal(typed.code === "BUDGET_EXHAUSTED" ? "budget_exhausted" : "failed", typed.code);
|
|
2562
2629
|
const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot(), error: { code: typed.code, message: typed.message } }));
|
|
2563
2630
|
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,
|
|
2631
|
+
await eventPublisher.runFailed(run.store, run.metadata, typed, state);
|
|
2565
2632
|
run.update?.(workflowToolUpdate(persisted));
|
|
2566
2633
|
if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state)) deliverFailure(pi, run.metadata.name, error);
|
|
2567
2634
|
});
|
|
@@ -2650,7 +2717,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2650
2717
|
for (const decision of await store.pendingWorkflowDecisions()) deliver(pi, budgetDecisionDelivery(loaded.snapshot.metadata, decision));
|
|
2651
2718
|
scheduler.restoreRun(runId, loaded.snapshot.settings.concurrency, loaded.snapshot.identityVersion === LAUNCH_SNAPSHOT_IDENTITY_VERSION ? await store.loadOwnership() : [], () => runs.get(runId)?.budget.checkAgentLaunch());
|
|
2652
2719
|
}
|
|
2653
|
-
const resumeSelect = (ctx.ui
|
|
2720
|
+
const resumeSelect = uiHostCapabilities(ctx.ui)?.select;
|
|
2654
2721
|
if (ctx.hasUI && resumeSelect) {
|
|
2655
2722
|
const interrupted = [...runs.values()].filter((r) => r.lifecycle.state === "interrupted");
|
|
2656
2723
|
if (interrupted.length > 0) {
|
|
@@ -2690,7 +2757,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2690
2757
|
const budget = validateBudget(params.budget);
|
|
2691
2758
|
const rootModel: ModelSpec = { provider: ctx.model.provider, model: ctx.model.id, thinking: pi.getThinkingLevel() };
|
|
2692
2759
|
const rootModelName = `${rootModel.provider}/${rootModel.model}`;
|
|
2693
|
-
const modelRegistry = (ctx
|
|
2760
|
+
const modelRegistry = contextHostCapabilities(ctx).modelRegistry;
|
|
2694
2761
|
const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? [ctx.model]).map((model) => `${model.provider}/${model.id}`));
|
|
2695
2762
|
knownModels.add(rootModelName);
|
|
2696
2763
|
const availableModels = knownModels;
|
|
@@ -2771,13 +2838,13 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2771
2838
|
} finally { await lifecycle.leave(); }
|
|
2772
2839
|
}, log: logBridge(lifecycle, checked.metadata.name) }, store, runContext, variables, registry), runController.signal);
|
|
2773
2840
|
(runs.get(runId) as NonNullable<ReturnType<typeof runs.get>>).execution = execution;
|
|
2774
|
-
await eventPublisher.runStarted(store, checked.metadata
|
|
2841
|
+
await eventPublisher.runStarted(store, checked.metadata);
|
|
2775
2842
|
const finish = execution.result.then(async (value) => {
|
|
2776
2843
|
await scheduler.flush();
|
|
2777
2844
|
if (budgetRuntime.hardExhausted) throw new WorkflowError("BUDGET_EXHAUSTED", "Budgeted work was attempted after hard exhaustion");
|
|
2778
2845
|
const resultPath = await store.saveResult(value);
|
|
2779
2846
|
await lifecycle.terminal("completed", "completed");
|
|
2780
|
-
await eventPublisher.runCompleted(store, checked.metadata, resultPath
|
|
2847
|
+
await eventPublisher.runCompleted(store, checked.metadata, resultPath);
|
|
2781
2848
|
return { value, resultPath };
|
|
2782
2849
|
}).catch(async (error: unknown) => {
|
|
2783
2850
|
await scheduler.flush();
|
|
@@ -2785,7 +2852,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2785
2852
|
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
2853
|
await persistRunState(store, checked.metadata, (current) => ({ ...current, ...budgetRuntime.snapshot(), error: { code: typed.code, message: typed.message } }));
|
|
2787
2854
|
const state = lifecycle.state === "stopped" || lifecycle.state === "interrupted" || lifecycle.state === "budget_exhausted" ? lifecycle.state : "failed";
|
|
2788
|
-
await eventPublisher.runFailed(store, checked.metadata, typed,
|
|
2855
|
+
await eventPublisher.runFailed(store, checked.metadata, typed, state);
|
|
2789
2856
|
throw typed;
|
|
2790
2857
|
});
|
|
2791
2858
|
(runs.get(runId) as NonNullable<ReturnType<typeof runs.get>>).completion = finish;
|
|
@@ -2842,7 +2909,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2842
2909
|
let stores = await loadStores();
|
|
2843
2910
|
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."
|
|
2844
2911
|
const setWorkflowStatus = (text: string | undefined) => {
|
|
2845
|
-
const setStatus = (ctx.ui
|
|
2912
|
+
const setStatus = uiHostCapabilities(ctx.ui)?.setStatus;
|
|
2846
2913
|
setStatus?.call(ctx.ui, "workflow-stop", text);
|
|
2847
2914
|
};
|
|
2848
2915
|
const runAction = async (actionCommand: string, keepContext: boolean, status: (text: string | undefined) => void = setWorkflowStatus): Promise<"dashboard" | "picker" | "done"> => {
|
|
@@ -2883,7 +2950,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2883
2950
|
return keepContext ? "dashboard" : "done";
|
|
2884
2951
|
}
|
|
2885
2952
|
if (action === "adjust" && run?.lifecycle.state === "budget_exhausted") {
|
|
2886
|
-
const input = await (ctx.ui
|
|
2953
|
+
const input = await uiHostCapabilities(ctx.ui)?.input?.call(ctx.ui, "Budget patch (JSON)", "{\"tokens\":{\"hard\":null}}" );
|
|
2887
2954
|
if (input === undefined) return keepContext ? "dashboard" : "done";
|
|
2888
2955
|
const result = await resumeWorkflowRun(run.store.runId, JSON.parse(input));
|
|
2889
2956
|
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");
|
|
@@ -2910,7 +2977,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2910
2977
|
};
|
|
2911
2978
|
const manageAliases = async (): Promise<void> => {
|
|
2912
2979
|
const settingsPath = workflowSettingsPath();
|
|
2913
|
-
const modelRegistry = (ctx
|
|
2980
|
+
const modelRegistry = contextHostCapabilities(ctx).modelRegistry;
|
|
2914
2981
|
const available = () => [...new Set((modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`))].sort();
|
|
2915
2982
|
const selectTarget = async (): Promise<string | undefined> => {
|
|
2916
2983
|
const models = available();
|
|
@@ -3016,7 +3083,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
3016
3083
|
await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
|
|
3017
3084
|
let offset = 0;
|
|
3018
3085
|
let renderedLines: string[] = [];
|
|
3019
|
-
const viewport = () => Math.max(1, (
|
|
3086
|
+
const viewport = () => Math.max(1, tuiRows(tui) - 3);
|
|
3020
3087
|
const move = (delta: number) => { offset = Math.max(0, Math.min(Math.max(0, renderedLines.length - viewport()), offset + delta)); };
|
|
3021
3088
|
return {
|
|
3022
3089
|
render(width: number) {
|
|
@@ -3137,11 +3204,10 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
3137
3204
|
let disposed = false;
|
|
3138
3205
|
let stopRequested = false;
|
|
3139
3206
|
let stopStatus: string | undefined;
|
|
3140
|
-
const terminalRows = () => Math.max(1, (
|
|
3207
|
+
const terminalRows = () => Math.max(1, tuiRows(tui));
|
|
3141
3208
|
const keyLabels: Record<string, string> = { up: "↑", down: "↓", pageUp: "pgup", pageDown: "pgdn", escape: "esc" };
|
|
3142
3209
|
const keyLabel = (binding: string, fallback: string) => {
|
|
3143
|
-
const
|
|
3144
|
-
const keys = getKeys?.call(keybindings, binding);
|
|
3210
|
+
const keys = keybindingKeys(keybindings, binding);
|
|
3145
3211
|
return keys?.length ? keys.map((key) => keyLabels[key] ?? key).join("/") : fallback;
|
|
3146
3212
|
};
|
|
3147
3213
|
const dashboardLayout = () => {
|
|
@@ -3235,7 +3301,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
3235
3301
|
const highlighted = highlightCode(view.script, "javascript");
|
|
3236
3302
|
let offset = 0;
|
|
3237
3303
|
let renderedLines: string[] = [];
|
|
3238
|
-
const viewport = () => Math.max(1, (
|
|
3304
|
+
const viewport = () => Math.max(1, tuiRows(tui) - 3);
|
|
3239
3305
|
const move = (delta: number) => {
|
|
3240
3306
|
const maxOffset = Math.max(0, renderedLines.length - viewport());
|
|
3241
3307
|
offset = Math.max(0, Math.min(maxOffset, offset + delta));
|
|
@@ -3289,7 +3355,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
3289
3355
|
let offset = 0;
|
|
3290
3356
|
let renderedLines: string[] = [];
|
|
3291
3357
|
const layout = () => {
|
|
3292
|
-
const rows = Math.max(1, (
|
|
3358
|
+
const rows = Math.max(1, tuiRows(tui));
|
|
3293
3359
|
const compactControls = rows < 4;
|
|
3294
3360
|
const titleRows = rows >= 5 ? 1 : 0;
|
|
3295
3361
|
const hintRows = rows >= 8 ? 1 : 0;
|
package/src/persistence.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { execFile } from "node:child_process";
|
|
3
|
+
import { renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
4
|
import { access, link, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
4
5
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
5
6
|
import { homedir } from "node:os";
|
|
@@ -158,10 +159,28 @@ export function structuralPath(...names: string[]): string {
|
|
|
158
159
|
return names.map((name) => encodeURIComponent(name)).join("/");
|
|
159
160
|
}
|
|
160
161
|
|
|
161
|
-
|
|
162
|
+
export function atomicWriteFile(path: string, content: string): Promise<void>;
|
|
163
|
+
export function atomicWriteFile(path: string, content: string, sync: true): void;
|
|
164
|
+
export function atomicWriteFile(path: string, content: string, sync = false): Promise<void> | void {
|
|
162
165
|
const temporary = `${path}.${String(process.pid)}.${randomUUID()}.tmp`;
|
|
163
|
-
|
|
164
|
-
|
|
166
|
+
if (sync) {
|
|
167
|
+
try {
|
|
168
|
+
writeFileSync(temporary, content, { encoding: "utf8", mode: 0o600 });
|
|
169
|
+
renameSync(temporary, path);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
try { rmSync(temporary, { force: true }); } catch { /* Preserve the original write error. */ }
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
return writeFile(temporary, content, { encoding: "utf8", mode: 0o600 }).then(() => rename(temporary, path)).catch(async (error: unknown) => {
|
|
177
|
+
try { await rm(temporary, { force: true }); } catch { /* Preserve the original write error. */ }
|
|
178
|
+
throw error;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function atomicJson(path: string, value: unknown): Promise<void> {
|
|
183
|
+
await atomicWriteFile(path, `${JSON.stringify(value)}\n`);
|
|
165
184
|
}
|
|
166
185
|
|
|
167
186
|
async function json<T>(path: string): Promise<T> { return JSON.parse(await readFile(path, "utf8")) as T; }
|
|
@@ -245,7 +264,7 @@ export class RunStore {
|
|
|
245
264
|
}
|
|
246
265
|
|
|
247
266
|
async appendEvent(event: WorkflowRunEvent): Promise<void> {
|
|
248
|
-
await this.updateState((run) => ({ ...run, events: [...(run.events ?? []), ...(run.events?.some((current) => current.message === event.message) ? [] : [event])] }));
|
|
267
|
+
await this.updateState((run) => ({ ...run, events: [...(run.events ?? []), ...(run.events?.some((current) => current.type === event.type && current.message === event.message) ? [] : [event])] }));
|
|
249
268
|
}
|
|
250
269
|
|
|
251
270
|
async saveOwnership(nodes: readonly PersistedOwnershipNode[]): Promise<void> {
|