pi-workflow-engine 1.0.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 +236 -94
- package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +9 -6
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +10 -3
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +18 -4
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +71 -23
- package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +12 -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/classify.ts +107 -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 +123 -40
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +78 -18
- package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +5 -0
- package/.pi/extensions/pi-workflow-engine/src/host-tools.ts +76 -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/model-ref.ts +19 -0
- package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +2 -2
- 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 +8 -6
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +15 -3
- package/.pi/extensions/pi-workflow-engine/src/types.ts +68 -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/usage.ts +20 -2
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +123 -9
- 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 +12 -4
- package/USAGE.md +167 -23
- package/package.json +13 -4
- package/skills/workflow-authoring/SKILL.md +160 -0
|
@@ -4,11 +4,19 @@ import { getSelectListTheme, keyText, VERSION, type ExtensionAPI, type Extension
|
|
|
4
4
|
import type { AutocompleteItem, SelectItem } from "@earendil-works/pi-tui";
|
|
5
5
|
import type { WorkflowProgressSnapshot } from "./src/progress-types.ts";
|
|
6
6
|
import type { WorkflowRunState } from "./src/workflow-run-record.ts";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
WORKFLOW_TOOL_NAME,
|
|
9
|
+
type LoadedWorkflow,
|
|
10
|
+
type WorkflowModule,
|
|
11
|
+
type WorkflowProgressSource,
|
|
12
|
+
type WorkflowRef,
|
|
13
|
+
type WorkflowRunMetadata,
|
|
14
|
+
} from "./src/types.ts";
|
|
8
15
|
import { showWorkflowInspector } from "./src/ui/workflow-inspector.ts";
|
|
9
16
|
import { WorkflowPicker } from "./src/ui/workflow-picker.ts";
|
|
10
17
|
import type { PerfSink } from "./src/perf.ts";
|
|
11
|
-
import {
|
|
18
|
+
import type { DiscoverWorkflowsOptions } from "./src/discovery.ts";
|
|
19
|
+
import { registerDynamax, resolveTurnBudget, type DynamaxHandle } from "./src/dynamax.ts";
|
|
12
20
|
import { sessionKey } from "./src/session-identity.ts";
|
|
13
21
|
import { resolveDynamaxShortcuts, type DynamaxShortcuts } from "./src/dynamax-shortcuts.ts";
|
|
14
22
|
import { ReviewSessionCoordinator } from "./src/review/review-session-coordinator.ts";
|
|
@@ -32,7 +40,7 @@ import {
|
|
|
32
40
|
WORKFLOW_USAGE_LIMIT_DELAY_MIN_MS,
|
|
33
41
|
} from "./src/options.ts";
|
|
34
42
|
import { executeWorkflowInvocation, type WorkflowResultEnvelope } from "./src/workflow-execution.ts";
|
|
35
|
-
import { formatWorkflowResultForContext } from "./src/workflow-result-context.ts";
|
|
43
|
+
import { formatWorkflowResultForContext, formatWorkflowRunFileLines, formatWorkflowRunLine } from "./src/workflow-result-context.ts";
|
|
36
44
|
import { registerWorkflowModelProfileCommand } from "./src/model-profile-command.ts";
|
|
37
45
|
import { BackgroundWorkflowCoordinator } from "./src/background-workflows.ts";
|
|
38
46
|
import { backgroundUnavailableResult, startBackgroundWorkflowTool } from "./src/background-workflow-tool.ts";
|
|
@@ -40,13 +48,25 @@ import { registerWorkflowRunCommand, WorkflowRunController } from "./src/workflo
|
|
|
40
48
|
import { completeCurrentArgument, splitArgumentPrefix } from "./src/command-completions.ts";
|
|
41
49
|
import { assertSupportedPiVersion } from "./src/pi-compat.ts";
|
|
42
50
|
import { formatWorkflowInspection, workflowInspectionSnapshot } from "./src/ui/workflow-format.ts";
|
|
51
|
+
import { workflowUsageForPi, type WorkflowUsageSnapshot } from "./src/usage.ts";
|
|
52
|
+
import { unknownErrorMessage } from "./src/unknown-error.ts";
|
|
53
|
+
import { createHostToolBridge } from "./src/host-tools.ts";
|
|
54
|
+
import { appendSectionToForcedPrompt } from "./src/prompt-sections.ts";
|
|
55
|
+
import { WORKFLOWS_SECTION, WorkflowCatalogPrompt } from "./src/workflow-catalog.ts";
|
|
56
|
+
import { registerWorkflowSizeCommand, resolveWorkflowSize, workflowSettingsPaths } from "./src/workflow-settings.ts";
|
|
57
|
+
import { isRecord } from "./src/guards.ts";
|
|
58
|
+
import { INVALID_WORKFLOW_INVOCATION_MESSAGE, normalizeWorkflowToolRequest, workflowToolReach } from "./src/workflow-tool-request.ts";
|
|
59
|
+
import { workflowArgsText } from "./src/workflow-input.ts";
|
|
43
60
|
|
|
44
61
|
/** Extension root (this file lives in <repo>/.pi/extensions/pi-workflow-engine/index.ts). */
|
|
45
62
|
const EXTENSION_DIR = fileURLToPath(new URL(".", import.meta.url));
|
|
63
|
+
/** The package's authoring skill, named by path so the guideline still resolves when skills are filtered out. */
|
|
64
|
+
const WORKFLOW_AUTHORING_SKILL_PATH = fileURLToPath(new URL("../../../skills/workflow-authoring/SKILL.md", import.meta.url));
|
|
46
65
|
|
|
47
66
|
type DiscoveryModule = typeof import("./src/discovery.ts");
|
|
48
67
|
type EngineModule = typeof import("./src/engine.ts");
|
|
49
68
|
type InlineWorkflowModule = typeof import("./src/inline-workflow.ts");
|
|
69
|
+
type WorkflowScriptFileModule = typeof import("./src/workflow-script-file.ts");
|
|
50
70
|
|
|
51
71
|
async function loadDiscovery(): Promise<DiscoveryModule> {
|
|
52
72
|
return await import("./src/discovery.ts");
|
|
@@ -60,18 +80,47 @@ async function loadInlineWorkflow(): Promise<InlineWorkflowModule> {
|
|
|
60
80
|
return await import("./src/inline-workflow.ts");
|
|
61
81
|
}
|
|
62
82
|
|
|
83
|
+
async function loadWorkflowScriptFile(): Promise<WorkflowScriptFileModule> {
|
|
84
|
+
return await import("./src/workflow-script-file.ts");
|
|
85
|
+
}
|
|
86
|
+
|
|
63
87
|
async function createInvocationPerf(options: ResolvedWorkflowRunOptions): Promise<PerfSink | undefined> {
|
|
64
88
|
if (!options.perf) return undefined;
|
|
65
89
|
const { createPerfRecorder } = await import("./src/perf.ts");
|
|
66
90
|
return createPerfRecorder(true);
|
|
67
91
|
}
|
|
68
92
|
|
|
93
|
+
/** What workflow discovery reads from a session: its cwd, and whether its project's saved scripts may load. */
|
|
94
|
+
export type WorkflowDiscoveryContext = Pick<ExtensionContext, "cwd" | "isProjectTrusted">;
|
|
95
|
+
|
|
96
|
+
/** Project `.pi/workflows` scripts load only in a project pi trusts, like pi's own project resources. */
|
|
97
|
+
function projectWorkflowDir(ctx: WorkflowDiscoveryContext): string | undefined {
|
|
98
|
+
return ctx.isProjectTrusted() ? ctx.cwd : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Every workflow a session can run, with the project-trust gate applied in this one place. */
|
|
102
|
+
async function discoverForSession(
|
|
103
|
+
ctx: WorkflowDiscoveryContext,
|
|
104
|
+
options: Omit<DiscoverWorkflowsOptions, "projectDir"> = {},
|
|
105
|
+
): Promise<Map<string, LoadedWorkflow>> {
|
|
106
|
+
const { discoverWorkflows } = await loadDiscovery();
|
|
107
|
+
return await discoverWorkflows(EXTENSION_DIR, { ...options, projectDir: projectWorkflowDir(ctx) });
|
|
108
|
+
}
|
|
109
|
+
|
|
69
110
|
/**
|
|
70
|
-
* Resolve an `api.workflow()` reference
|
|
111
|
+
* Resolve an `api.workflow()` reference: a registered workflow by name, or an inline-format script file
|
|
112
|
+
* via `{ scriptPath }` relative to the cwd. Throws on an unknown name, an unreadable scriptPath, or a
|
|
113
|
+
* compile error.
|
|
71
114
|
*/
|
|
72
|
-
export async function resolveWorkflowRef(ref: WorkflowRef, perf?: PerfSink): Promise<LoadedWorkflow> {
|
|
73
|
-
|
|
74
|
-
|
|
115
|
+
export async function resolveWorkflowRef(ctx: WorkflowDiscoveryContext, ref: WorkflowRef, perf?: PerfSink): Promise<LoadedWorkflow> {
|
|
116
|
+
if (typeof ref !== "string") {
|
|
117
|
+
// Inline scripts are untyped, so check the shape the type promises.
|
|
118
|
+
const scriptPath: unknown = isRecord(ref) ? ref.scriptPath : undefined;
|
|
119
|
+
if (typeof scriptPath !== "string") throw new Error("workflow() expects a workflow name or { scriptPath }");
|
|
120
|
+
const { compileWorkflowScriptFile } = await loadWorkflowScriptFile();
|
|
121
|
+
return await compileWorkflowScriptFile(ctx.cwd, scriptPath);
|
|
122
|
+
}
|
|
123
|
+
const workflows = await discoverForSession(ctx, { perf });
|
|
75
124
|
const mod = workflows.get(ref);
|
|
76
125
|
if (!mod) {
|
|
77
126
|
const available = [...workflows.keys()].join(", ") || "(none)";
|
|
@@ -86,7 +135,7 @@ const AUTHOR_TEMP_WORKFLOW_DESCRIPTION = "Ask the host agent to author and run a
|
|
|
86
135
|
|
|
87
136
|
const WORKFLOW_OPTION_COMPLETIONS = [
|
|
88
137
|
{ value: "--inspect", description: "Open the live workflow inspector" },
|
|
89
|
-
{ value: "--refresh", description: "
|
|
138
|
+
{ value: "--refresh", description: "Reload .ts drop-in workflows" },
|
|
90
139
|
{ value: "--perf", description: "Collect workflow performance metrics" },
|
|
91
140
|
{ value: "--result-viewer", description: "Open supported result viewers" },
|
|
92
141
|
{ value: "--no-result-viewer", description: "Skip supported result viewers" },
|
|
@@ -100,11 +149,12 @@ const WORKFLOW_OPTION_COMPLETIONS = [
|
|
|
100
149
|
{ value: "--resume=", description: "Resume from a retained workflow run ID" },
|
|
101
150
|
] as const;
|
|
102
151
|
|
|
103
|
-
|
|
152
|
+
/** Completions get no context, so the project's scripts come from the active session's tracked dir. */
|
|
153
|
+
async function workflowArgumentCompletions(argumentPrefix: string, projectDir: string | undefined): Promise<AutocompleteItem[] | null> {
|
|
104
154
|
const parts = splitArgumentPrefix(argumentPrefix);
|
|
105
155
|
if (parts.completed.length === 0) {
|
|
106
156
|
const { discoverWorkflows } = await loadDiscovery();
|
|
107
|
-
const workflows = await discoverWorkflows(EXTENSION_DIR);
|
|
157
|
+
const workflows = await discoverWorkflows(EXTENSION_DIR, { projectDir });
|
|
108
158
|
return completeCurrentArgument(
|
|
109
159
|
argumentPrefix,
|
|
110
160
|
[...workflows.values()].map((workflow) => ({
|
|
@@ -116,6 +166,21 @@ async function workflowArgumentCompletions(argumentPrefix: string): Promise<Auto
|
|
|
116
166
|
return completeCurrentArgument(argumentPrefix, WORKFLOW_OPTION_COMPLETIONS);
|
|
117
167
|
}
|
|
118
168
|
|
|
169
|
+
/**
|
|
170
|
+
* The active session's trusted project dir, kept for surfaces pi calls without a context. Session
|
|
171
|
+
* replacement invalidates a context, so only this value is kept.
|
|
172
|
+
*/
|
|
173
|
+
function trackProjectWorkflowDir(pi: ExtensionAPI): () => string | undefined {
|
|
174
|
+
let projectDir: string | undefined;
|
|
175
|
+
pi.on("session_start", (_event, ctx) => {
|
|
176
|
+
projectDir = projectWorkflowDir(ctx);
|
|
177
|
+
});
|
|
178
|
+
pi.on("session_shutdown", () => {
|
|
179
|
+
projectDir = undefined;
|
|
180
|
+
});
|
|
181
|
+
return () => projectDir;
|
|
182
|
+
}
|
|
183
|
+
|
|
119
184
|
/** Registered workflows in discovery order (built-ins first), then the author option. */
|
|
120
185
|
async function selectWorkflowValue(workflows: ReadonlyMap<string, WorkflowModule>, ctx: ExtensionCommandContext): Promise<string | undefined> {
|
|
121
186
|
const workflowItems: SelectItem[] = [...workflows.values()].map((workflow) => ({
|
|
@@ -149,6 +214,7 @@ export interface ActiveWorkflowInspection {
|
|
|
149
214
|
readonly startedAt: number;
|
|
150
215
|
readonly snapshot: () => WorkflowProgressSnapshot;
|
|
151
216
|
readonly state?: () => WorkflowRunState;
|
|
217
|
+
readonly skipAgent?: (agentId: number) => boolean;
|
|
152
218
|
}
|
|
153
219
|
|
|
154
220
|
interface SessionWorkflowInspections {
|
|
@@ -163,7 +229,9 @@ export async function openWorkflowInspector(ctx: ExtensionContext, inspection: L
|
|
|
163
229
|
ctx.ui.notify(formatWorkflowInspection(inspection), "info");
|
|
164
230
|
return;
|
|
165
231
|
}
|
|
166
|
-
|
|
232
|
+
// Only a live inspection can skip agents; a finished one is a snapshot.
|
|
233
|
+
const controls = "skipAgent" in inspection ? { skipAgent: inspection.skipAgent } : undefined;
|
|
234
|
+
await showWorkflowInspector(ctx.ui, () => workflowInspectionSnapshot(inspection), () => ({ state: inspection.state?.() }), controls);
|
|
167
235
|
}
|
|
168
236
|
|
|
169
237
|
function workflowInspectionState(pi: ExtensionAPI, ctx: ExtensionContext): SessionWorkflowInspections {
|
|
@@ -186,7 +254,7 @@ async function openAvailableWorkflowInspector(pi: ExtensionAPI, ctx: ExtensionCo
|
|
|
186
254
|
}
|
|
187
255
|
|
|
188
256
|
function bindActiveWorkflowInspection(name: string, args: string, source: WorkflowProgressSource): ActiveWorkflowInspection {
|
|
189
|
-
return { name, args, startedAt: Date.now(), snapshot: () => source.snapshot(), state: source.state };
|
|
257
|
+
return { name, args, startedAt: Date.now(), snapshot: () => source.snapshot(), state: source.state, skipAgent: source.skipAgent };
|
|
190
258
|
}
|
|
191
259
|
|
|
192
260
|
export function buildTemporaryWorkflowAuthorPrompt(brief: string): string {
|
|
@@ -195,52 +263,53 @@ export function buildTemporaryWorkflowAuthorPrompt(brief: string): string {
|
|
|
195
263
|
User brief:
|
|
196
264
|
${brief.trim()}
|
|
197
265
|
|
|
198
|
-
Use the workflow tool with a script argument, not a saved workflow name.
|
|
199
|
-
The script must start with export const meta = { ... } and default-export an async workflow function.
|
|
200
|
-
Use the injected Type object for schemas. Do not import anything or use dynamic import().
|
|
201
|
-
Set profile to "small", "medium", or "big" on each agent() call; use explicit model/thinkingLevel only for an intentional override.
|
|
202
|
-
Always pass a plain string as the first api.agent() argument; build prompts with template strings before calling agent().
|
|
203
|
-
If using \`isolation: "worktree"\`, remember api.agent() returns \`{ result, patch, changed }\`; read \`.result\` for the agent answer and \`.patch\` for the diff.
|
|
204
|
-
When the run is budgeted, guard expensive loops with \`while (api.budget.total && api.budget.remaining() > N) { ... }\`; api.agent() throws once the budget is spent.
|
|
205
|
-
Subagents receive no skills by default. When the brief asks for a skill or a stage clearly benefits from one, pass \`skills: ["skill-name"]\` on that agent call only.
|
|
206
|
-
${ADAPTIVE_WORKFLOW_GUIDANCE}
|
|
266
|
+
Use the workflow tool with a script argument, not a saved workflow name, and read the workflow-authoring skill before writing the script.
|
|
207
267
|
Do not edit files unless the user explicitly requested edits.`;
|
|
208
268
|
}
|
|
209
269
|
|
|
210
|
-
export interface
|
|
211
|
-
readonly name?: string;
|
|
212
|
-
readonly script?: string;
|
|
213
|
-
readonly resumeFromRunId?: string;
|
|
214
|
-
readonly background?: boolean;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
export type WorkflowToolRequest =
|
|
218
|
-
| { readonly kind: "named"; readonly name: string }
|
|
219
|
-
| { readonly kind: "inline"; readonly script: string }
|
|
220
|
-
| { readonly kind: "error"; readonly error: "invalid_workflow_invocation"; readonly message: string };
|
|
221
|
-
|
|
222
|
-
export interface WorkflowToolErrorResult {
|
|
270
|
+
export interface WorkflowToolErrorResult<TDetails = WorkflowToolErrorDetails> {
|
|
223
271
|
readonly content: Array<{ readonly type: "text"; readonly text: string }>;
|
|
224
|
-
readonly details:
|
|
272
|
+
readonly details: TDetails;
|
|
273
|
+
/** The model sees a failed call while the renderer still reads `details`. */
|
|
274
|
+
readonly isError: true;
|
|
225
275
|
}
|
|
226
276
|
|
|
227
|
-
|
|
277
|
+
type WorkflowToolErrorDetails =
|
|
278
|
+
| { readonly error: "invalid_workflow_invocation" }
|
|
279
|
+
| { readonly error: "inline_compile_error"; readonly message: string }
|
|
280
|
+
| { readonly error: "invalid_script_path"; readonly message: string };
|
|
228
281
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const script = params.script?.trim() ?? "";
|
|
232
|
-
const hasName = name.length > 0;
|
|
233
|
-
const hasScript = script.length > 0;
|
|
234
|
-
if (hasName === hasScript) return { kind: "error", error: "invalid_workflow_invocation", message: INVALID_WORKFLOW_INVOCATION_MESSAGE };
|
|
235
|
-
return hasName ? { kind: "named", name } : { kind: "inline", script };
|
|
282
|
+
function workflowToolError<TDetails>(text: string, details: TDetails): WorkflowToolErrorResult<TDetails> {
|
|
283
|
+
return { content: [{ type: "text", text }], details, isError: true };
|
|
236
284
|
}
|
|
237
285
|
|
|
238
286
|
export function invalidWorkflowInvocationResult(): WorkflowToolErrorResult {
|
|
239
|
-
return
|
|
287
|
+
return workflowToolError(INVALID_WORKFLOW_INVOCATION_MESSAGE, { error: "invalid_workflow_invocation" });
|
|
240
288
|
}
|
|
241
289
|
|
|
242
290
|
export function inlineCompileErrorResult(message: string): WorkflowToolErrorResult {
|
|
243
|
-
return
|
|
291
|
+
return workflowToolError(`Inline workflow did not compile: ${message}`, { error: "inline_compile_error", message });
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function invalidScriptPathResult(message: string): WorkflowToolErrorResult {
|
|
295
|
+
return workflowToolError(message, { error: "invalid_script_path", message });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Compile the tool's inline `script` or `scriptPath` file, shaping contract failures as tool errors. */
|
|
299
|
+
async function compileInlineToolWorkflow(
|
|
300
|
+
cwd: string,
|
|
301
|
+
request: { readonly kind: "inline"; readonly script: string } | { readonly kind: "script-path"; readonly scriptPath: string },
|
|
302
|
+
): Promise<LoadedWorkflow | WorkflowToolErrorResult> {
|
|
303
|
+
const [inline, scriptFile] = await Promise.all([loadInlineWorkflow(), loadWorkflowScriptFile()]);
|
|
304
|
+
try {
|
|
305
|
+
return request.kind === "inline"
|
|
306
|
+
? inline.compileInlineWorkflow(request.script)
|
|
307
|
+
: await scriptFile.compileWorkflowScriptFile(cwd, request.scriptPath);
|
|
308
|
+
} catch (error) {
|
|
309
|
+
if (error instanceof scriptFile.WorkflowScriptPathError) return invalidScriptPathResult(error.message);
|
|
310
|
+
if (error instanceof inline.InlineWorkflowCompileError) return inlineCompileErrorResult(error.message);
|
|
311
|
+
throw error;
|
|
312
|
+
}
|
|
244
313
|
}
|
|
245
314
|
|
|
246
315
|
export type WorkflowPickerSelection =
|
|
@@ -285,27 +354,29 @@ export async function sendWorkflowResult(
|
|
|
285
354
|
await reviewSessions.present(ctx, retained, options);
|
|
286
355
|
}
|
|
287
356
|
|
|
357
|
+
/** `input` is a slash command's argument string, or a tool call's string or JSON value. */
|
|
288
358
|
async function executeResolvedWorkflow(
|
|
289
359
|
pi: ExtensionAPI,
|
|
290
360
|
ctx: ExtensionContext,
|
|
291
361
|
name: string,
|
|
292
362
|
mod: LoadedWorkflow,
|
|
293
|
-
|
|
363
|
+
input: unknown,
|
|
294
364
|
options: ResolvedWorkflowRunOptions,
|
|
295
365
|
perfRecorder?: PerfSink,
|
|
296
366
|
): Promise<WorkflowResultEnvelope> {
|
|
297
367
|
const { runResolvedWorkflow } = await loadEngine();
|
|
298
368
|
let liveInspection: ActiveWorkflowInspection | undefined;
|
|
299
369
|
const inspections = workflowInspectionState(pi, ctx);
|
|
370
|
+
const args = workflowArgsText(input);
|
|
300
371
|
return await executeWorkflowInvocation({
|
|
301
372
|
ctx,
|
|
302
373
|
name,
|
|
303
374
|
mod,
|
|
304
|
-
args,
|
|
375
|
+
args: input,
|
|
305
376
|
options,
|
|
306
377
|
perfRecorder,
|
|
307
378
|
runResolvedWorkflow,
|
|
308
|
-
resolveWorkflow: (ref) => resolveWorkflowRef(ref, perfRecorder),
|
|
379
|
+
resolveWorkflow: (ref) => resolveWorkflowRef(ctx, ref, perfRecorder),
|
|
309
380
|
onProgressSource(source) {
|
|
310
381
|
if (source) {
|
|
311
382
|
liveInspection = bindActiveWorkflowInspection(name, args, source);
|
|
@@ -348,10 +419,10 @@ export default function workflowEngine(pi: ExtensionAPI, shortcuts: DynamaxShort
|
|
|
348
419
|
assertSupportedPiVersion(VERSION);
|
|
349
420
|
const reviewSessions = createReviewSessionCoordinator(pi);
|
|
350
421
|
const backgroundWorkflows = new BackgroundWorkflowCoordinator(pi);
|
|
422
|
+
const sessionProjectDir = trackProjectWorkflowDir(pi);
|
|
351
423
|
const workflowRuns = new WorkflowRunController(backgroundWorkflows, {
|
|
352
|
-
async resolveWorkflow(name) {
|
|
353
|
-
|
|
354
|
-
return (await discoverWorkflows(EXTENSION_DIR)).get(name);
|
|
424
|
+
async resolveWorkflow(name, ctx) {
|
|
425
|
+
return (await discoverForSession(ctx)).get(name);
|
|
355
426
|
},
|
|
356
427
|
async execute(ctx, name, workflow, options) {
|
|
357
428
|
const perfRecorder = await createInvocationPerf(options);
|
|
@@ -360,6 +431,15 @@ export default function workflowEngine(pi: ExtensionAPI, shortcuts: DynamaxShort
|
|
|
360
431
|
},
|
|
361
432
|
});
|
|
362
433
|
backgroundWorkflows.onRunSettled((ctx, runId) => workflowRuns.runSettled(ctx, runId));
|
|
434
|
+
const workflowCatalog = new WorkflowCatalogPrompt({
|
|
435
|
+
async discover(ctx) {
|
|
436
|
+
return [...(await discoverForSession(ctx)).values()].map((workflow) => workflow.meta);
|
|
437
|
+
},
|
|
438
|
+
size: (ctx) => resolveWorkflowSize(workflowSettingsPaths(ctx.cwd)).size,
|
|
439
|
+
});
|
|
440
|
+
// Before Dynamax, so the stable catalog section precedes the per-turn opt-in section.
|
|
441
|
+
registerWorkflowCatalog(pi, workflowCatalog);
|
|
442
|
+
registerWorkflowSizeCommand(pi, (ctx) => workflowCatalog.forget(ctx));
|
|
363
443
|
const dynamax = registerDynamax(pi, shortcuts, { openInspector: (ctx) => openAvailableWorkflowInspector(pi, ctx) });
|
|
364
444
|
registerWorkflowModelProfileCommand(pi);
|
|
365
445
|
registerWorkflowRunCommand(pi, workflowRuns);
|
|
@@ -420,7 +500,7 @@ export default function workflowEngine(pi: ExtensionAPI, shortcuts: DynamaxShort
|
|
|
420
500
|
// /workflow <name> [args] — user-invoked.
|
|
421
501
|
pi.registerCommand("workflow", {
|
|
422
502
|
description: "Run a multi-agent workflow: /workflow <name> [args]",
|
|
423
|
-
getArgumentCompletions: workflowArgumentCompletions,
|
|
503
|
+
getArgumentCompletions: (argumentPrefix) => workflowArgumentCompletions(argumentPrefix, sessionProjectDir()),
|
|
424
504
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
425
505
|
const invocation = parseWorkflowInvocation(args);
|
|
426
506
|
if (invocation.optionErrors.length > 0) {
|
|
@@ -429,8 +509,7 @@ export default function workflowEngine(pi: ExtensionAPI, shortcuts: DynamaxShort
|
|
|
429
509
|
}
|
|
430
510
|
const options = resolveWorkflowRunOptions(invocation.options);
|
|
431
511
|
const perfRecorder = await createInvocationPerf(options);
|
|
432
|
-
const {
|
|
433
|
-
const workflows = await discoverWorkflows(EXTENSION_DIR, { refresh: invocation.refreshDiscovery, perf: perfRecorder });
|
|
512
|
+
const workflows = await discoverForSession(ctx, { refresh: invocation.refreshDiscovery, perf: perfRecorder });
|
|
434
513
|
const available = [...workflows.keys()].join(", ") || "(none)";
|
|
435
514
|
if (!invocation.name && !ctx.hasUI) {
|
|
436
515
|
ctx.ui.notify(`Usage: /workflow <name> [args]. Available: ${available}`, "warning");
|
|
@@ -460,7 +539,22 @@ export default function workflowEngine(pi: ExtensionAPI, shortcuts: DynamaxShort
|
|
|
460
539
|
},
|
|
461
540
|
});
|
|
462
541
|
|
|
463
|
-
registerWorkflowTool(pi, reviewSessions, backgroundWorkflows, shortcuts);
|
|
542
|
+
registerWorkflowTool(pi, reviewSessions, backgroundWorkflows, shortcuts, dynamax);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Add the `<workflows>` catalog to every run that can call the workflow tool. */
|
|
546
|
+
function registerWorkflowCatalog(pi: ExtensionAPI, catalog: WorkflowCatalogPrompt): void {
|
|
547
|
+
pi.on("session_start", (_event, ctx) => catalog.forget(ctx));
|
|
548
|
+
pi.on("session_shutdown", (_event, ctx) => catalog.forget(ctx));
|
|
549
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
550
|
+
const options = event.systemPromptOptions;
|
|
551
|
+
// Subagent sessions withhold the workflow tool, so they skip the catalog as well.
|
|
552
|
+
if (!options.selectedTools.includes(WORKFLOW_TOOL_NAME)) return undefined;
|
|
553
|
+
const text = await catalog.section(ctx);
|
|
554
|
+
if (text === undefined) return undefined;
|
|
555
|
+
options.sections[WORKFLOWS_SECTION] = text;
|
|
556
|
+
return appendSectionToForcedPrompt(options, WORKFLOWS_SECTION);
|
|
557
|
+
});
|
|
464
558
|
}
|
|
465
559
|
|
|
466
560
|
/** Result footers advertise the configured triage and inspector shortcuts and pi's current expand key, read at render time. */
|
|
@@ -478,6 +572,7 @@ function registerWorkflowTool(
|
|
|
478
572
|
reviewSessions: ReviewSessionCoordinator,
|
|
479
573
|
backgroundWorkflows: BackgroundWorkflowCoordinator,
|
|
480
574
|
shortcuts: DynamaxShortcuts,
|
|
575
|
+
dynamax: DynamaxHandle,
|
|
481
576
|
): void {
|
|
482
577
|
pi.registerTool({
|
|
483
578
|
name: WORKFLOW_TOOL_NAME,
|
|
@@ -485,27 +580,38 @@ function registerWorkflowTool(
|
|
|
485
580
|
description:
|
|
486
581
|
"ONLY call workflow when the user opted into multi-agent orchestration via the literal token `dynamax`, sticky `/workflow:dynamax on`, an explicit request to run or author a workflow, or a command/skill instruction. Runs either a registered named workflow or an inline one-off workflow script (fan-out → verify → synthesize), synchronously by default or explicitly in the background.",
|
|
487
582
|
promptSnippet: "Run an existing named workflow or an inline one-off workflow script",
|
|
583
|
+
// Like codemode itself, an orchestrating tool stays a top-level model action: from a codemode script a
|
|
584
|
+
// run would lose its tool row, hit the script's output cap, and parallel runs would each get their own
|
|
585
|
+
// concurrency cap. It also stays declared under `codemode.mode: "only"`, which hides direct tools.
|
|
586
|
+
exposure: "model-only",
|
|
587
|
+
reachesTools: workflowToolReach,
|
|
488
588
|
promptGuidelines: [
|
|
489
|
-
"Use workflow only when the user opted into workflow orchestration via `dynamax`, `/workflow:dynamax
|
|
490
|
-
|
|
491
|
-
"
|
|
492
|
-
"Inline workflow scripts must use the injected `Type`
|
|
493
|
-
"
|
|
494
|
-
"Subagents receive no skills by default. In inline workflows, pass `skills: [\"skill-name\"]` per `agent()` call when the user asks for a skill or a stage should use one; grant only the needed skills.",
|
|
495
|
-
"Always pass a plain string as the first `api.agent()` argument; build prompts with template strings before calling agent().",
|
|
589
|
+
"Use workflow only when the user opted into workflow orchestration via `dynamax`, sticky `/workflow:dynamax`, an explicit request to run/author a workflow, or a command/skill instruction.",
|
|
590
|
+
`Before writing or editing an inline workflow script, read the workflow-authoring skill (${WORKFLOW_AUTHORING_SKILL_PATH}); it is the script API and pattern reference.`,
|
|
591
|
+
"Every workflow tool call must provide exactly one of `name`, `script`, or `scriptPath`.",
|
|
592
|
+
"Inline workflow scripts must use the injected `Type` global for schemas and must not contain imports or dynamic import().",
|
|
593
|
+
"Set `profile: \"small\"`, `\"medium\"`, or `\"big\"` on every `api.agent()` call so the user's central model routes apply; use `model`/`thinkingLevel` only for an intentional override.",
|
|
496
594
|
"When using `isolation: \"worktree\"`, `api.agent()` returns `{ result, patch, changed }`; use `.result` for the answer and `.patch` for the isolated diff.",
|
|
497
|
-
"
|
|
498
|
-
"`api.budget` exposes `{ total, spent(), remaining() }` (output tokens). When the run is budgeted, scale fleets from `budget.total` and guard loops with `while (budget.total && budget.remaining() > N) { await api.agent(...) }`; `api.agent()` throws once the ceiling is reached.",
|
|
499
|
-
ADAPTIVE_WORKFLOW_GUIDANCE,
|
|
595
|
+
"Host MCP tools reach subagents only in synchronous workflow tool runs, never in background runs.",
|
|
500
596
|
"Set background: true only when the user explicitly wants the workflow to continue after this tool call; the tool returns a durable run ID and completion is delivered later.",
|
|
501
597
|
"Set autoResumeOnUsageLimit: true only for an explicitly backgrounded workflow when the user wants bounded automatic continuation after a recognized provider usage window.",
|
|
502
598
|
"Set resumeEditedWorkflow: true only with resumeFromRunId when the user explicitly accepts reusing behaviorally identical calls after workflow source edits.",
|
|
503
|
-
"Every workflow tool call must provide exactly one of `name` or `script`, never both.",
|
|
504
599
|
],
|
|
505
600
|
parameters: Type.Object({
|
|
506
|
-
name: Type.Optional(Type.String({ description: "Workflow name, e.g. code-review. Provide exactly one of name or
|
|
507
|
-
script: Type.Optional(Type.String({ description: "Inline workflow script. Provide exactly one of script or
|
|
508
|
-
|
|
601
|
+
name: Type.Optional(Type.String({ description: "Workflow name, e.g. code-review. Provide exactly one of name, script, or scriptPath." })),
|
|
602
|
+
script: Type.Optional(Type.String({ description: "Inline workflow script. Provide exactly one of name, script, or scriptPath." })),
|
|
603
|
+
scriptPath: Type.Optional(
|
|
604
|
+
Type.String({
|
|
605
|
+
description:
|
|
606
|
+
"Path, relative to the working directory, of a file holding an inline workflow script. To iterate, edit in place the persisted .pi/.workflow-runs/<runId>.script.js a result names and rerun it here with resumeFromRunId, which accepts the edits as with resumeEditedWorkflow; editing any other copy in the repository, or a .pi/workflows script, changes repository state, so shared read-only agents rerun live. Provide exactly one of name, script, or scriptPath.",
|
|
607
|
+
}),
|
|
608
|
+
),
|
|
609
|
+
args: Type.Optional(
|
|
610
|
+
Type.Unknown({
|
|
611
|
+
description:
|
|
612
|
+
"Arguments for the workflow: a string (e.g. target or focus) or any JSON value. The workflow reads the value as `api.input` and its text (JSON for non-strings) as `api.args`.",
|
|
613
|
+
}),
|
|
614
|
+
),
|
|
509
615
|
concurrency: Type.Optional(Type.Number({ description: "Optional per-run agent concurrency cap" })),
|
|
510
616
|
parallelSubmissionLimit: Type.Optional(Type.Number({ description: "Optional limit for eagerly submitted parallel thunks" })),
|
|
511
617
|
maxAgents: Type.Optional(
|
|
@@ -565,21 +671,23 @@ function registerWorkflowTool(
|
|
|
565
671
|
if (request.kind === "error") return invalidWorkflowInvocationResult();
|
|
566
672
|
const resumeFromRunId = params.resumeFromRunId?.trim();
|
|
567
673
|
if (params.resumeFromRunId !== undefined && resumeFromRunId === "") {
|
|
568
|
-
return {
|
|
569
|
-
content: [{ type: "text", text: "resumeFromRunId must be non-empty." }],
|
|
570
|
-
details: { error: "invalid_resume_from_run_id" },
|
|
571
|
-
};
|
|
674
|
+
return workflowToolError("resumeFromRunId must be non-empty.", { error: "invalid_resume_from_run_id" });
|
|
572
675
|
}
|
|
573
676
|
if (params.resumeEditedWorkflow && !resumeFromRunId) {
|
|
574
|
-
return {
|
|
575
|
-
content: [{ type: "text", text: "resumeEditedWorkflow requires resumeFromRunId." }],
|
|
576
|
-
details: { error: "invalid_edited_workflow_resume" },
|
|
577
|
-
};
|
|
677
|
+
return workflowToolError("resumeEditedWorkflow requires resumeFromRunId.", { error: "invalid_edited_workflow_resume" });
|
|
578
678
|
}
|
|
579
679
|
if (params.background) {
|
|
580
680
|
const unavailable = backgroundUnavailableResult(ctx.mode);
|
|
581
681
|
if (unavailable) return unavailable;
|
|
582
682
|
}
|
|
683
|
+
const turnBudget = dynamax.turnBudget(ctx);
|
|
684
|
+
const budget = resolveTurnBudget(params.budget, turnBudget, { min: WORKFLOW_BUDGET_MIN, max: WORKFLOW_BUDGET_MAX });
|
|
685
|
+
if (budget.kind === "spent") {
|
|
686
|
+
return workflowToolError(
|
|
687
|
+
`The user's +${budget.total} output-token budget for this turn is spent by its earlier workflow runs. Do not start another workflow this turn unless the user raises the budget.`,
|
|
688
|
+
{ error: "turn_budget_spent", total: budget.total },
|
|
689
|
+
);
|
|
690
|
+
}
|
|
583
691
|
|
|
584
692
|
const runOptions = resolveWorkflowRunOptions({
|
|
585
693
|
concurrency: params.concurrency,
|
|
@@ -590,10 +698,11 @@ function registerWorkflowTool(
|
|
|
590
698
|
autoResumeOnUsageLimit: params.autoResumeOnUsageLimit,
|
|
591
699
|
usageLimitMaxAttempts: params.usageLimitMaxAttempts,
|
|
592
700
|
usageLimitMaxDelayMs: params.usageLimitMaxDelayMs,
|
|
593
|
-
budget:
|
|
701
|
+
budget: budget.budget,
|
|
594
702
|
perf: params.perf,
|
|
595
703
|
resumeFromRunId,
|
|
596
|
-
|
|
704
|
+
// Rerunning an edited script file is the point of scriptPath; each call's own replay identity still guards reuse.
|
|
705
|
+
resumeEditedWorkflow: params.resumeEditedWorkflow === true || request.kind === "script-path",
|
|
597
706
|
signal,
|
|
598
707
|
});
|
|
599
708
|
const perfRecorder = await createInvocationPerf(runOptions);
|
|
@@ -601,36 +710,31 @@ function registerWorkflowTool(
|
|
|
601
710
|
let resultName: string;
|
|
602
711
|
|
|
603
712
|
if (request.kind === "named") {
|
|
604
|
-
const
|
|
605
|
-
const workflows = await discoverWorkflows(EXTENSION_DIR, { perf: perfRecorder });
|
|
713
|
+
const workflows = await discoverForSession(ctx, { perf: perfRecorder });
|
|
606
714
|
const named = workflows.get(request.name);
|
|
607
715
|
if (!named) {
|
|
608
716
|
const available = [...workflows.keys()].join(", ") || "(none)";
|
|
609
|
-
return {
|
|
610
|
-
content: [{ type: "text", text: `Unknown workflow "${request.name}". Available: ${available}` }],
|
|
611
|
-
details: { error: "unknown_workflow", available },
|
|
612
|
-
};
|
|
717
|
+
return workflowToolError(`Unknown workflow "${request.name}". Available: ${available}`, { error: "unknown_workflow", available });
|
|
613
718
|
}
|
|
614
719
|
mod = named;
|
|
615
720
|
resultName = request.name;
|
|
616
721
|
} else {
|
|
617
|
-
const
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
} catch (error) {
|
|
621
|
-
if (error instanceof inline.InlineWorkflowCompileError) return inlineCompileErrorResult(error.message);
|
|
622
|
-
throw error;
|
|
623
|
-
}
|
|
722
|
+
const compiled = await compileInlineToolWorkflow(ctx.cwd, request);
|
|
723
|
+
if ("isError" in compiled) return compiled;
|
|
724
|
+
mod = compiled;
|
|
624
725
|
resultName = mod.meta.name;
|
|
625
726
|
}
|
|
626
727
|
|
|
627
|
-
|
|
728
|
+
// Omitted args match a slash command without arguments; any other value, null included, is kept verbatim.
|
|
729
|
+
const resultArgs: unknown = params.args === undefined ? "" : params.args;
|
|
628
730
|
if (params.background) {
|
|
629
731
|
return await startBackgroundWorkflowTool({
|
|
630
732
|
coordinator: backgroundWorkflows,
|
|
631
733
|
ctx,
|
|
632
734
|
name: resultName,
|
|
633
735
|
options: runOptions,
|
|
736
|
+
// Under Dynamax the agent is orchestrating, so its completion starts a turn to act on the result.
|
|
737
|
+
wakeOnComplete: dynamax.isActive(ctx),
|
|
634
738
|
async execute(backgroundCtx, backgroundOptions) {
|
|
635
739
|
const envelope = await executeResolvedWorkflow(
|
|
636
740
|
pi,
|
|
@@ -647,11 +751,49 @@ function registerWorkflowTool(
|
|
|
647
751
|
}
|
|
648
752
|
// Marks the tool row as running (rendered `● running <name>`) until the envelope replaces it.
|
|
649
753
|
onUpdate?.({ content: [{ type: "text", text: `Running workflow ${resultName}.` }], details: { state: "running", name: resultName } });
|
|
650
|
-
|
|
754
|
+
// Only this synchronous path may bridge host MCP tools: executeTool ends with the tool call.
|
|
755
|
+
const hostTools = createHostToolBridge(() => pi.getAllTools(), ctx);
|
|
756
|
+
// Keep the latest usage: a failed run still reports what its agents spent instead of dropping it with the error.
|
|
757
|
+
let spent: WorkflowUsageSnapshot | undefined;
|
|
758
|
+
const onUsageSnapshot = (snapshot: WorkflowUsageSnapshot) => {
|
|
759
|
+
spent = snapshot;
|
|
760
|
+
return runOptions.onUsageSnapshot?.(snapshot);
|
|
761
|
+
};
|
|
762
|
+
// A failed run still names its run, script and journal, so the host can fix the script and resume.
|
|
763
|
+
let run: WorkflowRunMetadata | undefined;
|
|
764
|
+
const onRunMetadata = (metadata: WorkflowRunMetadata) => {
|
|
765
|
+
run = metadata;
|
|
766
|
+
return runOptions.onRunMetadata?.(metadata);
|
|
767
|
+
};
|
|
768
|
+
let envelope: WorkflowResultEnvelope;
|
|
769
|
+
try {
|
|
770
|
+
envelope = await executeResolvedWorkflow(
|
|
771
|
+
pi,
|
|
772
|
+
ctx,
|
|
773
|
+
resultName,
|
|
774
|
+
mod,
|
|
775
|
+
resultArgs,
|
|
776
|
+
{ ...runOptions, hostTools, onUsageSnapshot, onRunMetadata },
|
|
777
|
+
perfRecorder,
|
|
778
|
+
);
|
|
779
|
+
} catch (error) {
|
|
780
|
+
const message = unknownErrorMessage(error);
|
|
781
|
+
const lines = [`Workflow ${resultName} failed: ${message}`];
|
|
782
|
+
if (run) lines.push("", formatWorkflowRunLine(run), ...formatWorkflowRunFileLines(run.runId, run.scriptPath !== undefined));
|
|
783
|
+
return {
|
|
784
|
+
...workflowToolError(lines.join("\n"), { error: "workflow_failed", name: resultName, message }),
|
|
785
|
+
usage: workflowUsageForPi(spent),
|
|
786
|
+
};
|
|
787
|
+
} finally {
|
|
788
|
+
// Only synchronous runs draw the turn pool down; a background run's spend lands after the turn.
|
|
789
|
+
turnBudget?.spend(spent?.totals.output ?? 0);
|
|
790
|
+
}
|
|
651
791
|
reviewSessions.remember(ctx, envelope, runOptions);
|
|
652
792
|
return {
|
|
653
793
|
content: [{ type: "text", text: formatWorkflowResultForContext(envelope) }],
|
|
654
794
|
details: envelope,
|
|
795
|
+
// pi counts tool-result usage toward the session cost; subagent spend was invisible there before.
|
|
796
|
+
usage: workflowUsageForPi(envelope.usage),
|
|
655
797
|
};
|
|
656
798
|
},
|
|
657
799
|
});
|
|
@@ -49,16 +49,19 @@ export function dedupeCandidates<T extends IdentifiedAdvisoryCandidate>(candidat
|
|
|
49
49
|
const key = candidateDedupKey(candidate);
|
|
50
50
|
const previous = seen.get(key);
|
|
51
51
|
if (!previous) seen.set(key, { ...candidate });
|
|
52
|
-
else
|
|
53
|
-
previous.sourceCandidateIds = [...new Set([...previous.sourceCandidateIds, ...candidate.sourceCandidateIds])];
|
|
54
|
-
previous.locations = uniqueLocations([...previous.locations, ...candidate.locations]);
|
|
55
|
-
previous.impact = [...new Set([previous.impact, candidate.impact])].join("\n");
|
|
56
|
-
previous.discoveryEvidence = [...new Set([...(previous.discoveryEvidence ?? []), ...(candidate.discoveryEvidence ?? [])])];
|
|
57
|
-
}
|
|
52
|
+
else mergeCandidateInto(previous, candidate);
|
|
58
53
|
}
|
|
59
54
|
return [...seen.values()];
|
|
60
55
|
}
|
|
61
56
|
|
|
57
|
+
/** Fold a duplicate into the kept candidate, keeping every source id, location, impact, and piece of evidence. */
|
|
58
|
+
export function mergeCandidateInto<T extends IdentifiedAdvisoryCandidate>(kept: T, duplicate: T): void {
|
|
59
|
+
kept.sourceCandidateIds = [...new Set([...kept.sourceCandidateIds, ...duplicate.sourceCandidateIds])];
|
|
60
|
+
kept.locations = uniqueLocations([...kept.locations, ...duplicate.locations]);
|
|
61
|
+
kept.impact = [...new Set([kept.impact, duplicate.impact])].join("\n");
|
|
62
|
+
kept.discoveryEvidence = [...new Set([...(kept.discoveryEvidence ?? []), ...(duplicate.discoveryEvidence ?? [])])];
|
|
63
|
+
}
|
|
64
|
+
|
|
62
65
|
export function uniqueLocations(locations: AdvisoryCandidate["locations"]): AdvisoryCandidate["locations"] {
|
|
63
66
|
return [...new Map(locations.map((location) => [JSON.stringify(location), location])).values()];
|
|
64
67
|
}
|