pi-workflow-engine 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.pi/extensions/pi-workflow-engine/index.ts +199 -78
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +3 -1
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +18 -4
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +34 -7
- package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +9 -3
- package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +51 -8
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +14 -0
- package/.pi/extensions/pi-workflow-engine/src/codemode-slots.ts +73 -0
- package/.pi/extensions/pi-workflow-engine/src/config-file.ts +52 -0
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +127 -58
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +114 -31
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +74 -18
- package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +5 -0
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +7 -1
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +22 -4
- package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +9 -47
- package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +32 -4
- package/.pi/extensions/pi-workflow-engine/src/prompt-sections.ts +15 -0
- package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +4 -4
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/types.ts +44 -9
- package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +9 -3
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +69 -18
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +17 -6
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +8 -3
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +16 -8
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +17 -6
- package/.pi/extensions/pi-workflow-engine/src/workflow-catalog.ts +79 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +6 -2
- package/.pi/extensions/pi-workflow-engine/src/workflow-input.ts +13 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +21 -4
- package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +26 -6
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +2 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +69 -13
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +23 -4
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +27 -1
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-seal.ts +83 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +30 -4
- package/.pi/extensions/pi-workflow-engine/src/workflow-script-file.ts +50 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-settings.ts +147 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-tool-request.ts +61 -0
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/workflows/research.ts +12 -4
- package/README.md +11 -3
- package/USAGE.md +121 -22
- package/package.json +13 -4
- package/skills/workflow-authoring/SKILL.md +160 -0
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { CustomEditor, type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import { CustomEditor, type BeforeAgentStartEvent, type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { EditorComponent } from "@earendil-works/pi-tui";
|
|
3
3
|
import { completeCurrentArgument } from "./command-completions.ts";
|
|
4
4
|
import type { DynamaxShortcuts } from "./dynamax-shortcuts.ts";
|
|
5
|
+
import { appendSectionToForcedPrompt } from "./prompt-sections.ts";
|
|
5
6
|
import { sessionKey } from "./session-identity.ts";
|
|
7
|
+
import { WORKFLOW_TOOL_NAME } from "./types.ts";
|
|
6
8
|
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
7
9
|
import { dot, GLYPH } from "./ui/kit.ts";
|
|
8
10
|
import { setWorkflowInspectorShortcut } from "./ui/workflow-widget.ts";
|
|
@@ -20,8 +22,19 @@ export interface DynamaxState {
|
|
|
20
22
|
turnActive: boolean;
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
/** The output-token pool a `+500k`-style directive in the user's prompt sets for that turn's workflow runs. */
|
|
26
|
+
export interface DynamaxTurnBudget {
|
|
27
|
+
readonly total: number;
|
|
28
|
+
/** Tokens not yet spent by this turn's synchronous runs; never negative. */
|
|
29
|
+
remaining(): number;
|
|
30
|
+
/** Count a finished synchronous run's output tokens against the pool. */
|
|
31
|
+
spend(outputTokens: number): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
23
34
|
export interface DynamaxRuntime {
|
|
24
35
|
state: DynamaxState;
|
|
36
|
+
/** Set by a budget directive in a user prompt, cleared when that run settles. */
|
|
37
|
+
turnBudget?: DynamaxTurnBudget;
|
|
25
38
|
}
|
|
26
39
|
|
|
27
40
|
export type DynamaxRuntimeStore = Map<string, DynamaxRuntime>;
|
|
@@ -35,9 +48,18 @@ export interface DynamaxRegistrationOptions {
|
|
|
35
48
|
export interface DynamaxHandle {
|
|
36
49
|
/** Opt the next agent run in. The `input` hook ignores prompts this extension sends (pi tags them `source: "extension"`). */
|
|
37
50
|
markOneShot(ctx: ExtensionContext): void;
|
|
51
|
+
/** The current turn's output-token pool, when the user's prompt set one. */
|
|
52
|
+
turnBudget(ctx: ExtensionContext): DynamaxTurnBudget | undefined;
|
|
53
|
+
/** Whether Dynamax covers this session now: sticky, a pending one-shot, or a one-shot turn in progress. */
|
|
54
|
+
isActive(ctx: ExtensionContext): boolean;
|
|
38
55
|
}
|
|
39
56
|
|
|
40
57
|
export const DYNAMAX_TOKEN_PATTERN = /(^|[^A-Za-z0-9_])dynamax([^A-Za-z0-9_]|$)/i;
|
|
58
|
+
/** `+500k`, `+1.5m`, `+2M`, `+250000`: after the start or whitespace, before the end, whitespace, or punctuation. */
|
|
59
|
+
export const BUDGET_DIRECTIVE_PATTERN = /(?:^|\s)\+(\d+(?:\.\d+)?)([km]?)(?=$|\s|[.,;:!?)\]}"'])/i;
|
|
60
|
+
/** Smaller values are ignored, so a `+1` or `+2` in ordinary prose never caps a run at a few tokens. */
|
|
61
|
+
export const BUDGET_DIRECTIVE_MIN_TOKENS = 1_000;
|
|
62
|
+
const BUDGET_DIRECTIVE_MULTIPLIERS: Readonly<Record<string, number>> = { "": 1, k: 1_000, m: 1_000_000 };
|
|
41
63
|
export const DYNAMAX_STATUS_KEY = "dynamax";
|
|
42
64
|
|
|
43
65
|
type EditorFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>>;
|
|
@@ -53,29 +75,31 @@ function disposeDynamaxDecorations(decorations: Set<DynamaxEditorDecoration>): v
|
|
|
53
75
|
decorations.clear();
|
|
54
76
|
}
|
|
55
77
|
|
|
78
|
+
/** With no argument the command toggles sticky mode, so these name only the explicit forms. */
|
|
56
79
|
const DYNAMAX_ACTION_COMPLETIONS = [
|
|
57
|
-
{ value: "on", description: "
|
|
80
|
+
{ value: "on", description: "Make workflow orchestration the default for this session" },
|
|
58
81
|
{ value: "off", description: "Disable sticky and pending Dynamax modes" },
|
|
59
82
|
{ value: "status", description: "Show the current Dynamax state" },
|
|
60
83
|
] as const;
|
|
61
84
|
|
|
62
|
-
export const ADAPTIVE_WORKFLOW_GUIDANCE = `
|
|
63
|
-
Adaptive multi-pass workflows are optional. Use a simple single-pass fan-out when it is sufficient. When the first pass may expose gaps, conflicts, weak claims, or missing evidence:
|
|
64
|
-
- run the bounded first-pass agents;
|
|
65
|
-
- give their surviving results to a structured gap-analysis agent so the LLM decides what needs follow-up;
|
|
66
|
-
- put a hard maxItems bound on LLM-authored task arrays and defensively slice them before fan-out;
|
|
67
|
-
- use ordinary TypeScript conditionals or bounded loops to launch follow-up agents only when gaps exist;
|
|
68
|
-
- synthesize the first-pass and follow-up results together.
|
|
69
|
-
Do not generate a second pass when the first pass is sufficient, and do not invent iteration, quorum, graph, reduction, or retry primitives for this pattern.`;
|
|
70
|
-
|
|
71
85
|
/** System prompt section pi wraps as `<dynamax>`; adding or dropping it appends a section patch instead of rewriting the prompt. */
|
|
72
86
|
export const DYNAMAX_SECTION = "dynamax";
|
|
73
87
|
|
|
74
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* Only the opt-in itself: the workflow tool's guidelines send the model to the workflow-authoring
|
|
90
|
+
* skill, which carries the authoring rules and patterns.
|
|
91
|
+
*/
|
|
75
92
|
export const DYNAMAX_REMINDER =
|
|
76
93
|
"The user opted into dynamax multi-agent orchestration for this request, so the workflow tool is permitted. " +
|
|
77
94
|
"Run a registered workflow by name, or author an inline workflow script when none fits, following the workflow tool's guidelines.";
|
|
78
95
|
|
|
96
|
+
/** Sticky mode is a standing default for the session, not a one-request permission. */
|
|
97
|
+
export const DYNAMAX_STICKY_REMINDER =
|
|
98
|
+
"Dynamax is on for this session. Orchestrate every substantive task with the workflow tool by default: " +
|
|
99
|
+
"scout inline first to find the work list, then run one single-phase workflow per phase (understand, design, implement, review) " +
|
|
100
|
+
"and read each result before choosing the next. Lean toward adversarial verification of findings. " +
|
|
101
|
+
"Work without a workflow only for conversational turns and trivial or already-verified edits.";
|
|
102
|
+
|
|
79
103
|
export function createDynamaxState(): DynamaxState {
|
|
80
104
|
return { sticky: false, oneShotPending: false, turnActive: false };
|
|
81
105
|
}
|
|
@@ -97,6 +121,48 @@ export function hasDynamaxToken(text: string): boolean {
|
|
|
97
121
|
return DYNAMAX_TOKEN_PATTERN.test(text);
|
|
98
122
|
}
|
|
99
123
|
|
|
124
|
+
/** The output tokens the first budget directive in `text` asks for, if any. */
|
|
125
|
+
export function parseBudgetDirective(text: string): number | undefined {
|
|
126
|
+
const match = BUDGET_DIRECTIVE_PATTERN.exec(text);
|
|
127
|
+
if (!match) return undefined;
|
|
128
|
+
const multiplier = BUDGET_DIRECTIVE_MULTIPLIERS[(match[2] ?? "").toLowerCase()] ?? 1;
|
|
129
|
+
// Round, not floor: 2.3 * 1000 is 2299.999... in floating point.
|
|
130
|
+
const tokens = Math.round(Number(match[1]) * multiplier);
|
|
131
|
+
return Number.isSafeInteger(tokens) && tokens >= BUDGET_DIRECTIVE_MIN_TOKENS ? tokens : undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function createDynamaxTurnBudget(total: number): DynamaxTurnBudget {
|
|
135
|
+
let spent = 0;
|
|
136
|
+
return {
|
|
137
|
+
total,
|
|
138
|
+
remaining: () => Math.max(0, total - spent),
|
|
139
|
+
spend(outputTokens) {
|
|
140
|
+
if (Number.isFinite(outputTokens) && outputTokens > 0) spent += outputTokens;
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export type DynamaxTurnBudgetResolution =
|
|
146
|
+
| { readonly kind: "budget"; readonly budget: number | undefined }
|
|
147
|
+
| { readonly kind: "spent"; readonly total: number };
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* A run's budget. Without a turn pool it is the explicit one, if any. The user's pool caps every run in
|
|
151
|
+
* its turn: the remainder, clamped to the run budget range, is the budget, and an explicit one can only
|
|
152
|
+
* lower it. A pool with less than `min` left is spent.
|
|
153
|
+
*/
|
|
154
|
+
export function resolveTurnBudget(
|
|
155
|
+
explicit: number | undefined,
|
|
156
|
+
pool: DynamaxTurnBudget | undefined,
|
|
157
|
+
range: { readonly min: number; readonly max: number },
|
|
158
|
+
): DynamaxTurnBudgetResolution {
|
|
159
|
+
if (pool === undefined) return { kind: "budget", budget: explicit };
|
|
160
|
+
const remaining = pool.remaining();
|
|
161
|
+
if (remaining < range.min) return { kind: "spent", total: pool.total };
|
|
162
|
+
const cap = Math.min(range.max, Math.floor(remaining));
|
|
163
|
+
return { kind: "budget", budget: explicit === undefined ? cap : Math.min(explicit, cap) };
|
|
164
|
+
}
|
|
165
|
+
|
|
100
166
|
export function markDynamaxOneShot(state: DynamaxState): void {
|
|
101
167
|
state.oneShotPending = true;
|
|
102
168
|
}
|
|
@@ -136,12 +202,21 @@ export function dynamaxStatusText(state: DynamaxState, theme: Theme): string {
|
|
|
136
202
|
/**
|
|
137
203
|
* Add the opt-in section for this run and consume a pending one-shot. pi rebuilds `sections` for every
|
|
138
204
|
* run, so an inactive run simply leaves it out and pi records the removal as a patch.
|
|
205
|
+
*
|
|
206
|
+
* Sticky mode covers every run, including one a custom message started (`sendMessage` with
|
|
207
|
+
* `triggerTurn`, such as a background completion). Only a user prompt consumes the one-shot: the
|
|
208
|
+
* opt-in belongs to the request the user typed it in.
|
|
139
209
|
*/
|
|
140
|
-
export function applyDynamaxPromptSection(
|
|
141
|
-
|
|
142
|
-
state
|
|
210
|
+
export function applyDynamaxPromptSection(
|
|
211
|
+
sections: Record<string, string>,
|
|
212
|
+
state: DynamaxState,
|
|
213
|
+
messageType: BeforeAgentStartEvent["messageType"] = "user",
|
|
214
|
+
): boolean {
|
|
215
|
+
const oneShot = messageType === "user" && state.oneShotPending;
|
|
216
|
+
if (!state.sticky && !oneShot) return false;
|
|
217
|
+
if (oneShot) state.oneShotPending = false;
|
|
143
218
|
state.turnActive = true;
|
|
144
|
-
sections[DYNAMAX_SECTION] = DYNAMAX_REMINDER;
|
|
219
|
+
sections[DYNAMAX_SECTION] = state.sticky ? DYNAMAX_STICKY_REMINDER : DYNAMAX_REMINDER;
|
|
145
220
|
return true;
|
|
146
221
|
}
|
|
147
222
|
|
|
@@ -230,23 +305,30 @@ export function registerDynamax(pi: ExtensionAPI, shortcuts: DynamaxShortcuts, o
|
|
|
230
305
|
});
|
|
231
306
|
|
|
232
307
|
pi.on("input", (event, ctx) => {
|
|
233
|
-
if (event.source
|
|
308
|
+
if (event.source === "extension") return { action: "continue" };
|
|
309
|
+
if (hasDynamaxToken(event.text)) markOneShot(ctx);
|
|
310
|
+
const budget = parseBudgetDirective(event.text);
|
|
311
|
+
if (budget !== undefined) getDynamaxRuntime(runtimes, ctx).turnBudget = createDynamaxTurnBudget(budget);
|
|
234
312
|
return { action: "continue" };
|
|
235
313
|
});
|
|
236
314
|
|
|
237
315
|
pi.on("before_agent_start", (event, ctx) => {
|
|
238
|
-
const runtime = getDynamaxRuntime(runtimes, ctx);
|
|
239
316
|
const options = event.systemPromptOptions;
|
|
240
|
-
|
|
317
|
+
// Subagent sessions load this extension but withhold the workflow tool, so a `dynamax` in a
|
|
318
|
+
// subagent prompt must neither permit that tool nor consume the one-shot.
|
|
319
|
+
if (!options.selectedTools.includes(WORKFLOW_TOOL_NAME)) return undefined;
|
|
320
|
+
const runtime = getDynamaxRuntime(runtimes, ctx);
|
|
321
|
+
if (!applyDynamaxPromptSection(options.sections, runtime.state, event.messageType)) return undefined;
|
|
241
322
|
updateDynamaxSurfaces(ctx, runtime);
|
|
242
|
-
|
|
243
|
-
if (options.forceSystemPrompt === undefined) return undefined;
|
|
244
|
-
return { systemPrompt: `${options.forceSystemPrompt}\n\n<${DYNAMAX_SECTION}>\n${DYNAMAX_REMINDER}\n</${DYNAMAX_SECTION}>` };
|
|
323
|
+
return appendSectionToForcedPrompt(options, DYNAMAX_SECTION);
|
|
245
324
|
});
|
|
246
325
|
|
|
247
|
-
|
|
326
|
+
// Not agent_end: pi emits that per agent loop, and one run continues in fresh loops after a
|
|
327
|
+
// provider retry, overflow compaction, or queued follow-up. agent_settled fires once per run.
|
|
328
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
248
329
|
const runtime = getDynamaxRuntime(runtimes, ctx);
|
|
249
330
|
if (!runtime.state.sticky) runtime.state.turnActive = false;
|
|
331
|
+
runtime.turnBudget = undefined;
|
|
250
332
|
updateDynamaxSurfaces(ctx, runtime);
|
|
251
333
|
});
|
|
252
334
|
|
|
@@ -260,15 +342,12 @@ export function registerDynamax(pi: ExtensionAPI, shortcuts: DynamaxShortcuts, o
|
|
|
260
342
|
}
|
|
261
343
|
|
|
262
344
|
pi.registerCommand("workflow:dynamax", {
|
|
263
|
-
description: "Toggle Dynamax
|
|
345
|
+
description: "Toggle sticky Dynamax orchestration for this session: /workflow:dynamax [on|off|status]",
|
|
264
346
|
getArgumentCompletions: (argumentPrefix) => completeCurrentArgument(argumentPrefix, DYNAMAX_ACTION_COMPLETIONS),
|
|
265
347
|
handler: async (args, ctx) => {
|
|
266
348
|
const runtime = getDynamaxRuntime(runtimes, ctx);
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
ctx.ui.notify(`Dynamax ${describeDynamaxState(runtime.state)}. Usage: /workflow:dynamax [on|off|status]`, "info");
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
349
|
+
const typed = args.trim().toLowerCase();
|
|
350
|
+
const action = typed === "" ? (runtime.state.sticky ? "off" : "on") : typed;
|
|
272
351
|
if (action === "on") {
|
|
273
352
|
enableDynamaxSticky(runtime.state);
|
|
274
353
|
updateDynamaxSurfaces(ctx, runtime);
|
|
@@ -285,11 +364,15 @@ export function registerDynamax(pi: ExtensionAPI, shortcuts: DynamaxShortcuts, o
|
|
|
285
364
|
ctx.ui.notify(`Dynamax ${describeDynamaxState(runtime.state)}`, "info");
|
|
286
365
|
return;
|
|
287
366
|
}
|
|
288
|
-
ctx.ui.notify("Usage: /workflow:dynamax [on|off|status]", "warning");
|
|
367
|
+
ctx.ui.notify("Usage: /workflow:dynamax [on|off|status] (no argument toggles sticky mode)", "warning");
|
|
289
368
|
},
|
|
290
369
|
});
|
|
291
370
|
|
|
292
|
-
return {
|
|
371
|
+
return {
|
|
372
|
+
markOneShot,
|
|
373
|
+
turnBudget: (ctx) => getDynamaxRuntime(runtimes, ctx).turnBudget,
|
|
374
|
+
isActive: (ctx) => isDynamaxActive(getDynamaxRuntime(runtimes, ctx).state),
|
|
375
|
+
};
|
|
293
376
|
}
|
|
294
377
|
|
|
295
378
|
export function updateDynamaxSurfaces(ctx: Pick<ExtensionContext, "hasUI" | "ui">, runtime: DynamaxRuntime): void {
|
|
@@ -15,11 +15,12 @@ import {
|
|
|
15
15
|
resolveWorkflowRunOptions,
|
|
16
16
|
type ResolvedWorkflowRunOptions,
|
|
17
17
|
} from "./options.ts";
|
|
18
|
-
import type { AgentOptions, IsolatedAgentResult, LoadedWorkflow, WorkflowApi, WorkflowProgressEvent, WorkflowRef, WorkflowRunOptions } from "./types.ts";
|
|
18
|
+
import type { AgentOptions, IsolatedAgentResult, LoadedWorkflow, WorkflowApi, WorkflowMeta, WorkflowProgressEvent, WorkflowRef, WorkflowRunOptions } from "./types.ts";
|
|
19
19
|
import { showWorkflowInspector } from "./ui/workflow-inspector.ts";
|
|
20
20
|
import { createWorkflowJournal, createWorkflowRunId, pruneWorkflowJournals, workflowJournalPath } from "./journal.ts";
|
|
21
21
|
import { WorktreeRegistry } from "./worktree.ts";
|
|
22
22
|
import { runFinalizers } from "./finalizers.ts";
|
|
23
|
+
import { workflowArgsText, workflowInputPresent } from "./workflow-input.ts";
|
|
23
24
|
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
24
25
|
import {
|
|
25
26
|
captureWorkflowResumeContext,
|
|
@@ -27,11 +28,14 @@ import {
|
|
|
27
28
|
} from "./resume-context.ts";
|
|
28
29
|
import {
|
|
29
30
|
DurableWorkflowRun,
|
|
31
|
+
persistWorkflowRunScript,
|
|
30
32
|
ProjectWorkflowRunStore,
|
|
31
33
|
workflowRunRecordPath,
|
|
32
34
|
type WorkflowRunStore,
|
|
33
35
|
} from "./workflow-run-store.ts";
|
|
34
36
|
import { createWorkflowRunRecord } from "./workflow-run-record.ts";
|
|
37
|
+
import { canRelaunchWorkflowRun } from "./workflow-run-history.ts";
|
|
38
|
+
import { sealWorkflowRunScript } from "./workflow-run-seal.ts";
|
|
35
39
|
import {
|
|
36
40
|
createProviderUsageLimitPauseRecord,
|
|
37
41
|
WorkflowProviderUsageLimitError,
|
|
@@ -76,22 +80,23 @@ export interface WorkflowEngineDependencies {
|
|
|
76
80
|
/**
|
|
77
81
|
* Run a workflow module: build the per-run primitives (binding agent/parallel/pipeline
|
|
78
82
|
* to a shared semaphore + progress tracker), invoke the workflow, return its result.
|
|
83
|
+
* `input` is a slash command's argument string or a tool call's JSON value.
|
|
79
84
|
*/
|
|
80
85
|
export async function runWorkflow(
|
|
81
86
|
ctx: ExtensionContext,
|
|
82
87
|
mod: LoadedWorkflow,
|
|
83
|
-
|
|
88
|
+
input: unknown,
|
|
84
89
|
options: WorkflowRunOptions = {},
|
|
85
90
|
dependencies: WorkflowEngineDependencies = {},
|
|
86
91
|
): Promise<unknown> {
|
|
87
|
-
return await runResolvedWorkflow(ctx, mod,
|
|
92
|
+
return await runResolvedWorkflow(ctx, mod, input, resolveWorkflowRunOptions(options), dependencies);
|
|
88
93
|
}
|
|
89
94
|
|
|
90
95
|
/** Execute an options-normalized workflow without re-reading environment defaults. */
|
|
91
96
|
export async function runResolvedWorkflow(
|
|
92
97
|
ctx: ExtensionContext,
|
|
93
98
|
mod: LoadedWorkflow,
|
|
94
|
-
|
|
99
|
+
input: unknown,
|
|
95
100
|
resolvedOptions: ResolvedWorkflowRunOptions,
|
|
96
101
|
dependencies: WorkflowEngineDependencies = {},
|
|
97
102
|
): Promise<unknown> {
|
|
@@ -107,16 +112,20 @@ export async function runResolvedWorkflow(
|
|
|
107
112
|
const journalPath = workflowJournalPath(ctx.cwd, runId);
|
|
108
113
|
const resumePath = resolvedOptions.resumeFromRunId ? workflowJournalPath(ctx.cwd, resolvedOptions.resumeFromRunId) : undefined;
|
|
109
114
|
const runStore = new ProjectWorkflowRunStore(ctx.cwd);
|
|
115
|
+
const script = await sealRunScript(ctx.cwd, runId, mod);
|
|
110
116
|
let persistenceFailureReported = false;
|
|
117
|
+
const initialRecord = createWorkflowRunRecord({
|
|
118
|
+
runId,
|
|
119
|
+
workflow: mod,
|
|
120
|
+
options: resolvedOptions,
|
|
121
|
+
progress: progress.snapshot(),
|
|
122
|
+
argumentsPresent: workflowInputPresent(input),
|
|
123
|
+
scriptPersisted: script.path !== undefined,
|
|
124
|
+
scriptSeal: script.seal,
|
|
125
|
+
});
|
|
111
126
|
const durableRun = new DurableWorkflowRun(
|
|
112
127
|
runStore,
|
|
113
|
-
|
|
114
|
-
runId,
|
|
115
|
-
workflow: mod,
|
|
116
|
-
options: resolvedOptions,
|
|
117
|
-
progress: progress.snapshot(),
|
|
118
|
-
argumentsPresent: args.length > 0,
|
|
119
|
-
}),
|
|
128
|
+
initialRecord,
|
|
120
129
|
(error) => {
|
|
121
130
|
if (persistenceFailureReported) return;
|
|
122
131
|
persistenceFailureReported = true;
|
|
@@ -124,7 +133,11 @@ export async function runResolvedWorkflow(
|
|
|
124
133
|
},
|
|
125
134
|
);
|
|
126
135
|
durableProgress = durableRun;
|
|
127
|
-
const progressSource = {
|
|
136
|
+
const progressSource = {
|
|
137
|
+
snapshot: () => progress.snapshot(),
|
|
138
|
+
state: () => durableRun.state,
|
|
139
|
+
skipAgent: (agentId: number) => progress.skipAgent(agentId),
|
|
140
|
+
};
|
|
128
141
|
const worktrees = dependencies.worktrees ?? new WorktreeRegistry(ctx.cwd);
|
|
129
142
|
const runAbortController = new AbortController();
|
|
130
143
|
const unlinkContextAbortSignal = linkAbortSignal(ctx.signal, runAbortController);
|
|
@@ -132,7 +145,7 @@ export async function runResolvedWorkflow(
|
|
|
132
145
|
const workflowOutcome = await captureOutcome(async () => {
|
|
133
146
|
await notifyLifecycleObserver(progress, "progress source callback", () => resolvedOptions.onProgressSource?.(progressSource));
|
|
134
147
|
if (resolvedOptions.inspect && ctx.hasUI && ctx.mode === "tui") {
|
|
135
|
-
void showWorkflowInspector(ctx.ui, progressSource.snapshot, () => ({ state: progressSource.state() }))
|
|
148
|
+
void showWorkflowInspector(ctx.ui, progressSource.snapshot, () => ({ state: progressSource.state() }), progressSource)
|
|
136
149
|
.catch((error: unknown) => {
|
|
137
150
|
try {
|
|
138
151
|
progress.log(`inspector failed: ${unknownErrorMessage(error)}`);
|
|
@@ -151,6 +164,7 @@ export async function runResolvedWorkflow(
|
|
|
151
164
|
resumedFromRunId: resolvedOptions.resumeFromRunId,
|
|
152
165
|
journalPath,
|
|
153
166
|
recordPath: workflowRunRecordPath(ctx.cwd, runId),
|
|
167
|
+
...(script.path === undefined ? {} : { scriptPath: script.path }),
|
|
154
168
|
}),
|
|
155
169
|
);
|
|
156
170
|
progress.log(resolvedOptions.resumeFromRunId ? `run id: ${runId} (resuming from ${resolvedOptions.resumeFromRunId})` : `run id: ${runId}`);
|
|
@@ -184,7 +198,7 @@ export async function runResolvedWorkflow(
|
|
|
184
198
|
|
|
185
199
|
// perf.total_ms wraps the whole tree: sub-workflows run inside this span via api.workflow().
|
|
186
200
|
return perf.time("workflow.total_ms", () =>
|
|
187
|
-
runWorkflowWithContext(rc, progress, mod,
|
|
201
|
+
runWorkflowWithContext(rc, progress, mod, input, {
|
|
188
202
|
abortController: runAbortController,
|
|
189
203
|
submissionLimit: resolvedOptions.parallelSubmissionLimit ?? resolvedOptions.concurrency * 2,
|
|
190
204
|
resolveWorkflow: resolvedOptions.resolveWorkflow,
|
|
@@ -225,7 +239,7 @@ export async function runResolvedWorkflow(
|
|
|
225
239
|
durableRun.transition({
|
|
226
240
|
state: "paused",
|
|
227
241
|
progress: progress.snapshot(),
|
|
228
|
-
...createProviderUsageLimitPauseRecord(pauseError, resolvedOptions,
|
|
242
|
+
...createProviderUsageLimitPauseRecord(pauseError, resolvedOptions, canRelaunchWorkflowRun(initialRecord)),
|
|
229
243
|
});
|
|
230
244
|
} else if (pauseError) {
|
|
231
245
|
durableRun.transition({
|
|
@@ -247,6 +261,33 @@ export async function runResolvedWorkflow(
|
|
|
247
261
|
throw outcome.error;
|
|
248
262
|
}
|
|
249
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Seal a run whose code came from a script for this install, so a repository cannot ship a run record
|
|
266
|
+
* whose relaunch runs its own code. An inline run's script is also written beside its record; a saved
|
|
267
|
+
* script resolves again by name, and the seal pins the content that ran. A failed write or seal only
|
|
268
|
+
* costs the run its relaunch from `/workflow:runs`.
|
|
269
|
+
*/
|
|
270
|
+
async function sealRunScript(cwd: string, runId: string, mod: LoadedWorkflow): Promise<{ readonly path?: string; readonly seal?: string }> {
|
|
271
|
+
const { source } = mod;
|
|
272
|
+
const savedScript = source.kind === "file" && source.selfContained === true;
|
|
273
|
+
if (!savedScript && (source.kind !== "fingerprint" || mod.inlineScript === undefined)) return {};
|
|
274
|
+
let path: string | undefined;
|
|
275
|
+
if (mod.inlineScript !== undefined && source.kind === "fingerprint") {
|
|
276
|
+
try {
|
|
277
|
+
path = await persistWorkflowRunScript(cwd, runId, mod.inlineScript);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
process.stderr.write(`[${mod.meta.name}] script persistence failed: ${unknownErrorMessage(error)}\n`);
|
|
280
|
+
return {};
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
return { path, seal: await sealWorkflowRunScript(runId, source.fingerprint) };
|
|
285
|
+
} catch (error) {
|
|
286
|
+
process.stderr.write(`[${mod.meta.name}] script seal failed, so /workflow:runs cannot relaunch this run: ${unknownErrorMessage(error)}\n`);
|
|
287
|
+
return { path };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
250
291
|
function backgroundPauseError(error: unknown, ...signals: Array<AbortSignal | undefined>): unknown {
|
|
251
292
|
if (isWorkflowPauseError(error)) return error;
|
|
252
293
|
for (const signal of signals) {
|
|
@@ -345,7 +386,7 @@ function createWorkflowAgent(
|
|
|
345
386
|
function agent<S extends TSchema>(prompt: string, opts: AgentOptions<S> & { schema: S }): Promise<Static<S>>;
|
|
346
387
|
function agent(prompt: string, opts?: AgentOptions): Promise<string>;
|
|
347
388
|
function agent(prompt: string, agentOpts?: AgentOptions): Promise<unknown> {
|
|
348
|
-
const scopedOptions = scope.agentOptions(agentOpts);
|
|
389
|
+
const scopedOptions = scope.agentOptions(withPhaseProfile(mod.meta, agentOpts));
|
|
349
390
|
const executionOptions: AgentExecutionOptions =
|
|
350
391
|
scopedOptions.isolation === "worktree" && mod.isolatedWorktreeBaseline !== undefined
|
|
351
392
|
? { ...scopedOptions, worktreeBaseline: mod.isolatedWorktreeBaseline }
|
|
@@ -355,6 +396,17 @@ function createWorkflowAgent(
|
|
|
355
396
|
return agent;
|
|
356
397
|
}
|
|
357
398
|
|
|
399
|
+
/**
|
|
400
|
+
* Routing order is the agent's `model`, then its `profile`, then its declared phase's `profile`, then
|
|
401
|
+
* the host. Only an explicit `opts.phase` selects a phase profile: the global `phase()` is shared
|
|
402
|
+
* state that concurrent `parallel`/`pipeline` branches race on.
|
|
403
|
+
*/
|
|
404
|
+
function withPhaseProfile(meta: WorkflowMeta, opts: AgentOptions | undefined): AgentOptions | undefined {
|
|
405
|
+
if (opts?.phase === undefined || opts.model !== undefined || opts.profile !== undefined) return opts;
|
|
406
|
+
const profile = meta.phases?.find((phase) => phase.title === opts.phase)?.profile;
|
|
407
|
+
return profile === undefined ? opts : { ...opts, profile };
|
|
408
|
+
}
|
|
409
|
+
|
|
358
410
|
/**
|
|
359
411
|
* Build the `WorkflowApi` from an existing run context and invoke the module. Reused for both
|
|
360
412
|
* the top-level run and every `api.workflow()` sub-step, so children share the parent's semaphore,
|
|
@@ -364,10 +416,12 @@ export async function runWorkflowWithContext(
|
|
|
364
416
|
rc: WorkflowRunContext,
|
|
365
417
|
progress: WorkflowProgress,
|
|
366
418
|
mod: LoadedWorkflow,
|
|
367
|
-
|
|
419
|
+
input: unknown,
|
|
368
420
|
opts: WorkflowContextOptions,
|
|
369
421
|
): Promise<unknown> {
|
|
370
422
|
const depth = opts.depth ?? 0;
|
|
423
|
+
// Converted before any agent runs, so an argument with no JSON form rejects the call up front.
|
|
424
|
+
const args = workflowArgsText(input);
|
|
371
425
|
const scope = createWorkflowScope(progress, opts.progressPrefix ?? "", opts.progressNamespace);
|
|
372
426
|
const resumeContext: AgentResumeBaseContext = {
|
|
373
427
|
workflow: await captureWorkflowResumeContext(mod, rc.signal),
|
|
@@ -388,7 +442,8 @@ export async function runWorkflowWithContext(
|
|
|
388
442
|
const childController = new AbortController();
|
|
389
443
|
const unlink = linkAbortSignal(rc.signal, childController);
|
|
390
444
|
try {
|
|
391
|
-
|
|
445
|
+
const childInput = childArgs === undefined ? "" : childArgs;
|
|
446
|
+
return await runWorkflowWithContext({ ...rc, signal: childController.signal }, progress, childMod, childInput, {
|
|
392
447
|
abortController: childController,
|
|
393
448
|
submissionLimit: opts.submissionLimit,
|
|
394
449
|
resolveWorkflow: opts.resolveWorkflow,
|
|
@@ -416,6 +471,7 @@ export async function runWorkflowWithContext(
|
|
|
416
471
|
log: scope.log,
|
|
417
472
|
progress: scope.event,
|
|
418
473
|
args,
|
|
474
|
+
input,
|
|
419
475
|
cwd: rc.cwd,
|
|
420
476
|
budget: rc.budget,
|
|
421
477
|
signal: rc.signal,
|
|
@@ -2,3 +2,8 @@
|
|
|
2
2
|
export function isMissingPathError(error: unknown): boolean {
|
|
3
3
|
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
4
4
|
}
|
|
5
|
+
|
|
6
|
+
/** Identify a path that already exists, as reported for an exclusive (`wx`) create. */
|
|
7
|
+
export function isExistingPathError(error: unknown): boolean {
|
|
8
|
+
return error instanceof Error && "code" in error && error.code === "EEXIST";
|
|
9
|
+
}
|
|
@@ -36,10 +36,16 @@ export function compileInlineWorkflow(source: string): LoadedWorkflow {
|
|
|
36
36
|
return {
|
|
37
37
|
meta: parsedMeta.meta,
|
|
38
38
|
default: (api) => executor(api, Type),
|
|
39
|
-
source: { kind: "fingerprint", fingerprint:
|
|
39
|
+
source: { kind: "fingerprint", fingerprint: inlineScriptFingerprint(source) },
|
|
40
|
+
inlineScript: source,
|
|
40
41
|
};
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/** A compiled script can import nothing, so the hash of its source is its whole identity. */
|
|
45
|
+
export function inlineScriptFingerprint(source: string): string {
|
|
46
|
+
return createHash("sha256").update(source).digest("hex");
|
|
47
|
+
}
|
|
48
|
+
|
|
43
49
|
/** The script's pure-literal meta, read without compiling or running anything; undefined when it does not parse. */
|
|
44
50
|
export function readInlineWorkflowMeta(source: string): WorkflowMeta | undefined {
|
|
45
51
|
try {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { mkdir, readdir, readFile, rm, stat, appendFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, readdir, readFile, rm, stat, appendFile, writeFile } from "node:fs/promises";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import type { AgentOptions } from "./types.ts";
|
|
5
5
|
import type { WorktreeBaseline } from "./worktree.ts";
|
|
6
6
|
import { canonicalizeIdentity } from "./identity-canonicalization.ts";
|
|
7
|
+
import { isExistingPathError } from "./filesystem-error.ts";
|
|
7
8
|
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
8
9
|
import {
|
|
9
10
|
isAgentResumeContext,
|
|
@@ -15,6 +16,9 @@ import {
|
|
|
15
16
|
export const WORKFLOW_RUNS_DIR = join(".pi", ".workflow-runs");
|
|
16
17
|
export const WORKFLOW_JOURNAL_KEEP = 50;
|
|
17
18
|
|
|
19
|
+
/** Ignores everything in the runs directory, including this file, so user projects never commit run data. */
|
|
20
|
+
const WORKFLOW_RUNS_GITIGNORE = "*\n";
|
|
21
|
+
|
|
18
22
|
export interface JournalEntryV2 {
|
|
19
23
|
readonly version: 2;
|
|
20
24
|
readonly key: string;
|
|
@@ -56,6 +60,20 @@ export class WorkflowJournalLoadError extends Error {
|
|
|
56
60
|
override readonly name = "WorkflowJournalLoadError";
|
|
57
61
|
}
|
|
58
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Create the directory that holds run files, with a `.gitignore` that ignores all of them: records,
|
|
65
|
+
* journals, and scripts can carry prompts and results, and user projects do not list this directory in
|
|
66
|
+
* their own ignore files. An existing `.gitignore` is never overwritten.
|
|
67
|
+
*/
|
|
68
|
+
export async function ensureWorkflowRunsDir(dir: string): Promise<void> {
|
|
69
|
+
await mkdir(dir, { recursive: true });
|
|
70
|
+
try {
|
|
71
|
+
await writeFile(join(dir, ".gitignore"), WORKFLOW_RUNS_GITIGNORE, { encoding: "utf8", flag: "wx" });
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (!isExistingPathError(error)) throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
59
77
|
export function createWorkflowRunId(): string {
|
|
60
78
|
return randomUUID();
|
|
61
79
|
}
|
|
@@ -161,7 +179,7 @@ export async function createWorkflowJournal(options: {
|
|
|
161
179
|
readonly writePath: string;
|
|
162
180
|
}): Promise<WorkflowJournal> {
|
|
163
181
|
const priorEntries = options.resumePath ? await loadJournalEntries(options.resumePath, { required: true }) : [];
|
|
164
|
-
await
|
|
182
|
+
await ensureWorkflowRunsDir(dirname(options.writePath));
|
|
165
183
|
await appendFile(options.writePath, "", "utf8");
|
|
166
184
|
return createMemoryBackedJournal(priorEntries, options.writePath);
|
|
167
185
|
}
|
|
@@ -201,10 +219,10 @@ export function createMemoryBackedJournal(priorEntries: readonly JournalEntry[]
|
|
|
201
219
|
const entry: JournalEntryV2 = { version: 2, key, result, identity };
|
|
202
220
|
if (writePath) {
|
|
203
221
|
try {
|
|
204
|
-
await
|
|
222
|
+
await ensureWorkflowRunsDir(dirname(writePath));
|
|
205
223
|
await appendFile(writePath, `${JSON.stringify(entry)}\n`, "utf8");
|
|
206
224
|
} catch (error) {
|
|
207
|
-
|
|
225
|
+
return { ok: false, error: unknownErrorMessage(error) };
|
|
208
226
|
}
|
|
209
227
|
}
|
|
210
228
|
return { ok: true };
|
|
@@ -1,12 +1,8 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
4
1
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
5
2
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
3
|
+
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { configFilePaths, readJsonConfigFile, writeJsonConfigFile, type ConfigFilePaths } from "./config-file.ts";
|
|
8
5
|
import { isRecord } from "./guards.ts";
|
|
9
|
-
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
10
6
|
|
|
11
7
|
export const WORKFLOW_MODEL_PROFILE_NAMES = ["small", "medium", "big"] as const;
|
|
12
8
|
export type WorkflowModelProfileName = (typeof WORKFLOW_MODEL_PROFILE_NAMES)[number];
|
|
@@ -20,10 +16,7 @@ export interface WorkflowModelProfileFile {
|
|
|
20
16
|
readonly profiles: Partial<Record<WorkflowModelProfileName, WorkflowModelProfileConfig>>;
|
|
21
17
|
}
|
|
22
18
|
|
|
23
|
-
export
|
|
24
|
-
readonly user: string;
|
|
25
|
-
readonly project: string;
|
|
26
|
-
}
|
|
19
|
+
export type WorkflowModelProfilePaths = ConfigFilePaths;
|
|
27
20
|
|
|
28
21
|
interface ResolvedWorkflowModelProfileBase {
|
|
29
22
|
readonly name: WorkflowModelProfileName;
|
|
@@ -52,15 +45,10 @@ const HOST_FALLBACK_THINKING: Record<WorkflowModelProfileName, ThinkingLevel> =
|
|
|
52
45
|
big: "high",
|
|
53
46
|
};
|
|
54
47
|
const CONFIG_FILE_NAME = "workflow-models.json";
|
|
48
|
+
const CONFIG_LABEL = "workflow model profiles";
|
|
55
49
|
|
|
56
|
-
export function workflowModelProfilePaths(
|
|
57
|
-
cwd
|
|
58
|
-
agentDir: string = getAgentDir(),
|
|
59
|
-
): WorkflowModelProfilePaths {
|
|
60
|
-
return {
|
|
61
|
-
user: join(agentDir, CONFIG_FILE_NAME),
|
|
62
|
-
project: join(cwd, ".pi", CONFIG_FILE_NAME),
|
|
63
|
-
};
|
|
50
|
+
export function workflowModelProfilePaths(cwd: string, agentDir?: string): WorkflowModelProfilePaths {
|
|
51
|
+
return configFilePaths(CONFIG_FILE_NAME, cwd, agentDir);
|
|
64
52
|
}
|
|
65
53
|
|
|
66
54
|
export function resolveWorkflowModelProfiles(input: {
|
|
@@ -123,22 +111,8 @@ export function resolveAgentModelProfile(
|
|
|
123
111
|
}
|
|
124
112
|
|
|
125
113
|
export function readWorkflowModelProfileFile(configPath: string): WorkflowModelProfileFile {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
contents = readFileSync(configPath, "utf8");
|
|
129
|
-
} catch (error) {
|
|
130
|
-
if (isMissingPathError(error)) return { profiles: {} };
|
|
131
|
-
throw new Error(
|
|
132
|
-
`Could not read workflow model profiles at ${configPath}: ${unknownErrorMessage(error)}. Fix the file permissions or remove the file.`,
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
let parsed: unknown;
|
|
136
|
-
try {
|
|
137
|
-
parsed = JSON.parse(contents);
|
|
138
|
-
} catch (error) {
|
|
139
|
-
throw new Error(`Invalid JSON in workflow model profiles at ${configPath}: ${unknownErrorMessage(error)}. Fix or remove the file.`);
|
|
140
|
-
}
|
|
141
|
-
return parseWorkflowModelProfileFile(parsed, configPath);
|
|
114
|
+
const parsed = readJsonConfigFile(configPath, CONFIG_LABEL);
|
|
115
|
+
return parsed === undefined ? { profiles: {} } : parseWorkflowModelProfileFile(parsed, configPath);
|
|
142
116
|
}
|
|
143
117
|
|
|
144
118
|
export function setWorkflowModelProfile(input: {
|
|
@@ -239,19 +213,7 @@ function assertOnlyKeys(value: Record<string, unknown>, allowed: readonly string
|
|
|
239
213
|
}
|
|
240
214
|
|
|
241
215
|
function writeWorkflowModelProfileFile(configPath: string, config: WorkflowModelProfileFile): void {
|
|
242
|
-
|
|
243
|
-
try {
|
|
244
|
-
mkdirSync(dirname(configPath), { recursive: true });
|
|
245
|
-
writeFileSync(temporaryPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
246
|
-
renameSync(temporaryPath, configPath);
|
|
247
|
-
} catch (error) {
|
|
248
|
-
try {
|
|
249
|
-
unlinkSync(temporaryPath);
|
|
250
|
-
} catch {
|
|
251
|
-
// Best effort: preserve the original write error.
|
|
252
|
-
}
|
|
253
|
-
throw new Error(`Could not write workflow model profiles at ${configPath}: ${unknownErrorMessage(error)}.`);
|
|
254
|
-
}
|
|
216
|
+
writeJsonConfigFile(configPath, config, CONFIG_LABEL);
|
|
255
217
|
}
|
|
256
218
|
|
|
257
219
|
function invalidConfig(configPath: string, reason: string): Error {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { WorkflowUsageSnapshot } from "./usage.ts";
|
|
2
2
|
|
|
3
|
-
export type AgentRowStatus = "queued" | "running" | "done" | "failed";
|
|
3
|
+
export type AgentRowStatus = "queued" | "running" | "done" | "failed" | "skipped";
|
|
4
4
|
export type WorkflowLaneItemStatus = "pending" | "running" | "success" | "warning" | "error";
|
|
5
5
|
|
|
6
6
|
export interface AgentRowSnapshot {
|