pi-workflow-engine 0.10.2 → 0.11.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 -120
- package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +11 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +145 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +376 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +87 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +84 -529
- package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +256 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +413 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +43 -1
- package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +81 -0
- package/.pi/extensions/pi-workflow-engine/src/budget.ts +7 -0
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +17 -3
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +164 -61
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +117 -20
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +64 -15
- package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +59 -13
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +14 -3
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +208 -74
- package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +66 -0
- package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +138 -0
- package/.pi/extensions/pi-workflow-engine/src/identity-fingerprint.ts +15 -0
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +8 -3
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +148 -64
- package/.pi/extensions/pi-workflow-engine/src/options.ts +9 -6
- package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +203 -39
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +2 -5
- package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +48 -0
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +665 -0
- package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +75 -16
- package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +83 -17
- package/.pi/extensions/pi-workflow-engine/src/review/review-budget.ts +45 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +117 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +14 -66
- package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +38 -12
- package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +42 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +17 -22
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +143 -52
- package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +153 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +295 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +153 -0
- package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +404 -0
- package/.pi/extensions/pi-workflow-engine/src/types.ts +38 -10
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +5 -69
- package/.pi/extensions/pi-workflow-engine/src/unknown-error.ts +8 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +121 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +19 -1
- package/.pi/extensions/pi-workflow-engine/src/workflows.ts +28 -3
- package/.pi/extensions/pi-workflow-engine/src/worktree.ts +282 -41
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +48 -26
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +8 -1
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +8 -2
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +8 -1
- package/README.md +170 -226
- package/USAGE.md +92 -12
- package/package.json +9 -9
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
3
|
-
import { linkAbortSignal } from "./cancellation.ts";
|
|
2
|
+
import { bindParallel, bindPipeline, Semaphore } from "./concurrency.ts";
|
|
3
|
+
import { linkAbortSignal, throwIfAborted } from "./cancellation.ts";
|
|
4
4
|
import { createBudget } from "./budget.ts";
|
|
5
|
-
import { runAgent, type RunContext } from "./agent-runner.ts";
|
|
5
|
+
import { runAgent, type AgentExecutionOptions, type RunContext } from "./agent-runner.ts";
|
|
6
6
|
import { ProgressTracker } from "./progress.ts";
|
|
7
|
-
import { createPerfRecorder, type PerfSnapshot } from "./perf.ts";
|
|
8
|
-
import { createWorkflowUsageRecorder } from "./usage.ts";
|
|
9
|
-
import {
|
|
10
|
-
import type { AgentOptions,
|
|
7
|
+
import { createPerfRecorder, type PerfSink, type PerfSnapshot } from "./perf.ts";
|
|
8
|
+
import { createWorkflowUsageRecorder, type WorkflowUsageSink } from "./usage.ts";
|
|
9
|
+
import { resolveWorkflowRunOptions, type ResolvedWorkflowRunOptions } from "./options.ts";
|
|
10
|
+
import type { AgentOptions, LoadedWorkflow, WorkflowApi, WorkflowProgressEvent, WorkflowRef, WorkflowRunOptions } from "./types.ts";
|
|
11
11
|
import { WorkflowInspector } from "./ui/workflow-inspector.ts";
|
|
12
12
|
import { createWorkflowJournal, createWorkflowRunId, pruneWorkflowJournals, workflowJournalPath } from "./journal.ts";
|
|
13
13
|
import { WorktreeRegistry } from "./worktree.ts";
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
import { runFinalizers } from "./finalizers.ts";
|
|
15
|
+
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
16
|
+
import {
|
|
17
|
+
captureWorkflowResumeContext,
|
|
18
|
+
type AgentResumeBaseContext,
|
|
19
|
+
} from "./resume-context.ts";
|
|
17
20
|
|
|
18
21
|
/** The workflow-facing slice of the progress tracker (satisfied by `ProgressTracker`). */
|
|
19
22
|
export interface WorkflowProgress {
|
|
@@ -22,6 +25,9 @@ export interface WorkflowProgress {
|
|
|
22
25
|
event(event: WorkflowProgressEvent): void;
|
|
23
26
|
}
|
|
24
27
|
|
|
28
|
+
/** Engine state shared by the workflow and its individual agent calls. */
|
|
29
|
+
export type WorkflowRunContext = RunContext;
|
|
30
|
+
|
|
25
31
|
/** Per-context knobs threaded through `runWorkflowWithContext` (and re-derived for each sub-workflow). */
|
|
26
32
|
export interface WorkflowContextOptions {
|
|
27
33
|
/** Controller whose `.signal` equals `rc.signal`; aborts in-flight `parallel` siblings on failure. */
|
|
@@ -29,7 +35,7 @@ export interface WorkflowContextOptions {
|
|
|
29
35
|
/** Eager-submission cap for `parallel`. */
|
|
30
36
|
submissionLimit: number;
|
|
31
37
|
/** Resolve a sub-workflow reference for `api.workflow()`. Omit to disable composition. */
|
|
32
|
-
resolveWorkflow?: (ref: WorkflowRef) => Promise<
|
|
38
|
+
resolveWorkflow?: (ref: WorkflowRef) => Promise<LoadedWorkflow>;
|
|
33
39
|
/** Nesting depth. Sub-workflows run at depth >= 1, where `api.workflow()` rejects. */
|
|
34
40
|
depth?: number;
|
|
35
41
|
/** Prefix applied to phase titles so sub-workflow phases nest as "<name> ▸ <title>". */
|
|
@@ -38,59 +44,86 @@ export interface WorkflowContextOptions {
|
|
|
38
44
|
progressNamespace?: string;
|
|
39
45
|
}
|
|
40
46
|
|
|
47
|
+
type Outcome<T> =
|
|
48
|
+
| { readonly ok: true; readonly value: T }
|
|
49
|
+
| { readonly ok: false; readonly error: unknown };
|
|
50
|
+
|
|
51
|
+
export interface WorkflowEngineDependencies {
|
|
52
|
+
readonly worktrees?: WorktreeRegistry;
|
|
53
|
+
}
|
|
54
|
+
|
|
41
55
|
/**
|
|
42
56
|
* Run a workflow module: build the per-run primitives (binding agent/parallel/pipeline
|
|
43
57
|
* to a shared semaphore + progress tracker), invoke the workflow, return its result.
|
|
44
58
|
*/
|
|
45
59
|
export async function runWorkflow(
|
|
46
60
|
ctx: ExtensionContext,
|
|
47
|
-
mod:
|
|
61
|
+
mod: LoadedWorkflow,
|
|
48
62
|
args: string,
|
|
49
63
|
options: WorkflowRunOptions = {},
|
|
64
|
+
dependencies: WorkflowEngineDependencies = {},
|
|
65
|
+
): Promise<unknown> {
|
|
66
|
+
return await runResolvedWorkflow(ctx, mod, args, resolveWorkflowRunOptions(options), dependencies);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Execute an options-normalized workflow without re-reading environment defaults. */
|
|
70
|
+
export async function runResolvedWorkflow(
|
|
71
|
+
ctx: ExtensionContext,
|
|
72
|
+
mod: LoadedWorkflow,
|
|
73
|
+
args: string,
|
|
74
|
+
resolvedOptions: ResolvedWorkflowRunOptions,
|
|
75
|
+
dependencies: WorkflowEngineDependencies = {},
|
|
50
76
|
): Promise<unknown> {
|
|
51
|
-
const resolvedOptions = resolveWorkflowRunOptions(options);
|
|
52
77
|
const progress = new ProgressTracker(ctx, mod.meta.name);
|
|
53
78
|
const progressSource = { snapshot: () => progress.snapshot() };
|
|
54
|
-
resolvedOptions.onProgressSource?.(progressSource);
|
|
55
|
-
if (resolvedOptions.inspect && ctx.hasUI) {
|
|
56
|
-
void ctx.ui
|
|
57
|
-
.custom<void>(
|
|
58
|
-
(tui, theme, _keybindings, done) => new WorkflowInspector(() => progress.snapshot(), tui, theme, () => done(undefined)),
|
|
59
|
-
{ overlay: true, overlayOptions: { anchor: "right-center", width: "60%", maxHeight: "80%", margin: 1 } },
|
|
60
|
-
)
|
|
61
|
-
.catch((error: unknown) => progress.log(`inspector failed: ${error instanceof Error ? error.message : String(error)}`));
|
|
62
|
-
}
|
|
63
|
-
|
|
64
79
|
const perf = resolvedOptions.perfRecorder ?? createPerfRecorder(resolvedOptions.perf);
|
|
65
80
|
const usage = createWorkflowUsageRecorder();
|
|
66
|
-
const budget = createBudget(resolvedOptions.budget
|
|
81
|
+
const budget = createBudget(resolvedOptions.budget, usage);
|
|
67
82
|
const runId = resolvedOptions.runId ?? createWorkflowRunId();
|
|
68
83
|
const journalPath = workflowJournalPath(ctx.cwd, runId);
|
|
69
84
|
const resumePath = resolvedOptions.resumeFromRunId ? workflowJournalPath(ctx.cwd, resolvedOptions.resumeFromRunId) : undefined;
|
|
70
|
-
const
|
|
71
|
-
const worktrees = new WorktreeRegistry(ctx.cwd);
|
|
72
|
-
resolvedOptions.onRunMetadata?.({ runId, resumedFromRunId: resolvedOptions.resumeFromRunId, journalPath });
|
|
73
|
-
progress.log(resolvedOptions.resumeFromRunId ? `run id: ${runId} (resuming from ${resolvedOptions.resumeFromRunId})` : `run id: ${runId}`);
|
|
85
|
+
const worktrees = dependencies.worktrees ?? new WorktreeRegistry(ctx.cwd);
|
|
74
86
|
const runAbortController = new AbortController();
|
|
75
87
|
const unlinkContextAbortSignal = linkAbortSignal(ctx.signal, runAbortController);
|
|
76
88
|
const unlinkOptionAbortSignal = linkAbortSignal(resolvedOptions.signal, runAbortController);
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
89
|
+
const workflowOutcome = await captureOutcome(async () => {
|
|
90
|
+
await notifyLifecycleObserver(progress, "progress source callback", () => resolvedOptions.onProgressSource?.(progressSource));
|
|
91
|
+
if (resolvedOptions.inspect && ctx.hasUI) {
|
|
92
|
+
void ctx.ui
|
|
93
|
+
.custom<void>(
|
|
94
|
+
(tui, theme, _keybindings, done) => new WorkflowInspector(() => progress.snapshot(), tui, theme, () => done(undefined)),
|
|
95
|
+
{ overlay: true, overlayOptions: { anchor: "right-center", width: "60%", maxHeight: "80%", margin: 1 } },
|
|
96
|
+
)
|
|
97
|
+
.catch((error: unknown) => {
|
|
98
|
+
try {
|
|
99
|
+
progress.log(`inspector failed: ${unknownErrorMessage(error)}`);
|
|
100
|
+
} catch {
|
|
101
|
+
// Inspector reporting is detached and must never become another rejection.
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const journal = await createWorkflowJournal({ resumePath, writePath: journalPath });
|
|
107
|
+
await notifyLifecycleObserver(progress, "run metadata callback", () =>
|
|
108
|
+
resolvedOptions.onRunMetadata?.({ runId, resumedFromRunId: resolvedOptions.resumeFromRunId, journalPath }),
|
|
109
|
+
);
|
|
110
|
+
progress.log(resolvedOptions.resumeFromRunId ? `run id: ${runId} (resuming from ${resolvedOptions.resumeFromRunId})` : `run id: ${runId}`);
|
|
111
|
+
const rc: WorkflowRunContext = {
|
|
112
|
+
cwd: ctx.cwd,
|
|
113
|
+
hostModel: ctx.model,
|
|
114
|
+
modelRegistry: ctx.modelRegistry,
|
|
115
|
+
semaphore: new Semaphore(resolvedOptions.concurrency),
|
|
116
|
+
progress,
|
|
117
|
+
signal: runAbortController.signal,
|
|
118
|
+
perf,
|
|
119
|
+
usage,
|
|
120
|
+
budget,
|
|
121
|
+
journal,
|
|
122
|
+
worktrees,
|
|
123
|
+
};
|
|
90
124
|
|
|
91
|
-
try {
|
|
92
125
|
// perf.total_ms wraps the whole tree: sub-workflows run inside this span via api.workflow().
|
|
93
|
-
return
|
|
126
|
+
return perf.time("workflow.total_ms", () =>
|
|
94
127
|
runWorkflowWithContext(rc, progress, mod, args, {
|
|
95
128
|
abortController: runAbortController,
|
|
96
129
|
submissionLimit: resolvedOptions.parallelSubmissionLimit ?? resolvedOptions.concurrency * 2,
|
|
@@ -99,47 +132,132 @@ export async function runWorkflow(
|
|
|
99
132
|
progressPrefix: "",
|
|
100
133
|
}),
|
|
101
134
|
);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
progress
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const finalizationOutcome = await captureOutcome(() =>
|
|
138
|
+
finalizeWorkflowRun({
|
|
139
|
+
cwd: ctx.cwd,
|
|
140
|
+
options: resolvedOptions,
|
|
141
|
+
perf,
|
|
142
|
+
usage,
|
|
143
|
+
progress,
|
|
144
|
+
worktrees,
|
|
145
|
+
unlinkSignals: [unlinkContextAbortSignal, unlinkOptionAbortSignal],
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
if (workflowOutcome.ok) {
|
|
150
|
+
if (finalizationOutcome.ok) return workflowOutcome.value;
|
|
151
|
+
throw finalizationOutcome.error;
|
|
152
|
+
}
|
|
153
|
+
if (finalizationOutcome.ok) throw workflowOutcome.error;
|
|
154
|
+
throw combinedWorkflowError(workflowOutcome.error, finalizationOutcome.error);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function captureOutcome<T>(operation: () => Promise<T>): Promise<Outcome<T>> {
|
|
158
|
+
try {
|
|
159
|
+
return { ok: true, value: await operation() };
|
|
160
|
+
} catch (error) {
|
|
161
|
+
return { ok: false, error };
|
|
124
162
|
}
|
|
125
163
|
}
|
|
126
164
|
|
|
165
|
+
interface WorkflowFinalizationInput {
|
|
166
|
+
readonly cwd: string;
|
|
167
|
+
readonly options: ResolvedWorkflowRunOptions;
|
|
168
|
+
readonly perf: PerfSink;
|
|
169
|
+
readonly usage: WorkflowUsageSink;
|
|
170
|
+
readonly progress: ProgressTracker;
|
|
171
|
+
readonly worktrees: WorktreeRegistry;
|
|
172
|
+
readonly unlinkSignals: readonly (() => void)[];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function finalizeWorkflowRun(input: WorkflowFinalizationInput): Promise<void> {
|
|
176
|
+
await runFinalizers(
|
|
177
|
+
[
|
|
178
|
+
{
|
|
179
|
+
name: "usage snapshot callback",
|
|
180
|
+
criticality: "best-effort",
|
|
181
|
+
run: () => input.options.onUsageSnapshot?.(input.usage.snapshot()),
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
name: "performance snapshot callback",
|
|
185
|
+
criticality: "best-effort",
|
|
186
|
+
run: async () => {
|
|
187
|
+
if (!input.options.perf) return;
|
|
188
|
+
const snapshot = input.perf.snapshot();
|
|
189
|
+
await input.options.onPerfSnapshot?.(snapshot);
|
|
190
|
+
input.progress.log(formatPerfSummary(snapshot));
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
...input.unlinkSignals.map((run, index) => ({
|
|
194
|
+
name: `abort signal unlink ${index + 1}`,
|
|
195
|
+
criticality: "required" as const,
|
|
196
|
+
run,
|
|
197
|
+
})),
|
|
198
|
+
{
|
|
199
|
+
name: "worktree cleanup",
|
|
200
|
+
criticality: "required",
|
|
201
|
+
run: () => input.worktrees.removeAll(),
|
|
202
|
+
},
|
|
203
|
+
{ name: "journal pruning", criticality: "best-effort", run: () => pruneWorkflowJournals(input.cwd) },
|
|
204
|
+
{ name: "progress completion", criticality: "best-effort", run: () => input.progress.done() },
|
|
205
|
+
{
|
|
206
|
+
name: "progress snapshot callback",
|
|
207
|
+
criticality: "best-effort",
|
|
208
|
+
run: () => input.options.onProgressSnapshot?.(input.progress.snapshot()),
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
name: "progress source clear",
|
|
212
|
+
criticality: "best-effort",
|
|
213
|
+
run: () => input.options.onProgressSource?.(undefined),
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
{
|
|
217
|
+
onBestEffortFailure: (failure) => {
|
|
218
|
+
try {
|
|
219
|
+
input.progress.log(`${failure.name} failed: ${unknownErrorMessage(failure.error)}`);
|
|
220
|
+
} catch {
|
|
221
|
+
// Observer failures must never replace the workflow outcome.
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function combinedWorkflowError(workflowError: unknown, finalizationError: unknown): AggregateError {
|
|
229
|
+
return new AggregateError(
|
|
230
|
+
[workflowError, finalizationError],
|
|
231
|
+
`Workflow failed: ${unknownErrorMessage(workflowError)}; finalization also failed: ${unknownErrorMessage(finalizationError)}`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
127
235
|
/**
|
|
128
236
|
* Build the `WorkflowApi` from an existing run context and invoke the module. Reused for both
|
|
129
237
|
* the top-level run and every `api.workflow()` sub-step, so children share the parent's semaphore,
|
|
130
238
|
* abort signal, and perf sink. No setup/teardown of its own — that belongs to `runWorkflow`.
|
|
131
239
|
*/
|
|
132
240
|
export async function runWorkflowWithContext(
|
|
133
|
-
rc:
|
|
241
|
+
rc: WorkflowRunContext,
|
|
134
242
|
progress: WorkflowProgress,
|
|
135
|
-
mod:
|
|
243
|
+
mod: LoadedWorkflow,
|
|
136
244
|
args: string,
|
|
137
245
|
opts: WorkflowContextOptions,
|
|
138
246
|
): Promise<unknown> {
|
|
139
247
|
const depth = opts.depth ?? 0;
|
|
140
248
|
const scope = createWorkflowScope(progress, opts.progressPrefix ?? "", opts.progressNamespace);
|
|
249
|
+
const resumeContext: AgentResumeBaseContext = {
|
|
250
|
+
workflow: await captureWorkflowResumeContext(mod, rc.signal),
|
|
251
|
+
};
|
|
141
252
|
|
|
142
|
-
const agent = ((prompt: string, agentOpts?: AgentOptions) =>
|
|
253
|
+
const agent = ((prompt: string, agentOpts?: AgentOptions) => {
|
|
254
|
+
const scopedOptions = scope.agentOptions(agentOpts);
|
|
255
|
+
const executionOptions: AgentExecutionOptions =
|
|
256
|
+
scopedOptions.isolation === "worktree" && mod.isolatedWorktreeBaseline !== undefined
|
|
257
|
+
? { ...scopedOptions, worktreeBaseline: mod.isolatedWorktreeBaseline }
|
|
258
|
+
: scopedOptions;
|
|
259
|
+
return runAgent(rc, prompt, executionOptions, resumeContext);
|
|
260
|
+
}) as WorkflowApi["agent"];
|
|
143
261
|
|
|
144
262
|
const workflow: WorkflowApi["workflow"] =
|
|
145
263
|
depth >= 1
|
|
@@ -171,12 +289,11 @@ export async function runWorkflowWithContext(
|
|
|
171
289
|
const api: WorkflowApi = {
|
|
172
290
|
agent,
|
|
173
291
|
workflow,
|
|
174
|
-
parallel: (
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
}),
|
|
292
|
+
parallel: bindParallel({
|
|
293
|
+
signal: rc.signal,
|
|
294
|
+
abortController: opts.abortController,
|
|
295
|
+
limit: opts.submissionLimit,
|
|
296
|
+
}),
|
|
180
297
|
pipeline: bindPipeline({ signal: rc.signal, abortController: opts.abortController }),
|
|
181
298
|
phase: scope.phase,
|
|
182
299
|
log: scope.log,
|
|
@@ -187,6 +304,7 @@ export async function runWorkflowWithContext(
|
|
|
187
304
|
signal: rc.signal,
|
|
188
305
|
};
|
|
189
306
|
|
|
307
|
+
throwIfAborted(rc.signal);
|
|
190
308
|
return await mod.default(api);
|
|
191
309
|
}
|
|
192
310
|
|
|
@@ -242,3 +360,19 @@ function formatPerfSummary(snapshot: PerfSnapshot): string {
|
|
|
242
360
|
.map((aggregate) => `${aggregate.name} ${Math.round(aggregate.total)}ms`);
|
|
243
361
|
return parts.length > 0 ? `perf: ${parts.join(", ")}` : "perf: no samples";
|
|
244
362
|
}
|
|
363
|
+
|
|
364
|
+
async function notifyLifecycleObserver(
|
|
365
|
+
progress: ProgressTracker,
|
|
366
|
+
name: string,
|
|
367
|
+
notify: () => void | Promise<void>,
|
|
368
|
+
): Promise<void> {
|
|
369
|
+
try {
|
|
370
|
+
await notify();
|
|
371
|
+
} catch (error) {
|
|
372
|
+
try {
|
|
373
|
+
progress.log(`${name} failed: ${unknownErrorMessage(error)}`);
|
|
374
|
+
} catch {
|
|
375
|
+
// Lifecycle observers are advisory and must not replace the workflow outcome.
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
2
|
+
|
|
3
|
+
export type FinalizerCriticality = "required" | "best-effort";
|
|
4
|
+
|
|
5
|
+
export interface Finalizer {
|
|
6
|
+
readonly name: string;
|
|
7
|
+
readonly criticality: FinalizerCriticality;
|
|
8
|
+
readonly run: () => void | Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface FinalizerFailure {
|
|
12
|
+
readonly name: string;
|
|
13
|
+
readonly criticality: FinalizerCriticality;
|
|
14
|
+
readonly error: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface RunFinalizersOptions {
|
|
18
|
+
readonly onBestEffortFailure?: (failure: FinalizerFailure) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class RequiredFinalizerError extends AggregateError {
|
|
22
|
+
readonly failures: readonly FinalizerFailure[];
|
|
23
|
+
|
|
24
|
+
constructor(failures: readonly FinalizerFailure[]) {
|
|
25
|
+
super(
|
|
26
|
+
failures.map((failure) => failure.error),
|
|
27
|
+
`Required workflow finalization failed: ${failures.map((failure) => `${failure.name} (${unknownErrorMessage(failure.error)})`).join(", ")}`,
|
|
28
|
+
);
|
|
29
|
+
this.name = "RequiredFinalizerError";
|
|
30
|
+
this.failures = failures;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Execute every finalizer once, reporting advisory failures and aggregating only required failures. */
|
|
35
|
+
export async function runFinalizers(
|
|
36
|
+
finalizers: readonly Finalizer[],
|
|
37
|
+
options: RunFinalizersOptions = {},
|
|
38
|
+
): Promise<readonly FinalizerFailure[]> {
|
|
39
|
+
const requiredFailures: FinalizerFailure[] = [];
|
|
40
|
+
const bestEffortFailures: FinalizerFailure[] = [];
|
|
41
|
+
|
|
42
|
+
for (const finalizer of finalizers) {
|
|
43
|
+
try {
|
|
44
|
+
await finalizer.run();
|
|
45
|
+
} catch (error) {
|
|
46
|
+
const failure: FinalizerFailure = {
|
|
47
|
+
name: finalizer.name,
|
|
48
|
+
criticality: finalizer.criticality,
|
|
49
|
+
error,
|
|
50
|
+
};
|
|
51
|
+
if (finalizer.criticality === "required") {
|
|
52
|
+
requiredFailures.push(failure);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
bestEffortFailures.push(failure);
|
|
56
|
+
try {
|
|
57
|
+
options.onBestEffortFailure?.(failure);
|
|
58
|
+
} catch {
|
|
59
|
+
// Failure observers are advisory and must not change finalization semantics.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (requiredFailures.length > 0) throw new RequiredFinalizerError(requiredFailures);
|
|
65
|
+
return bestEffortFailures;
|
|
66
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
export type IdentityCanonicalization =
|
|
2
|
+
| { readonly kind: "verified"; readonly value: string }
|
|
3
|
+
| { readonly kind: "unverifiable"; readonly reason: string };
|
|
4
|
+
|
|
5
|
+
export interface IdentityCanonicalizationOptions {
|
|
6
|
+
readonly maxBytes?: number;
|
|
7
|
+
readonly maxDepth?: number;
|
|
8
|
+
readonly maxNodes?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const DEFAULT_MAX_BYTES = 1 << 20;
|
|
12
|
+
const DEFAULT_MAX_DEPTH = 64;
|
|
13
|
+
const DEFAULT_MAX_NODES = 16_384;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Produce a deterministic, bounded identity for JSON-like data.
|
|
17
|
+
*
|
|
18
|
+
* Replay inputs are not trusted: schemas and tool metadata can contain cycles,
|
|
19
|
+
* accessors, proxies, or values JSON cannot represent. Those inputs disable
|
|
20
|
+
* replay instead of throwing or being silently collapsed to the same key.
|
|
21
|
+
*/
|
|
22
|
+
export function canonicalizeIdentity(
|
|
23
|
+
value: unknown,
|
|
24
|
+
options: IdentityCanonicalizationOptions = {},
|
|
25
|
+
): IdentityCanonicalization {
|
|
26
|
+
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
27
|
+
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
28
|
+
const maxNodes = options.maxNodes ?? DEFAULT_MAX_NODES;
|
|
29
|
+
const active = new WeakSet<object>();
|
|
30
|
+
const chunks: string[] = [];
|
|
31
|
+
let bytes = 0;
|
|
32
|
+
let nodes = 0;
|
|
33
|
+
|
|
34
|
+
const append = (chunk: string): void => {
|
|
35
|
+
bytes += Buffer.byteLength(chunk);
|
|
36
|
+
if (bytes > maxBytes) throw new IdentityCanonicalizationError(`identity exceeded ${maxBytes} bytes`);
|
|
37
|
+
chunks.push(chunk);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const visit = (current: unknown, depth: number): void => {
|
|
41
|
+
nodes += 1;
|
|
42
|
+
if (nodes > maxNodes) throw new IdentityCanonicalizationError(`identity exceeded ${maxNodes} values`);
|
|
43
|
+
if (depth > maxDepth) throw new IdentityCanonicalizationError(`identity exceeded ${maxDepth} levels`);
|
|
44
|
+
|
|
45
|
+
if (current === null) {
|
|
46
|
+
append("n;");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
switch (typeof current) {
|
|
50
|
+
case "undefined":
|
|
51
|
+
append("u;");
|
|
52
|
+
return;
|
|
53
|
+
case "boolean":
|
|
54
|
+
append(current ? "b1;" : "b0;");
|
|
55
|
+
return;
|
|
56
|
+
case "number":
|
|
57
|
+
if (!Number.isFinite(current)) throw new IdentityCanonicalizationError("identity contains a non-finite number");
|
|
58
|
+
append(`d${Object.is(current, -0) ? "-0" : String(current)};`);
|
|
59
|
+
return;
|
|
60
|
+
case "string":
|
|
61
|
+
append(`s${Buffer.byteLength(current)}:${current};`);
|
|
62
|
+
return;
|
|
63
|
+
case "bigint":
|
|
64
|
+
case "function":
|
|
65
|
+
case "symbol":
|
|
66
|
+
throw new IdentityCanonicalizationError(`identity contains unsupported ${typeof current} data`);
|
|
67
|
+
case "object":
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (active.has(current)) throw new IdentityCanonicalizationError("identity contains a cycle");
|
|
72
|
+
active.add(current);
|
|
73
|
+
try {
|
|
74
|
+
const prototype = Object.getPrototypeOf(current);
|
|
75
|
+
if (Array.isArray(current)) {
|
|
76
|
+
if (prototype !== Array.prototype) throw new IdentityCanonicalizationError("identity contains an array with a custom prototype");
|
|
77
|
+
const descriptors = Object.getOwnPropertyDescriptors(current);
|
|
78
|
+
const ownKeys = Reflect.ownKeys(current);
|
|
79
|
+
if (ownKeys.some((key) => typeof key === "symbol")) {
|
|
80
|
+
throw new IdentityCanonicalizationError("identity contains symbol keys");
|
|
81
|
+
}
|
|
82
|
+
if (ownKeys.some((key) => typeof key === "string" && key !== "length" && !isArrayIndex(key, current.length))) {
|
|
83
|
+
throw new IdentityCanonicalizationError("identity array contains custom properties");
|
|
84
|
+
}
|
|
85
|
+
append(`a${current.length}[`);
|
|
86
|
+
for (let index = 0; index < current.length; index++) {
|
|
87
|
+
const descriptor = descriptors[String(index)];
|
|
88
|
+
if (!descriptor) {
|
|
89
|
+
append("h;");
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (!("value" in descriptor)) throw new IdentityCanonicalizationError("identity contains an array accessor");
|
|
93
|
+
visit(descriptor.value, depth + 1);
|
|
94
|
+
}
|
|
95
|
+
append("];");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
100
|
+
throw new IdentityCanonicalizationError("identity contains an object with a custom prototype");
|
|
101
|
+
}
|
|
102
|
+
const descriptors = Object.getOwnPropertyDescriptors(current);
|
|
103
|
+
const ownKeys = Reflect.ownKeys(current);
|
|
104
|
+
if (ownKeys.some((key) => typeof key === "symbol")) {
|
|
105
|
+
throw new IdentityCanonicalizationError("identity contains symbol keys");
|
|
106
|
+
}
|
|
107
|
+
const keys = Object.keys(descriptors).sort();
|
|
108
|
+
append(`o${keys.length}{`);
|
|
109
|
+
for (const key of keys) {
|
|
110
|
+
const descriptor = descriptors[key]!;
|
|
111
|
+
if (!("value" in descriptor)) throw new IdentityCanonicalizationError(`identity property ${key} is an accessor`);
|
|
112
|
+
append(`k${Buffer.byteLength(key)}:${key};`);
|
|
113
|
+
append(`p${descriptor.enumerable ? 1 : 0}${descriptor.configurable ? 1 : 0}${descriptor.writable ? 1 : 0};`);
|
|
114
|
+
visit(descriptor.value, depth + 1);
|
|
115
|
+
}
|
|
116
|
+
append("};");
|
|
117
|
+
} finally {
|
|
118
|
+
active.delete(current);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
visit(value, 0);
|
|
124
|
+
return { kind: "verified", value: chunks.join("") };
|
|
125
|
+
} catch (error) {
|
|
126
|
+
return {
|
|
127
|
+
kind: "unverifiable",
|
|
128
|
+
reason: error instanceof IdentityCanonicalizationError ? error.message : "identity inspection failed",
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
class IdentityCanonicalizationError extends Error {}
|
|
134
|
+
|
|
135
|
+
function isArrayIndex(key: string, length: number): boolean {
|
|
136
|
+
const index = Number(key);
|
|
137
|
+
return Number.isInteger(index) && index >= 0 && index < length && String(index) === key;
|
|
138
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { canonicalizeIdentity } from "./identity-canonicalization.ts";
|
|
3
|
+
|
|
4
|
+
export function hashIdentity(value: unknown): string {
|
|
5
|
+
const canonical = canonicalizeIdentity(value);
|
|
6
|
+
if (canonical.kind === "unverifiable") throw new Error(canonical.reason);
|
|
7
|
+
return `sha256:${createHash("sha256").update(canonical.value).digest("hex")}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function inspectableFunctionSource(value: unknown, label: string): string {
|
|
11
|
+
if (typeof value !== "function") throw new Error(`${label} is not a function`);
|
|
12
|
+
const source = Function.prototype.toString.call(value);
|
|
13
|
+
if (source.length === 0 || source.includes("[native code]")) throw new Error(`${label} has no inspectable source`);
|
|
14
|
+
return source;
|
|
15
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
|
-
import
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import type { LoadedWorkflow, WorkflowApi } from "./types.ts";
|
|
3
4
|
import { parseWorkflowMeta } from "./workflow-module.ts";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -28,7 +29,7 @@ type AsyncFunctionConstructor = new (...args: string[]) => InlineWorkflowExecuto
|
|
|
28
29
|
|
|
29
30
|
const AsyncFunction = Object.getPrototypeOf(async function inlineWorkflowCompilerSentinel() {}).constructor as AsyncFunctionConstructor;
|
|
30
31
|
|
|
31
|
-
export function compileInlineWorkflow(source: string):
|
|
32
|
+
export function compileInlineWorkflow(source: string): LoadedWorkflow {
|
|
32
33
|
rejectForbiddenModuleSyntax(source);
|
|
33
34
|
const metaLiteral = extractMetaLiteral(source);
|
|
34
35
|
const parsedMeta = parseWorkflowMeta(metaLiteral.value);
|
|
@@ -36,7 +37,11 @@ export function compileInlineWorkflow(source: string): WorkflowModule {
|
|
|
36
37
|
|
|
37
38
|
const defaultExpression = extractDefaultWorkflowExpression(source, metaLiteral.endOffset);
|
|
38
39
|
const executor = compileExecutor(defaultExpression);
|
|
39
|
-
return {
|
|
40
|
+
return {
|
|
41
|
+
meta: parsedMeta.meta,
|
|
42
|
+
default: (api) => executor(api, Type),
|
|
43
|
+
source: { kind: "fingerprint", fingerprint: createHash("sha256").update(source).digest("hex") },
|
|
44
|
+
};
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
export function extractMetaLiteral(source: string): InlineMetaLiteral {
|