@doclo/flows 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -468,7 +468,7 @@ type SerializableFlow = {
468
468
  /**
469
469
  * Serializable step definition
470
470
  */
471
- type SerializableStep = SerializableStandardStep | SerializableConditionalStep | SerializableForEachStep;
471
+ type SerializableStep = SerializableStandardStep | SerializableConditionalStep | SerializableForEachStep | SerializableRouteStep;
472
472
  /**
473
473
  * Standard sequential step
474
474
  */
@@ -514,6 +514,56 @@ type SerializableForEachStep = {
514
514
  config: SplitConfig;
515
515
  itemFlow: SerializableFlow | FlowReference;
516
516
  };
517
+ /**
518
+ * Route branch configuration
519
+ * Maps multiple MIME types to a single branch
520
+ */
521
+ type RouteBranchConfig = {
522
+ /**
523
+ * MIME types that route to this branch.
524
+ * Supports exact matches (e.g., 'application/pdf')
525
+ * and glob patterns (e.g., 'image/*')
526
+ */
527
+ mimeTypes: string[];
528
+ /**
529
+ * Optional description for documentation/debugging
530
+ */
531
+ description?: string;
532
+ };
533
+ /**
534
+ * Route step configuration
535
+ * Routes documents based on detected MIME type (no provider required)
536
+ */
537
+ type RouteConfig = {
538
+ type: 'route';
539
+ /**
540
+ * Branch definitions mapping branch names to MIME type configurations.
541
+ * Order matters for matching - first matching branch wins.
542
+ */
543
+ branches: Record<string, RouteBranchConfig>;
544
+ };
545
+ /**
546
+ * Route step (MIME-based routing + branches)
547
+ *
548
+ * Unlike conditional (which uses VLM categorization), route uses
549
+ * deterministic MIME detection from magic bytes - no provider needed.
550
+ *
551
+ * Branches can be either inline flows or references to separate flows.
552
+ * Use references to avoid hitting database JSON nesting limits.
553
+ */
554
+ type SerializableRouteStep = {
555
+ type: 'route';
556
+ id: string;
557
+ name?: string;
558
+ nodeType: 'route';
559
+ config: RouteConfig;
560
+ branches: Record<string, SerializableFlow | FlowReference>;
561
+ /**
562
+ * Fallback branch for unmatched MIME types.
563
+ * If not provided and no branch matches, throws error with details.
564
+ */
565
+ others?: SerializableFlow | FlowReference;
566
+ };
517
567
  /**
518
568
  * Input mapping configuration for trigger nodes
519
569
  * Declarative alternatives to mapInput functions (for serialization)
@@ -575,7 +625,18 @@ type ExtractConfig = {
575
625
  type SplitConfig = {
576
626
  type: 'split';
577
627
  providerRef: string;
578
- schemas: Record<string, JSONSchemaNode>;
628
+ /**
629
+ * Simple category definitions (recommended).
630
+ * Each category can be a string or an object with name and optional description.
631
+ */
632
+ categories?: (string | {
633
+ name: string;
634
+ description?: string;
635
+ })[];
636
+ /**
637
+ * @deprecated Use `categories` instead. Full schema definitions for backwards compatibility.
638
+ */
639
+ schemas?: Record<string, JSONSchemaNode>;
579
640
  includeOther?: boolean;
580
641
  consensus?: {
581
642
  runs: number;
@@ -770,6 +831,26 @@ interface ForEachCompositeConfig {
770
831
  * Includes full observability, metrics merging, and error context.
771
832
  */
772
833
  declare function createForEachCompositeNode(config: ForEachCompositeConfig): NodeDef<FlowInput, unknown[]>;
834
+ /**
835
+ * Configuration for route composite node
836
+ */
837
+ interface RouteCompositeConfig {
838
+ stepId: string;
839
+ routeConfig: RouteConfig;
840
+ branches: Record<string, SerializableFlow | FlowReference>;
841
+ others?: SerializableFlow | FlowReference;
842
+ providers: ProviderRegistry;
843
+ flows: FlowRegistry;
844
+ }
845
+ /**
846
+ * Creates a composite node that:
847
+ * 1. Detects MIME type from input (base64 or URL)
848
+ * 2. Routes to appropriate branch based on MIME type
849
+ * 3. Returns the branch flow's output
850
+ *
851
+ * No provider required - uses deterministic MIME detection from magic bytes.
852
+ */
853
+ declare function createRouteCompositeNode(config: RouteCompositeConfig): NodeDef<FlowInput, unknown>;
773
854
 
774
855
  /**
775
856
  * Flow Validation
@@ -950,4 +1031,4 @@ declare function buildTwoProviderFlow(opts: {
950
1031
  }>;
951
1032
  };
952
1033
 
953
- export { type BatchFlowResult, type BuiltFlow, type CategorizeConfig, type ConditionalCompositeConfig, type ExtractConfig, FLOW_REGISTRY, type FieldMapping, type FlowBuilder, type FlowOptions, type FlowProgressCallbacks, type FlowReference, type FlowRegistry$1 as FlowRegistry, type FlowRunOptions, FlowSerializationError, type FlowValidationResult, type ForEachCompositeConfig, type InputMappingConfig, type NodeConfig, type OutputConfig, type ParseConfig, type ProviderRegistry, type SerializableConditionalStep, type SerializableFlow, type SerializableForEachStep, type SerializableInputValidation, type SerializableStandardStep, type SerializableStep, type SplitConfig, type TriggerConfig, type ValidationError, type ValidationOptions, type ValidationResult, type ValidationWarning, buildFlowFromConfig, buildMultiProviderFlow, buildTwoProviderFlow, buildVLMDirectFlow, clearRegistry, createConditionalCompositeNode, createFlow, createForEachCompositeNode, defineFlowConfig, extractNodeMetadata, getFlow, getFlowCount, hasFlow, isBatchFlowResult, isFlowReference, isSingleFlowResult, listFlows, registerFlow, resolveFlowReference, unregisterFlow, validateFlow, validateFlowOrThrow };
1034
+ export { type BatchFlowResult, type BuiltFlow, type CategorizeConfig, type ConditionalCompositeConfig, type ExtractConfig, FLOW_REGISTRY, type FieldMapping, type FlowBuilder, type FlowOptions, type FlowProgressCallbacks, type FlowReference, type FlowRegistry$1 as FlowRegistry, type FlowRunOptions, FlowSerializationError, type FlowValidationResult, type ForEachCompositeConfig, type InputMappingConfig, type NodeConfig, type OutputConfig, type ParseConfig, type ProviderRegistry, type RouteBranchConfig, type RouteCompositeConfig, type RouteConfig, type SerializableConditionalStep, type SerializableFlow, type SerializableForEachStep, type SerializableInputValidation, type SerializableRouteStep, type SerializableStandardStep, type SerializableStep, type SplitConfig, type TriggerConfig, type ValidationError, type ValidationOptions, type ValidationResult, type ValidationWarning, buildFlowFromConfig, buildMultiProviderFlow, buildTwoProviderFlow, buildVLMDirectFlow, clearRegistry, createConditionalCompositeNode, createFlow, createForEachCompositeNode, createRouteCompositeNode, defineFlowConfig, extractNodeMetadata, getFlow, getFlowCount, hasFlow, isBatchFlowResult, isFlowReference, isSingleFlowResult, listFlows, registerFlow, resolveFlowReference, unregisterFlow, validateFlow, validateFlowOrThrow };
package/dist/index.js CHANGED
@@ -53,6 +53,15 @@ function normalizeFlowInput(input) {
53
53
  }
54
54
  return input;
55
55
  }
56
+ function filterInternalArtifacts(artifacts) {
57
+ const filtered = {};
58
+ for (const [key, value] of Object.entries(artifacts)) {
59
+ if (!key.startsWith("__")) {
60
+ filtered[key] = value;
61
+ }
62
+ }
63
+ return filtered;
64
+ }
56
65
  var Flow = class {
57
66
  steps = [];
58
67
  observability;
@@ -1051,7 +1060,7 @@ Correct: .conditional('step', () => parse({ provider }))`
1051
1060
  results: flowResults,
1052
1061
  metrics,
1053
1062
  aggregated: aggregateMetrics(metrics),
1054
- artifacts
1063
+ artifacts: filterInternalArtifacts(artifacts)
1055
1064
  };
1056
1065
  }
1057
1066
  } catch (error) {
@@ -1115,7 +1124,7 @@ Correct: .conditional('step', () => parse({ provider }))`
1115
1124
  outputs,
1116
1125
  metrics,
1117
1126
  aggregated: aggregateMetrics(metrics),
1118
- artifacts
1127
+ artifacts: filterInternalArtifacts(artifacts)
1119
1128
  };
1120
1129
  } else {
1121
1130
  result = {
@@ -1124,7 +1133,7 @@ Correct: .conditional('step', () => parse({ provider }))`
1124
1133
  outputs,
1125
1134
  metrics,
1126
1135
  aggregated: aggregateMetrics(metrics),
1127
- artifacts
1136
+ artifacts: filterInternalArtifacts(artifacts)
1128
1137
  };
1129
1138
  }
1130
1139
  } else {
@@ -1132,7 +1141,7 @@ Correct: .conditional('step', () => parse({ provider }))`
1132
1141
  output: currentData,
1133
1142
  metrics,
1134
1143
  aggregated: aggregateMetrics(metrics),
1135
- artifacts
1144
+ artifacts: filterInternalArtifacts(artifacts)
1136
1145
  };
1137
1146
  }
1138
1147
  if (this.observability && sampled && traceContext && executionId) {
@@ -1243,6 +1252,7 @@ import { parse, extract, split as split2, categorize as categorize2, trigger, ou
1243
1252
 
1244
1253
  // src/composite-nodes.ts
1245
1254
  import { FlowExecutionError as FlowExecutionError2 } from "@doclo/core";
1255
+ import { detectMimeTypeFromBase64Async, detectMimeTypeFromBase64 } from "@doclo/core";
1246
1256
  import { categorize, split } from "@doclo/nodes";
1247
1257
  function parseProviderName(name) {
1248
1258
  const colonIndex = name.indexOf(":");
@@ -1622,6 +1632,214 @@ function resolveBranchFlow(flowOrRef, flows) {
1622
1632
  }
1623
1633
  return flowOrRef;
1624
1634
  }
1635
+ function matchesMimePattern(mimeType, pattern) {
1636
+ if (pattern === mimeType) return true;
1637
+ if (pattern.endsWith("/*")) {
1638
+ const prefix = pattern.slice(0, -2);
1639
+ return mimeType.startsWith(prefix + "/");
1640
+ }
1641
+ return false;
1642
+ }
1643
+ function findMatchingBranch(mimeType, branches) {
1644
+ for (const [branchName, config] of Object.entries(branches)) {
1645
+ for (const pattern of config.mimeTypes) {
1646
+ if (matchesMimePattern(mimeType, pattern)) {
1647
+ return branchName;
1648
+ }
1649
+ }
1650
+ }
1651
+ return void 0;
1652
+ }
1653
+ function extractBase64Data(input) {
1654
+ if (input.startsWith("data:")) {
1655
+ const commaIndex = input.indexOf(",");
1656
+ if (commaIndex !== -1) {
1657
+ return input.substring(commaIndex + 1);
1658
+ }
1659
+ }
1660
+ return input;
1661
+ }
1662
+ function createRouteCompositeNode(config) {
1663
+ const { stepId, routeConfig, branches, others, providers, flows } = config;
1664
+ return {
1665
+ key: "route-composite",
1666
+ run: async (input, ctx) => {
1667
+ const t0 = Date.now();
1668
+ let detectedMimeType;
1669
+ let selectedBranch;
1670
+ let phase = "detect";
1671
+ try {
1672
+ let base64Data;
1673
+ if (input.base64) {
1674
+ base64Data = extractBase64Data(input.base64);
1675
+ } else if (input.url) {
1676
+ const response = await fetch(input.url);
1677
+ if (!response.ok) {
1678
+ throw new Error(`Failed to fetch URL: ${response.status} ${response.statusText}`);
1679
+ }
1680
+ const arrayBuffer = await response.arrayBuffer();
1681
+ const bytes = new Uint8Array(arrayBuffer);
1682
+ let binary = "";
1683
+ for (let i = 0; i < bytes.byteLength; i++) {
1684
+ binary += String.fromCharCode(bytes[i]);
1685
+ }
1686
+ base64Data = btoa(binary);
1687
+ } else {
1688
+ throw new Error(
1689
+ "Route node requires either url or base64 input for MIME detection"
1690
+ );
1691
+ }
1692
+ try {
1693
+ detectedMimeType = await detectMimeTypeFromBase64Async(base64Data);
1694
+ } catch {
1695
+ try {
1696
+ detectedMimeType = detectMimeTypeFromBase64(base64Data);
1697
+ } catch (syncErr) {
1698
+ throw new Error(
1699
+ `Unable to detect MIME type from input. Ensure the document is a supported format. Error: ${syncErr instanceof Error ? syncErr.message : String(syncErr)}`
1700
+ );
1701
+ }
1702
+ }
1703
+ if (ctx?.emit) {
1704
+ ctx.emit(`${stepId}:detectedMimeType`, detectedMimeType);
1705
+ }
1706
+ phase = "route";
1707
+ selectedBranch = findMatchingBranch(detectedMimeType, routeConfig.branches);
1708
+ if (!selectedBranch && others) {
1709
+ selectedBranch = "others";
1710
+ }
1711
+ if (!selectedBranch) {
1712
+ const availableBranches = Object.entries(routeConfig.branches).map(([name, cfg]) => `${name}: [${cfg.mimeTypes.join(", ")}]`).join("; ");
1713
+ throw new Error(
1714
+ `No branch matches MIME type "${detectedMimeType}". Available branches: ${availableBranches}. Add an 'others' fallback branch to handle unmatched types.`
1715
+ );
1716
+ }
1717
+ if (ctx?.emit) {
1718
+ ctx.emit(`${stepId}:selectedBranch`, selectedBranch);
1719
+ }
1720
+ phase = "branch";
1721
+ const branchFlowOrRef = selectedBranch === "others" ? others : branches[selectedBranch];
1722
+ if (!branchFlowOrRef) {
1723
+ throw new Error(
1724
+ `Branch "${selectedBranch}" not found in route configuration`
1725
+ );
1726
+ }
1727
+ const branchFlowDef = resolveBranchFlow(branchFlowOrRef, flows);
1728
+ const branchFlow = buildFlowFromConfig(
1729
+ branchFlowDef,
1730
+ providers,
1731
+ flows,
1732
+ ctx?.observability?.config ? {
1733
+ observability: ctx.observability.config,
1734
+ metadata: {
1735
+ ...ctx.observability?.metadata,
1736
+ parentNode: stepId,
1737
+ phase: "branch",
1738
+ detectedMimeType,
1739
+ selectedBranch
1740
+ }
1741
+ } : void 0
1742
+ );
1743
+ const branchT0 = Date.now();
1744
+ const branchResultRaw = await branchFlow.run(input);
1745
+ if (!isSingleFlowResult(branchResultRaw)) {
1746
+ throw new Error("Branch flow returned batch result instead of single result");
1747
+ }
1748
+ const branchResult = branchResultRaw;
1749
+ if (ctx?.metrics && branchResult.metrics) {
1750
+ const branchMetrics = flattenMetrics(
1751
+ branchResult.metrics,
1752
+ `${stepId}.branch.${selectedBranch}`
1753
+ );
1754
+ branchMetrics.forEach((m) => ctx.metrics.push(m));
1755
+ }
1756
+ if (ctx?.emit) {
1757
+ ctx.emit(`${stepId}:branchOutput`, branchResult.output);
1758
+ ctx.emit(`${stepId}:branchArtifacts`, branchResult.artifacts);
1759
+ }
1760
+ const branchCost = branchResult.metrics ? branchResult.metrics.reduce((sum, m) => sum + (m.costUSD ?? 0), 0) : 0;
1761
+ const totalMs = Date.now() - t0;
1762
+ const branchMs = branchResult.metrics ? branchResult.metrics.reduce((sum, m) => sum + (m.ms ?? 0), 0) : 0;
1763
+ const overheadMs = totalMs - branchMs;
1764
+ if (ctx?.metrics) {
1765
+ const wrapperMetric = {
1766
+ step: stepId,
1767
+ configStepId: ctx.stepId,
1768
+ startMs: t0,
1769
+ provider: "internal",
1770
+ // No external provider - uses built-in MIME detection
1771
+ model: "mime-detection",
1772
+ ms: totalMs,
1773
+ costUSD: branchCost,
1774
+ // Total cost from branch (MIME detection is free)
1775
+ attemptNumber: 1,
1776
+ // Composite wrappers don't retry, always 1
1777
+ metadata: {
1778
+ kind: "wrapper",
1779
+ // Distinguish wrapper from leaf metrics
1780
+ type: "route",
1781
+ rollup: true,
1782
+ // Duration includes child work
1783
+ overheadMs,
1784
+ // Pure wrapper overhead (MIME detection + routing)
1785
+ detectedMimeType,
1786
+ selectedBranch,
1787
+ branchStepCount: branchResult.metrics?.length || 0,
1788
+ branchFlowId: selectedBranch === "others" ? others && typeof others === "object" && "flowRef" in others ? others.flowRef : "inline" : typeof branches[selectedBranch] === "object" && "flowRef" in branches[selectedBranch] ? branches[selectedBranch].flowRef : "inline"
1789
+ }
1790
+ };
1791
+ ctx.metrics.push(wrapperMetric);
1792
+ }
1793
+ return branchResult.output;
1794
+ } catch (error) {
1795
+ const err = error instanceof Error ? error : new Error(String(error));
1796
+ const isNestedFlowError = err instanceof FlowExecutionError2;
1797
+ if (ctx?.metrics) {
1798
+ ctx.metrics.push({
1799
+ step: stepId,
1800
+ configStepId: ctx.stepId,
1801
+ startMs: t0,
1802
+ ms: Date.now() - t0,
1803
+ costUSD: 0,
1804
+ attemptNumber: 1,
1805
+ // @ts-ignore - Add error field
1806
+ error: err.message,
1807
+ metadata: {
1808
+ kind: "wrapper",
1809
+ type: "route",
1810
+ failed: true,
1811
+ detectedMimeType,
1812
+ selectedBranch,
1813
+ failedPhase: phase
1814
+ }
1815
+ });
1816
+ }
1817
+ const flowPath = [{
1818
+ stepId,
1819
+ stepIndex: 0,
1820
+ stepType: "route",
1821
+ branch: selectedBranch || void 0
1822
+ }];
1823
+ if (isNestedFlowError && err.flowPath) {
1824
+ flowPath.push(...err.flowPath);
1825
+ }
1826
+ const rootCauseMessage = isNestedFlowError ? err.getRootCause().message : err.message;
1827
+ throw new FlowExecutionError2(
1828
+ `Route step "${stepId}" failed${detectedMimeType ? ` (mimeType: ${detectedMimeType})` : ""}${selectedBranch ? ` (branch: ${selectedBranch})` : ""} in phase: ${phase}
1829
+ Error: ${rootCauseMessage}`,
1830
+ stepId,
1831
+ 0,
1832
+ "route",
1833
+ [],
1834
+ isNestedFlowError ? err.originalError : err,
1835
+ void 0,
1836
+ flowPath,
1837
+ isNestedFlowError ? err.allCompletedSteps : void 0
1838
+ );
1839
+ }
1840
+ }
1841
+ };
1842
+ }
1625
1843
 
1626
1844
  // src/serialization.ts
1627
1845
  function extractNodeMetadata(node2) {
@@ -1684,6 +1902,16 @@ function buildFlowFromConfig(flowDef, providers, flows, options) {
1684
1902
  flows: flows || {}
1685
1903
  });
1686
1904
  flow = flow.step(step.id, node2, step.name);
1905
+ } else if (step.type === "route") {
1906
+ const node2 = createRouteCompositeNode({
1907
+ stepId: step.id,
1908
+ routeConfig: step.config,
1909
+ branches: step.branches,
1910
+ others: step.others,
1911
+ providers,
1912
+ flows: flows || {}
1913
+ });
1914
+ flow = flow.step(step.id, node2, step.name);
1687
1915
  } else {
1688
1916
  const exhaustiveCheck = step;
1689
1917
  throw new FlowSerializationError(`Unknown step type: ${exhaustiveCheck.type}`);
@@ -1825,7 +2053,10 @@ function createNodeFromConfig(nodeType, config, providers, flows) {
1825
2053
  const cfg = config;
1826
2054
  return split2({
1827
2055
  provider,
1828
- schemas: cfg.schemas,
2056
+ // Support both categories (new) and schemas (legacy)
2057
+ ...cfg.categories && { categories: cfg.categories },
2058
+ ...cfg.schemas && { schemas: cfg.schemas },
2059
+ ...cfg.schemaRef && { schemaRef: cfg.schemaRef },
1829
2060
  includeOther: cfg.includeOther,
1830
2061
  consensus: cfg.consensus,
1831
2062
  maxTokens: cfg.maxTokens
@@ -1913,6 +2144,26 @@ function calculateFlowNestingDepth(flow, currentDepth = 1) {
1913
2144
  }
1914
2145
  }
1915
2146
  }
2147
+ } else if (step.type === "route") {
2148
+ const routeStep = step;
2149
+ if (routeStep.branches) {
2150
+ for (const branchFlowOrRef of Object.values(routeStep.branches)) {
2151
+ if ("flowRef" in branchFlowOrRef) {
2152
+ maxDepth = Math.max(maxDepth, stepDepth + 3);
2153
+ } else {
2154
+ const branchDepth = calculateFlowNestingDepth(branchFlowOrRef, stepDepth + 2);
2155
+ maxDepth = Math.max(maxDepth, branchDepth);
2156
+ }
2157
+ }
2158
+ }
2159
+ if (routeStep.others) {
2160
+ if ("flowRef" in routeStep.others) {
2161
+ maxDepth = Math.max(maxDepth, stepDepth + 3);
2162
+ } else {
2163
+ const othersDepth = calculateFlowNestingDepth(routeStep.others, stepDepth + 2);
2164
+ maxDepth = Math.max(maxDepth, othersDepth);
2165
+ }
2166
+ }
1916
2167
  } else if (step.type === "forEach") {
1917
2168
  const forEachStep = step;
1918
2169
  if (forEachStep.itemFlow) {
@@ -2073,8 +2324,111 @@ function validateFlow(flowDef, options = {}) {
2073
2324
  }
2074
2325
  }
2075
2326
  }
2327
+ } else if (step.type === "route") {
2328
+ const routeStep = step;
2329
+ if (!routeStep.branches || typeof routeStep.branches !== "object") {
2330
+ errors.push({
2331
+ type: "invalid_config",
2332
+ stepId,
2333
+ message: "Route step missing or invalid branches field"
2334
+ });
2335
+ } else if (Object.keys(routeStep.branches).length === 0) {
2336
+ errors.push({
2337
+ type: "invalid_config",
2338
+ stepId,
2339
+ message: "Route step must have at least one branch"
2340
+ });
2341
+ }
2342
+ if (routeStep.config?.branches && routeStep.branches) {
2343
+ for (const branchName of Object.keys(routeStep.config.branches)) {
2344
+ if (!routeStep.branches[branchName]) {
2345
+ errors.push({
2346
+ type: "invalid_config",
2347
+ stepId,
2348
+ message: `Branch "${branchName}" defined in config but missing flow definition`
2349
+ });
2350
+ }
2351
+ }
2352
+ for (const [branchName, branchConfig] of Object.entries(routeStep.config.branches)) {
2353
+ if (!branchConfig.mimeTypes || branchConfig.mimeTypes.length === 0) {
2354
+ errors.push({
2355
+ type: "invalid_config",
2356
+ stepId: `${stepId}.${branchName}`,
2357
+ message: `Branch "${branchName}" has no MIME types defined`
2358
+ });
2359
+ } else {
2360
+ for (const mimeType of branchConfig.mimeTypes) {
2361
+ if (!mimeType.includes("/")) {
2362
+ errors.push({
2363
+ type: "invalid_config",
2364
+ stepId: `${stepId}.${branchName}`,
2365
+ message: `Invalid MIME type pattern: "${mimeType}". Expected format: "type/subtype" or "type/*"`
2366
+ });
2367
+ }
2368
+ }
2369
+ }
2370
+ }
2371
+ }
2372
+ if (routeStep.branches) {
2373
+ for (const [branchName, branchFlowOrRef] of Object.entries(routeStep.branches)) {
2374
+ if ("flowRef" in branchFlowOrRef) {
2375
+ const flowRef = branchFlowOrRef.flowRef;
2376
+ if (typeof flowRef !== "string" || flowRef.trim() === "") {
2377
+ errors.push({
2378
+ type: "invalid_config",
2379
+ stepId: `${stepId}.${branchName}`,
2380
+ message: `Branch "${branchName}": flowRef must be a non-empty string`
2381
+ });
2382
+ }
2383
+ } else {
2384
+ const branchResult = validateFlow(branchFlowOrRef, options);
2385
+ for (const error of branchResult.errors) {
2386
+ errors.push({
2387
+ ...error,
2388
+ stepId: `${stepId}.${branchName}`,
2389
+ message: `Branch "${branchName}": ${error.message}`
2390
+ });
2391
+ }
2392
+ for (const warning of branchResult.warnings) {
2393
+ warnings.push({
2394
+ ...warning,
2395
+ stepId: `${stepId}.${branchName}`,
2396
+ message: `Branch "${branchName}": ${warning.message}`
2397
+ });
2398
+ }
2399
+ }
2400
+ }
2401
+ }
2402
+ if (routeStep.others) {
2403
+ if ("flowRef" in routeStep.others) {
2404
+ const flowRef = routeStep.others.flowRef;
2405
+ if (typeof flowRef !== "string" || flowRef.trim() === "") {
2406
+ errors.push({
2407
+ type: "invalid_config",
2408
+ stepId: `${stepId}.others`,
2409
+ message: "Others branch flowRef must be a non-empty string"
2410
+ });
2411
+ }
2412
+ } else {
2413
+ const othersResult = validateFlow(routeStep.others, options);
2414
+ for (const error of othersResult.errors) {
2415
+ errors.push({
2416
+ ...error,
2417
+ stepId: `${stepId}.others`,
2418
+ message: `Others branch: ${error.message}`
2419
+ });
2420
+ }
2421
+ for (const warning of othersResult.warnings) {
2422
+ warnings.push({
2423
+ ...warning,
2424
+ stepId: `${stepId}.others`,
2425
+ message: `Others branch: ${warning.message}`
2426
+ });
2427
+ }
2428
+ }
2429
+ }
2076
2430
  }
2077
- const validNodeTypes = ["parse", "extract", "split", "categorize", "trigger", "output"];
2431
+ const validNodeTypes = ["parse", "extract", "split", "categorize", "trigger", "output", "route"];
2078
2432
  if (!step.nodeType || !validNodeTypes.includes(step.nodeType)) {
2079
2433
  errors.push({
2080
2434
  type: "invalid_config",
@@ -2096,7 +2450,7 @@ function validateFlow(flowDef, options = {}) {
2096
2450
  const hasProviderRef2 = (cfg) => {
2097
2451
  return "providerRef" in cfg && typeof cfg.providerRef === "string";
2098
2452
  };
2099
- if (step.nodeType !== "trigger" && step.nodeType !== "output") {
2453
+ if (step.nodeType !== "trigger" && step.nodeType !== "output" && step.nodeType !== "route") {
2100
2454
  if (!hasProviderRef2(config)) {
2101
2455
  errors.push({
2102
2456
  type: "missing_provider",
@@ -2151,19 +2505,44 @@ function validateFlow(flowDef, options = {}) {
2151
2505
  }
2152
2506
  case "split": {
2153
2507
  const cfg = config;
2154
- if (!cfg.schemas) {
2508
+ const hasCategories = cfg.categories && Array.isArray(cfg.categories);
2509
+ const hasSchemas = cfg.schemas && typeof cfg.schemas === "object";
2510
+ const hasSchemaRef = cfg.schemaRef && typeof cfg.schemaRef === "string";
2511
+ if (!hasCategories && !hasSchemas && !hasSchemaRef) {
2155
2512
  errors.push({
2156
2513
  type: "invalid_config",
2157
2514
  stepId,
2158
- message: "Split node missing schemas"
2515
+ message: "Split node requires either categories, schemas, or schemaRef"
2159
2516
  });
2160
- } else if (typeof cfg.schemas !== "object") {
2517
+ }
2518
+ if (hasCategories && hasSchemas) {
2161
2519
  errors.push({
2162
2520
  type: "invalid_config",
2163
2521
  stepId,
2164
- message: "Split node schemas must be an object"
2522
+ message: "Split node cannot have both categories and schemas. Use categories (recommended) or schemas, not both."
2165
2523
  });
2166
- } else {
2524
+ }
2525
+ if (hasCategories) {
2526
+ if (cfg.categories.length === 0) {
2527
+ warnings.push({
2528
+ type: "best_practice",
2529
+ stepId,
2530
+ message: "Split node has no categories defined"
2531
+ });
2532
+ } else {
2533
+ for (let i = 0; i < cfg.categories.length; i++) {
2534
+ const cat = cfg.categories[i];
2535
+ if (typeof cat !== "string" && (cat === null || typeof cat !== "object" || !cat.name)) {
2536
+ errors.push({
2537
+ type: "invalid_config",
2538
+ stepId,
2539
+ message: `Split node category at index ${i} must be a string or an object with a 'name' property`
2540
+ });
2541
+ }
2542
+ }
2543
+ }
2544
+ }
2545
+ if (hasSchemas && !hasCategories) {
2167
2546
  for (const [schemaName, schema] of Object.entries(cfg.schemas)) {
2168
2547
  const schemaError = validateJSONSchemaStructure(schema);
2169
2548
  if (schemaError) {
@@ -2581,6 +2960,7 @@ export {
2581
2960
  createConditionalCompositeNode,
2582
2961
  createFlow,
2583
2962
  createForEachCompositeNode,
2963
+ createRouteCompositeNode,
2584
2964
  defineFlowConfig,
2585
2965
  extract2 as extract,
2586
2966
  extractNodeMetadata,