@doclo/flows 0.1.2
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/LICENSE +21 -0
- package/README.md +41 -0
- package/dist/chunk-USCWPTGU.js +16 -0
- package/dist/chunk-USCWPTGU.js.map +1 -0
- package/dist/index.d.ts +932 -0
- package/dist/index.js +2534 -0
- package/dist/index.js.map +1 -0
- package/dist/schemas.d.ts +32 -0
- package/dist/schemas.js +7 -0
- package/dist/schemas.js.map +1 -0
- package/package.json +39 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2534 @@
|
|
|
1
|
+
import {
|
|
2
|
+
simpleSchema
|
|
3
|
+
} from "./chunk-USCWPTGU.js";
|
|
4
|
+
|
|
5
|
+
// src/index.ts
|
|
6
|
+
import { runPipeline as runPipeline4 } from "@doclo/core";
|
|
7
|
+
import { parseNode as parseNode2, extractNode as extractNode2 } from "@doclo/nodes";
|
|
8
|
+
|
|
9
|
+
// src/flow-builder.ts
|
|
10
|
+
import {
|
|
11
|
+
runPipeline,
|
|
12
|
+
FlowExecutionError,
|
|
13
|
+
aggregateMetrics,
|
|
14
|
+
getNodeTypeName,
|
|
15
|
+
validateNodeConnection,
|
|
16
|
+
canStartForEachItemFlow,
|
|
17
|
+
getValidForEachStarters,
|
|
18
|
+
getProviderById,
|
|
19
|
+
validateFlowInputFormat
|
|
20
|
+
} from "@doclo/core";
|
|
21
|
+
import { shouldSkipValidation } from "@doclo/core/runtime/env";
|
|
22
|
+
import { output as createOutputNode } from "@doclo/nodes";
|
|
23
|
+
import {
|
|
24
|
+
mergeConfig,
|
|
25
|
+
shouldSample,
|
|
26
|
+
TraceContextManager,
|
|
27
|
+
generateExecutionId,
|
|
28
|
+
executeHook,
|
|
29
|
+
buildStepAttributes,
|
|
30
|
+
generateSpanId
|
|
31
|
+
} from "@doclo/core/observability";
|
|
32
|
+
function isSingleFlowResult(result) {
|
|
33
|
+
return "output" in result && "artifacts" in result;
|
|
34
|
+
}
|
|
35
|
+
function normalizeFlowInput(input) {
|
|
36
|
+
if (input == null) {
|
|
37
|
+
return input;
|
|
38
|
+
}
|
|
39
|
+
if (typeof input === "object" && (input.base64 || input.url)) {
|
|
40
|
+
return input;
|
|
41
|
+
}
|
|
42
|
+
if (typeof input === "string") {
|
|
43
|
+
if (input.startsWith("data:")) {
|
|
44
|
+
return { base64: input };
|
|
45
|
+
}
|
|
46
|
+
if (input.startsWith("http://") || input.startsWith("https://")) {
|
|
47
|
+
return { url: input };
|
|
48
|
+
}
|
|
49
|
+
return { base64: input };
|
|
50
|
+
}
|
|
51
|
+
return input;
|
|
52
|
+
}
|
|
53
|
+
var Flow = class {
|
|
54
|
+
steps = [];
|
|
55
|
+
observability;
|
|
56
|
+
metadata;
|
|
57
|
+
inputValidation;
|
|
58
|
+
traceContextManager;
|
|
59
|
+
currentExecution;
|
|
60
|
+
constructor(options) {
|
|
61
|
+
if (options?.observability) {
|
|
62
|
+
this.observability = mergeConfig(options.observability);
|
|
63
|
+
this.traceContextManager = new TraceContextManager(this.observability);
|
|
64
|
+
}
|
|
65
|
+
if (options?.metadata) {
|
|
66
|
+
this.metadata = options.metadata;
|
|
67
|
+
}
|
|
68
|
+
if (options?.inputValidation) {
|
|
69
|
+
this.inputValidation = options.inputValidation;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Set accepted input formats for this flow (fluent API).
|
|
74
|
+
* Validates input format before flow execution begins.
|
|
75
|
+
*
|
|
76
|
+
* @param formats - List of accepted MIME types (e.g., ['application/pdf', 'image/jpeg'])
|
|
77
|
+
* @returns This flow instance for chaining
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* ```typescript
|
|
81
|
+
* const pdfOnlyFlow = createFlow()
|
|
82
|
+
* .acceptFormats(['application/pdf'])
|
|
83
|
+
* .step('parse', parse({ provider }))
|
|
84
|
+
* .build();
|
|
85
|
+
*
|
|
86
|
+
* // Throws FlowInputValidationError if input is not a PDF
|
|
87
|
+
* await pdfOnlyFlow.run({ base64: jpegBase64 });
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
acceptFormats(formats) {
|
|
91
|
+
this.inputValidation = { acceptedFormats: formats };
|
|
92
|
+
return this;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Add a sequential step to the flow
|
|
96
|
+
*/
|
|
97
|
+
step(id, node2, name) {
|
|
98
|
+
this.steps.push({
|
|
99
|
+
type: "step",
|
|
100
|
+
id,
|
|
101
|
+
name,
|
|
102
|
+
node: node2
|
|
103
|
+
});
|
|
104
|
+
return this;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Add a conditional step that chooses a node based on input data
|
|
108
|
+
*
|
|
109
|
+
* IMPORTANT: Conditionals must return a NODE, not a promise or executed flow.
|
|
110
|
+
* The SDK will execute the returned node for you.
|
|
111
|
+
*
|
|
112
|
+
* The condition function receives the full wrapped data (e.g., { input, quality })
|
|
113
|
+
* but the returned node should accept the unwrapped input (e.g., just FlowInput).
|
|
114
|
+
* The SDK automatically unwraps the data before passing it to the selected node.
|
|
115
|
+
*
|
|
116
|
+
* ✅ CORRECT - Return a node (declarative):
|
|
117
|
+
* ```typescript
|
|
118
|
+
* .step('qualify', qualify({ provider, levels: ['low', 'medium', 'high'] }))
|
|
119
|
+
* .conditional('parse', (data) => {
|
|
120
|
+
* // data is { input: FlowInput, quality: string }
|
|
121
|
+
* if (data.quality === 'high') {
|
|
122
|
+
* return parse({ provider: fastProvider }); // Return the node
|
|
123
|
+
* }
|
|
124
|
+
* return parse({ provider: accurateProvider }); // Return the node
|
|
125
|
+
* })
|
|
126
|
+
* ```
|
|
127
|
+
*
|
|
128
|
+
* ❌ INCORRECT - Do NOT return a promise (imperative):
|
|
129
|
+
* ```typescript
|
|
130
|
+
* .conditional('parse', (data) => {
|
|
131
|
+
* // This will throw an error!
|
|
132
|
+
* return createFlow()
|
|
133
|
+
* .step('parse', parse({ provider }))
|
|
134
|
+
* .build()
|
|
135
|
+
* .run(data.input) // ❌ Don't call .run() here!
|
|
136
|
+
* .then(r => r.output);
|
|
137
|
+
* })
|
|
138
|
+
* ```
|
|
139
|
+
*
|
|
140
|
+
* 🆕 NEW - Access previous step outputs via context:
|
|
141
|
+
* ```typescript
|
|
142
|
+
* .step('categorize', categorize({ provider, categories }))
|
|
143
|
+
* .conditional('parse', (data) => parse({ provider }))
|
|
144
|
+
* .conditional('extract', (data, context) => {
|
|
145
|
+
* // Access category from earlier step via context.artifacts
|
|
146
|
+
* const category = context?.artifacts.categorize?.category;
|
|
147
|
+
* return extract({ provider, schema: SCHEMAS[category] });
|
|
148
|
+
* })
|
|
149
|
+
* ```
|
|
150
|
+
*
|
|
151
|
+
* Use the declarative pattern (return nodes) for consistent flow execution,
|
|
152
|
+
* proper error tracking, and accurate metrics collection.
|
|
153
|
+
*/
|
|
154
|
+
conditional(id, condition, name) {
|
|
155
|
+
this.steps.push({
|
|
156
|
+
type: "conditional",
|
|
157
|
+
id,
|
|
158
|
+
name,
|
|
159
|
+
condition
|
|
160
|
+
});
|
|
161
|
+
return this;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Process each item from previous step (which must return an array) with a child flow
|
|
165
|
+
* Each item is processed in parallel as its own isolated run
|
|
166
|
+
*/
|
|
167
|
+
forEach(id, childFlow, name) {
|
|
168
|
+
this.steps.push({
|
|
169
|
+
type: "forEach",
|
|
170
|
+
id,
|
|
171
|
+
name,
|
|
172
|
+
childFlow
|
|
173
|
+
});
|
|
174
|
+
return this;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Add an explicit output node to mark which data to return from the flow
|
|
178
|
+
*
|
|
179
|
+
* By default, flows return the output of the last step. Use output nodes to:
|
|
180
|
+
* - Return data from earlier steps
|
|
181
|
+
* - Return multiple named outputs
|
|
182
|
+
* - Transform outputs before returning
|
|
183
|
+
*
|
|
184
|
+
* @param config - Output configuration
|
|
185
|
+
* @returns Flow with output node added
|
|
186
|
+
*
|
|
187
|
+
* @example
|
|
188
|
+
* // Single output
|
|
189
|
+
* .output({ name: 'invoice_data' })
|
|
190
|
+
*
|
|
191
|
+
* // Select specific source
|
|
192
|
+
* .output({ name: 'result', source: 'step2' })
|
|
193
|
+
*
|
|
194
|
+
* // Multiple outputs
|
|
195
|
+
* .step('extract1', extract({ provider, schema1 }))
|
|
196
|
+
* .output({ name: 'summary', source: 'extract1' })
|
|
197
|
+
* .step('extract2', extract({ provider, schema2 }))
|
|
198
|
+
* .output({ name: 'details', source: 'extract2' })
|
|
199
|
+
*/
|
|
200
|
+
output(config) {
|
|
201
|
+
const name = config?.name?.trim();
|
|
202
|
+
const stepId = name || this.generateOutputStepId();
|
|
203
|
+
this.steps.push({
|
|
204
|
+
type: "step",
|
|
205
|
+
id: stepId,
|
|
206
|
+
node: createOutputNode({
|
|
207
|
+
...config,
|
|
208
|
+
name: stepId
|
|
209
|
+
// Ensure name matches step ID
|
|
210
|
+
})
|
|
211
|
+
});
|
|
212
|
+
return this;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Get current execution context
|
|
216
|
+
*
|
|
217
|
+
* Returns null if not currently executing.
|
|
218
|
+
*/
|
|
219
|
+
getExecutionContext() {
|
|
220
|
+
return this.currentExecution ?? null;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Get current trace context
|
|
224
|
+
*
|
|
225
|
+
* Returns null if not currently executing or observability not configured.
|
|
226
|
+
*/
|
|
227
|
+
getTraceContext() {
|
|
228
|
+
return this.traceContextManager?.getTraceContext() ?? null;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Set a custom attribute on the current execution
|
|
232
|
+
*
|
|
233
|
+
* Custom attributes appear in execution context and can be accessed by hooks.
|
|
234
|
+
*/
|
|
235
|
+
setCustomAttribute(key, value) {
|
|
236
|
+
if (this.currentExecution) {
|
|
237
|
+
this.currentExecution.customAttributes[key] = value;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Record a custom metric for the current execution
|
|
242
|
+
*
|
|
243
|
+
* Custom metrics appear in execution context and can be accessed by hooks.
|
|
244
|
+
*/
|
|
245
|
+
recordMetric(name, value, unit) {
|
|
246
|
+
if (this.currentExecution) {
|
|
247
|
+
this.currentExecution.customMetrics.push({
|
|
248
|
+
name,
|
|
249
|
+
value,
|
|
250
|
+
unit,
|
|
251
|
+
timestamp: Date.now()
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Build and return the executable flow
|
|
257
|
+
*/
|
|
258
|
+
build() {
|
|
259
|
+
return {
|
|
260
|
+
run: async (input, callbacks) => {
|
|
261
|
+
return this.execute(input, callbacks);
|
|
262
|
+
},
|
|
263
|
+
validate: () => {
|
|
264
|
+
return this.validate();
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Generate a unique step ID for unnamed output nodes
|
|
270
|
+
* Prevents duplicate IDs when multiple .output() calls without names
|
|
271
|
+
*/
|
|
272
|
+
generateOutputStepId() {
|
|
273
|
+
let counter = 0;
|
|
274
|
+
let candidateId = "output";
|
|
275
|
+
while (this.steps.some((step) => step.id === candidateId)) {
|
|
276
|
+
counter++;
|
|
277
|
+
candidateId = `output_${counter}`;
|
|
278
|
+
}
|
|
279
|
+
return candidateId;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Validate the flow configuration
|
|
283
|
+
*/
|
|
284
|
+
validate() {
|
|
285
|
+
const errors = [];
|
|
286
|
+
const warnings = [];
|
|
287
|
+
if (this.steps.length === 0) {
|
|
288
|
+
errors.push({
|
|
289
|
+
stepId: "<flow>",
|
|
290
|
+
stepIndex: -1,
|
|
291
|
+
stepType: "flow",
|
|
292
|
+
message: "Flow has no steps. Add at least one step using .step(), .conditional(), or .forEach()"
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
for (let stepIndex = 0; stepIndex < this.steps.length; stepIndex++) {
|
|
296
|
+
const step = this.steps[stepIndex];
|
|
297
|
+
const duplicateIndex = this.steps.findIndex((s, i) => i !== stepIndex && s.id === step.id);
|
|
298
|
+
if (duplicateIndex !== -1) {
|
|
299
|
+
errors.push({
|
|
300
|
+
stepId: step.id,
|
|
301
|
+
stepIndex,
|
|
302
|
+
stepType: step.type,
|
|
303
|
+
message: `Duplicate step ID "${step.id}" found at indices ${stepIndex} and ${duplicateIndex}`
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
if (step.type === "step") {
|
|
307
|
+
if (!step.node) {
|
|
308
|
+
errors.push({
|
|
309
|
+
stepId: step.id,
|
|
310
|
+
stepIndex,
|
|
311
|
+
stepType: step.type,
|
|
312
|
+
message: "Step node is missing. Use parse(), qualify(), categorize(), extract(), or split()"
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
} else if (step.type === "conditional") {
|
|
316
|
+
if (!step.condition || typeof step.condition !== "function") {
|
|
317
|
+
errors.push({
|
|
318
|
+
stepId: step.id,
|
|
319
|
+
stepIndex,
|
|
320
|
+
stepType: step.type,
|
|
321
|
+
message: "Conditional must have a condition function"
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
} else if (step.type === "forEach") {
|
|
325
|
+
if (!step.childFlow || typeof step.childFlow !== "function") {
|
|
326
|
+
errors.push({
|
|
327
|
+
stepId: step.id,
|
|
328
|
+
stepIndex,
|
|
329
|
+
stepType: step.type,
|
|
330
|
+
message: "forEach must have a childFlow function"
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
if (stepIndex === 0) {
|
|
334
|
+
warnings.push(`forEach step "${step.id}" at index ${stepIndex} is the first step - ensure input is an array`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (!step.id || step.id.trim() === "") {
|
|
338
|
+
errors.push({
|
|
339
|
+
stepId: "<empty>",
|
|
340
|
+
stepIndex,
|
|
341
|
+
stepType: step.type,
|
|
342
|
+
message: "Step ID cannot be empty"
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (!shouldSkipValidation()) {
|
|
347
|
+
this.validateTypeCompatibility(errors, warnings);
|
|
348
|
+
}
|
|
349
|
+
return {
|
|
350
|
+
valid: errors.length === 0,
|
|
351
|
+
errors,
|
|
352
|
+
warnings
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Validate type compatibility between consecutive steps
|
|
357
|
+
*/
|
|
358
|
+
validateTypeCompatibility(errors, warnings) {
|
|
359
|
+
for (let i = 0; i < this.steps.length - 1; i++) {
|
|
360
|
+
const currentStep = this.steps[i];
|
|
361
|
+
const nextStep = this.steps[i + 1];
|
|
362
|
+
if (currentStep.type === "step" && nextStep.type === "step") {
|
|
363
|
+
const sourceType = getNodeTypeName(currentStep.node);
|
|
364
|
+
const targetType = getNodeTypeName(nextStep.node);
|
|
365
|
+
if (!sourceType || !targetType) {
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
const forEachEnabled = false;
|
|
369
|
+
const validation = validateNodeConnection(sourceType, targetType, forEachEnabled);
|
|
370
|
+
if (!validation.valid) {
|
|
371
|
+
errors.push({
|
|
372
|
+
stepId: currentStep.id,
|
|
373
|
+
stepIndex: i,
|
|
374
|
+
stepType: currentStep.type,
|
|
375
|
+
message: `Invalid connection: ${sourceType} \u2192 ${targetType}. ${validation.reason || "Types incompatible"}`
|
|
376
|
+
});
|
|
377
|
+
if (validation.suggestions && validation.suggestions.length > 0) {
|
|
378
|
+
warnings.push(`Suggestions for step "${currentStep.id}":`);
|
|
379
|
+
validation.suggestions.forEach((s) => warnings.push(` ${s}`));
|
|
380
|
+
}
|
|
381
|
+
} else if (validation.warning) {
|
|
382
|
+
warnings.push(`Step "${currentStep.id}" \u2192 "${nextStep.id}": ${validation.warning}`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (currentStep.type === "step" && nextStep.type === "forEach") {
|
|
386
|
+
const sourceType = getNodeTypeName(currentStep.node);
|
|
387
|
+
if (sourceType) {
|
|
388
|
+
const sourceInfo = currentStep.node.__meta;
|
|
389
|
+
const outputsArray = sourceInfo?.outputsArray;
|
|
390
|
+
const isArrayOutput = typeof outputsArray === "function" ? outputsArray(null) : outputsArray;
|
|
391
|
+
if (!isArrayOutput) {
|
|
392
|
+
warnings.push(
|
|
393
|
+
`forEach step "${nextStep.id}" requires array input. Previous step "${currentStep.id}" (${sourceType}) may not output an array. Ensure ${sourceType} is configured to output an array (e.g., parse with chunked:true).`
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
if (nextStep.childFlow && typeof nextStep.childFlow === "function") {
|
|
397
|
+
try {
|
|
398
|
+
const childFlowInstance = nextStep.childFlow(null);
|
|
399
|
+
const childSteps = childFlowInstance.steps;
|
|
400
|
+
if (childSteps && Array.isArray(childSteps) && childSteps.length > 0) {
|
|
401
|
+
const firstStep = childSteps[0];
|
|
402
|
+
if (firstStep.type === "step") {
|
|
403
|
+
const firstNodeType = getNodeTypeName(firstStep.node);
|
|
404
|
+
if (firstNodeType) {
|
|
405
|
+
const validation = canStartForEachItemFlow(sourceType, firstNodeType);
|
|
406
|
+
if (!validation.valid) {
|
|
407
|
+
errors.push({
|
|
408
|
+
stepId: nextStep.id,
|
|
409
|
+
stepIndex: i + 1,
|
|
410
|
+
stepType: "forEach",
|
|
411
|
+
message: `Invalid forEach itemFlow starter: ${validation.reason || `${firstNodeType} cannot start forEach itemFlow after ${sourceType}`}`
|
|
412
|
+
});
|
|
413
|
+
if (validation.suggestions && validation.suggestions.length > 0) {
|
|
414
|
+
warnings.push(`Suggestions for forEach "${nextStep.id}":`);
|
|
415
|
+
validation.suggestions.forEach((s) => warnings.push(` ${s}`));
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
} else if (firstStep.type === "forEach") {
|
|
420
|
+
if (sourceType === "split") {
|
|
421
|
+
errors.push({
|
|
422
|
+
stepId: nextStep.id,
|
|
423
|
+
stepIndex: i + 1,
|
|
424
|
+
stepType: "forEach",
|
|
425
|
+
message: "Invalid forEach itemFlow: Cannot nest forEach operations. Split nodes cannot appear in forEach itemFlow."
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
} catch (error) {
|
|
431
|
+
warnings.push(
|
|
432
|
+
`forEach step "${nextStep.id}": Unable to validate itemFlow structure. Ensure the first node in itemFlow is compatible with ${sourceType} output. Valid starters: ${getValidForEachStarters(sourceType).join(", ")}`
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
this.checkEfficiencyPatterns(warnings);
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Check for inefficient flow patterns and add warnings.
|
|
443
|
+
*
|
|
444
|
+
* Detects patterns like:
|
|
445
|
+
* - parse() → extract(raw-document-provider): The extract provider ignores parse output
|
|
446
|
+
*/
|
|
447
|
+
checkEfficiencyPatterns(warnings) {
|
|
448
|
+
for (let i = 0; i < this.steps.length - 1; i++) {
|
|
449
|
+
const current = this.steps[i];
|
|
450
|
+
const next = this.steps[i + 1];
|
|
451
|
+
if (current.type !== "step" || next.type !== "step") continue;
|
|
452
|
+
const currNodeType = getNodeTypeName(current.node);
|
|
453
|
+
const nextNodeType = getNodeTypeName(next.node);
|
|
454
|
+
if (currNodeType === "parse" && nextNodeType === "extract") {
|
|
455
|
+
const extractProvider = this.getProviderFromNode(next.node);
|
|
456
|
+
if (extractProvider) {
|
|
457
|
+
const metadata = getProviderById(extractProvider);
|
|
458
|
+
if (metadata?.inputRequirements?.inputType === "raw-document") {
|
|
459
|
+
warnings.push(
|
|
460
|
+
`Efficiency warning: Step "${current.id}" (parse) output may be ignored. "${metadata.name}" processes raw documents directly, not parsed text. Consider: (1) Remove the parse step, or (2) Use an LLM provider for extraction that can use parsed text.`
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Extract provider ID from a node definition.
|
|
469
|
+
* Returns undefined if provider cannot be determined.
|
|
470
|
+
*/
|
|
471
|
+
getProviderFromNode(node2) {
|
|
472
|
+
const config = node2.__meta?.config;
|
|
473
|
+
if (config?.provider) {
|
|
474
|
+
if (typeof config.provider === "string") {
|
|
475
|
+
return config.provider;
|
|
476
|
+
}
|
|
477
|
+
if (typeof config.provider === "object") {
|
|
478
|
+
return config.provider.id ?? config.provider.name;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return void 0;
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Execute the flow with optional progress callbacks
|
|
485
|
+
*/
|
|
486
|
+
async execute(input, callbacks) {
|
|
487
|
+
const flowStartTime = Date.now();
|
|
488
|
+
const artifacts = {};
|
|
489
|
+
const metrics = [];
|
|
490
|
+
const completedSteps = [];
|
|
491
|
+
const outputs = {};
|
|
492
|
+
let lastNonOutputData = null;
|
|
493
|
+
let executionId;
|
|
494
|
+
let traceContext;
|
|
495
|
+
let sampled = false;
|
|
496
|
+
if (this.observability) {
|
|
497
|
+
sampled = shouldSample(this.observability);
|
|
498
|
+
if (this.traceContextManager && sampled) {
|
|
499
|
+
traceContext = this.traceContextManager.initialize(sampled);
|
|
500
|
+
}
|
|
501
|
+
const execIdGenerator = this.observability.generateExecutionId ?? generateExecutionId;
|
|
502
|
+
executionId = execIdGenerator();
|
|
503
|
+
this.currentExecution = {
|
|
504
|
+
flowId: "flow",
|
|
505
|
+
// TODO: Add flowId to Flow class
|
|
506
|
+
executionId,
|
|
507
|
+
startTime: flowStartTime,
|
|
508
|
+
status: "running",
|
|
509
|
+
customAttributes: {},
|
|
510
|
+
customMetrics: []
|
|
511
|
+
};
|
|
512
|
+
if (sampled && traceContext) {
|
|
513
|
+
const flowStartContext = {
|
|
514
|
+
flowId: "flow",
|
|
515
|
+
// TODO: Add flowId
|
|
516
|
+
flowVersion: "0.0.1",
|
|
517
|
+
executionId,
|
|
518
|
+
timestamp: flowStartTime,
|
|
519
|
+
input,
|
|
520
|
+
config: {},
|
|
521
|
+
// TODO: Capture flow config
|
|
522
|
+
metadata: this.metadata,
|
|
523
|
+
sdkVersion: "0.0.1",
|
|
524
|
+
observabilityVersion: this.observability.observabilityVersion ?? "1.0.0",
|
|
525
|
+
traceContext
|
|
526
|
+
};
|
|
527
|
+
await executeHook(this.observability.onFlowStart, {
|
|
528
|
+
hookName: "onFlowStart",
|
|
529
|
+
config: this.observability,
|
|
530
|
+
context: flowStartContext
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
let currentData = normalizeFlowInput(input);
|
|
535
|
+
if (this.inputValidation?.acceptedFormats?.length) {
|
|
536
|
+
const dataUrl = currentData?.base64 || currentData?.url;
|
|
537
|
+
if (dataUrl) {
|
|
538
|
+
validateFlowInputFormat(dataUrl, this.inputValidation.acceptedFormats);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
try {
|
|
542
|
+
for (let stepIndex = 0; stepIndex < this.steps.length; stepIndex++) {
|
|
543
|
+
const step = this.steps[stepIndex];
|
|
544
|
+
const stepStartTime = Date.now();
|
|
545
|
+
const stepSpanId = this.traceContextManager && sampled ? generateSpanId() : void 0;
|
|
546
|
+
callbacks?.onStepStart?.(step.id, stepIndex, step.type);
|
|
547
|
+
if (this.observability && sampled && traceContext && executionId && stepSpanId) {
|
|
548
|
+
const stepStartContext = {
|
|
549
|
+
flowId: "flow",
|
|
550
|
+
executionId,
|
|
551
|
+
stepId: step.id,
|
|
552
|
+
stepIndex,
|
|
553
|
+
stepType: step.node?.key ?? step.type,
|
|
554
|
+
stepName: step.name ?? step.id,
|
|
555
|
+
timestamp: stepStartTime,
|
|
556
|
+
provider: void 0,
|
|
557
|
+
// Will be populated from step config if available
|
|
558
|
+
model: void 0,
|
|
559
|
+
config: {},
|
|
560
|
+
input: currentData,
|
|
561
|
+
isConsensusEnabled: false,
|
|
562
|
+
// TODO: Check step config for consensus
|
|
563
|
+
isRetry: false,
|
|
564
|
+
metadata: this.metadata,
|
|
565
|
+
traceContext,
|
|
566
|
+
spanId: stepSpanId
|
|
567
|
+
};
|
|
568
|
+
await executeHook(this.observability.onStepStart, {
|
|
569
|
+
hookName: "onStepStart",
|
|
570
|
+
config: this.observability,
|
|
571
|
+
context: stepStartContext
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
try {
|
|
575
|
+
if (step.type === "step") {
|
|
576
|
+
const isOutputNode = step.node?.__meta?.isOutputNode === true;
|
|
577
|
+
const outputName = step.node?.__meta?.outputName?.trim() || step.id;
|
|
578
|
+
let result2;
|
|
579
|
+
if (isOutputNode) {
|
|
580
|
+
const ctx = {
|
|
581
|
+
stepId: step.id,
|
|
582
|
+
artifacts,
|
|
583
|
+
emit: (k, v) => {
|
|
584
|
+
artifacts[k] = v;
|
|
585
|
+
},
|
|
586
|
+
metrics: { push: (m) => metrics.push(m) },
|
|
587
|
+
observability: this.observability && sampled ? {
|
|
588
|
+
config: this.observability,
|
|
589
|
+
flowId: "flow",
|
|
590
|
+
executionId,
|
|
591
|
+
stepId: step.id,
|
|
592
|
+
stepIndex,
|
|
593
|
+
traceContext,
|
|
594
|
+
metadata: this.metadata
|
|
595
|
+
} : void 0
|
|
596
|
+
};
|
|
597
|
+
const outputData = await step.node.run(currentData, ctx);
|
|
598
|
+
result2 = { output: outputData, artifacts: {}, metrics: [] };
|
|
599
|
+
outputs[outputName] = outputData;
|
|
600
|
+
artifacts[step.id] = outputData;
|
|
601
|
+
completedSteps.push(step.id);
|
|
602
|
+
const stepDuration = Date.now() - stepStartTime;
|
|
603
|
+
callbacks?.onStepComplete?.(step.id, stepIndex, step.type, stepDuration);
|
|
604
|
+
if (this.observability && sampled && traceContext && executionId && stepSpanId) {
|
|
605
|
+
const stepEndContext = {
|
|
606
|
+
flowId: "flow",
|
|
607
|
+
executionId,
|
|
608
|
+
stepId: step.id,
|
|
609
|
+
stepIndex,
|
|
610
|
+
timestamp: Date.now(),
|
|
611
|
+
startTime: stepStartTime,
|
|
612
|
+
duration: stepDuration,
|
|
613
|
+
output: outputData,
|
|
614
|
+
usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
|
|
615
|
+
cost: 0,
|
|
616
|
+
metricKind: "prep",
|
|
617
|
+
// Output nodes are prep steps (no API call)
|
|
618
|
+
otelAttributes: buildStepAttributes({
|
|
619
|
+
stepType: step.node?.key ?? step.type
|
|
620
|
+
}),
|
|
621
|
+
metadata: this.metadata,
|
|
622
|
+
traceContext,
|
|
623
|
+
spanId: stepSpanId
|
|
624
|
+
};
|
|
625
|
+
await executeHook(this.observability.onStepEnd, {
|
|
626
|
+
hookName: "onStepEnd",
|
|
627
|
+
config: this.observability,
|
|
628
|
+
context: stepEndContext
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
currentData = lastNonOutputData !== null ? lastNonOutputData : currentData;
|
|
632
|
+
} else {
|
|
633
|
+
result2 = await runPipeline([step.node], currentData, this.observability && sampled ? {
|
|
634
|
+
config: this.observability,
|
|
635
|
+
flowId: "flow",
|
|
636
|
+
executionId,
|
|
637
|
+
stepId: step.id,
|
|
638
|
+
stepIndex,
|
|
639
|
+
traceContext,
|
|
640
|
+
metadata: this.metadata
|
|
641
|
+
} : {
|
|
642
|
+
// Always pass stepId for metrics tracking, even without observability
|
|
643
|
+
stepId: step.id
|
|
644
|
+
});
|
|
645
|
+
artifacts[step.id] = result2.output;
|
|
646
|
+
metrics.push(...result2.metrics);
|
|
647
|
+
completedSteps.push(step.id);
|
|
648
|
+
const stepDuration = Date.now() - stepStartTime;
|
|
649
|
+
callbacks?.onStepComplete?.(step.id, stepIndex, step.type, stepDuration);
|
|
650
|
+
if (this.observability && sampled && traceContext && executionId && stepSpanId) {
|
|
651
|
+
const leafMetrics = result2.metrics.filter((m) => m.metadata?.kind === "leaf");
|
|
652
|
+
const wrapperMetric = result2.metrics.find((m) => m.metadata?.kind === "wrapper");
|
|
653
|
+
const stepLeafMetrics = result2.metrics.filter(
|
|
654
|
+
(m) => m.configStepId === step.id && m.metadata?.kind === "leaf"
|
|
655
|
+
);
|
|
656
|
+
const isConsensus = stepLeafMetrics.length > 1;
|
|
657
|
+
const stepOwnLeafMetric = isConsensus ? void 0 : stepLeafMetrics[0];
|
|
658
|
+
const hasOtherChildMetrics = result2.metrics.some((m) => m.configStepId !== step.id);
|
|
659
|
+
const hasChildMetrics = isConsensus || hasOtherChildMetrics || leafMetrics.length > 1;
|
|
660
|
+
let metricKind;
|
|
661
|
+
let ownDuration;
|
|
662
|
+
let ownCost;
|
|
663
|
+
let ownInputTokens;
|
|
664
|
+
let ownOutputTokens;
|
|
665
|
+
let ownCacheCreationTokens;
|
|
666
|
+
let ownCacheReadTokens;
|
|
667
|
+
if (isConsensus) {
|
|
668
|
+
metricKind = "wrapper";
|
|
669
|
+
ownDuration = wrapperMetric?.metadata?.overheadMs ?? 0;
|
|
670
|
+
ownCost = 0;
|
|
671
|
+
ownInputTokens = 0;
|
|
672
|
+
ownOutputTokens = 0;
|
|
673
|
+
ownCacheCreationTokens = void 0;
|
|
674
|
+
ownCacheReadTokens = void 0;
|
|
675
|
+
} else if (stepOwnLeafMetric) {
|
|
676
|
+
metricKind = hasOtherChildMetrics ? "wrapper" : "leaf";
|
|
677
|
+
ownDuration = stepOwnLeafMetric.ms ?? 0;
|
|
678
|
+
ownCost = stepOwnLeafMetric.costUSD ?? 0;
|
|
679
|
+
ownInputTokens = stepOwnLeafMetric.inputTokens ?? 0;
|
|
680
|
+
ownOutputTokens = stepOwnLeafMetric.outputTokens ?? 0;
|
|
681
|
+
ownCacheCreationTokens = stepOwnLeafMetric.cacheCreationInputTokens;
|
|
682
|
+
ownCacheReadTokens = stepOwnLeafMetric.cacheReadInputTokens;
|
|
683
|
+
} else if (wrapperMetric || hasChildMetrics) {
|
|
684
|
+
metricKind = "wrapper";
|
|
685
|
+
ownDuration = wrapperMetric?.metadata?.overheadMs ?? 0;
|
|
686
|
+
ownCost = 0;
|
|
687
|
+
ownInputTokens = 0;
|
|
688
|
+
ownOutputTokens = 0;
|
|
689
|
+
ownCacheCreationTokens = void 0;
|
|
690
|
+
ownCacheReadTokens = void 0;
|
|
691
|
+
} else {
|
|
692
|
+
metricKind = "prep";
|
|
693
|
+
ownDuration = stepDuration;
|
|
694
|
+
ownCost = 0;
|
|
695
|
+
ownInputTokens = 0;
|
|
696
|
+
ownOutputTokens = 0;
|
|
697
|
+
ownCacheCreationTokens = void 0;
|
|
698
|
+
ownCacheReadTokens = void 0;
|
|
699
|
+
}
|
|
700
|
+
const firstMetric = result2.metrics.length > 0 ? result2.metrics[0] : void 0;
|
|
701
|
+
const stepEndContext = {
|
|
702
|
+
flowId: "flow",
|
|
703
|
+
executionId,
|
|
704
|
+
stepId: step.id,
|
|
705
|
+
stepIndex,
|
|
706
|
+
timestamp: Date.now(),
|
|
707
|
+
startTime: stepStartTime,
|
|
708
|
+
duration: ownDuration,
|
|
709
|
+
output: result2.output,
|
|
710
|
+
usage: {
|
|
711
|
+
inputTokens: ownInputTokens,
|
|
712
|
+
outputTokens: ownOutputTokens,
|
|
713
|
+
totalTokens: ownInputTokens + ownOutputTokens,
|
|
714
|
+
cacheCreationInputTokens: ownCacheCreationTokens,
|
|
715
|
+
cacheReadInputTokens: ownCacheReadTokens
|
|
716
|
+
},
|
|
717
|
+
cost: ownCost,
|
|
718
|
+
metricKind,
|
|
719
|
+
otelAttributes: buildStepAttributes({
|
|
720
|
+
stepType: step.node?.key ?? step.type,
|
|
721
|
+
provider: firstMetric?.provider,
|
|
722
|
+
model: firstMetric?.model,
|
|
723
|
+
inputTokens: ownInputTokens,
|
|
724
|
+
outputTokens: ownOutputTokens
|
|
725
|
+
}),
|
|
726
|
+
metadata: this.metadata,
|
|
727
|
+
traceContext,
|
|
728
|
+
spanId: stepSpanId
|
|
729
|
+
};
|
|
730
|
+
await executeHook(this.observability.onStepEnd, {
|
|
731
|
+
hookName: "onStepEnd",
|
|
732
|
+
config: this.observability,
|
|
733
|
+
context: stepEndContext
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
lastNonOutputData = result2.output;
|
|
737
|
+
const hasNextStep = stepIndex < this.steps.length - 1;
|
|
738
|
+
const nextStep = hasNextStep ? this.steps[stepIndex + 1] : null;
|
|
739
|
+
const shouldUnwrap = hasNextStep && nextStep?.type === "step" && result2.output && typeof result2.output === "object" && "input" in result2.output;
|
|
740
|
+
if (shouldUnwrap) {
|
|
741
|
+
currentData = result2.output.input;
|
|
742
|
+
} else {
|
|
743
|
+
currentData = result2.output;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
} else if (step.type === "conditional") {
|
|
747
|
+
const context = {
|
|
748
|
+
artifacts: { ...artifacts },
|
|
749
|
+
metrics: [...metrics]
|
|
750
|
+
};
|
|
751
|
+
const node2 = step.condition(currentData, context);
|
|
752
|
+
if (!node2 || typeof node2 !== "object" || !node2.key || typeof node2.run !== "function") {
|
|
753
|
+
throw new Error(
|
|
754
|
+
`Conditional step "${step.id}" must return a node (e.g., parse(), categorize(), extract()). Got: ${typeof node2}${node2 && typeof node2 === "object" ? ` with keys: ${Object.keys(node2).join(", ")}` : ""}.
|
|
755
|
+
|
|
756
|
+
A valid node must have 'key' and 'run' properties.
|
|
757
|
+
|
|
758
|
+
Incorrect: .conditional('step', () => flow.run(...).then(r => r.output))
|
|
759
|
+
Correct: .conditional('step', () => parse({ provider }))`
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
const nodeInput = currentData && typeof currentData === "object" && "input" in currentData ? currentData.input : currentData;
|
|
763
|
+
const result2 = await runPipeline([node2], nodeInput, this.observability && sampled ? {
|
|
764
|
+
config: this.observability,
|
|
765
|
+
flowId: "flow",
|
|
766
|
+
executionId,
|
|
767
|
+
stepId: step.id,
|
|
768
|
+
stepIndex,
|
|
769
|
+
traceContext,
|
|
770
|
+
metadata: this.metadata
|
|
771
|
+
} : void 0);
|
|
772
|
+
artifacts[step.id] = result2.output;
|
|
773
|
+
metrics.push(...result2.metrics);
|
|
774
|
+
completedSteps.push(step.id);
|
|
775
|
+
const stepDuration = Date.now() - stepStartTime;
|
|
776
|
+
callbacks?.onStepComplete?.(step.id, stepIndex, step.type, stepDuration);
|
|
777
|
+
if (this.observability && sampled && traceContext && executionId && stepSpanId) {
|
|
778
|
+
const leafMetrics = result2.metrics.filter((m) => m.metadata?.kind === "leaf");
|
|
779
|
+
const wrapperMetric = result2.metrics.find((m) => m.metadata?.kind === "wrapper");
|
|
780
|
+
const stepLeafMetrics = result2.metrics.filter(
|
|
781
|
+
(m) => m.configStepId === step.id && m.metadata?.kind === "leaf"
|
|
782
|
+
);
|
|
783
|
+
const isConsensus = stepLeafMetrics.length > 1;
|
|
784
|
+
const stepOwnLeafMetric = isConsensus ? void 0 : stepLeafMetrics[0];
|
|
785
|
+
const hasOtherChildMetrics = result2.metrics.some((m) => m.configStepId !== step.id);
|
|
786
|
+
const hasChildMetrics = isConsensus || hasOtherChildMetrics || leafMetrics.length > 1;
|
|
787
|
+
let metricKind;
|
|
788
|
+
let ownDuration;
|
|
789
|
+
let ownCost;
|
|
790
|
+
let ownInputTokens;
|
|
791
|
+
let ownOutputTokens;
|
|
792
|
+
let ownCacheCreationTokens;
|
|
793
|
+
let ownCacheReadTokens;
|
|
794
|
+
if (isConsensus) {
|
|
795
|
+
metricKind = "wrapper";
|
|
796
|
+
ownDuration = wrapperMetric?.metadata?.overheadMs ?? 0;
|
|
797
|
+
ownCost = 0;
|
|
798
|
+
ownInputTokens = 0;
|
|
799
|
+
ownOutputTokens = 0;
|
|
800
|
+
ownCacheCreationTokens = void 0;
|
|
801
|
+
ownCacheReadTokens = void 0;
|
|
802
|
+
} else if (stepOwnLeafMetric) {
|
|
803
|
+
metricKind = hasOtherChildMetrics ? "wrapper" : "leaf";
|
|
804
|
+
ownDuration = stepOwnLeafMetric.ms ?? 0;
|
|
805
|
+
ownCost = stepOwnLeafMetric.costUSD ?? 0;
|
|
806
|
+
ownInputTokens = stepOwnLeafMetric.inputTokens ?? 0;
|
|
807
|
+
ownOutputTokens = stepOwnLeafMetric.outputTokens ?? 0;
|
|
808
|
+
ownCacheCreationTokens = stepOwnLeafMetric.cacheCreationInputTokens;
|
|
809
|
+
ownCacheReadTokens = stepOwnLeafMetric.cacheReadInputTokens;
|
|
810
|
+
} else if (wrapperMetric || hasChildMetrics) {
|
|
811
|
+
metricKind = "wrapper";
|
|
812
|
+
ownDuration = wrapperMetric?.metadata?.overheadMs ?? 0;
|
|
813
|
+
ownCost = 0;
|
|
814
|
+
ownInputTokens = 0;
|
|
815
|
+
ownOutputTokens = 0;
|
|
816
|
+
ownCacheCreationTokens = void 0;
|
|
817
|
+
ownCacheReadTokens = void 0;
|
|
818
|
+
} else {
|
|
819
|
+
metricKind = "prep";
|
|
820
|
+
ownDuration = stepDuration;
|
|
821
|
+
ownCost = 0;
|
|
822
|
+
ownInputTokens = 0;
|
|
823
|
+
ownOutputTokens = 0;
|
|
824
|
+
ownCacheCreationTokens = void 0;
|
|
825
|
+
ownCacheReadTokens = void 0;
|
|
826
|
+
}
|
|
827
|
+
const firstMetric = result2.metrics.length > 0 ? result2.metrics[0] : void 0;
|
|
828
|
+
const stepEndContext = {
|
|
829
|
+
flowId: "flow",
|
|
830
|
+
executionId,
|
|
831
|
+
stepId: step.id,
|
|
832
|
+
stepIndex,
|
|
833
|
+
timestamp: Date.now(),
|
|
834
|
+
startTime: stepStartTime,
|
|
835
|
+
duration: ownDuration,
|
|
836
|
+
output: result2.output,
|
|
837
|
+
usage: {
|
|
838
|
+
inputTokens: ownInputTokens,
|
|
839
|
+
outputTokens: ownOutputTokens,
|
|
840
|
+
totalTokens: ownInputTokens + ownOutputTokens,
|
|
841
|
+
cacheCreationInputTokens: ownCacheCreationTokens,
|
|
842
|
+
cacheReadInputTokens: ownCacheReadTokens
|
|
843
|
+
},
|
|
844
|
+
cost: ownCost,
|
|
845
|
+
metricKind,
|
|
846
|
+
otelAttributes: buildStepAttributes({
|
|
847
|
+
stepType: "conditional",
|
|
848
|
+
provider: firstMetric?.provider,
|
|
849
|
+
model: firstMetric?.model,
|
|
850
|
+
inputTokens: ownInputTokens,
|
|
851
|
+
outputTokens: ownOutputTokens
|
|
852
|
+
}),
|
|
853
|
+
metadata: this.metadata,
|
|
854
|
+
traceContext,
|
|
855
|
+
spanId: stepSpanId
|
|
856
|
+
};
|
|
857
|
+
await executeHook(this.observability.onStepEnd, {
|
|
858
|
+
hookName: "onStepEnd",
|
|
859
|
+
config: this.observability,
|
|
860
|
+
context: stepEndContext
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
lastNonOutputData = result2.output;
|
|
864
|
+
currentData = result2.output;
|
|
865
|
+
} else if (step.type === "forEach") {
|
|
866
|
+
if (!Array.isArray(currentData)) {
|
|
867
|
+
throw new Error(`forEach step "${step.id}" requires array input, got ${typeof currentData}`);
|
|
868
|
+
}
|
|
869
|
+
const items = currentData;
|
|
870
|
+
const batchId = executionId ? `${executionId}-batch-${stepIndex}` : `batch-${stepIndex}`;
|
|
871
|
+
const batchStartTime = Date.now();
|
|
872
|
+
if (this.observability && sampled && traceContext && executionId) {
|
|
873
|
+
const batchStartContext = {
|
|
874
|
+
flowId: "flow",
|
|
875
|
+
executionId,
|
|
876
|
+
batchId,
|
|
877
|
+
stepId: step.id,
|
|
878
|
+
totalItems: items.length,
|
|
879
|
+
timestamp: batchStartTime,
|
|
880
|
+
metadata: this.metadata,
|
|
881
|
+
traceContext
|
|
882
|
+
};
|
|
883
|
+
await executeHook(this.observability.onBatchStart, {
|
|
884
|
+
hookName: "onBatchStart",
|
|
885
|
+
config: this.observability,
|
|
886
|
+
context: batchStartContext
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
const results = await Promise.allSettled(
|
|
890
|
+
items.map(async (item, itemIndex) => {
|
|
891
|
+
const itemStartTime = Date.now();
|
|
892
|
+
const itemSpanId = this.traceContextManager && sampled ? generateSpanId() : void 0;
|
|
893
|
+
if (this.observability && sampled && traceContext && executionId) {
|
|
894
|
+
const batchItemContext = {
|
|
895
|
+
flowId: "flow",
|
|
896
|
+
executionId,
|
|
897
|
+
batchId,
|
|
898
|
+
stepId: step.id,
|
|
899
|
+
itemIndex,
|
|
900
|
+
totalItems: items.length,
|
|
901
|
+
timestamp: itemStartTime,
|
|
902
|
+
item,
|
|
903
|
+
metadata: this.metadata,
|
|
904
|
+
traceContext
|
|
905
|
+
};
|
|
906
|
+
await executeHook(this.observability.onBatchItemStart, {
|
|
907
|
+
hookName: "onBatchItemStart",
|
|
908
|
+
config: this.observability,
|
|
909
|
+
context: batchItemContext
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
try {
|
|
913
|
+
const childFlow = step.childFlow(item);
|
|
914
|
+
const builtFlow = childFlow.build();
|
|
915
|
+
const flowInput = item && typeof item === "object" && "input" in item ? item.input : item;
|
|
916
|
+
const result2 = await builtFlow.run(flowInput);
|
|
917
|
+
let itemResult;
|
|
918
|
+
if ("results" in result2 && Array.isArray(result2.results)) {
|
|
919
|
+
const aggregatedMetrics = (result2.results || []).flatMap((r) => r && r.metrics || []);
|
|
920
|
+
const aggregatedArtifacts = (result2.results || []).map((r) => r && r.artifacts);
|
|
921
|
+
itemResult = {
|
|
922
|
+
output: (result2.results || []).map((r) => r && r.output),
|
|
923
|
+
metrics: aggregatedMetrics,
|
|
924
|
+
artifacts: aggregatedArtifacts
|
|
925
|
+
};
|
|
926
|
+
} else {
|
|
927
|
+
const flowResult = result2;
|
|
928
|
+
itemResult = {
|
|
929
|
+
output: flowResult.output,
|
|
930
|
+
metrics: flowResult.metrics || [],
|
|
931
|
+
artifacts: flowResult.artifacts || {}
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
if (this.observability && sampled && traceContext && executionId) {
|
|
935
|
+
const batchItemEndContext = {
|
|
936
|
+
flowId: "flow",
|
|
937
|
+
executionId,
|
|
938
|
+
batchId,
|
|
939
|
+
stepId: step.id,
|
|
940
|
+
itemIndex,
|
|
941
|
+
totalItems: items.length,
|
|
942
|
+
item,
|
|
943
|
+
timestamp: Date.now(),
|
|
944
|
+
duration: Date.now() - itemStartTime,
|
|
945
|
+
result: itemResult.output,
|
|
946
|
+
status: "success",
|
|
947
|
+
metadata: this.metadata,
|
|
948
|
+
traceContext
|
|
949
|
+
};
|
|
950
|
+
await executeHook(this.observability.onBatchItemEnd, {
|
|
951
|
+
hookName: "onBatchItemEnd",
|
|
952
|
+
config: this.observability,
|
|
953
|
+
context: batchItemEndContext
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
return itemResult;
|
|
957
|
+
} catch (error) {
|
|
958
|
+
console.error("[forEach error]", error);
|
|
959
|
+
if (this.observability && sampled && traceContext && executionId) {
|
|
960
|
+
const batchItemEndContext = {
|
|
961
|
+
flowId: "flow",
|
|
962
|
+
executionId,
|
|
963
|
+
batchId,
|
|
964
|
+
stepId: step.id,
|
|
965
|
+
itemIndex,
|
|
966
|
+
totalItems: items.length,
|
|
967
|
+
item,
|
|
968
|
+
timestamp: Date.now(),
|
|
969
|
+
duration: Date.now() - itemStartTime,
|
|
970
|
+
result: null,
|
|
971
|
+
status: "failed",
|
|
972
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
973
|
+
metadata: this.metadata,
|
|
974
|
+
traceContext
|
|
975
|
+
};
|
|
976
|
+
await executeHook(this.observability.onBatchItemEnd, {
|
|
977
|
+
hookName: "onBatchItemEnd",
|
|
978
|
+
config: this.observability,
|
|
979
|
+
context: batchItemEndContext
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
return {
|
|
983
|
+
output: null,
|
|
984
|
+
error: error.message || String(error),
|
|
985
|
+
metrics: [],
|
|
986
|
+
artifacts: {}
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
})
|
|
990
|
+
);
|
|
991
|
+
const flowResults = results.map((result2, index) => {
|
|
992
|
+
if (result2.status === "fulfilled") {
|
|
993
|
+
return result2.value;
|
|
994
|
+
} else {
|
|
995
|
+
return {
|
|
996
|
+
output: null,
|
|
997
|
+
error: result2.reason,
|
|
998
|
+
metrics: [],
|
|
999
|
+
artifacts: {}
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
});
|
|
1003
|
+
artifacts[step.id] = flowResults.map((r) => r.artifacts);
|
|
1004
|
+
metrics.push(...flowResults.flatMap((r) => r.metrics));
|
|
1005
|
+
completedSteps.push(step.id);
|
|
1006
|
+
const successfulCount = flowResults.filter((r) => !r.error).length;
|
|
1007
|
+
const failedCount = flowResults.filter((r) => r.error).length;
|
|
1008
|
+
if (this.observability && sampled && traceContext && executionId) {
|
|
1009
|
+
const batchEndContext = {
|
|
1010
|
+
flowId: "flow",
|
|
1011
|
+
executionId,
|
|
1012
|
+
batchId,
|
|
1013
|
+
stepId: step.id,
|
|
1014
|
+
timestamp: Date.now(),
|
|
1015
|
+
startTime: batchStartTime,
|
|
1016
|
+
duration: Date.now() - batchStartTime,
|
|
1017
|
+
totalItems: items.length,
|
|
1018
|
+
successfulItems: successfulCount,
|
|
1019
|
+
failedItems: failedCount,
|
|
1020
|
+
results: flowResults.map((r) => r.output),
|
|
1021
|
+
metadata: this.metadata,
|
|
1022
|
+
traceContext
|
|
1023
|
+
};
|
|
1024
|
+
await executeHook(this.observability.onBatchEnd, {
|
|
1025
|
+
hookName: "onBatchEnd",
|
|
1026
|
+
config: this.observability,
|
|
1027
|
+
context: batchEndContext
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
const stepDuration = Date.now() - stepStartTime;
|
|
1031
|
+
callbacks?.onStepComplete?.(step.id, stepIndex, step.type, stepDuration);
|
|
1032
|
+
return {
|
|
1033
|
+
results: flowResults,
|
|
1034
|
+
metrics,
|
|
1035
|
+
aggregated: aggregateMetrics(metrics),
|
|
1036
|
+
artifacts
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
} catch (error) {
|
|
1040
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
1041
|
+
callbacks?.onStepError?.(step.id, stepIndex, step.type, err);
|
|
1042
|
+
if (this.observability && sampled && traceContext && executionId && stepSpanId) {
|
|
1043
|
+
const stepErrorTime = Date.now();
|
|
1044
|
+
const stepErrorContext = {
|
|
1045
|
+
flowId: "flow",
|
|
1046
|
+
executionId,
|
|
1047
|
+
stepId: step.id,
|
|
1048
|
+
stepIndex,
|
|
1049
|
+
timestamp: stepErrorTime,
|
|
1050
|
+
startTime: stepStartTime,
|
|
1051
|
+
duration: stepErrorTime - stepStartTime,
|
|
1052
|
+
error: err,
|
|
1053
|
+
errorCode: err.code,
|
|
1054
|
+
willRetry: false,
|
|
1055
|
+
// TODO: Determine if will retry
|
|
1056
|
+
metadata: this.metadata,
|
|
1057
|
+
traceContext,
|
|
1058
|
+
spanId: stepSpanId
|
|
1059
|
+
};
|
|
1060
|
+
await executeHook(this.observability.onStepError, {
|
|
1061
|
+
hookName: "onStepError",
|
|
1062
|
+
config: this.observability,
|
|
1063
|
+
context: stepErrorContext
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
const completedStepsStr = completedSteps.length > 0 ? `
|
|
1067
|
+
Completed steps: ${completedSteps.join(" \u2192 ")}` : "\n No steps completed before failure";
|
|
1068
|
+
const artifactsStr = Object.keys(artifacts).length > 0 ? `
|
|
1069
|
+
Partial results available in: ${Object.keys(artifacts).join(", ")}` : "";
|
|
1070
|
+
throw new FlowExecutionError(
|
|
1071
|
+
`Flow execution failed at step "${step.id}" (index ${stepIndex}, type: ${step.type})
|
|
1072
|
+
Error: ${err.message}` + completedStepsStr + artifactsStr,
|
|
1073
|
+
step.id,
|
|
1074
|
+
stepIndex,
|
|
1075
|
+
step.type,
|
|
1076
|
+
completedSteps,
|
|
1077
|
+
err,
|
|
1078
|
+
artifacts
|
|
1079
|
+
// Include partial artifacts for debugging
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
const hasOutputNodes = Object.keys(outputs).length > 0;
|
|
1084
|
+
let result;
|
|
1085
|
+
if (hasOutputNodes) {
|
|
1086
|
+
const outputCount = Object.keys(outputs).length;
|
|
1087
|
+
if (outputCount === 1) {
|
|
1088
|
+
const singleOutput = Object.values(outputs)[0];
|
|
1089
|
+
result = {
|
|
1090
|
+
output: singleOutput,
|
|
1091
|
+
outputs,
|
|
1092
|
+
metrics,
|
|
1093
|
+
aggregated: aggregateMetrics(metrics),
|
|
1094
|
+
artifacts
|
|
1095
|
+
};
|
|
1096
|
+
} else {
|
|
1097
|
+
result = {
|
|
1098
|
+
output: outputs,
|
|
1099
|
+
// For backward compatibility, set output to outputs
|
|
1100
|
+
outputs,
|
|
1101
|
+
metrics,
|
|
1102
|
+
aggregated: aggregateMetrics(metrics),
|
|
1103
|
+
artifacts
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
} else {
|
|
1107
|
+
result = {
|
|
1108
|
+
output: currentData,
|
|
1109
|
+
metrics,
|
|
1110
|
+
aggregated: aggregateMetrics(metrics),
|
|
1111
|
+
artifacts
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
if (this.observability && sampled && traceContext && executionId) {
|
|
1115
|
+
const flowEndTime = Date.now();
|
|
1116
|
+
const aggregated = aggregateMetrics(metrics);
|
|
1117
|
+
const flowStats = {
|
|
1118
|
+
stepsTotal: this.steps.length,
|
|
1119
|
+
stepsCompleted: completedSteps.length,
|
|
1120
|
+
stepsFailed: 0,
|
|
1121
|
+
totalTokens: aggregated.totalInputTokens + aggregated.totalOutputTokens,
|
|
1122
|
+
totalCost: aggregated.totalCostUSD
|
|
1123
|
+
};
|
|
1124
|
+
const flowEndContext = {
|
|
1125
|
+
flowId: "flow",
|
|
1126
|
+
executionId,
|
|
1127
|
+
timestamp: flowEndTime,
|
|
1128
|
+
startTime: flowStartTime,
|
|
1129
|
+
duration: flowEndTime - flowStartTime,
|
|
1130
|
+
output: result.output,
|
|
1131
|
+
stats: flowStats,
|
|
1132
|
+
metadata: this.metadata,
|
|
1133
|
+
traceContext
|
|
1134
|
+
};
|
|
1135
|
+
await executeHook(this.observability.onFlowEnd, {
|
|
1136
|
+
hookName: "onFlowEnd",
|
|
1137
|
+
config: this.observability,
|
|
1138
|
+
context: flowEndContext
|
|
1139
|
+
});
|
|
1140
|
+
if (this.currentExecution) {
|
|
1141
|
+
this.currentExecution.status = "completed";
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
return result;
|
|
1145
|
+
} catch (error) {
|
|
1146
|
+
if (this.observability && sampled && traceContext && executionId) {
|
|
1147
|
+
const flowErrorTime = Date.now();
|
|
1148
|
+
const aggregated = aggregateMetrics(metrics);
|
|
1149
|
+
const flowStats = {
|
|
1150
|
+
stepsTotal: this.steps.length,
|
|
1151
|
+
stepsCompleted: completedSteps.length,
|
|
1152
|
+
stepsFailed: 1,
|
|
1153
|
+
totalTokens: aggregated.totalInputTokens + aggregated.totalOutputTokens,
|
|
1154
|
+
totalCost: aggregated.totalCostUSD
|
|
1155
|
+
};
|
|
1156
|
+
const failedStepIndex = completedSteps.length;
|
|
1157
|
+
const flowErrorContext = {
|
|
1158
|
+
flowId: "flow",
|
|
1159
|
+
executionId,
|
|
1160
|
+
timestamp: flowErrorTime,
|
|
1161
|
+
startTime: flowStartTime,
|
|
1162
|
+
duration: flowErrorTime - flowStartTime,
|
|
1163
|
+
error,
|
|
1164
|
+
errorCode: error.code,
|
|
1165
|
+
failedAtStepIndex: failedStepIndex,
|
|
1166
|
+
partialStats: flowStats,
|
|
1167
|
+
metadata: this.metadata,
|
|
1168
|
+
traceContext
|
|
1169
|
+
};
|
|
1170
|
+
await executeHook(this.observability.onFlowError, {
|
|
1171
|
+
hookName: "onFlowError",
|
|
1172
|
+
config: this.observability,
|
|
1173
|
+
context: flowErrorContext
|
|
1174
|
+
});
|
|
1175
|
+
if (this.currentExecution) {
|
|
1176
|
+
this.currentExecution.status = "failed";
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
throw error;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
};
|
|
1183
|
+
function createFlow(options) {
|
|
1184
|
+
return new Flow(options);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// src/index.ts
|
|
1188
|
+
import { parse as parse2, split as split3, categorize as categorize3, extract as extract2, chunk, combine, trigger as trigger2 } from "@doclo/nodes";
|
|
1189
|
+
|
|
1190
|
+
// src/flow-registry.ts
|
|
1191
|
+
var FLOW_REGISTRY = /* @__PURE__ */ new Map();
|
|
1192
|
+
function registerFlow(id, builder) {
|
|
1193
|
+
if (FLOW_REGISTRY.has(id)) {
|
|
1194
|
+
console.warn(`[Flow Registry] Overwriting existing flow: ${id}`);
|
|
1195
|
+
}
|
|
1196
|
+
FLOW_REGISTRY.set(id, builder);
|
|
1197
|
+
}
|
|
1198
|
+
function getFlow(id) {
|
|
1199
|
+
return FLOW_REGISTRY.get(id);
|
|
1200
|
+
}
|
|
1201
|
+
function hasFlow(id) {
|
|
1202
|
+
return FLOW_REGISTRY.has(id);
|
|
1203
|
+
}
|
|
1204
|
+
function unregisterFlow(id) {
|
|
1205
|
+
return FLOW_REGISTRY.delete(id);
|
|
1206
|
+
}
|
|
1207
|
+
function clearRegistry() {
|
|
1208
|
+
FLOW_REGISTRY.clear();
|
|
1209
|
+
}
|
|
1210
|
+
function listFlows() {
|
|
1211
|
+
return Array.from(FLOW_REGISTRY.keys());
|
|
1212
|
+
}
|
|
1213
|
+
function getFlowCount() {
|
|
1214
|
+
return FLOW_REGISTRY.size;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// src/serialization.ts
|
|
1218
|
+
import { parse, extract, split as split2, categorize as categorize2, trigger, output } from "@doclo/nodes";
|
|
1219
|
+
|
|
1220
|
+
// src/composite-nodes.ts
|
|
1221
|
+
import { categorize, split } from "@doclo/nodes";
|
|
1222
|
+
function parseProviderName(name) {
|
|
1223
|
+
const colonIndex = name.indexOf(":");
|
|
1224
|
+
if (colonIndex === -1) {
|
|
1225
|
+
return { provider: name, model: "unknown" };
|
|
1226
|
+
}
|
|
1227
|
+
return {
|
|
1228
|
+
provider: name.substring(0, colonIndex),
|
|
1229
|
+
model: name.substring(colonIndex + 1)
|
|
1230
|
+
};
|
|
1231
|
+
}
|
|
1232
|
+
function parseRef(refString) {
|
|
1233
|
+
if (!refString) return null;
|
|
1234
|
+
const atIndex = refString.indexOf("@");
|
|
1235
|
+
if (atIndex === -1) {
|
|
1236
|
+
return { id: refString, version: void 0 };
|
|
1237
|
+
}
|
|
1238
|
+
return {
|
|
1239
|
+
id: refString.substring(0, atIndex),
|
|
1240
|
+
version: refString.substring(atIndex + 1)
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
function flattenMetrics(childMetrics, prefix) {
|
|
1244
|
+
return childMetrics.map((m) => ({
|
|
1245
|
+
...m,
|
|
1246
|
+
step: `${prefix}.${m.step}`,
|
|
1247
|
+
// @ts-ignore - Add metadata for nested metrics (not in official type but works at runtime)
|
|
1248
|
+
nested: true
|
|
1249
|
+
}));
|
|
1250
|
+
}
|
|
1251
|
+
function createConditionalCompositeNode(config) {
|
|
1252
|
+
const { stepId, categorizeConfig, branches, providers, flows } = config;
|
|
1253
|
+
return {
|
|
1254
|
+
key: "conditional-composite",
|
|
1255
|
+
run: async (input, ctx) => {
|
|
1256
|
+
const t0 = Date.now();
|
|
1257
|
+
let selectedCategory;
|
|
1258
|
+
let phase = "categorize";
|
|
1259
|
+
try {
|
|
1260
|
+
const categorizeNode = categorize({
|
|
1261
|
+
...categorizeConfig,
|
|
1262
|
+
provider: providers[categorizeConfig.providerRef],
|
|
1263
|
+
categories: categorizeConfig.categories || Object.keys(branches)
|
|
1264
|
+
});
|
|
1265
|
+
const categorizeT0 = Date.now();
|
|
1266
|
+
const categorizeCostTracker = [];
|
|
1267
|
+
const categorizeCtx = {
|
|
1268
|
+
stepId,
|
|
1269
|
+
// Use composite step's ID so categorize metric is attributed to this step
|
|
1270
|
+
metrics: { push: (m) => categorizeCostTracker.push(m) },
|
|
1271
|
+
artifacts: ctx?.artifacts ?? {},
|
|
1272
|
+
emit: ctx?.emit ?? (() => {
|
|
1273
|
+
}),
|
|
1274
|
+
// No-op if emit not provided
|
|
1275
|
+
observability: ctx?.observability
|
|
1276
|
+
};
|
|
1277
|
+
const categorizeResult = await categorizeNode.run(input, categorizeCtx);
|
|
1278
|
+
selectedCategory = categorizeResult.category;
|
|
1279
|
+
categorizeCostTracker.forEach((m) => ctx?.metrics?.push(m));
|
|
1280
|
+
if (ctx?.emit) {
|
|
1281
|
+
ctx.emit(`${stepId}:category`, selectedCategory);
|
|
1282
|
+
}
|
|
1283
|
+
phase = "branch";
|
|
1284
|
+
if (!branches[selectedCategory]) {
|
|
1285
|
+
throw new Error(
|
|
1286
|
+
`No branch defined for category "${selectedCategory}". Available branches: ${Object.keys(branches).join(", ")}`
|
|
1287
|
+
);
|
|
1288
|
+
}
|
|
1289
|
+
const branchFlowDef = resolveBranchFlow(branches[selectedCategory], flows);
|
|
1290
|
+
const branchFlow = buildFlowFromConfig(
|
|
1291
|
+
branchFlowDef,
|
|
1292
|
+
providers,
|
|
1293
|
+
flows,
|
|
1294
|
+
ctx?.observability?.config ? {
|
|
1295
|
+
observability: ctx.observability.config,
|
|
1296
|
+
metadata: {
|
|
1297
|
+
...ctx.observability?.metadata,
|
|
1298
|
+
parentNode: stepId,
|
|
1299
|
+
phase: "branch",
|
|
1300
|
+
category: selectedCategory
|
|
1301
|
+
}
|
|
1302
|
+
} : void 0
|
|
1303
|
+
);
|
|
1304
|
+
const branchT0 = Date.now();
|
|
1305
|
+
const branchResultRaw = await branchFlow.run(input);
|
|
1306
|
+
if (!isSingleFlowResult(branchResultRaw)) {
|
|
1307
|
+
throw new Error("Branch flow returned batch result instead of single result");
|
|
1308
|
+
}
|
|
1309
|
+
const branchResult = branchResultRaw;
|
|
1310
|
+
if (ctx?.metrics && branchResult.metrics) {
|
|
1311
|
+
const branchMetrics = flattenMetrics(
|
|
1312
|
+
branchResult.metrics,
|
|
1313
|
+
`${stepId}.branch.${selectedCategory}`
|
|
1314
|
+
);
|
|
1315
|
+
branchMetrics.forEach((m) => ctx.metrics.push(m));
|
|
1316
|
+
}
|
|
1317
|
+
if (ctx?.emit) {
|
|
1318
|
+
ctx.emit(`${stepId}:branchOutput`, branchResult.output);
|
|
1319
|
+
ctx.emit(`${stepId}:branchArtifacts`, branchResult.artifacts);
|
|
1320
|
+
}
|
|
1321
|
+
const categorizeCost = categorizeCostTracker.reduce((sum, m) => sum + (m.costUSD ?? 0), 0);
|
|
1322
|
+
const branchCost = branchResult.metrics ? branchResult.metrics.reduce((sum, m) => sum + (m.costUSD ?? 0), 0) : 0;
|
|
1323
|
+
const aggregateCost = categorizeCost + branchCost;
|
|
1324
|
+
const totalMs = Date.now() - t0;
|
|
1325
|
+
const categorizeMs = categorizeCostTracker.reduce((sum, m) => sum + (m.ms ?? 0), 0);
|
|
1326
|
+
const branchMs = branchResult.metrics ? branchResult.metrics.reduce((sum, m) => sum + (m.ms ?? 0), 0) : 0;
|
|
1327
|
+
const overheadMs = totalMs - categorizeMs - branchMs;
|
|
1328
|
+
if (ctx?.metrics) {
|
|
1329
|
+
const provider = providers[categorizeConfig.providerRef];
|
|
1330
|
+
const { provider: providerName, model } = parseProviderName(provider.name ?? "");
|
|
1331
|
+
const promptRefData = parseRef(categorizeConfig.promptRef);
|
|
1332
|
+
const wrapperMetric = {
|
|
1333
|
+
step: stepId,
|
|
1334
|
+
configStepId: ctx.stepId,
|
|
1335
|
+
startMs: t0,
|
|
1336
|
+
provider: providerName,
|
|
1337
|
+
model,
|
|
1338
|
+
ms: totalMs,
|
|
1339
|
+
costUSD: aggregateCost,
|
|
1340
|
+
// Total cost from categorize + branch
|
|
1341
|
+
attemptNumber: 1,
|
|
1342
|
+
// Composite wrappers don't retry, always 1
|
|
1343
|
+
metadata: {
|
|
1344
|
+
kind: "wrapper",
|
|
1345
|
+
// Distinguish wrapper from leaf metrics
|
|
1346
|
+
type: "conditional",
|
|
1347
|
+
rollup: true,
|
|
1348
|
+
// Duration includes child work
|
|
1349
|
+
overheadMs,
|
|
1350
|
+
// Pure wrapper overhead (flow orchestration)
|
|
1351
|
+
category: selectedCategory,
|
|
1352
|
+
branchStepCount: branchResult.metrics?.length || 0,
|
|
1353
|
+
branchFlowId: typeof branches[selectedCategory] === "object" && "flowRef" in branches[selectedCategory] ? branches[selectedCategory].flowRef : "inline",
|
|
1354
|
+
// Include prompt metadata if available
|
|
1355
|
+
...promptRefData && {
|
|
1356
|
+
promptId: promptRefData.id,
|
|
1357
|
+
...promptRefData.version && { promptVersion: promptRefData.version }
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
};
|
|
1361
|
+
ctx.metrics.push(wrapperMetric);
|
|
1362
|
+
}
|
|
1363
|
+
return branchResult.output;
|
|
1364
|
+
} catch (error) {
|
|
1365
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
1366
|
+
if (ctx?.metrics) {
|
|
1367
|
+
ctx.metrics.push({
|
|
1368
|
+
step: stepId,
|
|
1369
|
+
configStepId: ctx.stepId,
|
|
1370
|
+
startMs: t0,
|
|
1371
|
+
ms: Date.now() - t0,
|
|
1372
|
+
costUSD: 0,
|
|
1373
|
+
attemptNumber: 1,
|
|
1374
|
+
// @ts-ignore - Add error field
|
|
1375
|
+
error: err.message,
|
|
1376
|
+
metadata: {
|
|
1377
|
+
kind: "wrapper",
|
|
1378
|
+
type: "conditional",
|
|
1379
|
+
failed: true,
|
|
1380
|
+
category: selectedCategory,
|
|
1381
|
+
failedPhase: phase
|
|
1382
|
+
}
|
|
1383
|
+
});
|
|
1384
|
+
}
|
|
1385
|
+
throw new Error(
|
|
1386
|
+
`Conditional step "${stepId}" failed (${selectedCategory ? `category: ${selectedCategory}, ` : ""}phase: ${phase}): ` + err.message
|
|
1387
|
+
);
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
function createForEachCompositeNode(config) {
|
|
1393
|
+
const { stepId, splitConfig, itemFlow, providers, flows } = config;
|
|
1394
|
+
return {
|
|
1395
|
+
key: "forEach-composite",
|
|
1396
|
+
run: async (input, ctx) => {
|
|
1397
|
+
const t0 = Date.now();
|
|
1398
|
+
let items;
|
|
1399
|
+
let phase = "split";
|
|
1400
|
+
try {
|
|
1401
|
+
const splitNode = split({
|
|
1402
|
+
provider: providers[splitConfig.providerRef],
|
|
1403
|
+
...splitConfig
|
|
1404
|
+
});
|
|
1405
|
+
const splitT0 = Date.now();
|
|
1406
|
+
const splitCostTracker = [];
|
|
1407
|
+
const splitCtx = {
|
|
1408
|
+
stepId,
|
|
1409
|
+
// Use composite step's ID for attribution
|
|
1410
|
+
metrics: { push: (m) => splitCostTracker.push(m) },
|
|
1411
|
+
artifacts: ctx?.artifacts ?? {},
|
|
1412
|
+
emit: ctx?.emit ?? (() => {
|
|
1413
|
+
}),
|
|
1414
|
+
// No-op if emit not provided
|
|
1415
|
+
observability: ctx?.observability
|
|
1416
|
+
};
|
|
1417
|
+
const splitResult = await splitNode.run(input, splitCtx);
|
|
1418
|
+
items = splitResult;
|
|
1419
|
+
splitCostTracker.forEach((m) => ctx?.metrics?.push(m));
|
|
1420
|
+
if (!Array.isArray(items)) {
|
|
1421
|
+
throw new Error(
|
|
1422
|
+
`Split node did not return an array. Got: ${typeof items}`
|
|
1423
|
+
);
|
|
1424
|
+
}
|
|
1425
|
+
if (ctx?.emit) {
|
|
1426
|
+
ctx.emit(`${stepId}:itemCount`, items.length);
|
|
1427
|
+
}
|
|
1428
|
+
phase = "forEach";
|
|
1429
|
+
const itemFlowDef = resolveBranchFlow(itemFlow, flows);
|
|
1430
|
+
const itemFlowResults = [];
|
|
1431
|
+
const results = await Promise.allSettled(
|
|
1432
|
+
items.map(async (item, index) => {
|
|
1433
|
+
const flow = buildFlowFromConfig(
|
|
1434
|
+
itemFlowDef,
|
|
1435
|
+
providers,
|
|
1436
|
+
flows,
|
|
1437
|
+
ctx?.observability?.config ? {
|
|
1438
|
+
observability: ctx.observability.config,
|
|
1439
|
+
metadata: {
|
|
1440
|
+
...ctx.observability?.metadata,
|
|
1441
|
+
parentNode: stepId,
|
|
1442
|
+
phase: "forEach",
|
|
1443
|
+
itemIndex: index,
|
|
1444
|
+
totalItems: items.length
|
|
1445
|
+
}
|
|
1446
|
+
} : void 0
|
|
1447
|
+
);
|
|
1448
|
+
const itemT0 = Date.now();
|
|
1449
|
+
const resultRaw = await flow.run(item.input);
|
|
1450
|
+
if (!isSingleFlowResult(resultRaw)) {
|
|
1451
|
+
throw new Error("Item flow returned batch result instead of single result");
|
|
1452
|
+
}
|
|
1453
|
+
const result = resultRaw;
|
|
1454
|
+
itemFlowResults.push(result);
|
|
1455
|
+
if (ctx?.metrics && result.metrics) {
|
|
1456
|
+
const itemMetrics = flattenMetrics(
|
|
1457
|
+
result.metrics,
|
|
1458
|
+
`${stepId}.item[${index}]`
|
|
1459
|
+
);
|
|
1460
|
+
itemMetrics.forEach((m) => ctx.metrics.push(m));
|
|
1461
|
+
}
|
|
1462
|
+
return result.output;
|
|
1463
|
+
})
|
|
1464
|
+
);
|
|
1465
|
+
const successCount = results.filter((r) => r.status === "fulfilled").length;
|
|
1466
|
+
const failureCount = results.filter((r) => r.status === "rejected").length;
|
|
1467
|
+
const splitCost = splitCostTracker.reduce((sum, m) => sum + (m.costUSD ?? 0), 0);
|
|
1468
|
+
const itemsCost = itemFlowResults.reduce((sum, result) => {
|
|
1469
|
+
const itemCost = result.metrics ? result.metrics.reduce((s, m) => s + (m.costUSD ?? 0), 0) : 0;
|
|
1470
|
+
return sum + itemCost;
|
|
1471
|
+
}, 0);
|
|
1472
|
+
const aggregateCost = splitCost + itemsCost;
|
|
1473
|
+
const totalMs = Date.now() - t0;
|
|
1474
|
+
const splitMs = splitCostTracker.reduce((sum, m) => sum + (m.ms ?? 0), 0);
|
|
1475
|
+
const itemsMs = itemFlowResults.reduce((sum, result) => {
|
|
1476
|
+
const itemMs = result.metrics ? result.metrics.reduce((s, m) => s + (m.ms ?? 0), 0) : 0;
|
|
1477
|
+
return sum + itemMs;
|
|
1478
|
+
}, 0);
|
|
1479
|
+
const overheadMs = totalMs - splitMs - itemsMs;
|
|
1480
|
+
if (ctx?.emit) {
|
|
1481
|
+
ctx.emit(`${stepId}:results`, results);
|
|
1482
|
+
ctx.emit(`${stepId}:successCount`, successCount);
|
|
1483
|
+
ctx.emit(`${stepId}:failureCount`, failureCount);
|
|
1484
|
+
}
|
|
1485
|
+
if (ctx?.metrics) {
|
|
1486
|
+
const provider = providers[splitConfig.providerRef];
|
|
1487
|
+
const { provider: providerName, model } = parseProviderName(provider.name ?? "");
|
|
1488
|
+
const schemaRefData = parseRef(splitConfig.schemaRef);
|
|
1489
|
+
ctx.metrics.push({
|
|
1490
|
+
step: stepId,
|
|
1491
|
+
configStepId: ctx.stepId,
|
|
1492
|
+
startMs: t0,
|
|
1493
|
+
provider: providerName,
|
|
1494
|
+
model,
|
|
1495
|
+
ms: totalMs,
|
|
1496
|
+
costUSD: aggregateCost,
|
|
1497
|
+
// Total cost from split + all items
|
|
1498
|
+
attemptNumber: 1,
|
|
1499
|
+
// Composite wrappers don't retry, always 1
|
|
1500
|
+
metadata: {
|
|
1501
|
+
kind: "wrapper",
|
|
1502
|
+
// Distinguish wrapper from leaf metrics
|
|
1503
|
+
type: "forEach",
|
|
1504
|
+
rollup: true,
|
|
1505
|
+
// Duration includes child work
|
|
1506
|
+
overheadMs,
|
|
1507
|
+
// Pure wrapper overhead (flow orchestration)
|
|
1508
|
+
itemCount: items.length,
|
|
1509
|
+
successCount,
|
|
1510
|
+
failureCount,
|
|
1511
|
+
itemFlowId: typeof itemFlow === "object" && "flowRef" in itemFlow ? itemFlow.flowRef : "inline",
|
|
1512
|
+
// Include schema metadata if available
|
|
1513
|
+
...schemaRefData && {
|
|
1514
|
+
schemaId: schemaRefData.id,
|
|
1515
|
+
...schemaRefData.version && { schemaVersion: schemaRefData.version }
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1520
|
+
return results;
|
|
1521
|
+
} catch (error) {
|
|
1522
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
1523
|
+
if (ctx?.metrics) {
|
|
1524
|
+
ctx.metrics.push({
|
|
1525
|
+
step: stepId,
|
|
1526
|
+
configStepId: ctx.stepId,
|
|
1527
|
+
startMs: t0,
|
|
1528
|
+
ms: Date.now() - t0,
|
|
1529
|
+
costUSD: 0,
|
|
1530
|
+
attemptNumber: 1,
|
|
1531
|
+
// @ts-ignore - Add error field
|
|
1532
|
+
error: err.message,
|
|
1533
|
+
metadata: {
|
|
1534
|
+
kind: "wrapper",
|
|
1535
|
+
type: "forEach",
|
|
1536
|
+
failed: true,
|
|
1537
|
+
itemCount: items?.length,
|
|
1538
|
+
failedPhase: phase
|
|
1539
|
+
}
|
|
1540
|
+
});
|
|
1541
|
+
}
|
|
1542
|
+
throw new Error(
|
|
1543
|
+
`ForEach step "${stepId}" failed (${items ? `itemCount: ${items.length}, ` : ""}phase: ${phase}): ` + err.message
|
|
1544
|
+
);
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1549
|
+
function resolveBranchFlow(flowOrRef, flows) {
|
|
1550
|
+
if (typeof flowOrRef === "object" && flowOrRef !== null && "flowRef" in flowOrRef) {
|
|
1551
|
+
const flowRef = flowOrRef.flowRef;
|
|
1552
|
+
if (!flows[flowRef]) {
|
|
1553
|
+
throw new Error(
|
|
1554
|
+
`Flow reference "${flowRef}" not found in registry. Available flows: ${Object.keys(flows).join(", ")}`
|
|
1555
|
+
);
|
|
1556
|
+
}
|
|
1557
|
+
return flows[flowRef];
|
|
1558
|
+
}
|
|
1559
|
+
return flowOrRef;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
// src/serialization.ts
|
|
1563
|
+
function extractNodeMetadata(node2) {
|
|
1564
|
+
return null;
|
|
1565
|
+
}
|
|
1566
|
+
var FlowSerializationError = class extends Error {
|
|
1567
|
+
constructor(message) {
|
|
1568
|
+
super(message);
|
|
1569
|
+
this.name = "FlowSerializationError";
|
|
1570
|
+
}
|
|
1571
|
+
};
|
|
1572
|
+
function isFlowReference(value) {
|
|
1573
|
+
return typeof value === "object" && value !== null && "flowRef" in value && typeof value.flowRef === "string";
|
|
1574
|
+
}
|
|
1575
|
+
function resolveFlowReference(flowOrRef, flows) {
|
|
1576
|
+
if (isFlowReference(flowOrRef)) {
|
|
1577
|
+
if (!flows) {
|
|
1578
|
+
throw new FlowSerializationError(
|
|
1579
|
+
`Flow reference "${flowOrRef.flowRef}" found but no flow registry provided`
|
|
1580
|
+
);
|
|
1581
|
+
}
|
|
1582
|
+
const resolvedFlow = flows[flowOrRef.flowRef];
|
|
1583
|
+
if (!resolvedFlow) {
|
|
1584
|
+
throw new FlowSerializationError(
|
|
1585
|
+
`Flow reference "${flowOrRef.flowRef}" not found in registry. Available flows: ${Object.keys(flows).join(", ")}`
|
|
1586
|
+
);
|
|
1587
|
+
}
|
|
1588
|
+
return resolvedFlow;
|
|
1589
|
+
}
|
|
1590
|
+
return flowOrRef;
|
|
1591
|
+
}
|
|
1592
|
+
function buildFlowFromConfig(flowDef, providers, flows, options) {
|
|
1593
|
+
if (flowDef.version !== "1.0.0") {
|
|
1594
|
+
throw new FlowSerializationError(`Unsupported flow version: ${flowDef.version}`);
|
|
1595
|
+
}
|
|
1596
|
+
const mergedOptions = {
|
|
1597
|
+
...options,
|
|
1598
|
+
inputValidation: flowDef.inputValidation ?? options?.inputValidation
|
|
1599
|
+
};
|
|
1600
|
+
let flow = createFlow(mergedOptions);
|
|
1601
|
+
for (const step of flowDef.steps) {
|
|
1602
|
+
if (step.type === "step") {
|
|
1603
|
+
const node2 = createNodeFromConfig(step.nodeType, step.config, providers, flows);
|
|
1604
|
+
flow = flow.step(step.id, node2, step.name);
|
|
1605
|
+
} else if (step.type === "conditional") {
|
|
1606
|
+
const node2 = createConditionalCompositeNode({
|
|
1607
|
+
stepId: step.id,
|
|
1608
|
+
categorizeConfig: step.config,
|
|
1609
|
+
branches: step.branches,
|
|
1610
|
+
providers,
|
|
1611
|
+
flows: flows || {}
|
|
1612
|
+
});
|
|
1613
|
+
flow = flow.step(step.id, node2, step.name);
|
|
1614
|
+
} else if (step.type === "forEach") {
|
|
1615
|
+
const node2 = createForEachCompositeNode({
|
|
1616
|
+
stepId: step.id,
|
|
1617
|
+
splitConfig: step.config,
|
|
1618
|
+
itemFlow: step.itemFlow,
|
|
1619
|
+
providers,
|
|
1620
|
+
flows: flows || {}
|
|
1621
|
+
});
|
|
1622
|
+
flow = flow.step(step.id, node2, step.name);
|
|
1623
|
+
} else {
|
|
1624
|
+
const exhaustiveCheck = step;
|
|
1625
|
+
throw new FlowSerializationError(`Unknown step type: ${exhaustiveCheck.type}`);
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
return flow.build();
|
|
1629
|
+
}
|
|
1630
|
+
function getByPath(obj, path) {
|
|
1631
|
+
return path.reduce((current, key) => {
|
|
1632
|
+
if (current && typeof current === "object" && key in current) {
|
|
1633
|
+
return current[key];
|
|
1634
|
+
}
|
|
1635
|
+
return void 0;
|
|
1636
|
+
}, obj);
|
|
1637
|
+
}
|
|
1638
|
+
function createInputMapper(mappingConfig) {
|
|
1639
|
+
return (input, context) => {
|
|
1640
|
+
switch (mappingConfig.type) {
|
|
1641
|
+
case "passthrough":
|
|
1642
|
+
return input;
|
|
1643
|
+
case "unwrap":
|
|
1644
|
+
if (input && typeof input === "object" && "input" in input) {
|
|
1645
|
+
return input.input;
|
|
1646
|
+
}
|
|
1647
|
+
return input;
|
|
1648
|
+
case "artifact": {
|
|
1649
|
+
const pathParts = mappingConfig.path.split(".");
|
|
1650
|
+
return getByPath(context.artifacts, pathParts);
|
|
1651
|
+
}
|
|
1652
|
+
case "merge": {
|
|
1653
|
+
const pathParts = mappingConfig.artifactPath.split(".");
|
|
1654
|
+
const artifactValue = getByPath(context.artifacts, pathParts);
|
|
1655
|
+
if (typeof input === "object" && input !== null && typeof artifactValue === "object" && artifactValue !== null) {
|
|
1656
|
+
return { ...input, ...artifactValue };
|
|
1657
|
+
}
|
|
1658
|
+
return input;
|
|
1659
|
+
}
|
|
1660
|
+
case "construct": {
|
|
1661
|
+
const result = {};
|
|
1662
|
+
for (const [fieldName, fieldMapping] of Object.entries(mappingConfig.fields)) {
|
|
1663
|
+
switch (fieldMapping.source) {
|
|
1664
|
+
case "input":
|
|
1665
|
+
if (fieldMapping.path) {
|
|
1666
|
+
const pathParts = fieldMapping.path.split(".");
|
|
1667
|
+
result[fieldName] = getByPath(input, pathParts);
|
|
1668
|
+
} else {
|
|
1669
|
+
result[fieldName] = input;
|
|
1670
|
+
}
|
|
1671
|
+
break;
|
|
1672
|
+
case "artifact": {
|
|
1673
|
+
const pathParts = fieldMapping.path.split(".");
|
|
1674
|
+
result[fieldName] = getByPath(context.artifacts, pathParts);
|
|
1675
|
+
break;
|
|
1676
|
+
}
|
|
1677
|
+
case "literal":
|
|
1678
|
+
result[fieldName] = fieldMapping.value;
|
|
1679
|
+
break;
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
return result;
|
|
1683
|
+
}
|
|
1684
|
+
default:
|
|
1685
|
+
return input;
|
|
1686
|
+
}
|
|
1687
|
+
};
|
|
1688
|
+
}
|
|
1689
|
+
function hasProviderRef(config) {
|
|
1690
|
+
return "providerRef" in config && typeof config.providerRef === "string";
|
|
1691
|
+
}
|
|
1692
|
+
function createNodeFromConfig(nodeType, config, providers, flows) {
|
|
1693
|
+
if (nodeType === "trigger") {
|
|
1694
|
+
const cfg = config;
|
|
1695
|
+
if (!flows || !flows[cfg.flowRef]) {
|
|
1696
|
+
throw new FlowSerializationError(
|
|
1697
|
+
`Flow "${cfg.flowRef}" not found in flow registry. Available flows: ${flows ? Object.keys(flows).join(", ") : "none"}`
|
|
1698
|
+
);
|
|
1699
|
+
}
|
|
1700
|
+
const overrideProviders = {};
|
|
1701
|
+
if (cfg.providerOverrides) {
|
|
1702
|
+
for (const [childRef, parentRef] of Object.entries(cfg.providerOverrides)) {
|
|
1703
|
+
if (!providers[parentRef]) {
|
|
1704
|
+
throw new FlowSerializationError(
|
|
1705
|
+
`Provider "${parentRef}" not found in provider registry. Available providers: ${Object.keys(providers).join(", ")}`
|
|
1706
|
+
);
|
|
1707
|
+
}
|
|
1708
|
+
overrideProviders[childRef] = providers[parentRef];
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
const flowBuilder = flows[cfg.flowRef];
|
|
1712
|
+
return trigger({
|
|
1713
|
+
flow: flowBuilder,
|
|
1714
|
+
flowId: cfg.flowRef,
|
|
1715
|
+
providers: Object.keys(overrideProviders).length > 0 ? overrideProviders : void 0,
|
|
1716
|
+
mapInput: cfg.inputMapping ? createInputMapper(cfg.inputMapping) : void 0,
|
|
1717
|
+
mergeMetrics: cfg.mergeMetrics,
|
|
1718
|
+
timeout: cfg.timeout
|
|
1719
|
+
});
|
|
1720
|
+
}
|
|
1721
|
+
if (nodeType === "output") {
|
|
1722
|
+
const cfg = config;
|
|
1723
|
+
return output({
|
|
1724
|
+
name: cfg.name,
|
|
1725
|
+
source: cfg.source,
|
|
1726
|
+
transform: cfg.transform,
|
|
1727
|
+
fields: cfg.fields
|
|
1728
|
+
});
|
|
1729
|
+
}
|
|
1730
|
+
if (!hasProviderRef(config)) {
|
|
1731
|
+
throw new FlowSerializationError(
|
|
1732
|
+
`Config for node type "${nodeType}" is missing providerRef`
|
|
1733
|
+
);
|
|
1734
|
+
}
|
|
1735
|
+
const provider = providers[config.providerRef];
|
|
1736
|
+
if (!provider) {
|
|
1737
|
+
throw new FlowSerializationError(
|
|
1738
|
+
`Provider "${config.providerRef}" not found in registry. Available providers: ${Object.keys(providers).join(", ")}`
|
|
1739
|
+
);
|
|
1740
|
+
}
|
|
1741
|
+
switch (nodeType) {
|
|
1742
|
+
case "parse": {
|
|
1743
|
+
const cfg = config;
|
|
1744
|
+
return parse({
|
|
1745
|
+
provider,
|
|
1746
|
+
consensus: cfg.consensus
|
|
1747
|
+
});
|
|
1748
|
+
}
|
|
1749
|
+
case "extract": {
|
|
1750
|
+
const cfg = config;
|
|
1751
|
+
return extract({
|
|
1752
|
+
provider,
|
|
1753
|
+
schema: cfg.schema,
|
|
1754
|
+
consensus: cfg.consensus,
|
|
1755
|
+
reasoning: cfg.reasoning
|
|
1756
|
+
});
|
|
1757
|
+
}
|
|
1758
|
+
case "split": {
|
|
1759
|
+
const cfg = config;
|
|
1760
|
+
return split2({
|
|
1761
|
+
provider,
|
|
1762
|
+
schemas: cfg.schemas,
|
|
1763
|
+
includeOther: cfg.includeOther,
|
|
1764
|
+
consensus: cfg.consensus
|
|
1765
|
+
});
|
|
1766
|
+
}
|
|
1767
|
+
case "categorize": {
|
|
1768
|
+
const cfg = config;
|
|
1769
|
+
return categorize2({
|
|
1770
|
+
provider,
|
|
1771
|
+
categories: cfg.categories,
|
|
1772
|
+
consensus: cfg.consensus
|
|
1773
|
+
});
|
|
1774
|
+
}
|
|
1775
|
+
default:
|
|
1776
|
+
throw new FlowSerializationError(`Unknown node type: ${nodeType}`);
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
function defineFlowConfig(config) {
|
|
1780
|
+
return {
|
|
1781
|
+
version: "1.0.0",
|
|
1782
|
+
...config
|
|
1783
|
+
};
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
// src/validation.ts
|
|
1787
|
+
import { isDebugValidation } from "@doclo/core/runtime/env";
|
|
1788
|
+
function validateJSONSchemaStructure(schema, depth = 0) {
|
|
1789
|
+
const MAX_DEPTH = 50;
|
|
1790
|
+
if (depth > MAX_DEPTH) {
|
|
1791
|
+
return `Schema nesting depth exceeds maximum (${MAX_DEPTH})`;
|
|
1792
|
+
}
|
|
1793
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
|
|
1794
|
+
return "Schema must be an object";
|
|
1795
|
+
}
|
|
1796
|
+
const schemaObj = schema;
|
|
1797
|
+
if (!schemaObj.type || typeof schemaObj.type !== "string") {
|
|
1798
|
+
return 'Schema missing "type" property';
|
|
1799
|
+
}
|
|
1800
|
+
const validTypes = ["object", "array", "string", "number", "integer", "boolean", "null"];
|
|
1801
|
+
if (!validTypes.includes(schemaObj.type)) {
|
|
1802
|
+
return `Invalid schema type: "${schemaObj.type}". Must be one of: ${validTypes.join(", ")}`;
|
|
1803
|
+
}
|
|
1804
|
+
if (schemaObj.type === "object") {
|
|
1805
|
+
if (schemaObj.properties && typeof schemaObj.properties !== "object") {
|
|
1806
|
+
return "Schema properties must be an object";
|
|
1807
|
+
}
|
|
1808
|
+
if (schemaObj.required && !Array.isArray(schemaObj.required)) {
|
|
1809
|
+
return "Schema required must be an array";
|
|
1810
|
+
}
|
|
1811
|
+
if (schemaObj.properties && typeof schemaObj.properties === "object" && !Array.isArray(schemaObj.properties)) {
|
|
1812
|
+
const properties = schemaObj.properties;
|
|
1813
|
+
for (const [propName, propSchema] of Object.entries(properties)) {
|
|
1814
|
+
const propError = validateJSONSchemaStructure(propSchema, depth + 1);
|
|
1815
|
+
if (propError) {
|
|
1816
|
+
return `Invalid schema for property "${propName}": ${propError}`;
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
if (schemaObj.type === "array") {
|
|
1822
|
+
if (!schemaObj.items) {
|
|
1823
|
+
return 'Array schema missing "items" property';
|
|
1824
|
+
}
|
|
1825
|
+
const itemsError = validateJSONSchemaStructure(schemaObj.items, depth + 1);
|
|
1826
|
+
if (itemsError) {
|
|
1827
|
+
return `Invalid array items schema: ${itemsError}`;
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
return null;
|
|
1831
|
+
}
|
|
1832
|
+
function calculateFlowNestingDepth(flow, currentDepth = 1) {
|
|
1833
|
+
let maxDepth = currentDepth + 1;
|
|
1834
|
+
for (const step of flow.steps) {
|
|
1835
|
+
let stepDepth = currentDepth + 2;
|
|
1836
|
+
if (step.type === "conditional") {
|
|
1837
|
+
const conditionalStep = step;
|
|
1838
|
+
if (conditionalStep.branches) {
|
|
1839
|
+
for (const branchFlowOrRef of Object.values(conditionalStep.branches)) {
|
|
1840
|
+
if ("flowRef" in branchFlowOrRef) {
|
|
1841
|
+
maxDepth = Math.max(maxDepth, stepDepth + 3);
|
|
1842
|
+
} else {
|
|
1843
|
+
const branchDepth = calculateFlowNestingDepth(branchFlowOrRef, stepDepth + 2);
|
|
1844
|
+
maxDepth = Math.max(maxDepth, branchDepth);
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
} else if (step.type === "forEach") {
|
|
1849
|
+
const forEachStep = step;
|
|
1850
|
+
if (forEachStep.itemFlow) {
|
|
1851
|
+
const itemFlowOrRef = forEachStep.itemFlow;
|
|
1852
|
+
if ("flowRef" in itemFlowOrRef) {
|
|
1853
|
+
maxDepth = Math.max(maxDepth, stepDepth + 2);
|
|
1854
|
+
} else {
|
|
1855
|
+
const itemDepth = calculateFlowNestingDepth(itemFlowOrRef, stepDepth + 1);
|
|
1856
|
+
maxDepth = Math.max(maxDepth, itemDepth);
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
} else {
|
|
1860
|
+
const config = step.config;
|
|
1861
|
+
let configDepth = stepDepth + 1;
|
|
1862
|
+
if ("schema" in config && config.schema) {
|
|
1863
|
+
configDepth += 4;
|
|
1864
|
+
}
|
|
1865
|
+
if ("schemas" in config && config.schemas) {
|
|
1866
|
+
configDepth += 4;
|
|
1867
|
+
}
|
|
1868
|
+
maxDepth = Math.max(maxDepth, configDepth);
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
return maxDepth;
|
|
1872
|
+
}
|
|
1873
|
+
function validateFlow(flowDef, options = {}) {
|
|
1874
|
+
const errors = [];
|
|
1875
|
+
const warnings = [];
|
|
1876
|
+
const opts = {
|
|
1877
|
+
checkProviders: true,
|
|
1878
|
+
checkSchemas: true,
|
|
1879
|
+
checkVersion: true,
|
|
1880
|
+
...options
|
|
1881
|
+
};
|
|
1882
|
+
if (opts.checkVersion) {
|
|
1883
|
+
if (!flowDef.version) {
|
|
1884
|
+
errors.push({
|
|
1885
|
+
type: "version_mismatch",
|
|
1886
|
+
message: "Flow definition missing version field"
|
|
1887
|
+
});
|
|
1888
|
+
} else if (flowDef.version !== "1.0.0") {
|
|
1889
|
+
errors.push({
|
|
1890
|
+
type: "version_mismatch",
|
|
1891
|
+
message: `Unsupported flow version: ${flowDef.version}. Expected: 1.0.0`,
|
|
1892
|
+
details: { version: flowDef.version }
|
|
1893
|
+
});
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
if (!flowDef.steps || !Array.isArray(flowDef.steps)) {
|
|
1897
|
+
errors.push({
|
|
1898
|
+
type: "invalid_config",
|
|
1899
|
+
message: "Flow definition missing or invalid steps array"
|
|
1900
|
+
});
|
|
1901
|
+
return { valid: false, errors, warnings };
|
|
1902
|
+
}
|
|
1903
|
+
if (flowDef.steps.length === 0) {
|
|
1904
|
+
warnings.push({
|
|
1905
|
+
type: "best_practice",
|
|
1906
|
+
message: "Flow has no steps defined"
|
|
1907
|
+
});
|
|
1908
|
+
}
|
|
1909
|
+
const nestingDepth = calculateFlowNestingDepth(flowDef);
|
|
1910
|
+
if (nestingDepth > 14) {
|
|
1911
|
+
errors.push({
|
|
1912
|
+
type: "invalid_config",
|
|
1913
|
+
message: `Flow nesting depth (${nestingDepth} levels) exceeds recommended maximum (14 levels). This will cause issues with Convex and other databases with 16-level JSON nesting limits. Consider using flow references (flowRef) to reduce nesting depth.`,
|
|
1914
|
+
details: { nestingDepth, limit: 14 }
|
|
1915
|
+
});
|
|
1916
|
+
} else if (nestingDepth > 12) {
|
|
1917
|
+
warnings.push({
|
|
1918
|
+
type: "performance",
|
|
1919
|
+
message: `Flow nesting depth (${nestingDepth} levels) is approaching the database limit (16 levels). Consider using flow references to reduce complexity.`,
|
|
1920
|
+
details: { nestingDepth, warningThreshold: 12 }
|
|
1921
|
+
});
|
|
1922
|
+
}
|
|
1923
|
+
for (const step of flowDef.steps) {
|
|
1924
|
+
const stepId = step.id || "unknown";
|
|
1925
|
+
if (!step.type) {
|
|
1926
|
+
errors.push({
|
|
1927
|
+
type: "invalid_config",
|
|
1928
|
+
stepId,
|
|
1929
|
+
message: "Step missing type field"
|
|
1930
|
+
});
|
|
1931
|
+
continue;
|
|
1932
|
+
}
|
|
1933
|
+
if (step.type === "conditional") {
|
|
1934
|
+
const conditionalStep = step;
|
|
1935
|
+
if (!conditionalStep.branches || typeof conditionalStep.branches !== "object") {
|
|
1936
|
+
errors.push({
|
|
1937
|
+
type: "invalid_config",
|
|
1938
|
+
stepId,
|
|
1939
|
+
message: "Conditional step missing or invalid branches field"
|
|
1940
|
+
});
|
|
1941
|
+
} else {
|
|
1942
|
+
for (const [category, branchFlowOrRef] of Object.entries(conditionalStep.branches)) {
|
|
1943
|
+
if ("flowRef" in branchFlowOrRef) {
|
|
1944
|
+
const flowRef = branchFlowOrRef.flowRef;
|
|
1945
|
+
if (typeof flowRef !== "string" || flowRef.trim() === "") {
|
|
1946
|
+
errors.push({
|
|
1947
|
+
type: "invalid_config",
|
|
1948
|
+
stepId: `${stepId}.${category}`,
|
|
1949
|
+
message: `Branch "${category}": flowRef must be a non-empty string`
|
|
1950
|
+
});
|
|
1951
|
+
}
|
|
1952
|
+
} else {
|
|
1953
|
+
const branchResult = validateFlow(branchFlowOrRef, options);
|
|
1954
|
+
for (const error of branchResult.errors) {
|
|
1955
|
+
errors.push({
|
|
1956
|
+
...error,
|
|
1957
|
+
stepId: `${stepId}.${category}`,
|
|
1958
|
+
message: `Branch "${category}": ${error.message}`
|
|
1959
|
+
});
|
|
1960
|
+
}
|
|
1961
|
+
for (const warning of branchResult.warnings) {
|
|
1962
|
+
warnings.push({
|
|
1963
|
+
...warning,
|
|
1964
|
+
stepId: `${stepId}.${category}`,
|
|
1965
|
+
message: `Branch "${category}": ${warning.message}`
|
|
1966
|
+
});
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
} else if (step.type === "forEach") {
|
|
1972
|
+
const forEachStep = step;
|
|
1973
|
+
if (!forEachStep.itemFlow) {
|
|
1974
|
+
errors.push({
|
|
1975
|
+
type: "invalid_config",
|
|
1976
|
+
stepId,
|
|
1977
|
+
message: "ForEach step missing itemFlow field"
|
|
1978
|
+
});
|
|
1979
|
+
} else {
|
|
1980
|
+
const itemFlowOrRef = forEachStep.itemFlow;
|
|
1981
|
+
if ("flowRef" in itemFlowOrRef) {
|
|
1982
|
+
const flowRef = itemFlowOrRef.flowRef;
|
|
1983
|
+
if (typeof flowRef !== "string" || flowRef.trim() === "") {
|
|
1984
|
+
errors.push({
|
|
1985
|
+
type: "invalid_config",
|
|
1986
|
+
stepId: `${stepId}.itemFlow`,
|
|
1987
|
+
message: `itemFlow: flowRef must be a non-empty string`
|
|
1988
|
+
});
|
|
1989
|
+
}
|
|
1990
|
+
} else {
|
|
1991
|
+
const itemResult = validateFlow(itemFlowOrRef, options);
|
|
1992
|
+
for (const error of itemResult.errors) {
|
|
1993
|
+
errors.push({
|
|
1994
|
+
...error,
|
|
1995
|
+
stepId: `${stepId}.itemFlow`,
|
|
1996
|
+
message: `Item flow: ${error.message}`
|
|
1997
|
+
});
|
|
1998
|
+
}
|
|
1999
|
+
for (const warning of itemResult.warnings) {
|
|
2000
|
+
warnings.push({
|
|
2001
|
+
...warning,
|
|
2002
|
+
stepId: `${stepId}.itemFlow`,
|
|
2003
|
+
message: `Item flow: ${warning.message}`
|
|
2004
|
+
});
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
const validNodeTypes = ["parse", "extract", "split", "categorize", "trigger", "output"];
|
|
2010
|
+
if (!step.nodeType || !validNodeTypes.includes(step.nodeType)) {
|
|
2011
|
+
errors.push({
|
|
2012
|
+
type: "invalid_config",
|
|
2013
|
+
stepId,
|
|
2014
|
+
message: `Invalid node type: ${step.nodeType}. Must be one of: ${validNodeTypes.join(", ")}`,
|
|
2015
|
+
details: { nodeType: step.nodeType }
|
|
2016
|
+
});
|
|
2017
|
+
continue;
|
|
2018
|
+
}
|
|
2019
|
+
if (!step.config) {
|
|
2020
|
+
errors.push({
|
|
2021
|
+
type: "invalid_config",
|
|
2022
|
+
stepId,
|
|
2023
|
+
message: "Step missing config field"
|
|
2024
|
+
});
|
|
2025
|
+
continue;
|
|
2026
|
+
}
|
|
2027
|
+
const config = step.config;
|
|
2028
|
+
const hasProviderRef2 = (cfg) => {
|
|
2029
|
+
return "providerRef" in cfg && typeof cfg.providerRef === "string";
|
|
2030
|
+
};
|
|
2031
|
+
if (step.nodeType !== "trigger" && step.nodeType !== "output") {
|
|
2032
|
+
if (!hasProviderRef2(config)) {
|
|
2033
|
+
errors.push({
|
|
2034
|
+
type: "missing_provider",
|
|
2035
|
+
stepId,
|
|
2036
|
+
message: "Step config missing providerRef"
|
|
2037
|
+
});
|
|
2038
|
+
} else if (opts.checkProviders && opts.providers) {
|
|
2039
|
+
if (!opts.providers[config.providerRef]) {
|
|
2040
|
+
errors.push({
|
|
2041
|
+
type: "missing_provider",
|
|
2042
|
+
stepId,
|
|
2043
|
+
message: `Provider "${config.providerRef}" not found in registry`,
|
|
2044
|
+
details: {
|
|
2045
|
+
providerRef: config.providerRef,
|
|
2046
|
+
availableProviders: Object.keys(opts.providers)
|
|
2047
|
+
}
|
|
2048
|
+
});
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
if (opts.checkSchemas) {
|
|
2053
|
+
switch (step.nodeType) {
|
|
2054
|
+
case "extract": {
|
|
2055
|
+
const cfg = config;
|
|
2056
|
+
if (!cfg.schema) {
|
|
2057
|
+
errors.push({
|
|
2058
|
+
type: "invalid_config",
|
|
2059
|
+
stepId,
|
|
2060
|
+
message: "Extract node missing schema"
|
|
2061
|
+
});
|
|
2062
|
+
} else {
|
|
2063
|
+
const schemaError = validateJSONSchemaStructure(cfg.schema);
|
|
2064
|
+
if (schemaError) {
|
|
2065
|
+
errors.push({
|
|
2066
|
+
type: "invalid_schema",
|
|
2067
|
+
stepId,
|
|
2068
|
+
message: `Invalid JSON schema: ${schemaError}`,
|
|
2069
|
+
details: { schema: cfg.schema }
|
|
2070
|
+
});
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
if (cfg.reasoning) {
|
|
2074
|
+
if (cfg.reasoning.effort && !["low", "medium", "high"].includes(cfg.reasoning.effort)) {
|
|
2075
|
+
errors.push({
|
|
2076
|
+
type: "invalid_config",
|
|
2077
|
+
stepId,
|
|
2078
|
+
message: `Invalid reasoning effort: ${cfg.reasoning.effort}. Must be: low, medium, or high`
|
|
2079
|
+
});
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
break;
|
|
2083
|
+
}
|
|
2084
|
+
case "split": {
|
|
2085
|
+
const cfg = config;
|
|
2086
|
+
if (!cfg.schemas) {
|
|
2087
|
+
errors.push({
|
|
2088
|
+
type: "invalid_config",
|
|
2089
|
+
stepId,
|
|
2090
|
+
message: "Split node missing schemas"
|
|
2091
|
+
});
|
|
2092
|
+
} else if (typeof cfg.schemas !== "object") {
|
|
2093
|
+
errors.push({
|
|
2094
|
+
type: "invalid_config",
|
|
2095
|
+
stepId,
|
|
2096
|
+
message: "Split node schemas must be an object"
|
|
2097
|
+
});
|
|
2098
|
+
} else {
|
|
2099
|
+
for (const [schemaName, schema] of Object.entries(cfg.schemas)) {
|
|
2100
|
+
const schemaError = validateJSONSchemaStructure(schema);
|
|
2101
|
+
if (schemaError) {
|
|
2102
|
+
errors.push({
|
|
2103
|
+
type: "invalid_schema",
|
|
2104
|
+
stepId,
|
|
2105
|
+
message: `Invalid JSON schema for "${schemaName}": ${schemaError}`,
|
|
2106
|
+
details: { schemaName, schema }
|
|
2107
|
+
});
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
if (Object.keys(cfg.schemas).length === 0) {
|
|
2111
|
+
warnings.push({
|
|
2112
|
+
type: "best_practice",
|
|
2113
|
+
stepId,
|
|
2114
|
+
message: "Split node has no schemas defined"
|
|
2115
|
+
});
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
break;
|
|
2119
|
+
}
|
|
2120
|
+
case "categorize": {
|
|
2121
|
+
const cfg = config;
|
|
2122
|
+
if (!cfg.categories) {
|
|
2123
|
+
errors.push({
|
|
2124
|
+
type: "invalid_config",
|
|
2125
|
+
stepId,
|
|
2126
|
+
message: "Categorize node missing categories"
|
|
2127
|
+
});
|
|
2128
|
+
} else if (!Array.isArray(cfg.categories)) {
|
|
2129
|
+
errors.push({
|
|
2130
|
+
type: "invalid_config",
|
|
2131
|
+
stepId,
|
|
2132
|
+
message: "Categorize node categories must be an array"
|
|
2133
|
+
});
|
|
2134
|
+
} else if (cfg.categories.length === 0) {
|
|
2135
|
+
warnings.push({
|
|
2136
|
+
type: "best_practice",
|
|
2137
|
+
stepId,
|
|
2138
|
+
message: "Categorize node has no categories defined"
|
|
2139
|
+
});
|
|
2140
|
+
}
|
|
2141
|
+
break;
|
|
2142
|
+
}
|
|
2143
|
+
case "trigger": {
|
|
2144
|
+
const cfg = config;
|
|
2145
|
+
if (!cfg.flowRef) {
|
|
2146
|
+
errors.push({
|
|
2147
|
+
type: "invalid_config",
|
|
2148
|
+
stepId,
|
|
2149
|
+
message: "Trigger node missing flowRef"
|
|
2150
|
+
});
|
|
2151
|
+
}
|
|
2152
|
+
if (cfg.providerOverrides) {
|
|
2153
|
+
if (typeof cfg.providerOverrides !== "object") {
|
|
2154
|
+
errors.push({
|
|
2155
|
+
type: "invalid_config",
|
|
2156
|
+
stepId,
|
|
2157
|
+
message: "Trigger node providerOverrides must be an object"
|
|
2158
|
+
});
|
|
2159
|
+
} else if (opts.checkProviders && opts.providers) {
|
|
2160
|
+
for (const [childRef, parentRef] of Object.entries(cfg.providerOverrides)) {
|
|
2161
|
+
if (!opts.providers[parentRef]) {
|
|
2162
|
+
errors.push({
|
|
2163
|
+
type: "missing_provider",
|
|
2164
|
+
stepId,
|
|
2165
|
+
message: `Provider override "${parentRef}" not found in registry`,
|
|
2166
|
+
details: {
|
|
2167
|
+
childRef,
|
|
2168
|
+
parentRef,
|
|
2169
|
+
availableProviders: Object.keys(opts.providers)
|
|
2170
|
+
}
|
|
2171
|
+
});
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
if (cfg.inputMapping) {
|
|
2177
|
+
if (!cfg.inputMapping.type) {
|
|
2178
|
+
errors.push({
|
|
2179
|
+
type: "invalid_config",
|
|
2180
|
+
stepId,
|
|
2181
|
+
message: "Trigger node inputMapping missing type field"
|
|
2182
|
+
});
|
|
2183
|
+
} else {
|
|
2184
|
+
const validMappingTypes = ["passthrough", "unwrap", "artifact", "merge", "construct"];
|
|
2185
|
+
if (!validMappingTypes.includes(cfg.inputMapping.type)) {
|
|
2186
|
+
errors.push({
|
|
2187
|
+
type: "invalid_config",
|
|
2188
|
+
stepId,
|
|
2189
|
+
message: `Invalid inputMapping type: ${cfg.inputMapping.type}. Must be one of: ${validMappingTypes.join(", ")}`
|
|
2190
|
+
});
|
|
2191
|
+
}
|
|
2192
|
+
if (cfg.inputMapping.type === "artifact" && !("path" in cfg.inputMapping)) {
|
|
2193
|
+
errors.push({
|
|
2194
|
+
type: "invalid_config",
|
|
2195
|
+
stepId,
|
|
2196
|
+
message: 'Trigger node inputMapping type "artifact" requires path field'
|
|
2197
|
+
});
|
|
2198
|
+
}
|
|
2199
|
+
if (cfg.inputMapping.type === "merge" && !("artifactPath" in cfg.inputMapping)) {
|
|
2200
|
+
errors.push({
|
|
2201
|
+
type: "invalid_config",
|
|
2202
|
+
stepId,
|
|
2203
|
+
message: 'Trigger node inputMapping type "merge" requires artifactPath field'
|
|
2204
|
+
});
|
|
2205
|
+
}
|
|
2206
|
+
if (cfg.inputMapping.type === "construct" && !("fields" in cfg.inputMapping)) {
|
|
2207
|
+
errors.push({
|
|
2208
|
+
type: "invalid_config",
|
|
2209
|
+
stepId,
|
|
2210
|
+
message: 'Trigger node inputMapping type "construct" requires fields object'
|
|
2211
|
+
});
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
}
|
|
2215
|
+
if (cfg.timeout !== void 0) {
|
|
2216
|
+
if (typeof cfg.timeout !== "number" || cfg.timeout <= 0) {
|
|
2217
|
+
errors.push({
|
|
2218
|
+
type: "invalid_config",
|
|
2219
|
+
stepId,
|
|
2220
|
+
message: "Trigger node timeout must be a positive number"
|
|
2221
|
+
});
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
if (cfg.mergeMetrics !== void 0 && typeof cfg.mergeMetrics !== "boolean") {
|
|
2225
|
+
errors.push({
|
|
2226
|
+
type: "invalid_config",
|
|
2227
|
+
stepId,
|
|
2228
|
+
message: "Trigger node mergeMetrics must be a boolean"
|
|
2229
|
+
});
|
|
2230
|
+
}
|
|
2231
|
+
break;
|
|
2232
|
+
}
|
|
2233
|
+
case "output": {
|
|
2234
|
+
const cfg = config;
|
|
2235
|
+
if (cfg.transform) {
|
|
2236
|
+
const validTransforms = ["first", "last", "merge", "pick"];
|
|
2237
|
+
if (!validTransforms.includes(cfg.transform)) {
|
|
2238
|
+
errors.push({
|
|
2239
|
+
type: "invalid_config",
|
|
2240
|
+
stepId,
|
|
2241
|
+
message: `Invalid output transform: ${cfg.transform}. Must be one of: ${validTransforms.join(", ")}`
|
|
2242
|
+
});
|
|
2243
|
+
}
|
|
2244
|
+
if (cfg.transform === "pick" && (!cfg.fields || cfg.fields.length === 0)) {
|
|
2245
|
+
errors.push({
|
|
2246
|
+
type: "invalid_config",
|
|
2247
|
+
stepId,
|
|
2248
|
+
message: 'Output transform "pick" requires fields array'
|
|
2249
|
+
});
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
if (cfg.fields && !Array.isArray(cfg.fields)) {
|
|
2253
|
+
errors.push({
|
|
2254
|
+
type: "invalid_config",
|
|
2255
|
+
stepId,
|
|
2256
|
+
message: "Output fields must be an array"
|
|
2257
|
+
});
|
|
2258
|
+
}
|
|
2259
|
+
if (cfg.source) {
|
|
2260
|
+
if (typeof cfg.source !== "string" && !Array.isArray(cfg.source)) {
|
|
2261
|
+
errors.push({
|
|
2262
|
+
type: "invalid_config",
|
|
2263
|
+
stepId,
|
|
2264
|
+
message: "Output source must be a string or array of strings"
|
|
2265
|
+
});
|
|
2266
|
+
} else if (Array.isArray(cfg.source) && cfg.source.length === 0) {
|
|
2267
|
+
warnings.push({
|
|
2268
|
+
type: "best_practice",
|
|
2269
|
+
stepId,
|
|
2270
|
+
message: "Output source array is empty"
|
|
2271
|
+
});
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
break;
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
if ("consensus" in config && config.consensus) {
|
|
2278
|
+
const consensus = config.consensus;
|
|
2279
|
+
if (!consensus.runs || consensus.runs < 1) {
|
|
2280
|
+
errors.push({
|
|
2281
|
+
type: "invalid_config",
|
|
2282
|
+
stepId,
|
|
2283
|
+
message: "Consensus runs must be >= 1"
|
|
2284
|
+
});
|
|
2285
|
+
}
|
|
2286
|
+
if (consensus.strategy && !["majority", "unanimous"].includes(consensus.strategy)) {
|
|
2287
|
+
errors.push({
|
|
2288
|
+
type: "invalid_config",
|
|
2289
|
+
stepId,
|
|
2290
|
+
message: `Invalid consensus strategy: ${consensus.strategy}. Must be: majority or unanimous`
|
|
2291
|
+
});
|
|
2292
|
+
}
|
|
2293
|
+
if (consensus.onTie && !["random", "fail", "retry"].includes(consensus.onTie)) {
|
|
2294
|
+
errors.push({
|
|
2295
|
+
type: "invalid_config",
|
|
2296
|
+
stepId,
|
|
2297
|
+
message: `Invalid consensus onTie: ${consensus.onTie}. Must be: random, fail, or retry`
|
|
2298
|
+
});
|
|
2299
|
+
}
|
|
2300
|
+
if (consensus.runs > 1) {
|
|
2301
|
+
warnings.push({
|
|
2302
|
+
type: "performance",
|
|
2303
|
+
stepId,
|
|
2304
|
+
message: `Consensus with ${consensus.runs} runs will execute the step ${consensus.runs} times`
|
|
2305
|
+
});
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
return {
|
|
2311
|
+
valid: errors.length === 0,
|
|
2312
|
+
errors,
|
|
2313
|
+
warnings
|
|
2314
|
+
};
|
|
2315
|
+
}
|
|
2316
|
+
function validateFlowOrThrow(flowDef, options = {}) {
|
|
2317
|
+
const result = validateFlow(flowDef, options);
|
|
2318
|
+
if (!result.valid) {
|
|
2319
|
+
const errorMessages = result.errors.map(
|
|
2320
|
+
(e) => e.stepId ? `[${e.stepId}] ${e.message}` : e.message
|
|
2321
|
+
).join("\n");
|
|
2322
|
+
throw new Error(`Flow validation failed:
|
|
2323
|
+
${errorMessages}`);
|
|
2324
|
+
}
|
|
2325
|
+
if (result.warnings.length > 0 && isDebugValidation()) {
|
|
2326
|
+
console.warn("[Flow Validation] Warnings:");
|
|
2327
|
+
for (const warning of result.warnings) {
|
|
2328
|
+
const prefix = warning.stepId ? `[${warning.stepId}]` : "";
|
|
2329
|
+
console.warn(` ${prefix} ${warning.message}`);
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
// src/index.ts
|
|
2335
|
+
import { bufferToDataUri, bufferToBase64 } from "@doclo/core";
|
|
2336
|
+
|
|
2337
|
+
// src/multi-provider-flow.ts
|
|
2338
|
+
import { runPipeline as runPipeline2 } from "@doclo/core";
|
|
2339
|
+
import { parseNode, extractNode } from "@doclo/nodes";
|
|
2340
|
+
import { buildLLMProvider } from "@doclo/providers-llm";
|
|
2341
|
+
function buildMultiProviderFlow(opts) {
|
|
2342
|
+
const parse3 = parseNode({ ocr: opts.ocr });
|
|
2343
|
+
const coreLLMProvider = buildLLMProvider({
|
|
2344
|
+
providers: opts.llmConfigs,
|
|
2345
|
+
maxRetries: opts.maxRetries ?? 2,
|
|
2346
|
+
retryDelay: opts.retryDelay ?? 1e3,
|
|
2347
|
+
useExponentialBackoff: true,
|
|
2348
|
+
circuitBreakerThreshold: opts.circuitBreakerThreshold ?? 3
|
|
2349
|
+
});
|
|
2350
|
+
const mkPrompt = (ir) => `Extract JSON matching the schema fields: vessel, port, quantity_mt.
|
|
2351
|
+
Document (first page preview):
|
|
2352
|
+
${ir.pages[0]?.lines.slice(0, 50).map((l) => l.text).join("\n")}`;
|
|
2353
|
+
const extract3 = extractNode({
|
|
2354
|
+
llm: coreLLMProvider,
|
|
2355
|
+
schema: simpleSchema,
|
|
2356
|
+
makePrompt: mkPrompt
|
|
2357
|
+
});
|
|
2358
|
+
return {
|
|
2359
|
+
async run(input) {
|
|
2360
|
+
const parsed = await runPipeline2([parse3], input);
|
|
2361
|
+
const ir = parsed.output;
|
|
2362
|
+
const result = await runPipeline2([extract3], ir);
|
|
2363
|
+
return {
|
|
2364
|
+
ir,
|
|
2365
|
+
output: result.output,
|
|
2366
|
+
metrics: [...parsed.metrics, ...result.metrics],
|
|
2367
|
+
artifacts: {
|
|
2368
|
+
parse: parsed.artifacts.parse,
|
|
2369
|
+
extract: result.artifacts.extract
|
|
2370
|
+
}
|
|
2371
|
+
};
|
|
2372
|
+
}
|
|
2373
|
+
};
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
// src/vlm-direct-flow.ts
|
|
2377
|
+
import { runPipeline as runPipeline3 } from "@doclo/core";
|
|
2378
|
+
import { node } from "@doclo/core";
|
|
2379
|
+
import { buildLLMProvider as buildLLMProvider2 } from "@doclo/providers-llm";
|
|
2380
|
+
function buildVLMDirectFlow(opts) {
|
|
2381
|
+
const coreLLMProvider = buildLLMProvider2({
|
|
2382
|
+
providers: opts.llmConfigs,
|
|
2383
|
+
maxRetries: opts.maxRetries ?? 2,
|
|
2384
|
+
retryDelay: opts.retryDelay ?? 1e3,
|
|
2385
|
+
useExponentialBackoff: true,
|
|
2386
|
+
circuitBreakerThreshold: opts.circuitBreakerThreshold ?? 3
|
|
2387
|
+
});
|
|
2388
|
+
const vlmExtract = node(
|
|
2389
|
+
"vlm_extract",
|
|
2390
|
+
async (input, ctx) => {
|
|
2391
|
+
const t0 = Date.now();
|
|
2392
|
+
const prompt = {
|
|
2393
|
+
text: `You are a document data extraction expert. Extract the following fields from this maritime document:
|
|
2394
|
+
- vessel: The vessel/ship name
|
|
2395
|
+
- port: The port name or location
|
|
2396
|
+
- quantity_mt: The quantity in metric tons (MT)
|
|
2397
|
+
|
|
2398
|
+
Return ONLY a JSON object with these fields. Use null if a field is not found.`
|
|
2399
|
+
};
|
|
2400
|
+
let isPDF = false;
|
|
2401
|
+
if (input.url) {
|
|
2402
|
+
isPDF = input.url.endsWith(".pdf") || input.url.toLowerCase().includes(".pdf");
|
|
2403
|
+
} else if (input.base64) {
|
|
2404
|
+
isPDF = input.base64.startsWith("data:application/pdf");
|
|
2405
|
+
}
|
|
2406
|
+
if (isPDF) {
|
|
2407
|
+
prompt.pdfs = [];
|
|
2408
|
+
if (input.url) {
|
|
2409
|
+
if (input.url.startsWith("data:")) {
|
|
2410
|
+
const base64Data = input.url.replace(/^data:application\/pdf;base64,/, "");
|
|
2411
|
+
prompt.pdfs.push({ base64: base64Data });
|
|
2412
|
+
} else {
|
|
2413
|
+
prompt.pdfs.push({ url: input.url });
|
|
2414
|
+
}
|
|
2415
|
+
} else if (input.base64) {
|
|
2416
|
+
const base64Data = input.base64.startsWith("data:") ? input.base64.replace(/^data:application\/pdf;base64,/, "") : input.base64;
|
|
2417
|
+
prompt.pdfs.push({ base64: base64Data });
|
|
2418
|
+
}
|
|
2419
|
+
} else {
|
|
2420
|
+
prompt.images = [];
|
|
2421
|
+
if (input.url) {
|
|
2422
|
+
if (input.url.startsWith("data:")) {
|
|
2423
|
+
const base64Data = input.url.replace(/^data:image\/[^;]+;base64,/, "");
|
|
2424
|
+
const mimeType = input.url.match(/^data:(image\/[^;]+);/)?.[1] || "image/jpeg";
|
|
2425
|
+
prompt.images.push({ base64: base64Data, mimeType });
|
|
2426
|
+
} else {
|
|
2427
|
+
prompt.images.push({ url: input.url, mimeType: "image/jpeg" });
|
|
2428
|
+
}
|
|
2429
|
+
} else if (input.base64) {
|
|
2430
|
+
const base64Data = input.base64.startsWith("data:") ? input.base64.replace(/^data:image\/[^;]+;base64,/, "") : input.base64;
|
|
2431
|
+
const mimeType = input.base64.startsWith("data:") ? input.base64.match(/^data:(image\/[^;]+);/)?.[1] || "image/jpeg" : "image/jpeg";
|
|
2432
|
+
prompt.images.push({ base64: base64Data, mimeType });
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
const { json, costUSD, inputTokens, outputTokens, cacheCreationInputTokens, cacheReadInputTokens } = await coreLLMProvider.completeJson({
|
|
2436
|
+
prompt,
|
|
2437
|
+
schema: simpleSchema
|
|
2438
|
+
});
|
|
2439
|
+
ctx.metrics.push({
|
|
2440
|
+
step: "vlm_extract",
|
|
2441
|
+
startMs: t0,
|
|
2442
|
+
provider: coreLLMProvider.name,
|
|
2443
|
+
model: "unknown",
|
|
2444
|
+
ms: Date.now() - t0,
|
|
2445
|
+
costUSD,
|
|
2446
|
+
inputTokens,
|
|
2447
|
+
outputTokens,
|
|
2448
|
+
cacheCreationInputTokens,
|
|
2449
|
+
cacheReadInputTokens,
|
|
2450
|
+
attemptNumber: 1,
|
|
2451
|
+
metadata: { kind: "leaf" }
|
|
2452
|
+
});
|
|
2453
|
+
return json;
|
|
2454
|
+
}
|
|
2455
|
+
);
|
|
2456
|
+
return {
|
|
2457
|
+
async run(input) {
|
|
2458
|
+
const result = await runPipeline3([vlmExtract], input);
|
|
2459
|
+
return {
|
|
2460
|
+
output: result.output,
|
|
2461
|
+
metrics: result.metrics,
|
|
2462
|
+
artifacts: {
|
|
2463
|
+
vlm_extract: result.artifacts.vlm_extract
|
|
2464
|
+
}
|
|
2465
|
+
};
|
|
2466
|
+
}
|
|
2467
|
+
};
|
|
2468
|
+
}
|
|
2469
|
+
|
|
2470
|
+
// src/index.ts
|
|
2471
|
+
function buildTwoProviderFlow(opts) {
|
|
2472
|
+
const parse3 = parseNode2({ ocr: opts.ocr });
|
|
2473
|
+
const mkPrompt = (ir) => `Extract JSON matching the schema fields: vessel, port, quantity_mt.
|
|
2474
|
+
Document (first page preview):
|
|
2475
|
+
${ir.pages[0]?.lines.slice(0, 50).map((l) => l.text).join("\n")}`;
|
|
2476
|
+
const extractA = extractNode2({ llm: opts.llmA, schema: simpleSchema, makePrompt: mkPrompt });
|
|
2477
|
+
const extractB = extractNode2({ llm: opts.llmB, schema: simpleSchema, makePrompt: mkPrompt });
|
|
2478
|
+
return {
|
|
2479
|
+
async run(input) {
|
|
2480
|
+
const parsed = await runPipeline4([parse3], input);
|
|
2481
|
+
const ir = parsed.output;
|
|
2482
|
+
const [resA, resB] = await Promise.all([
|
|
2483
|
+
runPipeline4([extractA], ir),
|
|
2484
|
+
runPipeline4([extractB], ir)
|
|
2485
|
+
]);
|
|
2486
|
+
return {
|
|
2487
|
+
ir,
|
|
2488
|
+
outputA: resA.output,
|
|
2489
|
+
outputB: resB.output,
|
|
2490
|
+
metrics: [...parsed.metrics, ...resA.metrics, ...resB.metrics],
|
|
2491
|
+
artifacts: {
|
|
2492
|
+
parse: parsed.artifacts.parse,
|
|
2493
|
+
extractA: resA.artifacts.extract,
|
|
2494
|
+
extractB: resB.artifacts.extract
|
|
2495
|
+
}
|
|
2496
|
+
};
|
|
2497
|
+
}
|
|
2498
|
+
};
|
|
2499
|
+
}
|
|
2500
|
+
export {
|
|
2501
|
+
FLOW_REGISTRY,
|
|
2502
|
+
FlowSerializationError,
|
|
2503
|
+
bufferToBase64,
|
|
2504
|
+
bufferToDataUri,
|
|
2505
|
+
buildFlowFromConfig,
|
|
2506
|
+
buildMultiProviderFlow,
|
|
2507
|
+
buildTwoProviderFlow,
|
|
2508
|
+
buildVLMDirectFlow,
|
|
2509
|
+
categorize3 as categorize,
|
|
2510
|
+
chunk,
|
|
2511
|
+
clearRegistry,
|
|
2512
|
+
combine,
|
|
2513
|
+
createConditionalCompositeNode,
|
|
2514
|
+
createFlow,
|
|
2515
|
+
createForEachCompositeNode,
|
|
2516
|
+
defineFlowConfig,
|
|
2517
|
+
extract2 as extract,
|
|
2518
|
+
extractNodeMetadata,
|
|
2519
|
+
getFlow,
|
|
2520
|
+
getFlowCount,
|
|
2521
|
+
hasFlow,
|
|
2522
|
+
isFlowReference,
|
|
2523
|
+
listFlows,
|
|
2524
|
+
parse2 as parse,
|
|
2525
|
+
registerFlow,
|
|
2526
|
+
resolveFlowReference,
|
|
2527
|
+
simpleSchema,
|
|
2528
|
+
split3 as split,
|
|
2529
|
+
trigger2 as trigger,
|
|
2530
|
+
unregisterFlow,
|
|
2531
|
+
validateFlow,
|
|
2532
|
+
validateFlowOrThrow
|
|
2533
|
+
};
|
|
2534
|
+
//# sourceMappingURL=index.js.map
|