@withone/cli 1.47.8 → 1.47.10
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.
|
@@ -746,1822 +746,2079 @@ function validateActionInput(action, args) {
|
|
|
746
746
|
return { valid: false, missing };
|
|
747
747
|
}
|
|
748
748
|
|
|
749
|
-
// src/lib/flow-
|
|
750
|
-
var
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
return Math.round(delay);
|
|
806
|
-
}
|
|
807
|
-
function resolveSelector(selectorPath, context) {
|
|
808
|
-
if (!selectorPath.startsWith("$.")) return selectorPath;
|
|
809
|
-
const parts = selectorPath.slice(2).split(/\.|\[/).map((p) => p.replace(/\]$/, ""));
|
|
810
|
-
let current = context;
|
|
811
|
-
for (const part of parts) {
|
|
812
|
-
if (current === null || current === void 0) return void 0;
|
|
813
|
-
if (part === "*" && Array.isArray(current)) {
|
|
814
|
-
continue;
|
|
815
|
-
}
|
|
816
|
-
if (Array.isArray(current) && part === "*") {
|
|
817
|
-
continue;
|
|
818
|
-
}
|
|
819
|
-
if (Array.isArray(current)) {
|
|
820
|
-
const idx = Number(part);
|
|
821
|
-
if (!isNaN(idx)) {
|
|
822
|
-
current = current[idx];
|
|
823
|
-
} else {
|
|
824
|
-
current = current.map((item) => item?.[part]);
|
|
749
|
+
// src/lib/flow-schema.ts
|
|
750
|
+
var FLOW_SCHEMA = {
|
|
751
|
+
errorStrategies: ["fail", "continue", "retry", "fallback"],
|
|
752
|
+
validInputTypes: ["string", "number", "boolean", "object", "array"],
|
|
753
|
+
flowFields: {
|
|
754
|
+
key: { type: "string", required: true, description: "Unique kebab-case identifier", pattern: /^[a-z0-9][a-z0-9-]*[a-z0-9]$/ },
|
|
755
|
+
name: { type: "string", required: true, description: "Human-readable flow name" },
|
|
756
|
+
description: { type: "string", required: false, description: "What this flow does" },
|
|
757
|
+
version: { type: "string", required: false, description: "Semver or arbitrary version string" },
|
|
758
|
+
inputs: { type: "object", required: true, description: "Input declarations (Record<string, InputDeclaration>)" },
|
|
759
|
+
steps: { type: "array", required: true, description: "Ordered array of steps", stepsArray: true },
|
|
760
|
+
defaultOnError: { type: "object", required: false, description: 'Default error strategy inherited by every step without its own `onError` (e.g. { "strategy": "continue" }). A step opts out with its own `onError`.' }
|
|
761
|
+
},
|
|
762
|
+
inputFields: {
|
|
763
|
+
type: { type: "string", required: true, description: "Data type: string, number, boolean, object, array", enum: ["string", "number", "boolean", "object", "array"] },
|
|
764
|
+
required: { type: "boolean", required: false, description: "Whether this input must be provided" },
|
|
765
|
+
default: { type: "unknown", required: false, description: "Default value if not provided" },
|
|
766
|
+
description: { type: "string", required: false, description: "Human-readable description" },
|
|
767
|
+
connection: { type: "object", required: false, description: 'Connection metadata: { platform: "gmail" } \u2014 enables auto-resolution' },
|
|
768
|
+
enum: { type: "array", required: false, description: "Allowed values. Resolved input must equal one of these (post-coercion)." }
|
|
769
|
+
},
|
|
770
|
+
stepCommonFields: {
|
|
771
|
+
id: { type: "string", required: true, description: "Unique step identifier (used in selectors)" },
|
|
772
|
+
name: { type: "string", required: true, description: "Human-readable step label" },
|
|
773
|
+
type: { type: "string", required: true, description: "Step type (determines which config object is required)" },
|
|
774
|
+
if: { type: "string", required: false, description: "JS expression \u2014 skip step if falsy" },
|
|
775
|
+
unless: { type: "string", required: false, description: "JS expression \u2014 skip step if truthy" },
|
|
776
|
+
timeoutMs: { type: "number", required: false, description: 'Wall-clock timeout (ms). On expiry the step fails with errorCode:"TIMEOUT"; with onError:continue the result gets status:"timeout".' },
|
|
777
|
+
requires: { type: "array", required: false, description: "Presence preconditions: array of $.input.X or $.steps.X.output... selectors that must resolve to a non-empty value before the step runs. Failures honor onError." },
|
|
778
|
+
outputSchema: { type: "object", required: false, description: `Optional declaration of the shape this step's output produces. When set, the validator checks that downstream $.steps.<this.id>.output.<field> references point at declared fields. Format: { fieldName: "string"|"number"|"boolean"|"object"|"array"|"unknown" } \u2014 nested objects are supported.` }
|
|
779
|
+
},
|
|
780
|
+
stepTypes: [
|
|
781
|
+
{
|
|
782
|
+
type: "action",
|
|
783
|
+
configKey: "action",
|
|
784
|
+
description: "Execute a platform API action",
|
|
785
|
+
fields: {
|
|
786
|
+
platform: { type: "string", required: true, description: "Platform name (kebab-case)" },
|
|
787
|
+
actionId: { type: "string", required: true, description: "Action ID from `actions search`" },
|
|
788
|
+
connection: { type: "object", required: false, description: "Late-bound connection ref { platform, tag? } \u2014 survives re-auth. Exactly one of `connection` or `connectionKey` must be set." },
|
|
789
|
+
connectionKey: { type: "string", required: false, description: "Literal connection key (or $.input selector). Legacy form \u2014 prefer `connection: { platform, tag? }`. Exactly one of `connection` or `connectionKey` must be set." },
|
|
790
|
+
data: { type: "object", required: false, description: "Request body (POST/PUT/PATCH)" },
|
|
791
|
+
pathVars: { type: "object", required: false, description: "URL path variables" },
|
|
792
|
+
queryParams: { type: "object", required: false, description: "Query parameters" },
|
|
793
|
+
headers: { type: "object", required: false, description: "Additional headers" }
|
|
794
|
+
},
|
|
795
|
+
example: {
|
|
796
|
+
id: "findCustomer",
|
|
797
|
+
name: "Search Stripe customers",
|
|
798
|
+
type: "action",
|
|
799
|
+
action: {
|
|
800
|
+
platform: "stripe",
|
|
801
|
+
actionId: "conn_mod_def::xxx::yyy",
|
|
802
|
+
connection: { platform: "stripe" },
|
|
803
|
+
data: { query: "email:'{{$.input.customerEmail}}'" }
|
|
804
|
+
}
|
|
825
805
|
}
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
806
|
+
},
|
|
807
|
+
{
|
|
808
|
+
type: "transform",
|
|
809
|
+
configKey: "transform",
|
|
810
|
+
description: "Single JS expression with implicit return",
|
|
811
|
+
fields: {
|
|
812
|
+
expression: { type: "string", required: true, description: "JS expression evaluated with flow context as $" }
|
|
813
|
+
},
|
|
814
|
+
example: {
|
|
815
|
+
id: "extractNames",
|
|
816
|
+
name: "Extract customer names",
|
|
817
|
+
type: "transform",
|
|
818
|
+
transform: { expression: "$.steps.findCustomer.response.data.map(c => c.name)" }
|
|
819
|
+
}
|
|
820
|
+
},
|
|
821
|
+
{
|
|
822
|
+
type: "code",
|
|
823
|
+
configKey: "code",
|
|
824
|
+
description: "JS code \u2014 inline source or an external .mjs module under the flow's lib/ folder",
|
|
825
|
+
fields: {
|
|
826
|
+
source: { type: "string", required: false, description: 'Inline JS function body (flow context as $, supports await). Mutually exclusive with "module".' },
|
|
827
|
+
module: { type: "string", required: false, description: 'Relative path to a .mjs file under the flow folder (e.g. "lib/normalize.mjs"). Reads $ from stdin as JSON, writes result to stdout as JSON. Mutually exclusive with "source".' }
|
|
828
|
+
},
|
|
829
|
+
example: {
|
|
830
|
+
id: "processData",
|
|
831
|
+
name: "Process and enrich data",
|
|
832
|
+
type: "code",
|
|
833
|
+
code: { module: "lib/process-data.mjs" }
|
|
834
|
+
}
|
|
835
|
+
},
|
|
836
|
+
{
|
|
837
|
+
type: "condition",
|
|
838
|
+
configKey: "condition",
|
|
839
|
+
description: "If/then/else branching",
|
|
840
|
+
fields: {
|
|
841
|
+
expression: { type: "string", required: true, description: "JS expression \u2014 truthy runs then, falsy runs else" },
|
|
842
|
+
then: { type: "array", required: true, description: "Steps to run when true", stepsArray: true },
|
|
843
|
+
else: { type: "array", required: false, description: "Steps to run when false", stepsArray: true }
|
|
844
|
+
},
|
|
845
|
+
example: {
|
|
846
|
+
id: "checkFound",
|
|
847
|
+
name: "Check if customer exists",
|
|
848
|
+
type: "condition",
|
|
849
|
+
condition: {
|
|
850
|
+
expression: "$.steps.search.response.data.length > 0",
|
|
851
|
+
then: [{ id: "notify", name: "Send notification", type: "action", action: { platform: "slack", actionId: "...", connection: { platform: "slack" }, data: { text: "Found!" } } }],
|
|
852
|
+
else: [{ id: "logMiss", name: "Log not found", type: "transform", transform: { expression: "'Not found'" } }]
|
|
873
853
|
}
|
|
874
|
-
return applyHandlebarsPipe(value, pipe);
|
|
875
854
|
}
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
855
|
+
},
|
|
856
|
+
{
|
|
857
|
+
type: "loop",
|
|
858
|
+
configKey: "loop",
|
|
859
|
+
description: "Iterate over an array with optional concurrency",
|
|
860
|
+
fields: {
|
|
861
|
+
over: { type: "string", required: true, description: "Selector resolving to an array" },
|
|
862
|
+
as: { type: "string", required: true, description: "Variable name for current item ($.loop.<as>)" },
|
|
863
|
+
indexAs: { type: "string", required: false, description: "Variable name for index" },
|
|
864
|
+
steps: { type: "array", required: true, description: "Steps to run per iteration", stepsArray: true },
|
|
865
|
+
maxIterations: { type: "number", required: false, description: "Safety cap (default: no limit)" },
|
|
866
|
+
maxConcurrency: { type: "number", required: false, description: "Parallel batch size (default: 1 = sequential)" }
|
|
867
|
+
},
|
|
868
|
+
example: {
|
|
869
|
+
id: "processOrders",
|
|
870
|
+
name: "Process each order",
|
|
871
|
+
type: "loop",
|
|
872
|
+
loop: {
|
|
873
|
+
over: "$.steps.listOrders.response.data",
|
|
874
|
+
as: "order",
|
|
875
|
+
steps: [{ id: "createInvoice", name: "Create invoice", type: "action", action: { platform: "stripe", actionId: "...", connection: { platform: "stripe" }, data: { amount: "$.loop.order.total" } } }]
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
},
|
|
879
|
+
{
|
|
880
|
+
type: "parallel",
|
|
881
|
+
configKey: "parallel",
|
|
882
|
+
description: "Run steps concurrently",
|
|
883
|
+
fields: {
|
|
884
|
+
steps: { type: "array", required: true, description: "Steps to run in parallel", stepsArray: true },
|
|
885
|
+
maxConcurrency: { type: "number", required: false, description: "Max concurrent steps (default: 5)" }
|
|
886
|
+
},
|
|
887
|
+
example: {
|
|
888
|
+
id: "lookups",
|
|
889
|
+
name: "Parallel data lookups",
|
|
890
|
+
type: "parallel",
|
|
891
|
+
parallel: {
|
|
892
|
+
steps: [
|
|
893
|
+
{ id: "getStripe", name: "Get Stripe data", type: "action", action: { platform: "stripe", actionId: "...", connection: { platform: "stripe" } } },
|
|
894
|
+
{ id: "getSlack", name: "Get Slack data", type: "action", action: { platform: "slack", actionId: "...", connection: { platform: "slack" } } }
|
|
895
|
+
]
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
},
|
|
899
|
+
{
|
|
900
|
+
type: "file-read",
|
|
901
|
+
configKey: "fileRead",
|
|
902
|
+
description: 'Read a file (optional JSON parse). With parseJson:true you may add an optional `schema` (field \u2192 type string, or { type, required, enum, items, minItems/maxItems/length, properties }) enforced at runtime \u2014 a mismatch throws (errorCode SCHEMA_VALIDATION) handled by the step\'s onError. Array constraints (items/minItems/maxItems/length) require type:"array" and properties requires type:"object". Unlike outputSchema (doc/wiring only), this checks the actual parsed value.',
|
|
903
|
+
fields: {
|
|
904
|
+
path: { type: "string", required: true, description: "File path to read" },
|
|
905
|
+
parseJson: { type: "boolean", required: false, description: "Parse contents as JSON (default: false)" }
|
|
906
|
+
},
|
|
907
|
+
example: {
|
|
908
|
+
id: "readConfig",
|
|
909
|
+
name: "Read config file",
|
|
910
|
+
type: "file-read",
|
|
911
|
+
fileRead: {
|
|
912
|
+
path: "./data/config.json",
|
|
913
|
+
parseJson: true,
|
|
914
|
+
schema: {
|
|
915
|
+
name: { type: "string", required: true },
|
|
916
|
+
mode: { type: "string", required: true, enum: ["dev", "staging", "prod"] },
|
|
917
|
+
tags: { type: "array", items: "string", minItems: 1, maxItems: 10 }
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
},
|
|
922
|
+
{
|
|
923
|
+
type: "file-write",
|
|
924
|
+
configKey: "fileWrite",
|
|
925
|
+
description: "Write or append to a file",
|
|
926
|
+
fields: {
|
|
927
|
+
path: { type: "string", required: true, description: "File path to write" },
|
|
928
|
+
content: { type: "unknown", required: true, description: "Content to write (supports selectors)" },
|
|
929
|
+
append: { type: "boolean", required: false, description: "Append instead of overwrite (default: false)" }
|
|
930
|
+
},
|
|
931
|
+
example: {
|
|
932
|
+
id: "writeResults",
|
|
933
|
+
name: "Save results",
|
|
934
|
+
type: "file-write",
|
|
935
|
+
fileWrite: { path: "./output/results.json", content: "$.steps.transform.output" }
|
|
936
|
+
}
|
|
937
|
+
},
|
|
938
|
+
{
|
|
939
|
+
type: "while",
|
|
940
|
+
configKey: "while",
|
|
941
|
+
description: "Do-while loop with condition check",
|
|
942
|
+
fields: {
|
|
943
|
+
condition: { type: "string", required: true, description: "JS expression checked before each iteration (after first)" },
|
|
944
|
+
steps: { type: "array", required: true, description: "Steps to run each iteration", stepsArray: true },
|
|
945
|
+
maxIterations: { type: "number", required: false, description: "Safety cap (default: 100)" }
|
|
946
|
+
},
|
|
947
|
+
example: {
|
|
948
|
+
id: "paginate",
|
|
949
|
+
name: "Paginate through pages",
|
|
950
|
+
type: "while",
|
|
951
|
+
while: {
|
|
952
|
+
condition: "$.steps.paginate.output.lastResult.nextPageToken != null",
|
|
953
|
+
maxIterations: 50,
|
|
954
|
+
steps: [{ id: "fetchPage", name: "Fetch next page", type: "action", action: { platform: "gmail", actionId: "...", connection: { platform: "gmail" } } }]
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
},
|
|
958
|
+
{
|
|
959
|
+
type: "flow",
|
|
960
|
+
configKey: "flow",
|
|
961
|
+
description: "Execute a sub-flow (supports composition)",
|
|
962
|
+
fields: {
|
|
963
|
+
key: { type: "string", required: true, description: "Flow key or path of the sub-flow" },
|
|
964
|
+
inputs: { type: "object", required: false, description: "Inputs to pass to the sub-flow (supports selectors)" }
|
|
965
|
+
},
|
|
966
|
+
example: {
|
|
967
|
+
id: "enrich",
|
|
968
|
+
name: "Run enrichment sub-flow",
|
|
969
|
+
type: "flow",
|
|
970
|
+
flow: { key: "enrich-customer", inputs: { email: "$.steps.getCustomer.response.email" } }
|
|
971
|
+
}
|
|
972
|
+
},
|
|
973
|
+
{
|
|
974
|
+
type: "paginate",
|
|
975
|
+
configKey: "paginate",
|
|
976
|
+
description: "Auto-paginate API results into a single array",
|
|
977
|
+
fields: {
|
|
978
|
+
action: { type: "object", required: true, description: "Action config (same shape as action step: platform, actionId, plus exactly one of `connection` or `connectionKey`)" },
|
|
979
|
+
pageTokenField: { type: "string", required: true, description: "Dot-path in response to next page token" },
|
|
980
|
+
resultsField: { type: "string", required: true, description: "Dot-path in response to results array" },
|
|
981
|
+
inputTokenParam: { type: "string", required: true, description: "Dot-path in action config where page token is injected" },
|
|
982
|
+
maxPages: { type: "number", required: false, description: "Max pages to fetch (default: 10)" }
|
|
983
|
+
},
|
|
984
|
+
example: {
|
|
985
|
+
id: "allMessages",
|
|
986
|
+
name: "Fetch all Gmail messages",
|
|
987
|
+
type: "paginate",
|
|
988
|
+
paginate: {
|
|
989
|
+
action: { platform: "gmail", actionId: "...", connection: { platform: "gmail" }, queryParams: { maxResults: 100 } },
|
|
990
|
+
pageTokenField: "nextPageToken",
|
|
991
|
+
resultsField: "messages",
|
|
992
|
+
inputTokenParam: "queryParams.pageToken",
|
|
993
|
+
maxPages: 10
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
},
|
|
997
|
+
{
|
|
998
|
+
type: "bash",
|
|
999
|
+
configKey: "bash",
|
|
1000
|
+
description: "Shell command (requires --allow-bash). Output shape: $.steps.<id>.output is the parsed JSON when parseJson:true, otherwise the trimmed stdout string. $.steps.<id>.response always exposes { stdout, stderr, exitCode }.",
|
|
1001
|
+
fields: {
|
|
1002
|
+
command: { type: "string", required: true, description: "Shell command to execute (supports selectors)" },
|
|
1003
|
+
timeout: { type: "number", required: false, description: "Timeout in ms (default: 30000)" },
|
|
1004
|
+
parseJson: { type: "boolean", required: false, description: "Parse stdout as JSON (default: false). When true, $.steps.<id>.output is the parsed object/array; when false, it is the trimmed stdout string." },
|
|
1005
|
+
cwd: { type: "string", required: false, description: "Working directory (supports selectors)" },
|
|
1006
|
+
env: { type: "object", required: false, description: "Additional environment variables" }
|
|
1007
|
+
},
|
|
1008
|
+
example: {
|
|
1009
|
+
id: "analyze",
|
|
1010
|
+
name: "Analyze with Claude",
|
|
1011
|
+
type: "bash",
|
|
1012
|
+
bash: {
|
|
1013
|
+
command: "cat /tmp/data.json | claude --print 'Analyze this data' --output-format json",
|
|
1014
|
+
timeout: 18e4,
|
|
1015
|
+
parseJson: true
|
|
1016
|
+
}
|
|
883
1017
|
}
|
|
884
|
-
const str2 = String(value);
|
|
885
|
-
return qFlag ? shellQuote(str2) : str2;
|
|
886
|
-
}
|
|
887
|
-
);
|
|
888
|
-
}
|
|
889
|
-
function resolveValue(value, context) {
|
|
890
|
-
if (typeof value === "string") {
|
|
891
|
-
if (value.startsWith("$.") && !value.includes("{{")) {
|
|
892
|
-
return resolveSelector(value, context);
|
|
893
|
-
}
|
|
894
|
-
if (/\{\{\s*(q\s+)?\$\./.test(value)) {
|
|
895
|
-
return interpolateString(value, context);
|
|
896
|
-
}
|
|
897
|
-
return value;
|
|
898
|
-
}
|
|
899
|
-
if (Array.isArray(value)) {
|
|
900
|
-
return value.map((item) => resolveValue(item, context));
|
|
901
|
-
}
|
|
902
|
-
if (value && typeof value === "object") {
|
|
903
|
-
const resolved = {};
|
|
904
|
-
for (const [k, v] of Object.entries(value)) {
|
|
905
|
-
resolved[k] = resolveValue(v, context);
|
|
906
1018
|
}
|
|
907
|
-
|
|
908
|
-
}
|
|
909
|
-
return value;
|
|
910
|
-
}
|
|
911
|
-
function evaluateExpression(expr, context) {
|
|
912
|
-
const fn = new Function("$", `return (${expr})`);
|
|
913
|
-
return fn(context);
|
|
914
|
-
}
|
|
915
|
-
function evaluateCondition(expr, context) {
|
|
916
|
-
try {
|
|
917
|
-
return Boolean(evaluateExpression(expr, context));
|
|
918
|
-
} catch (err) {
|
|
919
|
-
if (err instanceof TypeError) return false;
|
|
920
|
-
throw err;
|
|
921
|
-
}
|
|
922
|
-
}
|
|
923
|
-
var ALLOWED_MODULES = {
|
|
924
|
-
buffer: () => import("buffer"),
|
|
925
|
-
crypto: () => import("crypto"),
|
|
926
|
-
url: () => import("url"),
|
|
927
|
-
path: () => import("path")
|
|
1019
|
+
]
|
|
928
1020
|
};
|
|
929
|
-
var
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
"cluster",
|
|
938
|
-
"dgram",
|
|
939
|
-
"tls",
|
|
940
|
-
"vm",
|
|
941
|
-
"worker_threads"
|
|
942
|
-
]);
|
|
943
|
-
function createSandboxedRequire() {
|
|
944
|
-
const cache = {};
|
|
945
|
-
return async (moduleName) => {
|
|
946
|
-
const clean = moduleName.replace(/^node:/, "");
|
|
947
|
-
if (BLOCKED_MODULES.has(clean)) {
|
|
948
|
-
throw new Error(`Module "${moduleName}" is blocked in code steps`);
|
|
949
|
-
}
|
|
950
|
-
if (!ALLOWED_MODULES[clean]) {
|
|
951
|
-
throw new Error(`Module "${moduleName}" not available. Allowed: ${Object.keys(ALLOWED_MODULES).join(", ")}`);
|
|
952
|
-
}
|
|
953
|
-
if (!cache[clean]) cache[clean] = await ALLOWED_MODULES[clean]();
|
|
954
|
-
return cache[clean];
|
|
955
|
-
};
|
|
1021
|
+
var _coveredTypes = Object.fromEntries(
|
|
1022
|
+
FLOW_SCHEMA.stepTypes.map((st) => [st.type, true])
|
|
1023
|
+
);
|
|
1024
|
+
var _stepTypeMap = new Map(
|
|
1025
|
+
FLOW_SCHEMA.stepTypes.map((st) => [st.type, st])
|
|
1026
|
+
);
|
|
1027
|
+
function getStepTypeDescriptor(type) {
|
|
1028
|
+
return _stepTypeMap.get(type);
|
|
956
1029
|
}
|
|
957
|
-
function
|
|
958
|
-
|
|
959
|
-
const match = trimmed.match(/^```(?:\w*)\s*\n([\s\S]*?)\n\s*```\s*$/);
|
|
960
|
-
return match ? match[1].trim() : trimmed;
|
|
1030
|
+
function getValidStepTypes() {
|
|
1031
|
+
return FLOW_SCHEMA.stepTypes.map((st) => st.type);
|
|
961
1032
|
}
|
|
962
|
-
|
|
963
|
-
const
|
|
964
|
-
const
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
throw new Error(
|
|
970
|
-
`Action step "${step.id}" has both "connectionKey" and "connection" \u2014 set exactly one. Prefer "connection: { platform, tag? }" so re-auth doesn't break the flow.`
|
|
971
|
-
);
|
|
972
|
-
}
|
|
973
|
-
if (!hasKey && !hasRef) {
|
|
974
|
-
throw new Error(
|
|
975
|
-
`Action step "${step.id}" must set "connection: { platform: <name> }" (or legacy "connectionKey: <key>").`
|
|
976
|
-
);
|
|
977
|
-
}
|
|
978
|
-
let connectionKey;
|
|
979
|
-
if (hasKey) {
|
|
980
|
-
connectionKey = resolveValue(action.connectionKey, context);
|
|
981
|
-
} else {
|
|
982
|
-
const ref = resolveValue(action.connection, context);
|
|
983
|
-
if (!context._connections) {
|
|
984
|
-
context._connections = await api.listConnections();
|
|
1033
|
+
function getNestedStepsKeys() {
|
|
1034
|
+
const result = [];
|
|
1035
|
+
for (const st of FLOW_SCHEMA.stepTypes) {
|
|
1036
|
+
for (const [fieldName, fd] of Object.entries(st.fields)) {
|
|
1037
|
+
if (fd.stepsArray) {
|
|
1038
|
+
result.push({ configKey: st.configKey, fieldName });
|
|
1039
|
+
}
|
|
985
1040
|
}
|
|
986
|
-
const conn = await api.resolveConnection(ref, context._connections);
|
|
987
|
-
connectionKey = conn.key;
|
|
988
|
-
}
|
|
989
|
-
const data = action.data ? resolveValue(action.data, context) : void 0;
|
|
990
|
-
const pathVars = action.pathVars ? resolveValue(action.pathVars, context) : void 0;
|
|
991
|
-
const queryParams = action.queryParams ? resolveValue(action.queryParams, context) : void 0;
|
|
992
|
-
const headers = action.headers ? resolveValue(action.headers, context) : void 0;
|
|
993
|
-
if (!isActionAllowed(actionId, allowedActionIds)) {
|
|
994
|
-
throw new Error(`Action "${actionId}" is not in the allowed action list`);
|
|
995
1041
|
}
|
|
996
|
-
|
|
997
|
-
if (!isMethodAllowed(actionDetails.method, permissions)) {
|
|
998
|
-
throw new Error(`Method "${actionDetails.method}" is not allowed under "${permissions}" permission level`);
|
|
999
|
-
}
|
|
1000
|
-
if (!options.skipValidation) {
|
|
1001
|
-
const validation = validateActionInput(actionDetails, { data, pathVariables: pathVars, queryParams });
|
|
1002
|
-
if (!validation.valid) {
|
|
1003
|
-
const details = validation.missing.map((m) => `${m.flag} is missing "${m.param}"`).join("; ");
|
|
1004
|
-
throw new Error(`Validation failed for step "${step.id}": ${details}. Pass --skip-validation to bypass.`);
|
|
1005
|
-
}
|
|
1006
|
-
}
|
|
1007
|
-
const result = await api.executePassthroughRequest({
|
|
1008
|
-
platform,
|
|
1009
|
-
actionId,
|
|
1010
|
-
connectionKey,
|
|
1011
|
-
data,
|
|
1012
|
-
pathVariables: pathVars,
|
|
1013
|
-
queryParams,
|
|
1014
|
-
headers
|
|
1015
|
-
}, actionDetails);
|
|
1016
|
-
return {
|
|
1017
|
-
status: "success",
|
|
1018
|
-
response: result.responseData,
|
|
1019
|
-
output: result.responseData
|
|
1020
|
-
};
|
|
1042
|
+
return result;
|
|
1021
1043
|
}
|
|
1022
|
-
function
|
|
1023
|
-
const
|
|
1024
|
-
|
|
1044
|
+
function generateFlowGuide() {
|
|
1045
|
+
const validTypes = getValidStepTypes();
|
|
1046
|
+
const sections = [];
|
|
1047
|
+
sections.push(`# One Flows \u2014 Reference
|
|
1048
|
+
|
|
1049
|
+
## Overview
|
|
1050
|
+
|
|
1051
|
+
Workflows live in \`.one/flows/\` (relative to your current working directory \u2014 the CLI does NOT walk up parent directories or fall back to a global location) and chain actions across platforms. Two layouts are supported:
|
|
1052
|
+
|
|
1053
|
+
- **Folder layout (REQUIRED for new flows)** \u2014 \`.one/flows/<key>/flow.json\`, with an optional \`lib/\` subfolder for JavaScript modules. This is like a skill: the folder groups the JSON spec with any JavaScript modules it needs, so the whole flow is shareable. **Always create new flows in this layout.**
|
|
1054
|
+
- **Single-file layout (DEPRECATED)** \u2014 \`.one/flows/<key>.flow.json\`. Still loads and runs for backward compatibility, but is deprecated. Do not create new flows in this layout. When editing an existing single-file flow, migrate it to the folder layout: move \`<key>.flow.json\` to \`<key>/flow.json\` and extract any non-trivial \`code.source\` blocks into \`<key>/lib/*.mjs\` modules.
|
|
1055
|
+
|
|
1056
|
+
**Subdirectory groups** \u2014 Flows can be organized into subdirectories: \`.one/flows/<group>/<key>/flow.json\`. For example:
|
|
1057
|
+
\`\`\`
|
|
1058
|
+
.one/flows/
|
|
1059
|
+
research/
|
|
1060
|
+
company-research/flow.json
|
|
1061
|
+
competitor-research/flow.json
|
|
1062
|
+
deal-ops/
|
|
1063
|
+
deal-log/flow.json
|
|
1064
|
+
\`\`\`
|
|
1065
|
+
Reference grouped flows with \`group/key\` (e.g. \`one flow execute research/company-research\`) or just the bare key if it's unique (e.g. \`one flow execute company-research\`). Create grouped flows with \`one flow create research/company-research --definition ...\`. \`flow list\` shows the group prefix.
|
|
1066
|
+
|
|
1067
|
+
When resolving a flow by key, the CLI checks the folder layout first, then the deprecated legacy file, then scans group subdirectories. The \`loadFlow\` helper in agent integrations behaves the same.
|
|
1068
|
+
|
|
1069
|
+
## Before you execute a flow you did NOT author \u2014 READ THIS
|
|
1070
|
+
|
|
1071
|
+
**Agents: always inspect a flow before running it.** Nothing about a flow's runtime requirements is guessable from its name. Before \`flow execute\`, do one of these:
|
|
1072
|
+
|
|
1073
|
+
1. Run \`one --agent flow list\` \u2014 the JSON output includes \`requiresBash\`, \`usesCodeModules\`, \`inputs\` (with \`autoResolvable\` flags), \`stepTypes\`, and the flow's \`description\`. This is the fastest path.
|
|
1074
|
+
2. Read the flow's \`description\` field directly from the JSON. Flow authors are required (see "Author conventions" below) to state any \`--allow-bash\` requirement and any non-auto-resolving inputs in the description.
|
|
1075
|
+
3. Run \`one --agent flow execute <key> --dry-run\` to see the resolved inputs and step plan without side effects.
|
|
1076
|
+
|
|
1077
|
+
If you skip this step you will hit errors like *"Workflow X contains bash steps. Re-run with --allow-bash."* \u2014 the CLI now pre-flights and fails fast, so you won't waste a long run, but the error is still avoidable by reading first.
|
|
1078
|
+
|
|
1079
|
+
## Author conventions \u2014 WRITE flows that are safe to execute blind
|
|
1080
|
+
|
|
1081
|
+
When you create a flow, its \`description\` field is the contract with future executors (human or agent). It MUST state:
|
|
1082
|
+
|
|
1083
|
+
- **\`--allow-bash\` if any step is type \`bash\`.** Example: *"Fetches recent Gmail threads and summarizes them with Claude Haiku. Requires \`--allow-bash\`."*
|
|
1084
|
+
- **Every input that does NOT have a \`connection\` hint.** Connection inputs auto-resolve when exactly one matching connection exists; everything else must be passed via \`-i name=value\` and the description must name it.
|
|
1085
|
+
- **Any files/directories the flow writes to** so operators know what will be modified on disk.
|
|
1086
|
+
|
|
1087
|
+
A good description is one paragraph. If a flow's description doesn't tell you how to run it, treat that as a bug in the flow and fix it.
|
|
1088
|
+
|
|
1089
|
+
## Commands
|
|
1090
|
+
|
|
1091
|
+
\`\`\`bash
|
|
1092
|
+
one --agent flow create <key> --definition '<json>' # Create (or --definition @file.json)
|
|
1093
|
+
one --agent flow create <key> --definition @flow.json # Create from file
|
|
1094
|
+
one --agent flow create <group/key> --definition '<json>' # Create in a subdirectory group
|
|
1095
|
+
one --agent flow list # List (shows group prefixes)
|
|
1096
|
+
one --agent flow validate <key> # Validate
|
|
1097
|
+
one --agent flow execute <key> -i name=value # Execute (bare key)
|
|
1098
|
+
one --agent flow execute <group/key> -i name=value # Execute (namespaced key)
|
|
1099
|
+
one --agent flow execute <key> --dry-run --mock # Test with mock data
|
|
1100
|
+
one --agent flow execute <key> --allow-bash # Enable bash steps
|
|
1101
|
+
one --agent flow runs [flowKey] # List past runs
|
|
1102
|
+
one --agent flow resume <runId> # Resume failed run
|
|
1103
|
+
one --agent flow scaffold [template] # Generate a starter template
|
|
1104
|
+
\`\`\`
|
|
1105
|
+
|
|
1106
|
+
You can also write the JSON file directly to \`.one/flows/<key>/flow.json\` (or \`.one/flows/<group>/<key>/flow.json\` for grouped flows) \u2014 often easier than passing large JSON via --definition. (The legacy \`.one/flows/<key>.flow.json\` single-file location is deprecated; don't use it for new flows.)
|
|
1107
|
+
|
|
1108
|
+
## Code modules (flow \`lib/\` folder)
|
|
1109
|
+
|
|
1110
|
+
A \`code\` step can either inline JS (\`code.source\`) or reference an external \`.mjs\` module (\`code.module\`). Modules live under the flow's \`lib/\` folder and run as a child \`node\` process:
|
|
1111
|
+
|
|
1112
|
+
\`\`\`
|
|
1113
|
+
.one/flows/my-flow/
|
|
1114
|
+
\u251C\u2500\u2500 flow.json
|
|
1115
|
+
\u2514\u2500\u2500 lib/
|
|
1116
|
+
\u2514\u2500\u2500 process-data.mjs
|
|
1117
|
+
\`\`\`
|
|
1118
|
+
|
|
1119
|
+
**Module contract:** the flow context \`$\` is piped to stdin as JSON; the module writes its result to stdout as JSON. That's the whole interface \u2014 no framework imports, no magic.
|
|
1120
|
+
|
|
1121
|
+
\`\`\`js
|
|
1122
|
+
// lib/process-data.mjs
|
|
1123
|
+
const $ = JSON.parse(await new Response(process.stdin).text());
|
|
1124
|
+
const items = $.steps.fetch.response.data ?? [];
|
|
1125
|
+
process.stdout.write(JSON.stringify(items.filter(i => i.active)));
|
|
1126
|
+
\`\`\`
|
|
1127
|
+
|
|
1128
|
+
\`\`\`json
|
|
1129
|
+
{
|
|
1130
|
+
"id": "processData",
|
|
1131
|
+
"name": "Process and enrich data",
|
|
1132
|
+
"type": "code",
|
|
1133
|
+
"code": { "module": "lib/process-data.mjs" }
|
|
1025
1134
|
}
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1135
|
+
\`\`\`
|
|
1136
|
+
|
|
1137
|
+
Modules are full Node processes \u2014 \`fs\`, \`https\`, any npm package installed in the host project, etc. are all available. Use this for anything non-trivial; keep \`code.source\` for one-liners.
|
|
1138
|
+
|
|
1139
|
+
**Step output shape:** whatever JSON a module writes to stdout becomes both \`$.steps.<id>.output\` and \`$.steps.<id>.response\` (aliases). Downstream steps can reference either; convention is to use \`.output\` for code/transform step results and \`.response\` for action step API payloads.
|
|
1140
|
+
|
|
1141
|
+
## Migrating a legacy single-file flow to the folder layout
|
|
1142
|
+
|
|
1143
|
+
If you're editing an existing \`.one/flows/<key>.flow.json\`, migrate it \u2014 it takes a minute and the result is cleaner. Checklist:
|
|
1144
|
+
|
|
1145
|
+
1. \`mkdir -p .one/flows/<key>/lib\`
|
|
1146
|
+
2. Move the file: \`mv .one/flows/<key>.flow.json .one/flows/<key>/flow.json\`
|
|
1147
|
+
3. For each non-trivial \`code\` step with inline \`source\`, extract it into \`lib/<step-id>.mjs\` (see translation pattern below) and swap the step config from \`{ "source": "..." }\` to \`{ "module": "lib/<step-id>.mjs" }\`. One-liners can stay inline.
|
|
1148
|
+
4. Validate: \`one --agent flow validate <key>\`.
|
|
1149
|
+
5. Run it and confirm behavior is unchanged.
|
|
1150
|
+
|
|
1151
|
+
**Inline source \u2192 module translation pattern.** Inline \`code.source\` is an async function body where \`$\` is already in scope and you \`return\` the result. A module is a standalone script where you read \`$\` from stdin and write the result to stdout as JSON. The transform is mechanical:
|
|
1152
|
+
|
|
1153
|
+
Before (inline \`code.source\`):
|
|
1154
|
+
\`\`\`js
|
|
1155
|
+
const items = $.steps.fetch.response.data;
|
|
1156
|
+
const active = items.filter(i => i.active);
|
|
1157
|
+
return { active, count: active.length };
|
|
1158
|
+
\`\`\`
|
|
1159
|
+
|
|
1160
|
+
After (\`lib/<step-id>.mjs\`):
|
|
1161
|
+
\`\`\`js
|
|
1162
|
+
const $ = JSON.parse(await new Response(process.stdin).text());
|
|
1163
|
+
const items = $.steps.fetch.response.data;
|
|
1164
|
+
const active = items.filter(i => i.active);
|
|
1165
|
+
process.stdout.write(JSON.stringify({ active, count: active.length }));
|
|
1166
|
+
\`\`\`
|
|
1167
|
+
|
|
1168
|
+
The only differences: (1) prepend the stdin-read line, (2) replace \`return X\` with \`process.stdout.write(JSON.stringify(X))\`. That's it.
|
|
1169
|
+
|
|
1170
|
+
## Building a Workflow
|
|
1171
|
+
|
|
1172
|
+
1. **Design first** \u2014 clarify the end goal, map the full value chain, identify where AI analysis is needed
|
|
1173
|
+
2. **Discover connections** \u2014 \`one --agent connection list\`
|
|
1174
|
+
3. **Get knowledge** for every action \u2014 \`one --agent actions knowledge <platform> <actionId>\`
|
|
1175
|
+
4. **Construct JSON** \u2014 declare inputs, wire steps with selectors
|
|
1176
|
+
5. **Validate** \u2014 \`one --agent flow validate <key>\`
|
|
1177
|
+
6. **Execute** \u2014 \`one --agent flow execute <key> -i param=value\``);
|
|
1178
|
+
sections.push(`## Flow JSON Schema
|
|
1179
|
+
|
|
1180
|
+
\`\`\`json
|
|
1181
|
+
{
|
|
1182
|
+
"key": "my-workflow",
|
|
1183
|
+
"name": "My Workflow",
|
|
1184
|
+
"description": "What this flow does",
|
|
1185
|
+
"version": "1",
|
|
1186
|
+
"inputs": {
|
|
1187
|
+
"param": {
|
|
1188
|
+
"type": "string",
|
|
1189
|
+
"required": true,
|
|
1190
|
+
"description": "A user parameter"
|
|
1191
|
+
}
|
|
1192
|
+
},
|
|
1193
|
+
"steps": [
|
|
1194
|
+
{
|
|
1195
|
+
"id": "stepId",
|
|
1196
|
+
"name": "Human-readable step name",
|
|
1197
|
+
"type": "action",
|
|
1198
|
+
"action": {
|
|
1199
|
+
"platform": "stripe",
|
|
1200
|
+
"actionId": "conn_mod_def::xxx::yyy",
|
|
1201
|
+
"connection": { "platform": "stripe" },
|
|
1202
|
+
"data": { "query": "{{$.input.param}}" }
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
]
|
|
1206
|
+
}
|
|
1207
|
+
\`\`\`
|
|
1208
|
+
|
|
1209
|
+
### Top-level fields
|
|
1210
|
+
|
|
1211
|
+
| Field | Type | Required | Description |
|
|
1212
|
+
|-------|------|----------|-------------|`);
|
|
1213
|
+
for (const [name, fd] of Object.entries(FLOW_SCHEMA.flowFields)) {
|
|
1214
|
+
sections.push(`| \`${name}\` | ${fd.type} | ${fd.required ? "yes" : "no"} | ${fd.description} |`);
|
|
1031
1215
|
}
|
|
1032
|
-
|
|
1033
|
-
|
|
1216
|
+
sections.push(`
|
|
1217
|
+
### Input declarations
|
|
1218
|
+
|
|
1219
|
+
| Field | Type | Required | Description |
|
|
1220
|
+
|-------|------|----------|-------------|`);
|
|
1221
|
+
for (const [name, fd] of Object.entries(FLOW_SCHEMA.inputFields)) {
|
|
1222
|
+
sections.push(`| \`${name}\` | ${fd.type} | ${fd.required ? "yes" : "no"} | ${fd.description} |`);
|
|
1034
1223
|
}
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
return { status: "success", output, response: output };
|
|
1045
|
-
} catch (err) {
|
|
1046
|
-
throw rewriteCodeStepError(err, step.id, config.source, sourceURL);
|
|
1224
|
+
sections.push(`
|
|
1225
|
+
### Step fields (all steps)
|
|
1226
|
+
|
|
1227
|
+
Every step MUST have \`id\`, \`name\`, and \`type\`. The \`type\` determines which config object is required.
|
|
1228
|
+
|
|
1229
|
+
| Field | Type | Required | Description |
|
|
1230
|
+
|-------|------|----------|-------------|`);
|
|
1231
|
+
for (const [name, fd] of Object.entries(FLOW_SCHEMA.stepCommonFields)) {
|
|
1232
|
+
sections.push(`| \`${name}\` | ${fd.type} | ${fd.required ? "yes" : "no"} | ${fd.description} |`);
|
|
1047
1233
|
}
|
|
1048
|
-
}
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1234
|
+
sections.push(`| \`onError\` | object | no | Error handling: \`{ "strategy": "${FLOW_SCHEMA.errorStrategies.join(" | ")}", "retries": 3, "retryDelayMs": 1000, "backoff": "fixed \\| exponential \\| exponential-jitter", "maxDelayMs": 30000, "retryOn": [429, 502, "ETIMEDOUT"], "failFastOn": [401, 403, 404] }\`. \`retryOn\`/\`failFastOn\` (cli#53) make retries conditional on the error code/status \u2014 \`failFastOn\` matches skip the retry entirely. |`);
|
|
1235
|
+
sections.push(`
|
|
1236
|
+
## Step Types
|
|
1237
|
+
|
|
1238
|
+
**IMPORTANT:** Each step type requires a config object nested under a specific key. The type name and config key differ for some types (noted below).
|
|
1239
|
+
|
|
1240
|
+
| Type | Config Key | Description |
|
|
1241
|
+
|------|-----------|-------------|`);
|
|
1242
|
+
for (const st of FLOW_SCHEMA.stepTypes) {
|
|
1243
|
+
const keyNote = st.type !== st.configKey ? ` \u26A0\uFE0F` : "";
|
|
1244
|
+
sections.push(`| \`${st.type}\` | \`${st.configKey}\`${keyNote} | ${st.description} |`);
|
|
1059
1245
|
}
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
const
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
if (path2.isAbsolute(modulePath)) {
|
|
1080
|
-
throw new Error(`Code module path must be relative to the flow root, got absolute: "${modulePath}"`);
|
|
1081
|
-
}
|
|
1082
|
-
const absPath = path2.resolve(rootDir, modulePath);
|
|
1083
|
-
const relFromRoot = path2.relative(rootDir, absPath);
|
|
1084
|
-
if (relFromRoot.startsWith("..") || path2.isAbsolute(relFromRoot)) {
|
|
1085
|
-
throw new Error(`Code module "${modulePath}" resolves outside the flow directory`);
|
|
1246
|
+
sections.push(`
|
|
1247
|
+
## Step Type Reference`);
|
|
1248
|
+
for (const st of FLOW_SCHEMA.stepTypes) {
|
|
1249
|
+
sections.push(`
|
|
1250
|
+
### \`${st.type}\` \u2014 ${st.description}`);
|
|
1251
|
+
if (st.type !== st.configKey) {
|
|
1252
|
+
sections.push(`
|
|
1253
|
+
> **Note:** Type is \`"${st.type}"\` but config key is \`"${st.configKey}"\` (camelCase).`);
|
|
1254
|
+
}
|
|
1255
|
+
sections.push(`
|
|
1256
|
+
| Field | Type | Required | Description |
|
|
1257
|
+
|-------|------|----------|-------------|`);
|
|
1258
|
+
for (const [name, fd] of Object.entries(st.fields)) {
|
|
1259
|
+
sections.push(`| \`${name}\` | ${fd.type} | ${fd.required ? "yes" : "no"} | ${fd.description} |`);
|
|
1260
|
+
}
|
|
1261
|
+
sections.push(`
|
|
1262
|
+
\`\`\`json
|
|
1263
|
+
${JSON.stringify(st.example, null, 2)}
|
|
1264
|
+
\`\`\``);
|
|
1086
1265
|
}
|
|
1087
|
-
|
|
1088
|
-
|
|
1266
|
+
sections.push(`
|
|
1267
|
+
## Selectors
|
|
1268
|
+
|
|
1269
|
+
| Pattern | Resolves To |
|
|
1270
|
+
|---------|-------------|
|
|
1271
|
+
| \`$.input.paramName\` | Input value |
|
|
1272
|
+
| \`$.steps.stepId.response\` | Full API response |
|
|
1273
|
+
| \`$.steps.stepId.response.data[0].email\` | Nested field |
|
|
1274
|
+
| \`$.steps.stepId.response.data[*].id\` | Wildcard array map |
|
|
1275
|
+
| \`$.env.MY_VAR\` | Environment variable |
|
|
1276
|
+
| \`$.loop.item\` / \`$.loop.i\` | Loop iteration |
|
|
1277
|
+
| \`"Hello {{$.steps.getUser.response.name}}"\` | String interpolation |
|
|
1278
|
+
|
|
1279
|
+
### Context-aware escape pipes (cli#53)
|
|
1280
|
+
|
|
1281
|
+
Handlebars interpolations support pipe-based escaping for safe embedding into shell commands, JSON, URLs, markdown, or HTML:
|
|
1282
|
+
|
|
1283
|
+
| Pipe | Effect |
|
|
1284
|
+
|------|--------|
|
|
1285
|
+
| \`{{ $.x \\| json }}\` | \`JSON.stringify\` (handles quotes, newlines, unicode) |
|
|
1286
|
+
| \`{{ $.x \\| shell }}\` | POSIX-shell-quote \u2014 safe inside bash arguments |
|
|
1287
|
+
| \`{{ $.x \\| url }}\` | \`encodeURIComponent\` |
|
|
1288
|
+
| \`{{ $.x \\| md }}\` | Escape markdown structural characters |
|
|
1289
|
+
| \`{{ $.x \\| html }}\` | Entity-escape \`& < > " '\` |
|
|
1290
|
+
|
|
1291
|
+
Pipes can be applied to any value (objects/arrays are JSON-stringified first for shell/url/md/html). An unknown pipe name throws at runtime. The legacy \`{{q $.x}}\` shell-quote helper still works but new flows should prefer \`{{$.x | shell}}\`.
|
|
1292
|
+
|
|
1293
|
+
### When to use bare selectors vs \`{{...}}\` interpolation
|
|
1294
|
+
|
|
1295
|
+
- **Bare selectors** (\`$.input.x\`): Use for fields the engine resolves directly \u2014 \`connectionKey\`, the \`tag\` (or \`platform\`) inside \`connection\`, \`over\`, \`path\`, \`expression\`, \`condition\`, and any field where the entire value is a single selector. The resolved value keeps its original type (object, array, number).
|
|
1296
|
+
- **Interpolation** (\`{{$.input.x}}\`): Use inside string values where the selector is embedded in text \u2014 e.g., \`"Hello {{$.steps.getUser.response.name}}"\`. The resolved value is always stringified. Use this in \`data\`, \`pathVars\`, and \`queryParams\` when mixing selectors with literal text.
|
|
1297
|
+
- **Rule of thumb**: If the value is purely a selector, use bare. If it's a string containing a selector, use \`{{...}}\`.
|
|
1298
|
+
|
|
1299
|
+
### Selectors vs expressions
|
|
1300
|
+
|
|
1301
|
+
Selectors in data fields (\`data\`, \`queryParams\`, \`pathVars\`, \`connectionKey\`, \`connection.tag\`) are **dot-path lookups only** \u2014 they do not support JavaScript operators like \`||\` or \`&&\`. For default values, use the \`default\` field on the input definition:
|
|
1302
|
+
|
|
1303
|
+
\`\`\`json
|
|
1304
|
+
{ "inputs": { "maxResults": { "type": "number", "default": 10 } } }
|
|
1305
|
+
\`\`\`
|
|
1306
|
+
|
|
1307
|
+
The \`if\`, \`unless\`, \`condition.expression\`, \`while.condition\`, \`transform.expression\`, and \`code.source\` fields **do** support full JavaScript expressions (e.g., \`$.input.email && $.input.email.length > 0\`).
|
|
1308
|
+
|
|
1309
|
+
### \`output\` vs \`response\` on step results
|
|
1310
|
+
|
|
1311
|
+
Every completed step produces both \`output\` and \`response\`:
|
|
1312
|
+
- **Action steps**: \`response\` is the raw API response. \`output\` is the same as \`response\`.
|
|
1313
|
+
- **Code/transform steps**: \`output\` is the return value. \`response\` is an alias for \`output\`.
|
|
1314
|
+
- **In practice**: Use \`$.steps.stepId.response\` for action steps (API data) and \`$.steps.stepId.output\` for code/transform steps (computed data). Both work interchangeably, but using the semantically correct one makes flows easier to read.
|
|
1315
|
+
|
|
1316
|
+
### Step result metadata (\`status\`, \`error\`, \`errorCode\`)
|
|
1317
|
+
|
|
1318
|
+
Every step result also exposes execution metadata that downstream steps can inspect:
|
|
1319
|
+
|
|
1320
|
+
| Field | Values | When set |
|
|
1321
|
+
|-------|--------|----------|
|
|
1322
|
+
| \`$.steps.X.status\` | \`"success"\` \\| \`"skipped"\` \\| \`"failed"\` \\| \`"timeout"\` | Always |
|
|
1323
|
+
| \`$.steps.X.error\` | error message string | When status is \`failed\` or \`timeout\` |
|
|
1324
|
+
| \`$.steps.X.errorCode\` | machine-readable code (e.g. \`"TIMEOUT"\`) | When the error has a code |
|
|
1325
|
+
| \`$.steps.X.durationMs\` | number | Always |
|
|
1326
|
+
| \`$.steps.X.retries\` | number | When the step was retried |
|
|
1327
|
+
|
|
1328
|
+
This lets downstream steps distinguish \`skipped\` (\`if\` condition false) from \`failed\` (error, \`onError:continue\`) from \`timeout\` (exceeded \`timeoutMs\`) \u2014 e.g. \`"if": "$.steps.enrichment.status === 'timeout'"\` to retry with a longer window.
|
|
1329
|
+
|
|
1330
|
+
### Sub-flow output (flattened)
|
|
1331
|
+
|
|
1332
|
+
When a step has \`type: "flow"\`, the sub-flow's final step output is flattened onto the parent step's \`output\`:
|
|
1333
|
+
|
|
1334
|
+
\`\`\`jsonc
|
|
1335
|
+
// Sub-flow "sub-consts" has a final step "load" that returns { CHART_URL, API_KEY }
|
|
1336
|
+
|
|
1337
|
+
// Preferred (flattened):
|
|
1338
|
+
"{{$.steps.loadConfig.output.CHART_URL}}"
|
|
1339
|
+
|
|
1340
|
+
// Legacy nested path (still works for backward compatibility):
|
|
1341
|
+
"{{$.steps.loadConfig.output.load.output.CHART_URL}}"
|
|
1342
|
+
|
|
1343
|
+
// Escape hatch for programmatic access to the full sub-flow steps map:
|
|
1344
|
+
"{{$.steps.loadConfig.output._steps.load.output.CHART_URL}}"
|
|
1345
|
+
\`\`\`
|
|
1346
|
+
|
|
1347
|
+
If a sub-step id collides with a flattened field name, the flattened field wins and the engine emits a \`flow:warning\` event.
|
|
1348
|
+
|
|
1349
|
+
## Error Handling
|
|
1350
|
+
|
|
1351
|
+
\`\`\`json
|
|
1352
|
+
{"onError": {"strategy": "retry", "retries": 3, "retryDelayMs": 1000}}
|
|
1353
|
+
\`\`\`
|
|
1354
|
+
|
|
1355
|
+
Strategies: \`${FLOW_SCHEMA.errorStrategies.join("`, `")}\`
|
|
1356
|
+
|
|
1357
|
+
**Conditional retry (cli#53):** add \`retryOn\` and/or \`failFastOn\` to discriminate transient errors from permanent ones. \`failFastOn\` takes precedence; \`retryOn\` (when set) requires a match for the retry to happen. Numbers match against any 3-digit substring of the error message (HTTP statuses); strings match against \`error.errorCode\` exactly OR as a case-insensitive substring of the message.
|
|
1358
|
+
|
|
1359
|
+
\`\`\`json
|
|
1360
|
+
{
|
|
1361
|
+
"onError": {
|
|
1362
|
+
"strategy": "retry",
|
|
1363
|
+
"retries": 4,
|
|
1364
|
+
"backoff": "exponential",
|
|
1365
|
+
"retryOn": [429, 502, 503, "ETIMEDOUT", "ECONNRESET"],
|
|
1366
|
+
"failFastOn": [401, 403, 404]
|
|
1089
1367
|
}
|
|
1090
|
-
const { env: _omitEnv, ...safeContext } = context;
|
|
1091
|
-
void _omitEnv;
|
|
1092
|
-
const stdinPayload = JSON.stringify(safeContext);
|
|
1093
|
-
return await new Promise((resolve2, reject) => {
|
|
1094
|
-
const child = spawn(process.execPath, [absPath], {
|
|
1095
|
-
cwd: rootDir,
|
|
1096
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
1097
|
-
});
|
|
1098
|
-
const stdoutChunks = [];
|
|
1099
|
-
const stderrChunks = [];
|
|
1100
|
-
child.stdout.on("data", (c) => stdoutChunks.push(c));
|
|
1101
|
-
child.stderr.on("data", (c) => stderrChunks.push(c));
|
|
1102
|
-
child.on("error", (err) => reject(err));
|
|
1103
|
-
child.on("close", (code) => {
|
|
1104
|
-
const stdout = Buffer.concat(stdoutChunks).toString("utf-8");
|
|
1105
|
-
const stderr = Buffer.concat(stderrChunks).toString("utf-8");
|
|
1106
|
-
if (code !== 0) {
|
|
1107
|
-
reject(new Error(`Code module "${modulePath}" exited with code ${code}${stderr ? `: ${stderr.trim()}` : ""}`));
|
|
1108
|
-
return;
|
|
1109
|
-
}
|
|
1110
|
-
const trimmed = stdout.trim();
|
|
1111
|
-
if (trimmed === "") {
|
|
1112
|
-
resolve2(void 0);
|
|
1113
|
-
return;
|
|
1114
|
-
}
|
|
1115
|
-
try {
|
|
1116
|
-
resolve2(JSON.parse(stripCodeFences(trimmed)));
|
|
1117
|
-
} catch (err) {
|
|
1118
|
-
reject(new Error(`Code module "${modulePath}" did not print valid JSON to stdout: ${err.message}`));
|
|
1119
|
-
}
|
|
1120
|
-
});
|
|
1121
|
-
child.stdin.write(stdinPayload);
|
|
1122
|
-
child.stdin.end();
|
|
1123
|
-
});
|
|
1124
1368
|
}
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1369
|
+
\`\`\`
|
|
1370
|
+
|
|
1371
|
+
## Step Output Contracts (\`outputSchema\`, cli#59)
|
|
1372
|
+
|
|
1373
|
+
Declare a step's output shape so the validator catches downstream field-name typos at flow load time:
|
|
1374
|
+
|
|
1375
|
+
\`\`\`json
|
|
1376
|
+
{
|
|
1377
|
+
"id": "research",
|
|
1378
|
+
"type": "flow",
|
|
1379
|
+
"flow": { "key": "company-research" },
|
|
1380
|
+
"outputSchema": {
|
|
1381
|
+
"company": "string",
|
|
1382
|
+
"charCount": "number",
|
|
1383
|
+
"quality": { "confidence": "string", "score": "number" }
|
|
1384
|
+
}
|
|
1135
1385
|
}
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1386
|
+
\`\`\`
|
|
1387
|
+
|
|
1388
|
+
Field types: \`string\`, \`number\`, \`boolean\`, \`object\`, \`array\`, \`unknown\`. Nested objects describe sub-fields. Any \`$.steps.<id>.output.<field>\` reference from a downstream step is checked against the schema; unknown fields fail validation. The runtime engine does not enforce the schema \u2014 it's a documentation / wiring-bug aid.
|
|
1389
|
+
|
|
1390
|
+
## Bash structured env vars (cli#54)
|
|
1391
|
+
|
|
1392
|
+
A \`bash\` step's \`env\` map accepts two structured forms in addition to plain strings:
|
|
1393
|
+
|
|
1394
|
+
\`\`\`json
|
|
1395
|
+
{
|
|
1396
|
+
"type": "bash",
|
|
1397
|
+
"bash": {
|
|
1398
|
+
"env": {
|
|
1399
|
+
"PAYLOAD_FILE": { "json": "$.steps.buildConfig.output" },
|
|
1400
|
+
"COMPANY": { "shell": "$.input.companyName" }
|
|
1401
|
+
},
|
|
1402
|
+
"command": "curl -X POST $ENDPOINT -d @$PAYLOAD_FILE && echo \\"$COMPANY\\""
|
|
1141
1403
|
}
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1404
|
+
}
|
|
1405
|
+
\`\`\`
|
|
1406
|
+
|
|
1407
|
+
- \`{ "json": <selector|value> }\` \u2014 JSON-serialized to a temp file; the env var holds the temp file path. Auto-cleaned after the step runs (success or failure).
|
|
1408
|
+
- \`{ "shell": <selector|value> }\` \u2014 exposed as a plain string env var; reference inside bash double quotes (\`"$VAR"\`).
|
|
1409
|
+
- A plain string is the legacy form (interpolated as-is, caller is responsible for escaping).
|
|
1410
|
+
|
|
1411
|
+
## Dynamic sub-flow dispatch (cli#61)
|
|
1412
|
+
|
|
1413
|
+
A \`flow\` step's \`flow.key\` accepts selectors and Handlebars interpolations, so a single orchestrator can route to different sub-flows at runtime:
|
|
1414
|
+
|
|
1415
|
+
\`\`\`json
|
|
1416
|
+
{ "type": "flow", "flow": { "key": "{{$.input.target}}", "inputs": { "company": "$.input.company" } } }
|
|
1417
|
+
\`\`\`
|
|
1418
|
+
|
|
1419
|
+
Conditional execution: \`"if": "$.steps.prev.response.data.length > 0"\`
|
|
1420
|
+
|
|
1421
|
+
## Connection Resolution \u2014 late-bound by default
|
|
1422
|
+
|
|
1423
|
+
Action steps reference a platform connection in one of two forms:
|
|
1424
|
+
|
|
1425
|
+
\`\`\`json
|
|
1426
|
+
// preferred \u2014 late-bound, survives re-auth
|
|
1427
|
+
"connection": { "platform": "gmail" }
|
|
1428
|
+
|
|
1429
|
+
// multi-account: disambiguate with the connection's tag
|
|
1430
|
+
"connection": { "platform": "gmail", "tag": "work@example.com" }
|
|
1431
|
+
|
|
1432
|
+
// legacy \u2014 works for backwards compat, breaks on re-auth
|
|
1433
|
+
"connectionKey": "live::gmail::default::abc123..."
|
|
1434
|
+
\`\`\`
|
|
1435
|
+
|
|
1436
|
+
The engine resolves the \`connection\` ref once per flow run (cached for the run's lifetime) by calling \`listConnections\` and matching on platform + optional tag. Resolution errors fail the step with a clear message \u2014 \`No connection found for platform "X"\`, \`Multiple "X" connections found (tags: ...). Add a "tag" field\`, or \`No "X" connection has tag "Y"\`.
|
|
1437
|
+
|
|
1438
|
+
Both \`platform\` and \`tag\` accept \`$.input.x\` selectors so a flow can be parameterised per-execution (e.g. multi-tenant orchestrators that pass the user's email as the tag).
|
|
1439
|
+
|
|
1440
|
+
The validator rejects any action that sets both forms or neither, at \`flow validate\` and \`flow execute\` time.
|
|
1441
|
+
|
|
1442
|
+
### Optional input metadata: connection-key auto-resolve (legacy)
|
|
1443
|
+
|
|
1444
|
+
For flows that still use literal \`connectionKey\` strings via inputs, an input declaration can carry a \`"connection": { "platform": "..." }\` hint so \`flow execute\` auto-fills a single matching connection's key. New flows don't need this \u2014 switch the action's connection form to \`{ platform, tag? }\` and skip the input entirely.
|
|
1445
|
+
|
|
1446
|
+
## Complete Example: Fetch Data, Transform, Notify
|
|
1447
|
+
|
|
1448
|
+
\`\`\`json
|
|
1449
|
+
{
|
|
1450
|
+
"key": "contacts-to-slack",
|
|
1451
|
+
"name": "CRM Contacts Summary to Slack",
|
|
1452
|
+
"description": "Fetch recent contacts from CRM, build a summary, post to Slack",
|
|
1453
|
+
"version": "1",
|
|
1454
|
+
"inputs": {
|
|
1455
|
+
"slackChannel": {
|
|
1456
|
+
"type": "string",
|
|
1457
|
+
"required": true,
|
|
1458
|
+
"description": "Slack channel name or ID"
|
|
1196
1459
|
}
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1460
|
+
},
|
|
1461
|
+
"steps": [
|
|
1462
|
+
{
|
|
1463
|
+
"id": "fetchContacts",
|
|
1464
|
+
"name": "Fetch recent contacts",
|
|
1465
|
+
"type": "action",
|
|
1466
|
+
"action": {
|
|
1467
|
+
"platform": "attio",
|
|
1468
|
+
"actionId": "ATTIO_LIST_PEOPLE_ACTION_ID",
|
|
1469
|
+
"connection": { "platform": "attio" },
|
|
1470
|
+
"queryParams": { "limit": "10" }
|
|
1471
|
+
}
|
|
1472
|
+
},
|
|
1473
|
+
{
|
|
1474
|
+
"id": "buildSummary",
|
|
1475
|
+
"name": "Build formatted summary",
|
|
1476
|
+
"type": "code",
|
|
1477
|
+
"code": {
|
|
1478
|
+
"source": "const contacts = $.steps.fetchContacts.response.data || [];\\nconst lines = contacts.map((c, i) => \`\${i+1}. \${c.name || 'Unknown'} \u2014 \${c.email || 'no email'}\`);\\nreturn { summary: \`Found \${contacts.length} contacts:\\n\${lines.join('\\n')}\` };"
|
|
1479
|
+
}
|
|
1480
|
+
},
|
|
1481
|
+
{
|
|
1482
|
+
"id": "notifySlack",
|
|
1483
|
+
"name": "Post summary to Slack",
|
|
1484
|
+
"type": "action",
|
|
1485
|
+
"action": {
|
|
1486
|
+
"platform": "slack",
|
|
1487
|
+
"actionId": "SLACK_SEND_MESSAGE_ACTION_ID",
|
|
1488
|
+
"connection": { "platform": "slack" },
|
|
1489
|
+
"data": {
|
|
1490
|
+
"channel": "$.input.slackChannel",
|
|
1491
|
+
"text": "{{$.steps.buildSummary.output.summary}}"
|
|
1492
|
+
}
|
|
1203
1493
|
}
|
|
1204
1494
|
}
|
|
1205
|
-
|
|
1206
|
-
|
|
1495
|
+
]
|
|
1496
|
+
}
|
|
1497
|
+
\`\`\`
|
|
1498
|
+
|
|
1499
|
+
Note: Action IDs above are placeholders. Always use \`one --agent actions search <platform> "<query>"\` to find real IDs.
|
|
1500
|
+
|
|
1501
|
+
## AI-Augmented Pattern
|
|
1502
|
+
|
|
1503
|
+
For workflows that need analysis/summarization, use the file-write \u2192 bash \u2192 code pattern:
|
|
1504
|
+
|
|
1505
|
+
1. \`file-write\` \u2014 save data to temp file
|
|
1506
|
+
2. \`bash\` \u2014 \`claude --print\` analyzes it (\`parseJson: true\`, \`timeout: 180000\`)
|
|
1507
|
+
3. \`code\` \u2014 parse and structure the output
|
|
1508
|
+
|
|
1509
|
+
Set timeout to at least 180000ms (3 min). Run Claude-heavy flows sequentially, not in parallel.
|
|
1510
|
+
|
|
1511
|
+
## Notes
|
|
1512
|
+
|
|
1513
|
+
- Connection keys are **inputs**, not hardcoded
|
|
1514
|
+
- Action IDs in examples are placeholders \u2014 always use \`actions search\`
|
|
1515
|
+
- Inline \`code.source\` steps allow \`require('crypto' | 'buffer' | 'url' | 'path')\` \u2014 \`fs\`, \`http\`, \`child_process\` are blocked
|
|
1516
|
+
- For anything beyond one-liners, use \`code.module\` to point at a \`.mjs\` file in the flow's \`lib/\` folder \u2014 runs as a child \`node\` process with full Node APIs, reads \`$\` from stdin, writes JSON to stdout
|
|
1517
|
+
- Bash steps require \`--allow-bash\` flag
|
|
1518
|
+
- State is persisted after every step \u2014 resume picks up where it left off`);
|
|
1519
|
+
return sections.join("\n");
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
// src/lib/flow-engine.ts
|
|
1523
|
+
var execAsync = promisify(exec);
|
|
1524
|
+
function sleep2(ms) {
|
|
1525
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
1526
|
+
}
|
|
1527
|
+
var StepTimeoutError = class extends Error {
|
|
1528
|
+
errorCode = "TIMEOUT";
|
|
1529
|
+
constructor(stepId, timeoutMs) {
|
|
1530
|
+
super(`Step "${stepId}" exceeded timeout of ${timeoutMs}ms`);
|
|
1531
|
+
this.name = "StepTimeoutError";
|
|
1207
1532
|
}
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
};
|
|
1533
|
+
};
|
|
1534
|
+
function withTimeout(promise, timeoutMs, stepId) {
|
|
1535
|
+
let timer;
|
|
1536
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
1537
|
+
timer = setTimeout(() => reject(new StepTimeoutError(stepId, timeoutMs)), timeoutMs);
|
|
1538
|
+
});
|
|
1539
|
+
return Promise.race([promise, timeoutPromise]).finally(() => {
|
|
1540
|
+
if (timer) clearTimeout(timer);
|
|
1541
|
+
});
|
|
1214
1542
|
}
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1543
|
+
var FileReadSchemaError = class extends Error {
|
|
1544
|
+
errorCode = "SCHEMA_VALIDATION";
|
|
1545
|
+
constructor(stepId, violations) {
|
|
1546
|
+
super(`Step "${stepId}" output failed schema validation: ${violations.join("; ")}`);
|
|
1547
|
+
this.name = "FileReadSchemaError";
|
|
1548
|
+
}
|
|
1549
|
+
};
|
|
1550
|
+
function asFieldSchema(node) {
|
|
1551
|
+
return typeof node === "string" ? { type: node } : node;
|
|
1552
|
+
}
|
|
1553
|
+
function actualType(v) {
|
|
1554
|
+
if (v === null) return "null";
|
|
1555
|
+
if (Array.isArray(v)) return "array";
|
|
1556
|
+
return typeof v;
|
|
1557
|
+
}
|
|
1558
|
+
function matchesLeafType(v, t) {
|
|
1559
|
+
switch (t) {
|
|
1560
|
+
case "unknown":
|
|
1561
|
+
return true;
|
|
1562
|
+
case "null":
|
|
1563
|
+
return v === null;
|
|
1564
|
+
case "array":
|
|
1565
|
+
return Array.isArray(v);
|
|
1566
|
+
case "object":
|
|
1567
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
1568
|
+
default:
|
|
1569
|
+
return typeof v === t;
|
|
1229
1570
|
}
|
|
1230
|
-
return { status: "success", output: results, response: results };
|
|
1231
1571
|
}
|
|
1232
|
-
function
|
|
1233
|
-
|
|
1234
|
-
const filePath = resolveValue(config.path, context);
|
|
1235
|
-
const resolvedPath = path2.resolve(filePath);
|
|
1236
|
-
const content = fs2.readFileSync(resolvedPath, "utf-8");
|
|
1237
|
-
const output = config.parseJson ? JSON.parse(stripCodeFences(content)) : content;
|
|
1238
|
-
return { status: "success", output, response: output };
|
|
1572
|
+
function isPlainObjectValue(v) {
|
|
1573
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
1239
1574
|
}
|
|
1240
|
-
function
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
const dir = path2.dirname(resolvedPath);
|
|
1246
|
-
if (!fs2.existsSync(dir)) {
|
|
1247
|
-
fs2.mkdirSync(dir, { recursive: true });
|
|
1575
|
+
function collectFileReadViolations(value, schema, basePath, out) {
|
|
1576
|
+
if (!isPlainObjectValue(value)) {
|
|
1577
|
+
const where = basePath ? `at "${basePath.replace(/\.$/, "")}"` : "at the root";
|
|
1578
|
+
out.push(`expected an object ${where} but got ${actualType(value)}`);
|
|
1579
|
+
return;
|
|
1248
1580
|
}
|
|
1249
|
-
const
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1581
|
+
const obj = value;
|
|
1582
|
+
for (const [field, raw] of Object.entries(schema)) {
|
|
1583
|
+
const rule = asFieldSchema(raw);
|
|
1584
|
+
const path4 = `${basePath}${field}`;
|
|
1585
|
+
const present = Object.prototype.hasOwnProperty.call(obj, field) && obj[field] !== void 0;
|
|
1586
|
+
if (!present) {
|
|
1587
|
+
if (rule.required) out.push(`field "${path4}" is required but missing`);
|
|
1588
|
+
continue;
|
|
1589
|
+
}
|
|
1590
|
+
checkFileReadRule(obj[field], rule, path4, out);
|
|
1254
1591
|
}
|
|
1255
|
-
return { status: "success", output: { path: resolvedPath, bytesWritten: stringContent.length }, response: { path: resolvedPath } };
|
|
1256
1592
|
}
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
}
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
if (!
|
|
1593
|
+
function checkFileReadRule(value, rule, path4, out) {
|
|
1594
|
+
if (rule.type && !matchesLeafType(value, rule.type)) {
|
|
1595
|
+
out.push(`field "${path4}" expected ${rule.type} but got ${actualType(value)}`);
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
if (Array.isArray(rule.enum) && !rule.enum.some((e) => e === value)) {
|
|
1599
|
+
out.push(`field "${path4}" value ${JSON.stringify(value)} is not one of [${rule.enum.map((e) => JSON.stringify(e)).join(", ")}]`);
|
|
1600
|
+
}
|
|
1601
|
+
const hasArrayConstraint = rule.items !== void 0 || rule.minItems !== void 0 || rule.maxItems !== void 0 || rule.length !== void 0;
|
|
1602
|
+
if (hasArrayConstraint) {
|
|
1603
|
+
if (!Array.isArray(value)) {
|
|
1604
|
+
if (!rule.type) out.push(`field "${path4}" expected array but got ${actualType(value)}`);
|
|
1605
|
+
} else {
|
|
1606
|
+
if (rule.length !== void 0 && value.length !== rule.length) {
|
|
1607
|
+
out.push(`field "${path4}" expected array of length ${rule.length} but got length ${value.length}`);
|
|
1608
|
+
}
|
|
1609
|
+
if (rule.minItems !== void 0 && value.length < rule.minItems) {
|
|
1610
|
+
out.push(`field "${path4}" expected at least ${rule.minItems} item${rule.minItems === 1 ? "" : "s"} but got ${value.length}`);
|
|
1611
|
+
}
|
|
1612
|
+
if (rule.maxItems !== void 0 && value.length > rule.maxItems) {
|
|
1613
|
+
out.push(`field "${path4}" expected at most ${rule.maxItems} item${rule.maxItems === 1 ? "" : "s"} but got ${value.length}`);
|
|
1614
|
+
}
|
|
1615
|
+
if (rule.items !== void 0) {
|
|
1616
|
+
const itemRule = asFieldSchema(rule.items);
|
|
1617
|
+
value.forEach((el, i) => checkFileReadRule(el, itemRule, `${path4}[${i}]`, out));
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
if (rule.properties) {
|
|
1622
|
+
if (isPlainObjectValue(value)) {
|
|
1623
|
+
collectFileReadViolations(value, rule.properties, `${path4}.`, out);
|
|
1624
|
+
} else if (!rule.type) {
|
|
1625
|
+
out.push(`field "${path4}" expected object but got ${actualType(value)}`);
|
|
1269
1626
|
}
|
|
1270
|
-
await executeSteps(config.steps, context, api, permissions, allowedActionIds, options, void 0, flowStack);
|
|
1271
|
-
const lastStepId = config.steps[config.steps.length - 1]?.id;
|
|
1272
|
-
const lastResult = lastStepId ? context.steps[lastStepId]?.output : void 0;
|
|
1273
|
-
results.push(lastResult);
|
|
1274
|
-
context.steps[step.id] = {
|
|
1275
|
-
status: "success",
|
|
1276
|
-
output: { lastResult, iteration, results }
|
|
1277
|
-
};
|
|
1278
1627
|
}
|
|
1279
|
-
return {
|
|
1280
|
-
status: "success",
|
|
1281
|
-
output: { lastResult: results[results.length - 1], iteration: results.length, results },
|
|
1282
|
-
response: { iterations: results.length, results }
|
|
1283
|
-
};
|
|
1284
1628
|
}
|
|
1285
|
-
|
|
1286
|
-
const
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
}
|
|
1292
|
-
const
|
|
1293
|
-
const
|
|
1294
|
-
const
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
permissions,
|
|
1299
|
-
allowedActionIds,
|
|
1300
|
-
{ ...options, rootDir: subRootDir },
|
|
1301
|
-
void 0,
|
|
1302
|
-
[...flowStack, resolvedKey]
|
|
1303
|
-
);
|
|
1304
|
-
const finalStep = subFlow.steps[subFlow.steps.length - 1];
|
|
1305
|
-
const finalOutput = finalStep ? subContext.steps[finalStep.id]?.output : void 0;
|
|
1306
|
-
let flattenedOutput;
|
|
1307
|
-
if (finalOutput && typeof finalOutput === "object" && !Array.isArray(finalOutput)) {
|
|
1308
|
-
const collisions = Object.keys(finalOutput).filter(
|
|
1309
|
-
(k) => k in subContext.steps
|
|
1310
|
-
);
|
|
1311
|
-
if (collisions.length > 0) {
|
|
1312
|
-
options.onEvent?.({
|
|
1313
|
-
event: "flow:warning",
|
|
1314
|
-
message: `Sub-flow "${resolvedKey}" final step output fields [${collisions.join(", ")}] collide with sub-step ids \u2014 flattened fields take precedence.`
|
|
1315
|
-
});
|
|
1629
|
+
function assertMatchesFileReadSchema(value, schema, stepId) {
|
|
1630
|
+
const violations = [];
|
|
1631
|
+
collectFileReadViolations(value, schema, "", violations);
|
|
1632
|
+
if (violations.length > 0) throw new FileReadSchemaError(stepId, violations);
|
|
1633
|
+
}
|
|
1634
|
+
function shouldRetryError(err, onError) {
|
|
1635
|
+
if (!onError) return { retry: false };
|
|
1636
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1637
|
+
const errorCode = err?.errorCode;
|
|
1638
|
+
const matches = (entry) => {
|
|
1639
|
+
if (typeof entry === "number") {
|
|
1640
|
+
const re = new RegExp(`\\b${entry}\\b`);
|
|
1641
|
+
return re.test(message);
|
|
1316
1642
|
}
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
...finalOutput,
|
|
1320
|
-
_steps: subContext.steps
|
|
1321
|
-
};
|
|
1322
|
-
} else {
|
|
1323
|
-
flattenedOutput = {
|
|
1324
|
-
...subContext.steps,
|
|
1325
|
-
_steps: subContext.steps,
|
|
1326
|
-
...finalOutput !== void 0 ? { _finalOutput: finalOutput } : {}
|
|
1327
|
-
};
|
|
1328
|
-
}
|
|
1329
|
-
return {
|
|
1330
|
-
status: "success",
|
|
1331
|
-
output: flattenedOutput,
|
|
1332
|
-
response: flattenedOutput
|
|
1643
|
+
if (errorCode && entry === errorCode) return true;
|
|
1644
|
+
return message.toLowerCase().includes(entry.toLowerCase());
|
|
1333
1645
|
};
|
|
1646
|
+
if (Array.isArray(onError.failFastOn) && onError.failFastOn.some(matches)) {
|
|
1647
|
+
return { retry: false, reason: "failFastOn" };
|
|
1648
|
+
}
|
|
1649
|
+
if (Array.isArray(onError.retryOn)) {
|
|
1650
|
+
if (onError.retryOn.some(matches)) return { retry: true, reason: "retryOn" };
|
|
1651
|
+
return { retry: false, reason: "no-retryOn-match" };
|
|
1652
|
+
}
|
|
1653
|
+
return { retry: true };
|
|
1334
1654
|
}
|
|
1335
|
-
|
|
1336
|
-
const
|
|
1337
|
-
const
|
|
1338
|
-
const
|
|
1339
|
-
|
|
1340
|
-
let
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
if (
|
|
1344
|
-
|
|
1345
|
-
setByDotPath(resolved, config.inputTokenParam, pageToken);
|
|
1346
|
-
const syntheticStep = {
|
|
1347
|
-
id: `${step.id}__page${page}`,
|
|
1348
|
-
name: `${step.id} page ${page}`,
|
|
1349
|
-
type: "action",
|
|
1350
|
-
action: resolved
|
|
1351
|
-
};
|
|
1352
|
-
const result = await executeActionStep(syntheticStep, context, api, permissions, allowedActionIds, options);
|
|
1353
|
-
const response = result.response;
|
|
1354
|
-
const pageResults = getByDotPath(response, config.resultsField);
|
|
1355
|
-
if (Array.isArray(pageResults)) allResults.push(...pageResults);
|
|
1356
|
-
pageToken = getByDotPath(response, config.pageTokenField);
|
|
1357
|
-
pages++;
|
|
1358
|
-
options.onEvent?.({ event: "step:page", stepId: step.id, page: pages });
|
|
1359
|
-
if (pageToken === void 0 || pageToken === null) break;
|
|
1360
|
-
} else if (page === 0) {
|
|
1361
|
-
const resolvedAction = resolveValue(actionConfig, context);
|
|
1362
|
-
const syntheticStep = {
|
|
1363
|
-
id: `${step.id}__page0`,
|
|
1364
|
-
name: `${step.id} page 0`,
|
|
1365
|
-
type: "action",
|
|
1366
|
-
action: resolvedAction
|
|
1367
|
-
};
|
|
1368
|
-
const result = await executeActionStep(syntheticStep, context, api, permissions, allowedActionIds, options);
|
|
1369
|
-
const response = result.response;
|
|
1370
|
-
const pageResults = getByDotPath(response, config.resultsField);
|
|
1371
|
-
if (Array.isArray(pageResults)) allResults.push(...pageResults);
|
|
1372
|
-
pageToken = getByDotPath(response, config.pageTokenField);
|
|
1373
|
-
pages++;
|
|
1374
|
-
options.onEvent?.({ event: "step:page", stepId: step.id, page: pages });
|
|
1375
|
-
if (pageToken === void 0 || pageToken === null) break;
|
|
1655
|
+
function computeRetryDelay(onError, attempt) {
|
|
1656
|
+
const base = onError.retryDelayMs ?? 1e3;
|
|
1657
|
+
const max = onError.maxDelayMs ?? 3e4;
|
|
1658
|
+
const backoff = onError.backoff ?? "fixed";
|
|
1659
|
+
const retryIndex = attempt - 2;
|
|
1660
|
+
let delay;
|
|
1661
|
+
if (backoff === "exponential" || backoff === "exponential-jitter") {
|
|
1662
|
+
delay = Math.min(base * Math.pow(2, retryIndex), max);
|
|
1663
|
+
if (backoff === "exponential-jitter") {
|
|
1664
|
+
delay = delay * (0.5 + Math.random() * 0.5);
|
|
1376
1665
|
}
|
|
1666
|
+
} else {
|
|
1667
|
+
delay = base;
|
|
1377
1668
|
}
|
|
1378
|
-
return
|
|
1379
|
-
status: "success",
|
|
1380
|
-
output: allResults,
|
|
1381
|
-
response: { pages, totalResults: allResults.length, results: allResults }
|
|
1382
|
-
};
|
|
1669
|
+
return Math.round(delay);
|
|
1383
1670
|
}
|
|
1384
|
-
function
|
|
1385
|
-
|
|
1386
|
-
const
|
|
1387
|
-
|
|
1388
|
-
for (const
|
|
1389
|
-
if (
|
|
1390
|
-
if (
|
|
1391
|
-
const obj = raw;
|
|
1392
|
-
if ("json" in obj) {
|
|
1393
|
-
const resolved2 = resolveValue(obj.json, context);
|
|
1394
|
-
const json = JSON.stringify(resolved2 ?? null);
|
|
1395
|
-
const tmp = path2.join(
|
|
1396
|
-
os2.tmpdir(),
|
|
1397
|
-
`one-flow-${stepId}-${key}-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json`
|
|
1398
|
-
);
|
|
1399
|
-
fs2.writeFileSync(tmp, json, { encoding: "utf-8" });
|
|
1400
|
-
tempFiles.push(tmp);
|
|
1401
|
-
out[key] = tmp;
|
|
1402
|
-
continue;
|
|
1403
|
-
}
|
|
1404
|
-
if ("shell" in obj) {
|
|
1405
|
-
const resolved2 = resolveValue(obj.shell, context);
|
|
1406
|
-
out[key] = resolved2 === void 0 || resolved2 === null ? "" : typeof resolved2 === "object" ? JSON.stringify(resolved2) : String(resolved2);
|
|
1407
|
-
continue;
|
|
1408
|
-
}
|
|
1409
|
-
out[key] = JSON.stringify(resolveValue(raw, context));
|
|
1671
|
+
function resolveSelector(selectorPath, context) {
|
|
1672
|
+
if (!selectorPath.startsWith("$.")) return selectorPath;
|
|
1673
|
+
const parts = selectorPath.slice(2).split(/\.|\[/).map((p) => p.replace(/\]$/, ""));
|
|
1674
|
+
let current = context;
|
|
1675
|
+
for (const part of parts) {
|
|
1676
|
+
if (current === null || current === void 0) return void 0;
|
|
1677
|
+
if (part === "*" && Array.isArray(current)) {
|
|
1410
1678
|
continue;
|
|
1411
1679
|
}
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
const config = step.bash;
|
|
1422
|
-
const command = resolveValue(config.command, context);
|
|
1423
|
-
const cwd = config.cwd ? resolveValue(config.cwd, context) : process.cwd();
|
|
1424
|
-
const { env: resolvedEnv, tempFiles } = resolveBashEnv(
|
|
1425
|
-
config.env,
|
|
1426
|
-
context,
|
|
1427
|
-
step.id
|
|
1428
|
-
);
|
|
1429
|
-
const env = config.env ? { ...process.env, ...resolvedEnv } : process.env;
|
|
1430
|
-
try {
|
|
1431
|
-
const { stdout, stderr } = await execAsync(command, {
|
|
1432
|
-
timeout: config.timeout || 3e4,
|
|
1433
|
-
cwd,
|
|
1434
|
-
env,
|
|
1435
|
-
maxBuffer: 10 * 1024 * 1024
|
|
1436
|
-
});
|
|
1437
|
-
const output = config.parseJson ? JSON.parse(stripCodeFences(stdout)) : stdout.trim();
|
|
1438
|
-
return {
|
|
1439
|
-
status: "success",
|
|
1440
|
-
output,
|
|
1441
|
-
response: { stdout: stdout.trim(), stderr: stderr.trim(), exitCode: 0 }
|
|
1442
|
-
};
|
|
1443
|
-
} finally {
|
|
1444
|
-
for (const tmp of tempFiles) {
|
|
1445
|
-
try {
|
|
1446
|
-
fs2.unlinkSync(tmp);
|
|
1447
|
-
} catch {
|
|
1680
|
+
if (Array.isArray(current) && part === "*") {
|
|
1681
|
+
continue;
|
|
1682
|
+
}
|
|
1683
|
+
if (Array.isArray(current)) {
|
|
1684
|
+
const idx = Number(part);
|
|
1685
|
+
if (!isNaN(idx)) {
|
|
1686
|
+
current = current[idx];
|
|
1687
|
+
} else {
|
|
1688
|
+
current = current.map((item) => item?.[part]);
|
|
1448
1689
|
}
|
|
1690
|
+
} else if (typeof current === "object") {
|
|
1691
|
+
current = current[part];
|
|
1692
|
+
} else {
|
|
1693
|
+
return void 0;
|
|
1449
1694
|
}
|
|
1450
1695
|
}
|
|
1696
|
+
return current;
|
|
1451
1697
|
}
|
|
1452
|
-
function
|
|
1453
|
-
|
|
1454
|
-
if (Array.isArray(value)) return `array (${JSON.stringify(value)})`;
|
|
1455
|
-
if (typeof value === "object") return `object (${JSON.stringify(value)})`;
|
|
1456
|
-
return `${typeof value} (${JSON.stringify(value)})`;
|
|
1457
|
-
}
|
|
1458
|
-
function isMissing(value) {
|
|
1459
|
-
if (value === void 0 || value === null) return true;
|
|
1460
|
-
if (typeof value === "string" && value.length === 0) return true;
|
|
1461
|
-
if (Array.isArray(value) && value.length === 0) return true;
|
|
1462
|
-
return false;
|
|
1463
|
-
}
|
|
1464
|
-
function explainMissing(selector, context) {
|
|
1465
|
-
const parts = selector.slice(2).split(".");
|
|
1466
|
-
if (parts[0] !== "steps" || parts.length < 2) return "";
|
|
1467
|
-
const stepId = parts[1].replace(/\[.*$/, "");
|
|
1468
|
-
const upstream = context.steps[stepId];
|
|
1469
|
-
if (!upstream) return ` (upstream step "${stepId}" has not run)`;
|
|
1470
|
-
if (upstream.status === "skipped") return ` (upstream step "${stepId}" was skipped)`;
|
|
1471
|
-
if (upstream.status === "failed") return ` (upstream step "${stepId}" failed: ${upstream.error ?? "unknown error"})`;
|
|
1472
|
-
if (upstream.status === "timeout") return ` (upstream step "${stepId}" timed out)`;
|
|
1473
|
-
return "";
|
|
1698
|
+
function shellQuote(value) {
|
|
1699
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
1474
1700
|
}
|
|
1475
|
-
function
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
);
|
|
1701
|
+
function applyHandlebarsPipe(value, pipe) {
|
|
1702
|
+
switch (pipe) {
|
|
1703
|
+
case "json":
|
|
1704
|
+
return JSON.stringify(value ?? null);
|
|
1705
|
+
case "shell": {
|
|
1706
|
+
if (value === void 0 || value === null) return `''`;
|
|
1707
|
+
const str = typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
1708
|
+
return shellQuote(str);
|
|
1484
1709
|
}
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
const condResult = evaluateCondition(step.if, context);
|
|
1490
|
-
if (!condResult) {
|
|
1491
|
-
const result = { status: "skipped" };
|
|
1492
|
-
context.steps[step.id] = result;
|
|
1493
|
-
return result;
|
|
1710
|
+
case "url": {
|
|
1711
|
+
if (value === void 0 || value === null) return "";
|
|
1712
|
+
const str = typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
1713
|
+
return encodeURIComponent(str);
|
|
1494
1714
|
}
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
return
|
|
1715
|
+
case "md": {
|
|
1716
|
+
if (value === void 0 || value === null) return "";
|
|
1717
|
+
const str = typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
1718
|
+
return str.replace(/([\\`*_{}\[\]()#+\-!|])/g, "\\$1");
|
|
1719
|
+
}
|
|
1720
|
+
case "html": {
|
|
1721
|
+
if (value === void 0 || value === null) return "";
|
|
1722
|
+
const str = typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
1723
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1502
1724
|
}
|
|
1725
|
+
default:
|
|
1726
|
+
throw new Error(`Unknown Handlebars pipe: "${pipe}". Supported: json, shell, url, md, html.`);
|
|
1503
1727
|
}
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
if (
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
event: "step:retry",
|
|
1514
|
-
stepId: step.id,
|
|
1515
|
-
attempt,
|
|
1516
|
-
maxRetries: onError.retries,
|
|
1517
|
-
delayMs: delay
|
|
1518
|
-
});
|
|
1519
|
-
await sleep2(delay);
|
|
1520
|
-
}
|
|
1521
|
-
checkRequires(step, context);
|
|
1522
|
-
if (options.mock && (step.type === "action" || step.type === "paginate" || step.type === "bash")) {
|
|
1523
|
-
const resolvedConfig = step[step.type] ? resolveValue(step[step.type], context) : {};
|
|
1524
|
-
let mockOutput = { _mock: true, ...resolvedConfig };
|
|
1525
|
-
if (step.type === "action" && step.action) {
|
|
1526
|
-
const actionId = resolveValue(step.action.actionId, context);
|
|
1527
|
-
try {
|
|
1528
|
-
const { details: actionDetails } = await resolveActionDetails(api, actionId);
|
|
1529
|
-
if (!options.skipValidation) {
|
|
1530
|
-
const data = step.action.data ? resolveValue(step.action.data, context) : void 0;
|
|
1531
|
-
const pathVars = step.action.pathVars ? resolveValue(step.action.pathVars, context) : void 0;
|
|
1532
|
-
const queryParams = step.action.queryParams ? resolveValue(step.action.queryParams, context) : void 0;
|
|
1533
|
-
const validation = validateActionInput(actionDetails, { data, pathVariables: pathVars, queryParams });
|
|
1534
|
-
if (!validation.valid) {
|
|
1535
|
-
const details = validation.missing.map((m) => `${m.flag} is missing "${m.param}"`).join("; ");
|
|
1536
|
-
throw new Error(`Validation failed for step "${step.id}": ${details}. Pass --skip-validation to bypass.`);
|
|
1537
|
-
}
|
|
1538
|
-
}
|
|
1539
|
-
if (actionDetails.ioSchema?.ioExample?.output) {
|
|
1540
|
-
mockOutput = actionDetails.ioSchema.ioExample.output;
|
|
1541
|
-
}
|
|
1542
|
-
} catch (e) {
|
|
1543
|
-
if (e instanceof Error && e.message.startsWith("Validation failed")) throw e;
|
|
1544
|
-
}
|
|
1545
|
-
}
|
|
1546
|
-
options.onEvent?.({ event: "step:mock", stepId: step.id, type: step.type, config: resolvedConfig });
|
|
1547
|
-
const result2 = {
|
|
1548
|
-
status: "success",
|
|
1549
|
-
output: mockOutput,
|
|
1550
|
-
response: { _mock: true },
|
|
1551
|
-
durationMs: Date.now() - startTime
|
|
1552
|
-
};
|
|
1553
|
-
context.steps[step.id] = result2;
|
|
1554
|
-
return result2;
|
|
1555
|
-
}
|
|
1556
|
-
const dispatch = async () => {
|
|
1557
|
-
switch (step.type) {
|
|
1558
|
-
case "action":
|
|
1559
|
-
return await executeActionStep(step, context, api, permissions, allowedActionIds, options);
|
|
1560
|
-
case "transform":
|
|
1561
|
-
return executeTransformStep(step, context);
|
|
1562
|
-
case "code":
|
|
1563
|
-
return await executeCodeStep(step, context, options);
|
|
1564
|
-
case "condition":
|
|
1565
|
-
return await executeConditionStep(step, context, api, permissions, allowedActionIds, options, flowStack);
|
|
1566
|
-
case "loop":
|
|
1567
|
-
return await executeLoopStep(step, context, api, permissions, allowedActionIds, options, flowStack);
|
|
1568
|
-
case "parallel":
|
|
1569
|
-
return await executeParallelStep(step, context, api, permissions, allowedActionIds, options, flowStack);
|
|
1570
|
-
case "file-read":
|
|
1571
|
-
return executeFileReadStep(step, context);
|
|
1572
|
-
case "file-write":
|
|
1573
|
-
return executeFileWriteStep(step, context);
|
|
1574
|
-
case "while":
|
|
1575
|
-
return await executeWhileStep(step, context, api, permissions, allowedActionIds, options, flowStack);
|
|
1576
|
-
case "flow":
|
|
1577
|
-
return await executeSubflowStep(step, context, api, permissions, allowedActionIds, options, flowStack);
|
|
1578
|
-
case "paginate":
|
|
1579
|
-
return await executePaginateStep(step, context, api, permissions, allowedActionIds, options);
|
|
1580
|
-
case "bash":
|
|
1581
|
-
return await executeBashStep(step, context, options);
|
|
1582
|
-
default:
|
|
1583
|
-
throw new Error(`Unknown step type: ${step.type}`);
|
|
1728
|
+
}
|
|
1729
|
+
function interpolateString(str, context) {
|
|
1730
|
+
return str.replace(
|
|
1731
|
+
/\{\{\s*(q\s+)?(\$\.[^}\s|]+)(?:\s*\|\s*([a-zA-Z]+))?\s*\}\}/g,
|
|
1732
|
+
(_match, qFlag, selector, pipe) => {
|
|
1733
|
+
const value = resolveSelector(selector, context);
|
|
1734
|
+
if (pipe) {
|
|
1735
|
+
if (qFlag) {
|
|
1736
|
+
throw new Error(`Handlebars expression "{{q ${selector} | ${pipe}}}" combines the legacy "q" prefix with a pipe \u2014 pick one (prefer the pipe form).`);
|
|
1584
1737
|
}
|
|
1585
|
-
|
|
1586
|
-
const result = step.timeoutMs ? await withTimeout(dispatch(), step.timeoutMs, step.id) : await dispatch();
|
|
1587
|
-
result.durationMs = Date.now() - startTime;
|
|
1588
|
-
if (attempt > 1) {
|
|
1589
|
-
result.retries = attempt - 1;
|
|
1590
|
-
options.onEvent?.({
|
|
1591
|
-
event: "step:retry-success",
|
|
1592
|
-
stepId: step.id,
|
|
1593
|
-
retries: attempt - 1
|
|
1594
|
-
});
|
|
1595
|
-
}
|
|
1596
|
-
context.steps[step.id] = result;
|
|
1597
|
-
return result;
|
|
1598
|
-
} catch (err) {
|
|
1599
|
-
lastError = err instanceof Error ? err : new Error(String(err));
|
|
1600
|
-
if (attempt === maxAttempts) {
|
|
1601
|
-
break;
|
|
1738
|
+
return applyHandlebarsPipe(value, pipe);
|
|
1602
1739
|
}
|
|
1603
|
-
if (
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
error: lastError.message
|
|
1611
|
-
});
|
|
1612
|
-
break;
|
|
1613
|
-
}
|
|
1740
|
+
if (value === void 0 || value === null) return qFlag ? `''` : "";
|
|
1741
|
+
if (typeof value === "object") {
|
|
1742
|
+
console.warn(
|
|
1743
|
+
`[flow] WARNING: Handlebars expression "{{${qFlag ? "q " : ""}${selector}}}" resolved to ${Array.isArray(value) ? "an array" : "an object"} and was stringified as JSON. To pass objects/arrays as native values, use a direct selector without {{ }}: "${selector}"`
|
|
1744
|
+
);
|
|
1745
|
+
const json = JSON.stringify(value);
|
|
1746
|
+
return qFlag ? shellQuote(json) : json;
|
|
1614
1747
|
}
|
|
1748
|
+
const str2 = String(value);
|
|
1749
|
+
return qFlag ? shellQuote(str2) : str2;
|
|
1750
|
+
}
|
|
1751
|
+
);
|
|
1752
|
+
}
|
|
1753
|
+
function resolveValue(value, context) {
|
|
1754
|
+
if (typeof value === "string") {
|
|
1755
|
+
if (value.startsWith("$.") && !value.includes("{{")) {
|
|
1756
|
+
return resolveSelector(value, context);
|
|
1615
1757
|
}
|
|
1758
|
+
if (/\{\{\s*(q\s+)?\$\./.test(value)) {
|
|
1759
|
+
return interpolateString(value, context);
|
|
1760
|
+
}
|
|
1761
|
+
return value;
|
|
1616
1762
|
}
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
const retriesUsed = Math.max(0, maxAttempts - 1);
|
|
1620
|
-
const isTimeout = lastError instanceof StepTimeoutError;
|
|
1621
|
-
const errorCode = lastError?.errorCode;
|
|
1622
|
-
if (strategy === "continue") {
|
|
1623
|
-
const result = {
|
|
1624
|
-
status: isTimeout ? "timeout" : "failed",
|
|
1625
|
-
error: errorMessage,
|
|
1626
|
-
...errorCode ? { errorCode } : {},
|
|
1627
|
-
durationMs: Date.now() - startTime,
|
|
1628
|
-
retries: retriesUsed
|
|
1629
|
-
};
|
|
1630
|
-
context.steps[step.id] = result;
|
|
1631
|
-
return result;
|
|
1763
|
+
if (Array.isArray(value)) {
|
|
1764
|
+
return value.map((item) => resolveValue(item, context));
|
|
1632
1765
|
}
|
|
1633
|
-
if (
|
|
1634
|
-
const
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
retries: retriesUsed
|
|
1640
|
-
};
|
|
1641
|
-
context.steps[step.id] = result;
|
|
1642
|
-
return result;
|
|
1766
|
+
if (value && typeof value === "object") {
|
|
1767
|
+
const resolved = {};
|
|
1768
|
+
for (const [k, v] of Object.entries(value)) {
|
|
1769
|
+
resolved[k] = resolveValue(v, context);
|
|
1770
|
+
}
|
|
1771
|
+
return resolved;
|
|
1643
1772
|
}
|
|
1644
|
-
|
|
1773
|
+
return value;
|
|
1645
1774
|
}
|
|
1646
|
-
|
|
1647
|
-
const
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1775
|
+
function evaluateExpression(expr, context) {
|
|
1776
|
+
const fn = new Function("$", `return (${expr})`);
|
|
1777
|
+
return fn(context);
|
|
1778
|
+
}
|
|
1779
|
+
function evaluateCondition(expr, context) {
|
|
1780
|
+
try {
|
|
1781
|
+
return Boolean(evaluateExpression(expr, context));
|
|
1782
|
+
} catch (err) {
|
|
1783
|
+
if (err instanceof TypeError) return false;
|
|
1784
|
+
throw err;
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
var FlowStopSignal = class extends Error {
|
|
1788
|
+
constructor(stepId) {
|
|
1789
|
+
super(`Flow stopped after step "${stepId}"`);
|
|
1790
|
+
this.stepId = stepId;
|
|
1791
|
+
this.name = "FlowStopSignal";
|
|
1792
|
+
}
|
|
1793
|
+
};
|
|
1794
|
+
var DRY_SELECTOR_TOKEN = /\$\.[A-Za-z0-9_$]+(?:[.[][A-Za-z0-9_$*\]]+)*/g;
|
|
1795
|
+
function extractSelectors(str) {
|
|
1796
|
+
return str.match(DRY_SELECTOR_TOKEN) ?? [];
|
|
1797
|
+
}
|
|
1798
|
+
function walkConfigStrings(value, visit) {
|
|
1799
|
+
if (typeof value === "string") {
|
|
1800
|
+
visit(value);
|
|
1801
|
+
} else if (Array.isArray(value)) {
|
|
1802
|
+
for (const v of value) walkConfigStrings(v, visit);
|
|
1803
|
+
} else if (value && typeof value === "object") {
|
|
1804
|
+
for (const v of Object.values(value)) walkConfigStrings(v, visit);
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
function classifyDryRef(selector, value) {
|
|
1808
|
+
if (value !== void 0) return "resolved";
|
|
1809
|
+
if (selector.startsWith("$.steps.") || selector.startsWith("$.loop.")) return "deferred";
|
|
1810
|
+
return "missing";
|
|
1811
|
+
}
|
|
1812
|
+
function expressionDeferredOn(expr, context) {
|
|
1813
|
+
const deps = /* @__PURE__ */ new Set();
|
|
1814
|
+
for (const selector of extractSelectors(expr)) {
|
|
1815
|
+
if (!selector.startsWith("$.steps.") && !selector.startsWith("$.loop.")) continue;
|
|
1816
|
+
const parts = selector.split(/[.[]/);
|
|
1817
|
+
if (parts.length < 3) continue;
|
|
1818
|
+
const root = `${parts[0]}.${parts[1]}.${parts[2]}`;
|
|
1819
|
+
if (resolveSelector(root, context) === void 0) deps.add(root);
|
|
1820
|
+
}
|
|
1821
|
+
return [...deps];
|
|
1822
|
+
}
|
|
1823
|
+
function stepInterpolationSource(step) {
|
|
1824
|
+
switch (step.type) {
|
|
1825
|
+
case "action": {
|
|
1826
|
+
const a = step.action;
|
|
1827
|
+
if (!a) return {};
|
|
1828
|
+
return {
|
|
1829
|
+
platform: a.platform,
|
|
1830
|
+
actionId: a.actionId,
|
|
1831
|
+
connectionKey: a.connectionKey,
|
|
1832
|
+
connection: a.connection,
|
|
1833
|
+
pathVars: a.pathVars,
|
|
1834
|
+
queryParams: a.queryParams,
|
|
1835
|
+
headers: a.headers,
|
|
1836
|
+
data: a.data
|
|
1837
|
+
};
|
|
1679
1838
|
}
|
|
1839
|
+
case "paginate": {
|
|
1840
|
+
const a = step.paginate?.action;
|
|
1841
|
+
return a ? { platform: a.platform, actionId: a.actionId, connectionKey: a.connectionKey, connection: a.connection, pathVars: a.pathVars, queryParams: a.queryParams, headers: a.headers, data: a.data } : {};
|
|
1842
|
+
}
|
|
1843
|
+
case "file-read":
|
|
1844
|
+
return { path: step.fileRead?.path };
|
|
1845
|
+
case "file-write":
|
|
1846
|
+
return { path: step.fileWrite?.path, content: step.fileWrite?.content };
|
|
1847
|
+
case "loop":
|
|
1848
|
+
return { over: step.loop?.over };
|
|
1849
|
+
case "flow":
|
|
1850
|
+
return { inputs: step.flow?.inputs };
|
|
1851
|
+
case "bash":
|
|
1852
|
+
return { command: step.bash?.command, cwd: step.bash?.cwd, env: step.bash?.env };
|
|
1853
|
+
default:
|
|
1854
|
+
return {};
|
|
1680
1855
|
}
|
|
1681
|
-
return results;
|
|
1682
1856
|
}
|
|
1683
|
-
|
|
1684
|
-
const
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1857
|
+
function dryResolveStep(step, context) {
|
|
1858
|
+
const base = { stepId: step.id, name: step.name, type: step.type };
|
|
1859
|
+
const expr = step.type === "transform" ? step.transform?.expression : step.type === "condition" ? step.condition?.expression : step.type === "while" ? step.while?.condition : void 0;
|
|
1860
|
+
if (step.type === "transform" || step.type === "condition" || step.type === "while") {
|
|
1861
|
+
if (!expr) return { ...base, references: [] };
|
|
1862
|
+
try {
|
|
1863
|
+
return { ...base, references: [], resolved: evaluateExpression(expr, context) };
|
|
1864
|
+
} catch (err) {
|
|
1865
|
+
if (err instanceof TypeError) {
|
|
1866
|
+
const deps = expressionDeferredOn(expr, context);
|
|
1867
|
+
if (deps.length > 0) {
|
|
1868
|
+
return {
|
|
1869
|
+
...base,
|
|
1870
|
+
references: deps.map((selector) => ({ selector, value: void 0, status: "deferred" })),
|
|
1871
|
+
deferred: true
|
|
1872
|
+
};
|
|
1873
|
+
}
|
|
1691
1874
|
}
|
|
1692
|
-
|
|
1693
|
-
resolvedInputs[name] = decl.default;
|
|
1694
|
-
}
|
|
1695
|
-
continue;
|
|
1875
|
+
return { ...base, references: [], error: err instanceof Error ? err.message : String(err) };
|
|
1696
1876
|
}
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
throw new Error(`Input "${name}" must be a number, got ${describe(provided)}`);
|
|
1708
|
-
}
|
|
1709
|
-
break;
|
|
1710
|
-
case "boolean":
|
|
1711
|
-
if (value === "true" || value === "1" || value === 1) value = true;
|
|
1712
|
-
else if (value === "false" || value === "0" || value === 0) value = false;
|
|
1713
|
-
if (typeof value !== "boolean") {
|
|
1714
|
-
throw new Error(`Input "${name}" must be a boolean, got ${describe(provided)}`);
|
|
1715
|
-
}
|
|
1716
|
-
break;
|
|
1717
|
-
case "array":
|
|
1718
|
-
if (typeof value === "string") {
|
|
1719
|
-
try {
|
|
1720
|
-
value = JSON.parse(value);
|
|
1721
|
-
} catch {
|
|
1722
|
-
}
|
|
1723
|
-
}
|
|
1724
|
-
if (!Array.isArray(value)) {
|
|
1725
|
-
throw new Error(`Input "${name}" must be an array, got ${describe(provided)}`);
|
|
1726
|
-
}
|
|
1727
|
-
break;
|
|
1728
|
-
case "object":
|
|
1729
|
-
if (typeof value === "string") {
|
|
1730
|
-
try {
|
|
1731
|
-
value = JSON.parse(value);
|
|
1732
|
-
} catch {
|
|
1733
|
-
}
|
|
1734
|
-
}
|
|
1735
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1736
|
-
throw new Error(`Input "${name}" must be an object, got ${describe(provided)}`);
|
|
1737
|
-
}
|
|
1738
|
-
break;
|
|
1877
|
+
}
|
|
1878
|
+
const src = stepInterpolationSource(step);
|
|
1879
|
+
const references = [];
|
|
1880
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1881
|
+
walkConfigStrings(src, (s) => {
|
|
1882
|
+
for (const selector of extractSelectors(s)) {
|
|
1883
|
+
if (seen.has(selector)) continue;
|
|
1884
|
+
seen.add(selector);
|
|
1885
|
+
const value = resolveSelector(selector, context);
|
|
1886
|
+
references.push({ selector, value, status: classifyDryRef(selector, value) });
|
|
1739
1887
|
}
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1888
|
+
});
|
|
1889
|
+
let resolved;
|
|
1890
|
+
try {
|
|
1891
|
+
resolved = resolveValue(src, context);
|
|
1892
|
+
} catch {
|
|
1893
|
+
}
|
|
1894
|
+
return { ...base, references, resolved };
|
|
1895
|
+
}
|
|
1896
|
+
function dryResolveAllSteps(steps, context) {
|
|
1897
|
+
const out = [];
|
|
1898
|
+
const visit = (list) => {
|
|
1899
|
+
for (const step of list) {
|
|
1900
|
+
out.push(dryResolveStep(step, context));
|
|
1901
|
+
const config = step;
|
|
1902
|
+
for (const { configKey, fieldName } of getNestedStepsKeys()) {
|
|
1903
|
+
const block = config[configKey];
|
|
1904
|
+
if (block && Array.isArray(block[fieldName])) {
|
|
1905
|
+
visit(block[fieldName]);
|
|
1906
|
+
}
|
|
1743
1907
|
}
|
|
1744
1908
|
}
|
|
1745
|
-
|
|
1909
|
+
};
|
|
1910
|
+
visit(steps);
|
|
1911
|
+
return out;
|
|
1912
|
+
}
|
|
1913
|
+
var ALLOWED_MODULES = {
|
|
1914
|
+
buffer: () => import("buffer"),
|
|
1915
|
+
crypto: () => import("crypto"),
|
|
1916
|
+
url: () => import("url"),
|
|
1917
|
+
path: () => import("path")
|
|
1918
|
+
};
|
|
1919
|
+
var BLOCKED_MODULES = /* @__PURE__ */ new Set([
|
|
1920
|
+
"fs",
|
|
1921
|
+
"http",
|
|
1922
|
+
"https",
|
|
1923
|
+
"net",
|
|
1924
|
+
"child_process",
|
|
1925
|
+
"process",
|
|
1926
|
+
"os",
|
|
1927
|
+
"cluster",
|
|
1928
|
+
"dgram",
|
|
1929
|
+
"tls",
|
|
1930
|
+
"vm",
|
|
1931
|
+
"worker_threads"
|
|
1932
|
+
]);
|
|
1933
|
+
function createSandboxedRequire() {
|
|
1934
|
+
const cache = {};
|
|
1935
|
+
return async (moduleName) => {
|
|
1936
|
+
const clean = moduleName.replace(/^node:/, "");
|
|
1937
|
+
if (BLOCKED_MODULES.has(clean)) {
|
|
1938
|
+
throw new Error(`Module "${moduleName}" is blocked in code steps`);
|
|
1939
|
+
}
|
|
1940
|
+
if (!ALLOWED_MODULES[clean]) {
|
|
1941
|
+
throw new Error(`Module "${moduleName}" not available. Allowed: ${Object.keys(ALLOWED_MODULES).join(", ")}`);
|
|
1942
|
+
}
|
|
1943
|
+
if (!cache[clean]) cache[clean] = await ALLOWED_MODULES[clean]();
|
|
1944
|
+
return cache[clean];
|
|
1945
|
+
};
|
|
1946
|
+
}
|
|
1947
|
+
function stripCodeFences(text) {
|
|
1948
|
+
const trimmed = text.trim();
|
|
1949
|
+
const match = trimmed.match(/^```(?:\w*)\s*\n([\s\S]*?)\n\s*```\s*$/);
|
|
1950
|
+
return match ? match[1].trim() : trimmed;
|
|
1951
|
+
}
|
|
1952
|
+
async function executeActionStep(step, context, api, permissions, allowedActionIds, options) {
|
|
1953
|
+
const action = step.action;
|
|
1954
|
+
const platform = resolveValue(action.platform, context);
|
|
1955
|
+
const actionId = resolveValue(action.actionId, context);
|
|
1956
|
+
const hasKey = action.connectionKey !== void 0 && action.connectionKey !== null && action.connectionKey !== "";
|
|
1957
|
+
const hasRef = !!action.connection?.platform;
|
|
1958
|
+
if (hasKey && hasRef) {
|
|
1959
|
+
throw new Error(
|
|
1960
|
+
`Action step "${step.id}" has both "connectionKey" and "connection" \u2014 set exactly one. Prefer "connection: { platform, tag? }" so re-auth doesn't break the flow.`
|
|
1961
|
+
);
|
|
1746
1962
|
}
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1963
|
+
if (!hasKey && !hasRef) {
|
|
1964
|
+
throw new Error(
|
|
1965
|
+
`Action step "${step.id}" must set "connection: { platform: <name> }" (or legacy "connectionKey: <key>").`
|
|
1966
|
+
);
|
|
1967
|
+
}
|
|
1968
|
+
let connectionKey;
|
|
1969
|
+
if (hasKey) {
|
|
1970
|
+
connectionKey = resolveValue(action.connectionKey, context);
|
|
1971
|
+
} else {
|
|
1972
|
+
const ref = resolveValue(action.connection, context);
|
|
1973
|
+
if (!context._connections) {
|
|
1974
|
+
context._connections = await api.listConnections();
|
|
1975
|
+
}
|
|
1976
|
+
const conn = await api.resolveConnection(ref, context._connections);
|
|
1977
|
+
connectionKey = conn.key;
|
|
1978
|
+
}
|
|
1979
|
+
const data = action.data ? resolveValue(action.data, context) : void 0;
|
|
1980
|
+
const pathVars = action.pathVars ? resolveValue(action.pathVars, context) : void 0;
|
|
1981
|
+
const queryParams = action.queryParams ? resolveValue(action.queryParams, context) : void 0;
|
|
1982
|
+
const headers = action.headers ? resolveValue(action.headers, context) : void 0;
|
|
1983
|
+
if (!isActionAllowed(actionId, allowedActionIds)) {
|
|
1984
|
+
throw new Error(`Action "${actionId}" is not in the allowed action list`);
|
|
1985
|
+
}
|
|
1986
|
+
const { details: actionDetails } = await resolveActionDetails(api, actionId);
|
|
1987
|
+
if (!isMethodAllowed(actionDetails.method, permissions)) {
|
|
1988
|
+
throw new Error(`Method "${actionDetails.method}" is not allowed under "${permissions}" permission level`);
|
|
1989
|
+
}
|
|
1990
|
+
if (!options.skipValidation) {
|
|
1991
|
+
const validation = validateActionInput(actionDetails, { data, pathVariables: pathVars, queryParams });
|
|
1992
|
+
if (!validation.valid) {
|
|
1993
|
+
const details = validation.missing.map((m) => `${m.flag} is missing "${m.param}"`).join("; ");
|
|
1994
|
+
throw new Error(`Validation failed for step "${step.id}": ${details}. Pass --skip-validation to bypass.`);
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
const result = await api.executePassthroughRequest({
|
|
1998
|
+
platform,
|
|
1999
|
+
actionId,
|
|
2000
|
+
connectionKey,
|
|
2001
|
+
data,
|
|
2002
|
+
pathVariables: pathVars,
|
|
2003
|
+
queryParams,
|
|
2004
|
+
headers
|
|
2005
|
+
}, actionDetails);
|
|
2006
|
+
return {
|
|
2007
|
+
status: "success",
|
|
2008
|
+
response: result.responseData,
|
|
2009
|
+
output: result.responseData
|
|
1752
2010
|
};
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
const
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
});
|
|
1764
|
-
return context;
|
|
2011
|
+
}
|
|
2012
|
+
function executeTransformStep(step, context) {
|
|
2013
|
+
const output = evaluateExpression(step.transform.expression, context);
|
|
2014
|
+
return { status: "success", output, response: output };
|
|
2015
|
+
}
|
|
2016
|
+
async function executeCodeStep(step, context, options) {
|
|
2017
|
+
const config = step.code;
|
|
2018
|
+
if (config.module) {
|
|
2019
|
+
const output = await executeCodeModule(step.id, config.module, context, options);
|
|
2020
|
+
return { status: "success", output, response: output };
|
|
1765
2021
|
}
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
const
|
|
2022
|
+
if (typeof config.source !== "string") {
|
|
2023
|
+
throw new Error(`Code step "${step.id}" must define either "source" or "module"`);
|
|
2024
|
+
}
|
|
2025
|
+
const AsyncFunction = Object.getPrototypeOf(async function() {
|
|
2026
|
+
}).constructor;
|
|
2027
|
+
const sandboxedRequire = createSandboxedRequire();
|
|
2028
|
+
const sourceURL = `code:${step.id}`;
|
|
2029
|
+
const taggedSource = `${config.source}
|
|
2030
|
+
//# sourceURL=${sourceURL}`;
|
|
2031
|
+
const fn = new AsyncFunction("$", "require", taggedSource);
|
|
1773
2032
|
try {
|
|
1774
|
-
await
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
const skipped = stepEntries.filter((s) => s.status === "skipped").length;
|
|
1779
|
-
options.onEvent?.({
|
|
1780
|
-
event: "flow:complete",
|
|
1781
|
-
flowKey: flow.key,
|
|
1782
|
-
status: "success",
|
|
1783
|
-
durationMs: Date.now() - flowStart,
|
|
1784
|
-
stepsCompleted: completed,
|
|
1785
|
-
stepsFailed: failed,
|
|
1786
|
-
stepsSkipped: skipped
|
|
1787
|
-
});
|
|
1788
|
-
} catch (error) {
|
|
1789
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1790
|
-
options.onEvent?.({
|
|
1791
|
-
event: "flow:error",
|
|
1792
|
-
flowKey: flow.key,
|
|
1793
|
-
status: "failed",
|
|
1794
|
-
error: errorMsg,
|
|
1795
|
-
durationMs: Date.now() - flowStart
|
|
1796
|
-
});
|
|
1797
|
-
throw error;
|
|
2033
|
+
const output = await fn(context, sandboxedRequire);
|
|
2034
|
+
return { status: "success", output, response: output };
|
|
2035
|
+
} catch (err) {
|
|
2036
|
+
throw rewriteCodeStepError(err, step.id, config.source, sourceURL);
|
|
1798
2037
|
}
|
|
1799
|
-
return context;
|
|
1800
2038
|
}
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
2039
|
+
function rewriteCodeStepError(err, stepId, source, sourceURL) {
|
|
2040
|
+
if (!(err instanceof Error)) return new Error(String(err));
|
|
2041
|
+
const WRAPPER_LINE_OFFSET = 2;
|
|
2042
|
+
const sourceLines = source.split("\n");
|
|
2043
|
+
const stack = err.stack || "";
|
|
2044
|
+
const re = new RegExp(`${sourceURL.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}:(\\d+):(\\d+)`);
|
|
2045
|
+
const match = stack.match(re);
|
|
2046
|
+
if (!match) {
|
|
2047
|
+
err.message = `Code step "${stepId}" failed: ${err.message}`;
|
|
2048
|
+
return err;
|
|
2049
|
+
}
|
|
2050
|
+
const wrappedLine = parseInt(match[1], 10);
|
|
2051
|
+
const col = parseInt(match[2], 10);
|
|
2052
|
+
const userLine = wrappedLine - WRAPPER_LINE_OFFSET;
|
|
2053
|
+
const lineContent = sourceLines[userLine - 1] ?? "";
|
|
2054
|
+
const trimmed = lineContent.trim();
|
|
2055
|
+
err.message = `Code step "${stepId}" failed at line ${userLine}:${col}
|
|
2056
|
+
${trimmed}
|
|
2057
|
+
${err.message}`;
|
|
2058
|
+
err.stack = stack.replace(
|
|
2059
|
+
new RegExp(`(${sourceURL.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}:)(\\d+)`, "g"),
|
|
2060
|
+
(_m, prefix, l) => `${prefix}${parseInt(l, 10) - WRAPPER_LINE_OFFSET}`
|
|
2061
|
+
);
|
|
2062
|
+
return err;
|
|
2063
|
+
}
|
|
2064
|
+
async function executeCodeModule(stepId, modulePath, context, options) {
|
|
2065
|
+
const rootDir = options.rootDir;
|
|
2066
|
+
if (!rootDir) {
|
|
2067
|
+
throw new Error(`Code step "${stepId}" uses module "${modulePath}" but no flow rootDir is available. Flows that use code modules must be loaded via loadFlowWithMeta.`);
|
|
2068
|
+
}
|
|
2069
|
+
if (path2.isAbsolute(modulePath)) {
|
|
2070
|
+
throw new Error(`Code module path must be relative to the flow root, got absolute: "${modulePath}"`);
|
|
2071
|
+
}
|
|
2072
|
+
const absPath = path2.resolve(rootDir, modulePath);
|
|
2073
|
+
const relFromRoot = path2.relative(rootDir, absPath);
|
|
2074
|
+
if (relFromRoot.startsWith("..") || path2.isAbsolute(relFromRoot)) {
|
|
2075
|
+
throw new Error(`Code module "${modulePath}" resolves outside the flow directory`);
|
|
2076
|
+
}
|
|
2077
|
+
if (!fs2.existsSync(absPath)) {
|
|
2078
|
+
throw new Error(`Code module not found: ${absPath}`);
|
|
2079
|
+
}
|
|
2080
|
+
const { env: _omitEnv, ...safeContext } = context;
|
|
2081
|
+
void _omitEnv;
|
|
2082
|
+
const stdinPayload = JSON.stringify(safeContext);
|
|
2083
|
+
return await new Promise((resolve2, reject) => {
|
|
2084
|
+
const child = spawn(process.execPath, [absPath], {
|
|
2085
|
+
cwd: rootDir,
|
|
2086
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
2087
|
+
});
|
|
2088
|
+
const stdoutChunks = [];
|
|
2089
|
+
const stderrChunks = [];
|
|
2090
|
+
child.stdout.on("data", (c) => stdoutChunks.push(c));
|
|
2091
|
+
child.stderr.on("data", (c) => stderrChunks.push(c));
|
|
2092
|
+
child.on("error", (err) => reject(err));
|
|
2093
|
+
child.on("close", (code) => {
|
|
2094
|
+
const stdout = Buffer.concat(stdoutChunks).toString("utf-8");
|
|
2095
|
+
const stderr = Buffer.concat(stderrChunks).toString("utf-8");
|
|
2096
|
+
if (code !== 0) {
|
|
2097
|
+
reject(new Error(`Code module "${modulePath}" exited with code ${code}${stderr ? `: ${stderr.trim()}` : ""}`));
|
|
2098
|
+
return;
|
|
1858
2099
|
}
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
description: "Single JS expression with implicit return",
|
|
1864
|
-
fields: {
|
|
1865
|
-
expression: { type: "string", required: true, description: "JS expression evaluated with flow context as $" }
|
|
1866
|
-
},
|
|
1867
|
-
example: {
|
|
1868
|
-
id: "extractNames",
|
|
1869
|
-
name: "Extract customer names",
|
|
1870
|
-
type: "transform",
|
|
1871
|
-
transform: { expression: "$.steps.findCustomer.response.data.map(c => c.name)" }
|
|
2100
|
+
const trimmed = stdout.trim();
|
|
2101
|
+
if (trimmed === "") {
|
|
2102
|
+
resolve2(void 0);
|
|
2103
|
+
return;
|
|
1872
2104
|
}
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
description: "JS code \u2014 inline source or an external .mjs module under the flow's lib/ folder",
|
|
1878
|
-
fields: {
|
|
1879
|
-
source: { type: "string", required: false, description: 'Inline JS function body (flow context as $, supports await). Mutually exclusive with "module".' },
|
|
1880
|
-
module: { type: "string", required: false, description: 'Relative path to a .mjs file under the flow folder (e.g. "lib/normalize.mjs"). Reads $ from stdin as JSON, writes result to stdout as JSON. Mutually exclusive with "source".' }
|
|
1881
|
-
},
|
|
1882
|
-
example: {
|
|
1883
|
-
id: "processData",
|
|
1884
|
-
name: "Process and enrich data",
|
|
1885
|
-
type: "code",
|
|
1886
|
-
code: { module: "lib/process-data.mjs" }
|
|
2105
|
+
try {
|
|
2106
|
+
resolve2(JSON.parse(stripCodeFences(trimmed)));
|
|
2107
|
+
} catch (err) {
|
|
2108
|
+
reject(new Error(`Code module "${modulePath}" did not print valid JSON to stdout: ${err.message}`));
|
|
1887
2109
|
}
|
|
1888
|
-
}
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
2110
|
+
});
|
|
2111
|
+
child.stdin.write(stdinPayload);
|
|
2112
|
+
child.stdin.end();
|
|
2113
|
+
});
|
|
2114
|
+
}
|
|
2115
|
+
async function executeConditionStep(step, context, api, permissions, allowedActionIds, options, flowStack) {
|
|
2116
|
+
const condition = step.condition;
|
|
2117
|
+
const result = evaluateCondition(condition.expression, context);
|
|
2118
|
+
const branch = result ? condition.then : condition.else || [];
|
|
2119
|
+
const branchResults = await executeSteps(branch, context, api, permissions, allowedActionIds, options, void 0, flowStack);
|
|
2120
|
+
return {
|
|
2121
|
+
status: "success",
|
|
2122
|
+
output: { conditionResult: !!result, stepsExecuted: branchResults },
|
|
2123
|
+
response: { conditionResult: !!result }
|
|
2124
|
+
};
|
|
2125
|
+
}
|
|
2126
|
+
async function executeLoopStep(step, context, api, permissions, allowedActionIds, options, flowStack) {
|
|
2127
|
+
const loop = step.loop;
|
|
2128
|
+
const items = resolveValue(loop.over, context);
|
|
2129
|
+
if (!Array.isArray(items)) {
|
|
2130
|
+
throw new Error(`Loop "over" must resolve to an array, got ${typeof items}`);
|
|
2131
|
+
}
|
|
2132
|
+
const maxIterations = loop.maxIterations || 1e3;
|
|
2133
|
+
const bounded = items.slice(0, maxIterations);
|
|
2134
|
+
const savedLoop = { ...context.loop };
|
|
2135
|
+
const iterationResults = [];
|
|
2136
|
+
if (loop.maxConcurrency && loop.maxConcurrency > 1) {
|
|
2137
|
+
const results2 = new Array(bounded.length);
|
|
2138
|
+
for (let batchStart = 0; batchStart < bounded.length; batchStart += loop.maxConcurrency) {
|
|
2139
|
+
const batch = bounded.slice(batchStart, batchStart + loop.maxConcurrency);
|
|
2140
|
+
const batchResults = await Promise.all(
|
|
2141
|
+
batch.map(async (item, batchIdx) => {
|
|
2142
|
+
const i = batchStart + batchIdx;
|
|
2143
|
+
const iterContext = {
|
|
2144
|
+
...context,
|
|
2145
|
+
loop: {
|
|
2146
|
+
[loop.as]: item,
|
|
2147
|
+
item,
|
|
2148
|
+
i,
|
|
2149
|
+
...loop.indexAs ? { [loop.indexAs]: i } : {}
|
|
2150
|
+
},
|
|
2151
|
+
steps: { ...context.steps }
|
|
2152
|
+
};
|
|
2153
|
+
const beforeKeys = new Set(Object.keys(iterContext.steps));
|
|
2154
|
+
await executeSteps(loop.steps, iterContext, api, permissions, allowedActionIds, options, void 0, flowStack);
|
|
2155
|
+
const iterResult = {};
|
|
2156
|
+
for (const [key, val] of Object.entries(iterContext.steps)) {
|
|
2157
|
+
if (!beforeKeys.has(key) || iterContext.steps[key] !== context.steps[key]) {
|
|
2158
|
+
iterResult[key] = val;
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
iterationResults[i] = iterResult;
|
|
2162
|
+
Object.assign(context.steps, iterContext.steps);
|
|
2163
|
+
return iterContext.loop[loop.as];
|
|
2164
|
+
})
|
|
2165
|
+
);
|
|
2166
|
+
for (let j = 0; j < batchResults.length; j++) {
|
|
2167
|
+
results2[batchStart + j] = batchResults[j];
|
|
1907
2168
|
}
|
|
1908
|
-
}
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
{
|
|
1933
|
-
type: "parallel",
|
|
1934
|
-
configKey: "parallel",
|
|
1935
|
-
description: "Run steps concurrently",
|
|
1936
|
-
fields: {
|
|
1937
|
-
steps: { type: "array", required: true, description: "Steps to run in parallel", stepsArray: true },
|
|
1938
|
-
maxConcurrency: { type: "number", required: false, description: "Max concurrent steps (default: 5)" }
|
|
1939
|
-
},
|
|
1940
|
-
example: {
|
|
1941
|
-
id: "lookups",
|
|
1942
|
-
name: "Parallel data lookups",
|
|
1943
|
-
type: "parallel",
|
|
1944
|
-
parallel: {
|
|
1945
|
-
steps: [
|
|
1946
|
-
{ id: "getStripe", name: "Get Stripe data", type: "action", action: { platform: "stripe", actionId: "...", connection: { platform: "stripe" } } },
|
|
1947
|
-
{ id: "getSlack", name: "Get Slack data", type: "action", action: { platform: "slack", actionId: "...", connection: { platform: "slack" } } }
|
|
1948
|
-
]
|
|
1949
|
-
}
|
|
1950
|
-
}
|
|
1951
|
-
},
|
|
1952
|
-
{
|
|
1953
|
-
type: "file-read",
|
|
1954
|
-
configKey: "fileRead",
|
|
1955
|
-
description: "Read a file (optional JSON parse)",
|
|
1956
|
-
fields: {
|
|
1957
|
-
path: { type: "string", required: true, description: "File path to read" },
|
|
1958
|
-
parseJson: { type: "boolean", required: false, description: "Parse contents as JSON (default: false)" }
|
|
1959
|
-
},
|
|
1960
|
-
example: {
|
|
1961
|
-
id: "readConfig",
|
|
1962
|
-
name: "Read config file",
|
|
1963
|
-
type: "file-read",
|
|
1964
|
-
fileRead: { path: "./data/config.json", parseJson: true }
|
|
1965
|
-
}
|
|
1966
|
-
},
|
|
1967
|
-
{
|
|
1968
|
-
type: "file-write",
|
|
1969
|
-
configKey: "fileWrite",
|
|
1970
|
-
description: "Write or append to a file",
|
|
1971
|
-
fields: {
|
|
1972
|
-
path: { type: "string", required: true, description: "File path to write" },
|
|
1973
|
-
content: { type: "unknown", required: true, description: "Content to write (supports selectors)" },
|
|
1974
|
-
append: { type: "boolean", required: false, description: "Append instead of overwrite (default: false)" }
|
|
1975
|
-
},
|
|
1976
|
-
example: {
|
|
1977
|
-
id: "writeResults",
|
|
1978
|
-
name: "Save results",
|
|
1979
|
-
type: "file-write",
|
|
1980
|
-
fileWrite: { path: "./output/results.json", content: "$.steps.transform.output" }
|
|
1981
|
-
}
|
|
1982
|
-
},
|
|
1983
|
-
{
|
|
1984
|
-
type: "while",
|
|
1985
|
-
configKey: "while",
|
|
1986
|
-
description: "Do-while loop with condition check",
|
|
1987
|
-
fields: {
|
|
1988
|
-
condition: { type: "string", required: true, description: "JS expression checked before each iteration (after first)" },
|
|
1989
|
-
steps: { type: "array", required: true, description: "Steps to run each iteration", stepsArray: true },
|
|
1990
|
-
maxIterations: { type: "number", required: false, description: "Safety cap (default: 100)" }
|
|
1991
|
-
},
|
|
1992
|
-
example: {
|
|
1993
|
-
id: "paginate",
|
|
1994
|
-
name: "Paginate through pages",
|
|
1995
|
-
type: "while",
|
|
1996
|
-
while: {
|
|
1997
|
-
condition: "$.steps.paginate.output.lastResult.nextPageToken != null",
|
|
1998
|
-
maxIterations: 50,
|
|
1999
|
-
steps: [{ id: "fetchPage", name: "Fetch next page", type: "action", action: { platform: "gmail", actionId: "...", connection: { platform: "gmail" } } }]
|
|
2000
|
-
}
|
|
2001
|
-
}
|
|
2002
|
-
},
|
|
2003
|
-
{
|
|
2004
|
-
type: "flow",
|
|
2005
|
-
configKey: "flow",
|
|
2006
|
-
description: "Execute a sub-flow (supports composition)",
|
|
2007
|
-
fields: {
|
|
2008
|
-
key: { type: "string", required: true, description: "Flow key or path of the sub-flow" },
|
|
2009
|
-
inputs: { type: "object", required: false, description: "Inputs to pass to the sub-flow (supports selectors)" }
|
|
2010
|
-
},
|
|
2011
|
-
example: {
|
|
2012
|
-
id: "enrich",
|
|
2013
|
-
name: "Run enrichment sub-flow",
|
|
2014
|
-
type: "flow",
|
|
2015
|
-
flow: { key: "enrich-customer", inputs: { email: "$.steps.getCustomer.response.email" } }
|
|
2016
|
-
}
|
|
2017
|
-
},
|
|
2018
|
-
{
|
|
2019
|
-
type: "paginate",
|
|
2020
|
-
configKey: "paginate",
|
|
2021
|
-
description: "Auto-paginate API results into a single array",
|
|
2022
|
-
fields: {
|
|
2023
|
-
action: { type: "object", required: true, description: "Action config (same shape as action step: platform, actionId, plus exactly one of `connection` or `connectionKey`)" },
|
|
2024
|
-
pageTokenField: { type: "string", required: true, description: "Dot-path in response to next page token" },
|
|
2025
|
-
resultsField: { type: "string", required: true, description: "Dot-path in response to results array" },
|
|
2026
|
-
inputTokenParam: { type: "string", required: true, description: "Dot-path in action config where page token is injected" },
|
|
2027
|
-
maxPages: { type: "number", required: false, description: "Max pages to fetch (default: 10)" }
|
|
2028
|
-
},
|
|
2029
|
-
example: {
|
|
2030
|
-
id: "allMessages",
|
|
2031
|
-
name: "Fetch all Gmail messages",
|
|
2032
|
-
type: "paginate",
|
|
2033
|
-
paginate: {
|
|
2034
|
-
action: { platform: "gmail", actionId: "...", connection: { platform: "gmail" }, queryParams: { maxResults: 100 } },
|
|
2035
|
-
pageTokenField: "nextPageToken",
|
|
2036
|
-
resultsField: "messages",
|
|
2037
|
-
inputTokenParam: "queryParams.pageToken",
|
|
2038
|
-
maxPages: 10
|
|
2039
|
-
}
|
|
2040
|
-
}
|
|
2041
|
-
},
|
|
2042
|
-
{
|
|
2043
|
-
type: "bash",
|
|
2044
|
-
configKey: "bash",
|
|
2045
|
-
description: "Shell command (requires --allow-bash). Output shape: $.steps.<id>.output is the parsed JSON when parseJson:true, otherwise the trimmed stdout string. $.steps.<id>.response always exposes { stdout, stderr, exitCode }.",
|
|
2046
|
-
fields: {
|
|
2047
|
-
command: { type: "string", required: true, description: "Shell command to execute (supports selectors)" },
|
|
2048
|
-
timeout: { type: "number", required: false, description: "Timeout in ms (default: 30000)" },
|
|
2049
|
-
parseJson: { type: "boolean", required: false, description: "Parse stdout as JSON (default: false). When true, $.steps.<id>.output is the parsed object/array; when false, it is the trimmed stdout string." },
|
|
2050
|
-
cwd: { type: "string", required: false, description: "Working directory (supports selectors)" },
|
|
2051
|
-
env: { type: "object", required: false, description: "Additional environment variables" }
|
|
2052
|
-
},
|
|
2053
|
-
example: {
|
|
2054
|
-
id: "analyze",
|
|
2055
|
-
name: "Analyze with Claude",
|
|
2056
|
-
type: "bash",
|
|
2057
|
-
bash: {
|
|
2058
|
-
command: "cat /tmp/data.json | claude --print 'Analyze this data' --output-format json",
|
|
2059
|
-
timeout: 18e4,
|
|
2060
|
-
parseJson: true
|
|
2061
|
-
}
|
|
2169
|
+
}
|
|
2170
|
+
context.loop = savedLoop;
|
|
2171
|
+
return {
|
|
2172
|
+
status: "success",
|
|
2173
|
+
output: results2,
|
|
2174
|
+
response: { items: results2, iterations: iterationResults }
|
|
2175
|
+
};
|
|
2176
|
+
}
|
|
2177
|
+
const results = [];
|
|
2178
|
+
for (let i = 0; i < bounded.length; i++) {
|
|
2179
|
+
context.loop = {
|
|
2180
|
+
[loop.as]: bounded[i],
|
|
2181
|
+
item: bounded[i],
|
|
2182
|
+
i
|
|
2183
|
+
};
|
|
2184
|
+
if (loop.indexAs) {
|
|
2185
|
+
context.loop[loop.indexAs] = i;
|
|
2186
|
+
}
|
|
2187
|
+
const beforeKeys = new Set(Object.keys(context.steps));
|
|
2188
|
+
await executeSteps(loop.steps, context, api, permissions, allowedActionIds, options, void 0, flowStack);
|
|
2189
|
+
const iterResult = {};
|
|
2190
|
+
for (const [key, val] of Object.entries(context.steps)) {
|
|
2191
|
+
if (!beforeKeys.has(key) || context.steps[key] !== (beforeKeys.has(key) ? void 0 : val)) {
|
|
2192
|
+
iterResult[key] = val;
|
|
2062
2193
|
}
|
|
2063
2194
|
}
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
return _stepTypeMap.get(type);
|
|
2195
|
+
iterationResults.push(iterResult);
|
|
2196
|
+
results.push(context.loop[loop.as]);
|
|
2197
|
+
}
|
|
2198
|
+
context.loop = savedLoop;
|
|
2199
|
+
return {
|
|
2200
|
+
status: "success",
|
|
2201
|
+
output: results,
|
|
2202
|
+
response: { items: results, iterations: iterationResults }
|
|
2203
|
+
};
|
|
2074
2204
|
}
|
|
2075
|
-
function
|
|
2076
|
-
|
|
2205
|
+
async function executeParallelStep(step, context, api, permissions, allowedActionIds, options, flowStack) {
|
|
2206
|
+
const parallel = step.parallel;
|
|
2207
|
+
const maxConcurrency = parallel.maxConcurrency || 5;
|
|
2208
|
+
const steps = parallel.steps;
|
|
2209
|
+
const results = [];
|
|
2210
|
+
for (let i = 0; i < steps.length; i += maxConcurrency) {
|
|
2211
|
+
const batch = steps.slice(i, i + maxConcurrency);
|
|
2212
|
+
const batchResults = await Promise.all(
|
|
2213
|
+
batch.map((s) => executeSingleStep(s, context, api, permissions, allowedActionIds, options, flowStack))
|
|
2214
|
+
);
|
|
2215
|
+
for (let j = 0; j < batch.length; j++) {
|
|
2216
|
+
context.steps[batch[j].id] = batchResults[j];
|
|
2217
|
+
}
|
|
2218
|
+
results.push(...batchResults);
|
|
2219
|
+
}
|
|
2220
|
+
return { status: "success", output: results, response: results };
|
|
2077
2221
|
}
|
|
2078
|
-
function
|
|
2079
|
-
const
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2222
|
+
function executeFileReadStep(step, context) {
|
|
2223
|
+
const config = step.fileRead;
|
|
2224
|
+
const filePath = resolveValue(config.path, context);
|
|
2225
|
+
const resolvedPath = path2.resolve(filePath);
|
|
2226
|
+
const content = fs2.readFileSync(resolvedPath, "utf-8");
|
|
2227
|
+
const output = config.parseJson ? JSON.parse(stripCodeFences(content)) : content;
|
|
2228
|
+
if (config.parseJson && config.schema) {
|
|
2229
|
+
assertMatchesFileReadSchema(output, config.schema, step.id);
|
|
2230
|
+
}
|
|
2231
|
+
return { status: "success", output, response: output };
|
|
2232
|
+
}
|
|
2233
|
+
function executeFileWriteStep(step, context) {
|
|
2234
|
+
const config = step.fileWrite;
|
|
2235
|
+
const filePath = resolveValue(config.path, context);
|
|
2236
|
+
const content = resolveValue(config.content, context);
|
|
2237
|
+
const resolvedPath = path2.resolve(filePath);
|
|
2238
|
+
const dir = path2.dirname(resolvedPath);
|
|
2239
|
+
if (!fs2.existsSync(dir)) {
|
|
2240
|
+
fs2.mkdirSync(dir, { recursive: true });
|
|
2241
|
+
}
|
|
2242
|
+
const stringContent = typeof content === "string" ? content : JSON.stringify(content, null, 2);
|
|
2243
|
+
if (config.append) {
|
|
2244
|
+
fs2.appendFileSync(resolvedPath, stringContent);
|
|
2245
|
+
} else {
|
|
2246
|
+
fs2.writeFileSync(resolvedPath, stringContent);
|
|
2247
|
+
}
|
|
2248
|
+
return { status: "success", output: { path: resolvedPath, bytesWritten: stringContent.length }, response: { path: resolvedPath } };
|
|
2249
|
+
}
|
|
2250
|
+
async function executeWhileStep(step, context, api, permissions, allowedActionIds, options, flowStack) {
|
|
2251
|
+
const config = step.while;
|
|
2252
|
+
const maxIterations = config.maxIterations ?? 100;
|
|
2253
|
+
const results = [];
|
|
2254
|
+
context.steps[step.id] = {
|
|
2255
|
+
status: "success",
|
|
2256
|
+
output: { lastResult: void 0, iteration: 0, results: [] }
|
|
2257
|
+
};
|
|
2258
|
+
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
|
2259
|
+
if (iteration > 0) {
|
|
2260
|
+
const conditionResult = evaluateCondition(config.condition, context);
|
|
2261
|
+
if (!conditionResult) break;
|
|
2085
2262
|
}
|
|
2263
|
+
await executeSteps(config.steps, context, api, permissions, allowedActionIds, options, void 0, flowStack);
|
|
2264
|
+
const lastStepId = config.steps[config.steps.length - 1]?.id;
|
|
2265
|
+
const lastResult = lastStepId ? context.steps[lastStepId]?.output : void 0;
|
|
2266
|
+
results.push(lastResult);
|
|
2267
|
+
context.steps[step.id] = {
|
|
2268
|
+
status: "success",
|
|
2269
|
+
output: { lastResult, iteration, results }
|
|
2270
|
+
};
|
|
2086
2271
|
}
|
|
2087
|
-
return
|
|
2272
|
+
return {
|
|
2273
|
+
status: "success",
|
|
2274
|
+
output: { lastResult: results[results.length - 1], iteration: results.length, results },
|
|
2275
|
+
response: { iterations: results.length, results }
|
|
2276
|
+
};
|
|
2088
2277
|
}
|
|
2089
|
-
function
|
|
2090
|
-
const
|
|
2091
|
-
const
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
one --agent flow create <key> --definition @flow.json # Create from file
|
|
2139
|
-
one --agent flow create <group/key> --definition '<json>' # Create in a subdirectory group
|
|
2140
|
-
one --agent flow list # List (shows group prefixes)
|
|
2141
|
-
one --agent flow validate <key> # Validate
|
|
2142
|
-
one --agent flow execute <key> -i name=value # Execute (bare key)
|
|
2143
|
-
one --agent flow execute <group/key> -i name=value # Execute (namespaced key)
|
|
2144
|
-
one --agent flow execute <key> --dry-run --mock # Test with mock data
|
|
2145
|
-
one --agent flow execute <key> --allow-bash # Enable bash steps
|
|
2146
|
-
one --agent flow runs [flowKey] # List past runs
|
|
2147
|
-
one --agent flow resume <runId> # Resume failed run
|
|
2148
|
-
one --agent flow scaffold [template] # Generate a starter template
|
|
2149
|
-
\`\`\`
|
|
2150
|
-
|
|
2151
|
-
You can also write the JSON file directly to \`.one/flows/<key>/flow.json\` (or \`.one/flows/<group>/<key>/flow.json\` for grouped flows) \u2014 often easier than passing large JSON via --definition. (The legacy \`.one/flows/<key>.flow.json\` single-file location is deprecated; don't use it for new flows.)
|
|
2152
|
-
|
|
2153
|
-
## Code modules (flow \`lib/\` folder)
|
|
2154
|
-
|
|
2155
|
-
A \`code\` step can either inline JS (\`code.source\`) or reference an external \`.mjs\` module (\`code.module\`). Modules live under the flow's \`lib/\` folder and run as a child \`node\` process:
|
|
2156
|
-
|
|
2157
|
-
\`\`\`
|
|
2158
|
-
.one/flows/my-flow/
|
|
2159
|
-
\u251C\u2500\u2500 flow.json
|
|
2160
|
-
\u2514\u2500\u2500 lib/
|
|
2161
|
-
\u2514\u2500\u2500 process-data.mjs
|
|
2162
|
-
\`\`\`
|
|
2163
|
-
|
|
2164
|
-
**Module contract:** the flow context \`$\` is piped to stdin as JSON; the module writes its result to stdout as JSON. That's the whole interface \u2014 no framework imports, no magic.
|
|
2165
|
-
|
|
2166
|
-
\`\`\`js
|
|
2167
|
-
// lib/process-data.mjs
|
|
2168
|
-
const $ = JSON.parse(await new Response(process.stdin).text());
|
|
2169
|
-
const items = $.steps.fetch.response.data ?? [];
|
|
2170
|
-
process.stdout.write(JSON.stringify(items.filter(i => i.active)));
|
|
2171
|
-
\`\`\`
|
|
2172
|
-
|
|
2173
|
-
\`\`\`json
|
|
2174
|
-
{
|
|
2175
|
-
"id": "processData",
|
|
2176
|
-
"name": "Process and enrich data",
|
|
2177
|
-
"type": "code",
|
|
2178
|
-
"code": { "module": "lib/process-data.mjs" }
|
|
2278
|
+
async function executeSubflowStep(step, context, api, permissions, allowedActionIds, options, flowStack) {
|
|
2279
|
+
const config = step.flow;
|
|
2280
|
+
const resolvedKey = resolveValue(config.key, context);
|
|
2281
|
+
const resolvedInputs = config.inputs ? resolveValue(config.inputs, context) : {};
|
|
2282
|
+
if (flowStack.includes(resolvedKey)) {
|
|
2283
|
+
throw new Error(`Circular flow detected: ${[...flowStack, resolvedKey].join(" \u2192 ")}`);
|
|
2284
|
+
}
|
|
2285
|
+
const { loadFlowWithMeta: loadFlowWithMeta2 } = await import("./flow-runner-JNEFFR2U.js");
|
|
2286
|
+
const { flow: subFlow, rootDir: subRootDir } = loadFlowWithMeta2(resolvedKey);
|
|
2287
|
+
const subContext = await executeFlow(
|
|
2288
|
+
subFlow,
|
|
2289
|
+
resolvedInputs,
|
|
2290
|
+
api,
|
|
2291
|
+
permissions,
|
|
2292
|
+
allowedActionIds,
|
|
2293
|
+
{ ...options, rootDir: subRootDir },
|
|
2294
|
+
void 0,
|
|
2295
|
+
[...flowStack, resolvedKey]
|
|
2296
|
+
);
|
|
2297
|
+
const finalStep = subFlow.steps[subFlow.steps.length - 1];
|
|
2298
|
+
const finalOutput = finalStep ? subContext.steps[finalStep.id]?.output : void 0;
|
|
2299
|
+
let flattenedOutput;
|
|
2300
|
+
if (finalOutput && typeof finalOutput === "object" && !Array.isArray(finalOutput)) {
|
|
2301
|
+
const collisions = Object.keys(finalOutput).filter(
|
|
2302
|
+
(k) => k in subContext.steps
|
|
2303
|
+
);
|
|
2304
|
+
if (collisions.length > 0) {
|
|
2305
|
+
options.onEvent?.({
|
|
2306
|
+
event: "flow:warning",
|
|
2307
|
+
message: `Sub-flow "${resolvedKey}" final step output fields [${collisions.join(", ")}] collide with sub-step ids \u2014 flattened fields take precedence.`
|
|
2308
|
+
});
|
|
2309
|
+
}
|
|
2310
|
+
flattenedOutput = {
|
|
2311
|
+
...subContext.steps,
|
|
2312
|
+
...finalOutput,
|
|
2313
|
+
_steps: subContext.steps
|
|
2314
|
+
};
|
|
2315
|
+
} else {
|
|
2316
|
+
flattenedOutput = {
|
|
2317
|
+
...subContext.steps,
|
|
2318
|
+
_steps: subContext.steps,
|
|
2319
|
+
...finalOutput !== void 0 ? { _finalOutput: finalOutput } : {}
|
|
2320
|
+
};
|
|
2321
|
+
}
|
|
2322
|
+
return {
|
|
2323
|
+
status: "success",
|
|
2324
|
+
output: flattenedOutput,
|
|
2325
|
+
response: flattenedOutput
|
|
2326
|
+
};
|
|
2179
2327
|
}
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
const
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
5. **Validate** \u2014 \`one --agent flow validate <key>\`
|
|
2222
|
-
6. **Execute** \u2014 \`one --agent flow execute <key> -i param=value\``);
|
|
2223
|
-
sections.push(`## Flow JSON Schema
|
|
2224
|
-
|
|
2225
|
-
\`\`\`json
|
|
2226
|
-
{
|
|
2227
|
-
"key": "my-workflow",
|
|
2228
|
-
"name": "My Workflow",
|
|
2229
|
-
"description": "What this flow does",
|
|
2230
|
-
"version": "1",
|
|
2231
|
-
"inputs": {
|
|
2232
|
-
"param": {
|
|
2233
|
-
"type": "string",
|
|
2234
|
-
"required": true,
|
|
2235
|
-
"description": "A user parameter"
|
|
2328
|
+
async function executePaginateStep(step, context, api, permissions, allowedActionIds, options) {
|
|
2329
|
+
const config = step.paginate;
|
|
2330
|
+
const maxPages = config.maxPages ?? 10;
|
|
2331
|
+
const allResults = [];
|
|
2332
|
+
let pageToken = void 0;
|
|
2333
|
+
let pages = 0;
|
|
2334
|
+
for (let page = 0; page < maxPages; page++) {
|
|
2335
|
+
const actionConfig = JSON.parse(JSON.stringify(config.action));
|
|
2336
|
+
if (pageToken !== void 0 && pageToken !== null) {
|
|
2337
|
+
const resolved = resolveValue(actionConfig, context);
|
|
2338
|
+
setByDotPath(resolved, config.inputTokenParam, pageToken);
|
|
2339
|
+
const syntheticStep = {
|
|
2340
|
+
id: `${step.id}__page${page}`,
|
|
2341
|
+
name: `${step.id} page ${page}`,
|
|
2342
|
+
type: "action",
|
|
2343
|
+
action: resolved
|
|
2344
|
+
};
|
|
2345
|
+
const result = await executeActionStep(syntheticStep, context, api, permissions, allowedActionIds, options);
|
|
2346
|
+
const response = result.response;
|
|
2347
|
+
const pageResults = getByDotPath(response, config.resultsField);
|
|
2348
|
+
if (Array.isArray(pageResults)) allResults.push(...pageResults);
|
|
2349
|
+
pageToken = getByDotPath(response, config.pageTokenField);
|
|
2350
|
+
pages++;
|
|
2351
|
+
options.onEvent?.({ event: "step:page", stepId: step.id, page: pages });
|
|
2352
|
+
if (pageToken === void 0 || pageToken === null) break;
|
|
2353
|
+
} else if (page === 0) {
|
|
2354
|
+
const resolvedAction = resolveValue(actionConfig, context);
|
|
2355
|
+
const syntheticStep = {
|
|
2356
|
+
id: `${step.id}__page0`,
|
|
2357
|
+
name: `${step.id} page 0`,
|
|
2358
|
+
type: "action",
|
|
2359
|
+
action: resolvedAction
|
|
2360
|
+
};
|
|
2361
|
+
const result = await executeActionStep(syntheticStep, context, api, permissions, allowedActionIds, options);
|
|
2362
|
+
const response = result.response;
|
|
2363
|
+
const pageResults = getByDotPath(response, config.resultsField);
|
|
2364
|
+
if (Array.isArray(pageResults)) allResults.push(...pageResults);
|
|
2365
|
+
pageToken = getByDotPath(response, config.pageTokenField);
|
|
2366
|
+
pages++;
|
|
2367
|
+
options.onEvent?.({ event: "step:page", stepId: step.id, page: pages });
|
|
2368
|
+
if (pageToken === void 0 || pageToken === null) break;
|
|
2236
2369
|
}
|
|
2237
|
-
}
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2370
|
+
}
|
|
2371
|
+
return {
|
|
2372
|
+
status: "success",
|
|
2373
|
+
output: allResults,
|
|
2374
|
+
response: { pages, totalResults: allResults.length, results: allResults }
|
|
2375
|
+
};
|
|
2376
|
+
}
|
|
2377
|
+
function resolveBashEnv(envConfig, context, stepId) {
|
|
2378
|
+
const out = {};
|
|
2379
|
+
const tempFiles = [];
|
|
2380
|
+
if (!envConfig) return { env: out, tempFiles };
|
|
2381
|
+
for (const [key, raw] of Object.entries(envConfig)) {
|
|
2382
|
+
if (raw === void 0 || raw === null) continue;
|
|
2383
|
+
if (typeof raw === "object" && !Array.isArray(raw)) {
|
|
2384
|
+
const obj = raw;
|
|
2385
|
+
if ("json" in obj) {
|
|
2386
|
+
const resolved2 = resolveValue(obj.json, context);
|
|
2387
|
+
const json = JSON.stringify(resolved2 ?? null);
|
|
2388
|
+
const tmp = path2.join(
|
|
2389
|
+
os2.tmpdir(),
|
|
2390
|
+
`one-flow-${stepId}-${key}-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json`
|
|
2391
|
+
);
|
|
2392
|
+
fs2.writeFileSync(tmp, json, { encoding: "utf-8" });
|
|
2393
|
+
tempFiles.push(tmp);
|
|
2394
|
+
out[key] = tmp;
|
|
2395
|
+
continue;
|
|
2396
|
+
}
|
|
2397
|
+
if ("shell" in obj) {
|
|
2398
|
+
const resolved2 = resolveValue(obj.shell, context);
|
|
2399
|
+
out[key] = resolved2 === void 0 || resolved2 === null ? "" : typeof resolved2 === "object" ? JSON.stringify(resolved2) : String(resolved2);
|
|
2400
|
+
continue;
|
|
2401
|
+
}
|
|
2402
|
+
out[key] = JSON.stringify(resolveValue(raw, context));
|
|
2403
|
+
continue;
|
|
2404
|
+
}
|
|
2405
|
+
const resolved = resolveValue(raw, context);
|
|
2406
|
+
out[key] = resolved === void 0 || resolved === null ? "" : typeof resolved === "object" ? JSON.stringify(resolved) : String(resolved);
|
|
2407
|
+
}
|
|
2408
|
+
return { env: out, tempFiles };
|
|
2409
|
+
}
|
|
2410
|
+
async function executeBashStep(step, context, options) {
|
|
2411
|
+
if (!options.allowBash) {
|
|
2412
|
+
throw new Error("Bash steps require --allow-bash flag for security");
|
|
2413
|
+
}
|
|
2414
|
+
const config = step.bash;
|
|
2415
|
+
const command = resolveValue(config.command, context);
|
|
2416
|
+
const cwd = config.cwd ? resolveValue(config.cwd, context) : process.cwd();
|
|
2417
|
+
const { env: resolvedEnv, tempFiles } = resolveBashEnv(
|
|
2418
|
+
config.env,
|
|
2419
|
+
context,
|
|
2420
|
+
step.id
|
|
2421
|
+
);
|
|
2422
|
+
const env = config.env ? { ...process.env, ...resolvedEnv } : process.env;
|
|
2423
|
+
try {
|
|
2424
|
+
const { stdout, stderr } = await execAsync(command, {
|
|
2425
|
+
timeout: config.timeout || 3e4,
|
|
2426
|
+
cwd,
|
|
2427
|
+
env,
|
|
2428
|
+
maxBuffer: 10 * 1024 * 1024
|
|
2429
|
+
});
|
|
2430
|
+
const output = config.parseJson ? JSON.parse(stripCodeFences(stdout)) : stdout.trim();
|
|
2431
|
+
return {
|
|
2432
|
+
status: "success",
|
|
2433
|
+
output,
|
|
2434
|
+
response: { stdout: stdout.trim(), stderr: stderr.trim(), exitCode: 0 }
|
|
2435
|
+
};
|
|
2436
|
+
} finally {
|
|
2437
|
+
for (const tmp of tempFiles) {
|
|
2438
|
+
try {
|
|
2439
|
+
fs2.unlinkSync(tmp);
|
|
2440
|
+
} catch {
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
function describe(value) {
|
|
2446
|
+
if (value === null) return "null";
|
|
2447
|
+
if (Array.isArray(value)) return `array (${JSON.stringify(value)})`;
|
|
2448
|
+
if (typeof value === "object") return `object (${JSON.stringify(value)})`;
|
|
2449
|
+
return `${typeof value} (${JSON.stringify(value)})`;
|
|
2450
|
+
}
|
|
2451
|
+
function isMissing(value) {
|
|
2452
|
+
if (value === void 0 || value === null) return true;
|
|
2453
|
+
if (typeof value === "string" && value.length === 0) return true;
|
|
2454
|
+
if (Array.isArray(value) && value.length === 0) return true;
|
|
2455
|
+
return false;
|
|
2456
|
+
}
|
|
2457
|
+
function explainMissing(selector, context) {
|
|
2458
|
+
const parts = selector.slice(2).split(".");
|
|
2459
|
+
if (parts[0] !== "steps" || parts.length < 2) return "";
|
|
2460
|
+
const stepId = parts[1].replace(/\[.*$/, "");
|
|
2461
|
+
const upstream = context.steps[stepId];
|
|
2462
|
+
if (!upstream) return ` (upstream step "${stepId}" has not run)`;
|
|
2463
|
+
if (upstream.status === "skipped") return ` (upstream step "${stepId}" was skipped)`;
|
|
2464
|
+
if (upstream.status === "failed") return ` (upstream step "${stepId}" failed: ${upstream.error ?? "unknown error"})`;
|
|
2465
|
+
if (upstream.status === "timeout") return ` (upstream step "${stepId}" timed out)`;
|
|
2466
|
+
return "";
|
|
2467
|
+
}
|
|
2468
|
+
function checkRequires(step, context) {
|
|
2469
|
+
if (!step.requires || step.requires.length === 0) return;
|
|
2470
|
+
for (const selector of step.requires) {
|
|
2471
|
+
const value = resolveSelector(selector, context);
|
|
2472
|
+
if (isMissing(value)) {
|
|
2473
|
+
const why = explainMissing(selector, context);
|
|
2474
|
+
throw new Error(
|
|
2475
|
+
`Step "${step.id}" requires ${selector} but it resolved to ${value === void 0 ? "undefined" : value === null ? "null" : Array.isArray(value) ? "an empty array" : "an empty string"}${why}`
|
|
2476
|
+
);
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
async function executeSingleStep(step, context, api, permissions, allowedActionIds, options, flowStack = []) {
|
|
2481
|
+
if (step.if) {
|
|
2482
|
+
const condResult = evaluateCondition(step.if, context);
|
|
2483
|
+
if (!condResult) {
|
|
2484
|
+
const result = { status: "skipped" };
|
|
2485
|
+
context.steps[step.id] = result;
|
|
2486
|
+
return result;
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
if (step.unless) {
|
|
2490
|
+
const condResult = evaluateCondition(step.unless, context);
|
|
2491
|
+
if (condResult) {
|
|
2492
|
+
const result = { status: "skipped" };
|
|
2493
|
+
context.steps[step.id] = result;
|
|
2494
|
+
return result;
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
const startTime = Date.now();
|
|
2498
|
+
let lastError;
|
|
2499
|
+
const onError = step.onError ?? context._defaultOnError;
|
|
2500
|
+
const maxAttempts = onError?.strategy === "retry" && onError.retries ? onError.retries + 1 : 1;
|
|
2501
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
2502
|
+
try {
|
|
2503
|
+
if (attempt > 1) {
|
|
2504
|
+
const delay = computeRetryDelay(onError, attempt);
|
|
2505
|
+
options.onEvent?.({
|
|
2506
|
+
event: "step:retry",
|
|
2507
|
+
stepId: step.id,
|
|
2508
|
+
attempt,
|
|
2509
|
+
maxRetries: onError.retries,
|
|
2510
|
+
delayMs: delay
|
|
2511
|
+
});
|
|
2512
|
+
await sleep2(delay);
|
|
2513
|
+
}
|
|
2514
|
+
checkRequires(step, context);
|
|
2515
|
+
if (options.mock && (step.type === "action" || step.type === "paginate" || step.type === "bash")) {
|
|
2516
|
+
const resolvedConfig = step[step.type] ? resolveValue(step[step.type], context) : {};
|
|
2517
|
+
let mockOutput = { _mock: true, ...resolvedConfig };
|
|
2518
|
+
if (step.type === "action" && step.action) {
|
|
2519
|
+
const actionId = resolveValue(step.action.actionId, context);
|
|
2520
|
+
try {
|
|
2521
|
+
const { details: actionDetails } = await resolveActionDetails(api, actionId);
|
|
2522
|
+
if (!options.skipValidation) {
|
|
2523
|
+
const data = step.action.data ? resolveValue(step.action.data, context) : void 0;
|
|
2524
|
+
const pathVars = step.action.pathVars ? resolveValue(step.action.pathVars, context) : void 0;
|
|
2525
|
+
const queryParams = step.action.queryParams ? resolveValue(step.action.queryParams, context) : void 0;
|
|
2526
|
+
const validation = validateActionInput(actionDetails, { data, pathVariables: pathVars, queryParams });
|
|
2527
|
+
if (!validation.valid) {
|
|
2528
|
+
const details = validation.missing.map((m) => `${m.flag} is missing "${m.param}"`).join("; ");
|
|
2529
|
+
throw new Error(`Validation failed for step "${step.id}": ${details}. Pass --skip-validation to bypass.`);
|
|
2530
|
+
}
|
|
2531
|
+
}
|
|
2532
|
+
if (actionDetails.ioSchema?.ioExample?.output) {
|
|
2533
|
+
mockOutput = actionDetails.ioSchema.ioExample.output;
|
|
2534
|
+
}
|
|
2535
|
+
} catch (e) {
|
|
2536
|
+
if (e instanceof Error && e.message.startsWith("Validation failed")) throw e;
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
options.onEvent?.({ event: "step:mock", stepId: step.id, type: step.type, config: resolvedConfig });
|
|
2540
|
+
const result2 = {
|
|
2541
|
+
status: "success",
|
|
2542
|
+
output: mockOutput,
|
|
2543
|
+
response: { _mock: true },
|
|
2544
|
+
durationMs: Date.now() - startTime
|
|
2545
|
+
};
|
|
2546
|
+
context.steps[step.id] = result2;
|
|
2547
|
+
return result2;
|
|
2548
|
+
}
|
|
2549
|
+
const dispatch = async () => {
|
|
2550
|
+
switch (step.type) {
|
|
2551
|
+
case "action":
|
|
2552
|
+
return await executeActionStep(step, context, api, permissions, allowedActionIds, options);
|
|
2553
|
+
case "transform":
|
|
2554
|
+
return executeTransformStep(step, context);
|
|
2555
|
+
case "code":
|
|
2556
|
+
return await executeCodeStep(step, context, options);
|
|
2557
|
+
case "condition":
|
|
2558
|
+
return await executeConditionStep(step, context, api, permissions, allowedActionIds, options, flowStack);
|
|
2559
|
+
case "loop":
|
|
2560
|
+
return await executeLoopStep(step, context, api, permissions, allowedActionIds, options, flowStack);
|
|
2561
|
+
case "parallel":
|
|
2562
|
+
return await executeParallelStep(step, context, api, permissions, allowedActionIds, options, flowStack);
|
|
2563
|
+
case "file-read":
|
|
2564
|
+
return executeFileReadStep(step, context);
|
|
2565
|
+
case "file-write":
|
|
2566
|
+
return executeFileWriteStep(step, context);
|
|
2567
|
+
case "while":
|
|
2568
|
+
return await executeWhileStep(step, context, api, permissions, allowedActionIds, options, flowStack);
|
|
2569
|
+
case "flow":
|
|
2570
|
+
return await executeSubflowStep(step, context, api, permissions, allowedActionIds, options, flowStack);
|
|
2571
|
+
case "paginate":
|
|
2572
|
+
return await executePaginateStep(step, context, api, permissions, allowedActionIds, options);
|
|
2573
|
+
case "bash":
|
|
2574
|
+
return await executeBashStep(step, context, options);
|
|
2575
|
+
default:
|
|
2576
|
+
throw new Error(`Unknown step type: ${step.type}`);
|
|
2577
|
+
}
|
|
2578
|
+
};
|
|
2579
|
+
const result = step.timeoutMs ? await withTimeout(dispatch(), step.timeoutMs, step.id) : await dispatch();
|
|
2580
|
+
result.durationMs = Date.now() - startTime;
|
|
2581
|
+
if (attempt > 1) {
|
|
2582
|
+
result.retries = attempt - 1;
|
|
2583
|
+
options.onEvent?.({
|
|
2584
|
+
event: "step:retry-success",
|
|
2585
|
+
stepId: step.id,
|
|
2586
|
+
retries: attempt - 1
|
|
2587
|
+
});
|
|
2588
|
+
}
|
|
2589
|
+
context.steps[step.id] = result;
|
|
2590
|
+
return result;
|
|
2591
|
+
} catch (err) {
|
|
2592
|
+
if (err instanceof FlowStopSignal) throw err;
|
|
2593
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
2594
|
+
if (attempt === maxAttempts) {
|
|
2595
|
+
break;
|
|
2596
|
+
}
|
|
2597
|
+
if (onError?.strategy === "retry" && (onError.retryOn || onError.failFastOn)) {
|
|
2598
|
+
const decision = shouldRetryError(lastError, onError);
|
|
2599
|
+
if (!decision.retry) {
|
|
2600
|
+
options.onEvent?.({
|
|
2601
|
+
event: "step:retry-skip",
|
|
2602
|
+
stepId: step.id,
|
|
2603
|
+
reason: decision.reason ?? "no-match",
|
|
2604
|
+
error: lastError.message
|
|
2605
|
+
});
|
|
2606
|
+
break;
|
|
2607
|
+
}
|
|
2248
2608
|
}
|
|
2249
2609
|
}
|
|
2250
|
-
]
|
|
2251
|
-
}
|
|
2252
|
-
\`\`\`
|
|
2253
|
-
|
|
2254
|
-
### Top-level fields
|
|
2255
|
-
|
|
2256
|
-
| Field | Type | Required | Description |
|
|
2257
|
-
|-------|------|----------|-------------|`);
|
|
2258
|
-
for (const [name, fd] of Object.entries(FLOW_SCHEMA.flowFields)) {
|
|
2259
|
-
sections.push(`| \`${name}\` | ${fd.type} | ${fd.required ? "yes" : "no"} | ${fd.description} |`);
|
|
2260
|
-
}
|
|
2261
|
-
sections.push(`
|
|
2262
|
-
### Input declarations
|
|
2263
|
-
|
|
2264
|
-
| Field | Type | Required | Description |
|
|
2265
|
-
|-------|------|----------|-------------|`);
|
|
2266
|
-
for (const [name, fd] of Object.entries(FLOW_SCHEMA.inputFields)) {
|
|
2267
|
-
sections.push(`| \`${name}\` | ${fd.type} | ${fd.required ? "yes" : "no"} | ${fd.description} |`);
|
|
2268
|
-
}
|
|
2269
|
-
sections.push(`
|
|
2270
|
-
### Step fields (all steps)
|
|
2271
|
-
|
|
2272
|
-
Every step MUST have \`id\`, \`name\`, and \`type\`. The \`type\` determines which config object is required.
|
|
2273
|
-
|
|
2274
|
-
| Field | Type | Required | Description |
|
|
2275
|
-
|-------|------|----------|-------------|`);
|
|
2276
|
-
for (const [name, fd] of Object.entries(FLOW_SCHEMA.stepCommonFields)) {
|
|
2277
|
-
sections.push(`| \`${name}\` | ${fd.type} | ${fd.required ? "yes" : "no"} | ${fd.description} |`);
|
|
2278
|
-
}
|
|
2279
|
-
sections.push(`| \`onError\` | object | no | Error handling: \`{ "strategy": "${FLOW_SCHEMA.errorStrategies.join(" | ")}", "retries": 3, "retryDelayMs": 1000, "backoff": "fixed \\| exponential \\| exponential-jitter", "maxDelayMs": 30000, "retryOn": [429, 502, "ETIMEDOUT"], "failFastOn": [401, 403, 404] }\`. \`retryOn\`/\`failFastOn\` (cli#53) make retries conditional on the error code/status \u2014 \`failFastOn\` matches skip the retry entirely. |`);
|
|
2280
|
-
sections.push(`
|
|
2281
|
-
## Step Types
|
|
2282
|
-
|
|
2283
|
-
**IMPORTANT:** Each step type requires a config object nested under a specific key. The type name and config key differ for some types (noted below).
|
|
2284
|
-
|
|
2285
|
-
| Type | Config Key | Description |
|
|
2286
|
-
|------|-----------|-------------|`);
|
|
2287
|
-
for (const st of FLOW_SCHEMA.stepTypes) {
|
|
2288
|
-
const keyNote = st.type !== st.configKey ? ` \u26A0\uFE0F` : "";
|
|
2289
|
-
sections.push(`| \`${st.type}\` | \`${st.configKey}\`${keyNote} | ${st.description} |`);
|
|
2290
|
-
}
|
|
2291
|
-
sections.push(`
|
|
2292
|
-
## Step Type Reference`);
|
|
2293
|
-
for (const st of FLOW_SCHEMA.stepTypes) {
|
|
2294
|
-
sections.push(`
|
|
2295
|
-
### \`${st.type}\` \u2014 ${st.description}`);
|
|
2296
|
-
if (st.type !== st.configKey) {
|
|
2297
|
-
sections.push(`
|
|
2298
|
-
> **Note:** Type is \`"${st.type}"\` but config key is \`"${st.configKey}"\` (camelCase).`);
|
|
2299
|
-
}
|
|
2300
|
-
sections.push(`
|
|
2301
|
-
| Field | Type | Required | Description |
|
|
2302
|
-
|-------|------|----------|-------------|`);
|
|
2303
|
-
for (const [name, fd] of Object.entries(st.fields)) {
|
|
2304
|
-
sections.push(`| \`${name}\` | ${fd.type} | ${fd.required ? "yes" : "no"} | ${fd.description} |`);
|
|
2305
|
-
}
|
|
2306
|
-
sections.push(`
|
|
2307
|
-
\`\`\`json
|
|
2308
|
-
${JSON.stringify(st.example, null, 2)}
|
|
2309
|
-
\`\`\``);
|
|
2310
2610
|
}
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
Handlebars interpolations support pipe-based escaping for safe embedding into shell commands, JSON, URLs, markdown, or HTML:
|
|
2327
|
-
|
|
2328
|
-
| Pipe | Effect |
|
|
2329
|
-
|------|--------|
|
|
2330
|
-
| \`{{ $.x \\| json }}\` | \`JSON.stringify\` (handles quotes, newlines, unicode) |
|
|
2331
|
-
| \`{{ $.x \\| shell }}\` | POSIX-shell-quote \u2014 safe inside bash arguments |
|
|
2332
|
-
| \`{{ $.x \\| url }}\` | \`encodeURIComponent\` |
|
|
2333
|
-
| \`{{ $.x \\| md }}\` | Escape markdown structural characters |
|
|
2334
|
-
| \`{{ $.x \\| html }}\` | Entity-escape \`& < > " '\` |
|
|
2335
|
-
|
|
2336
|
-
Pipes can be applied to any value (objects/arrays are JSON-stringified first for shell/url/md/html). An unknown pipe name throws at runtime. The legacy \`{{q $.x}}\` shell-quote helper still works but new flows should prefer \`{{$.x | shell}}\`.
|
|
2337
|
-
|
|
2338
|
-
### When to use bare selectors vs \`{{...}}\` interpolation
|
|
2339
|
-
|
|
2340
|
-
- **Bare selectors** (\`$.input.x\`): Use for fields the engine resolves directly \u2014 \`connectionKey\`, the \`tag\` (or \`platform\`) inside \`connection\`, \`over\`, \`path\`, \`expression\`, \`condition\`, and any field where the entire value is a single selector. The resolved value keeps its original type (object, array, number).
|
|
2341
|
-
- **Interpolation** (\`{{$.input.x}}\`): Use inside string values where the selector is embedded in text \u2014 e.g., \`"Hello {{$.steps.getUser.response.name}}"\`. The resolved value is always stringified. Use this in \`data\`, \`pathVars\`, and \`queryParams\` when mixing selectors with literal text.
|
|
2342
|
-
- **Rule of thumb**: If the value is purely a selector, use bare. If it's a string containing a selector, use \`{{...}}\`.
|
|
2343
|
-
|
|
2344
|
-
### Selectors vs expressions
|
|
2345
|
-
|
|
2346
|
-
Selectors in data fields (\`data\`, \`queryParams\`, \`pathVars\`, \`connectionKey\`, \`connection.tag\`) are **dot-path lookups only** \u2014 they do not support JavaScript operators like \`||\` or \`&&\`. For default values, use the \`default\` field on the input definition:
|
|
2347
|
-
|
|
2348
|
-
\`\`\`json
|
|
2349
|
-
{ "inputs": { "maxResults": { "type": "number", "default": 10 } } }
|
|
2350
|
-
\`\`\`
|
|
2351
|
-
|
|
2352
|
-
The \`if\`, \`unless\`, \`condition.expression\`, \`while.condition\`, \`transform.expression\`, and \`code.source\` fields **do** support full JavaScript expressions (e.g., \`$.input.email && $.input.email.length > 0\`).
|
|
2353
|
-
|
|
2354
|
-
### \`output\` vs \`response\` on step results
|
|
2355
|
-
|
|
2356
|
-
Every completed step produces both \`output\` and \`response\`:
|
|
2357
|
-
- **Action steps**: \`response\` is the raw API response. \`output\` is the same as \`response\`.
|
|
2358
|
-
- **Code/transform steps**: \`output\` is the return value. \`response\` is an alias for \`output\`.
|
|
2359
|
-
- **In practice**: Use \`$.steps.stepId.response\` for action steps (API data) and \`$.steps.stepId.output\` for code/transform steps (computed data). Both work interchangeably, but using the semantically correct one makes flows easier to read.
|
|
2360
|
-
|
|
2361
|
-
### Step result metadata (\`status\`, \`error\`, \`errorCode\`)
|
|
2362
|
-
|
|
2363
|
-
Every step result also exposes execution metadata that downstream steps can inspect:
|
|
2364
|
-
|
|
2365
|
-
| Field | Values | When set |
|
|
2366
|
-
|-------|--------|----------|
|
|
2367
|
-
| \`$.steps.X.status\` | \`"success"\` \\| \`"skipped"\` \\| \`"failed"\` \\| \`"timeout"\` | Always |
|
|
2368
|
-
| \`$.steps.X.error\` | error message string | When status is \`failed\` or \`timeout\` |
|
|
2369
|
-
| \`$.steps.X.errorCode\` | machine-readable code (e.g. \`"TIMEOUT"\`) | When the error has a code |
|
|
2370
|
-
| \`$.steps.X.durationMs\` | number | Always |
|
|
2371
|
-
| \`$.steps.X.retries\` | number | When the step was retried |
|
|
2372
|
-
|
|
2373
|
-
This lets downstream steps distinguish \`skipped\` (\`if\` condition false) from \`failed\` (error, \`onError:continue\`) from \`timeout\` (exceeded \`timeoutMs\`) \u2014 e.g. \`"if": "$.steps.enrichment.status === 'timeout'"\` to retry with a longer window.
|
|
2374
|
-
|
|
2375
|
-
### Sub-flow output (flattened)
|
|
2376
|
-
|
|
2377
|
-
When a step has \`type: "flow"\`, the sub-flow's final step output is flattened onto the parent step's \`output\`:
|
|
2378
|
-
|
|
2379
|
-
\`\`\`jsonc
|
|
2380
|
-
// Sub-flow "sub-consts" has a final step "load" that returns { CHART_URL, API_KEY }
|
|
2381
|
-
|
|
2382
|
-
// Preferred (flattened):
|
|
2383
|
-
"{{$.steps.loadConfig.output.CHART_URL}}"
|
|
2384
|
-
|
|
2385
|
-
// Legacy nested path (still works for backward compatibility):
|
|
2386
|
-
"{{$.steps.loadConfig.output.load.output.CHART_URL}}"
|
|
2387
|
-
|
|
2388
|
-
// Escape hatch for programmatic access to the full sub-flow steps map:
|
|
2389
|
-
"{{$.steps.loadConfig.output._steps.load.output.CHART_URL}}"
|
|
2390
|
-
\`\`\`
|
|
2391
|
-
|
|
2392
|
-
If a sub-step id collides with a flattened field name, the flattened field wins and the engine emits a \`flow:warning\` event.
|
|
2393
|
-
|
|
2394
|
-
## Error Handling
|
|
2395
|
-
|
|
2396
|
-
\`\`\`json
|
|
2397
|
-
{"onError": {"strategy": "retry", "retries": 3, "retryDelayMs": 1000}}
|
|
2398
|
-
\`\`\`
|
|
2399
|
-
|
|
2400
|
-
Strategies: \`${FLOW_SCHEMA.errorStrategies.join("`, `")}\`
|
|
2401
|
-
|
|
2402
|
-
**Conditional retry (cli#53):** add \`retryOn\` and/or \`failFastOn\` to discriminate transient errors from permanent ones. \`failFastOn\` takes precedence; \`retryOn\` (when set) requires a match for the retry to happen. Numbers match against any 3-digit substring of the error message (HTTP statuses); strings match against \`error.errorCode\` exactly OR as a case-insensitive substring of the message.
|
|
2403
|
-
|
|
2404
|
-
\`\`\`json
|
|
2405
|
-
{
|
|
2406
|
-
"onError": {
|
|
2407
|
-
"strategy": "retry",
|
|
2408
|
-
"retries": 4,
|
|
2409
|
-
"backoff": "exponential",
|
|
2410
|
-
"retryOn": [429, 502, 503, "ETIMEDOUT", "ECONNRESET"],
|
|
2411
|
-
"failFastOn": [401, 403, 404]
|
|
2611
|
+
const errorMessage = lastError?.message || "Unknown error";
|
|
2612
|
+
const strategy = onError?.strategy || "fail";
|
|
2613
|
+
const retriesUsed = Math.max(0, maxAttempts - 1);
|
|
2614
|
+
const isTimeout = lastError instanceof StepTimeoutError;
|
|
2615
|
+
const errorCode = lastError?.errorCode;
|
|
2616
|
+
if (strategy === "continue") {
|
|
2617
|
+
const result = {
|
|
2618
|
+
status: isTimeout ? "timeout" : "failed",
|
|
2619
|
+
error: errorMessage,
|
|
2620
|
+
...errorCode ? { errorCode } : {},
|
|
2621
|
+
durationMs: Date.now() - startTime,
|
|
2622
|
+
retries: retriesUsed
|
|
2623
|
+
};
|
|
2624
|
+
context.steps[step.id] = result;
|
|
2625
|
+
return result;
|
|
2412
2626
|
}
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
"type": "flow",
|
|
2424
|
-
"flow": { "key": "company-research" },
|
|
2425
|
-
"outputSchema": {
|
|
2426
|
-
"company": "string",
|
|
2427
|
-
"charCount": "number",
|
|
2428
|
-
"quality": { "confidence": "string", "score": "number" }
|
|
2627
|
+
if (strategy === "fallback" && onError?.fallbackStepId) {
|
|
2628
|
+
const result = {
|
|
2629
|
+
status: isTimeout ? "timeout" : "failed",
|
|
2630
|
+
error: errorMessage,
|
|
2631
|
+
...errorCode ? { errorCode } : {},
|
|
2632
|
+
durationMs: Date.now() - startTime,
|
|
2633
|
+
retries: retriesUsed
|
|
2634
|
+
};
|
|
2635
|
+
context.steps[step.id] = result;
|
|
2636
|
+
return result;
|
|
2429
2637
|
}
|
|
2638
|
+
throw lastError;
|
|
2430
2639
|
}
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
{
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
}
|
|
2447
|
-
|
|
2640
|
+
async function executeSteps(steps, context, api, permissions, allowedActionIds, options, completedStepIds, flowStack = []) {
|
|
2641
|
+
const results = [];
|
|
2642
|
+
const isDryResolve = !!options.dryRun && !options.mock;
|
|
2643
|
+
for (const step of steps) {
|
|
2644
|
+
if (completedStepIds?.has(step.id)) {
|
|
2645
|
+
results.push(context.steps[step.id] || { status: "success" });
|
|
2646
|
+
continue;
|
|
2647
|
+
}
|
|
2648
|
+
if (isDryResolve && options.stopAfter === step.id) {
|
|
2649
|
+
options.onEvent?.({
|
|
2650
|
+
event: "step:dry-resolve",
|
|
2651
|
+
...dryResolveStep(step, context),
|
|
2652
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2653
|
+
});
|
|
2654
|
+
throw new FlowStopSignal(step.id);
|
|
2655
|
+
}
|
|
2656
|
+
options.onEvent?.({
|
|
2657
|
+
event: "step:start",
|
|
2658
|
+
stepId: step.id,
|
|
2659
|
+
stepName: step.name,
|
|
2660
|
+
type: step.type,
|
|
2661
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2662
|
+
});
|
|
2663
|
+
try {
|
|
2664
|
+
const result = await executeSingleStep(step, context, api, permissions, allowedActionIds, options, flowStack);
|
|
2665
|
+
results.push(result);
|
|
2666
|
+
options.onEvent?.({
|
|
2667
|
+
event: "step:complete",
|
|
2668
|
+
stepId: step.id,
|
|
2669
|
+
status: result.status,
|
|
2670
|
+
durationMs: result.durationMs,
|
|
2671
|
+
retries: result.retries
|
|
2672
|
+
});
|
|
2673
|
+
} catch (error) {
|
|
2674
|
+
if (error instanceof FlowStopSignal) throw error;
|
|
2675
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
2676
|
+
options.onEvent?.({
|
|
2677
|
+
event: "step:error",
|
|
2678
|
+
stepId: step.id,
|
|
2679
|
+
error: errorMsg,
|
|
2680
|
+
strategy: step.onError?.strategy || "fail"
|
|
2681
|
+
});
|
|
2682
|
+
throw error;
|
|
2683
|
+
}
|
|
2684
|
+
if (!isDryResolve && options.stopAfter === step.id) {
|
|
2685
|
+
throw new FlowStopSignal(step.id);
|
|
2686
|
+
}
|
|
2448
2687
|
}
|
|
2688
|
+
return results;
|
|
2449
2689
|
}
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
A \`flow\` step's \`flow.key\` accepts selectors and Handlebars interpolations, so a single orchestrator can route to different sub-flows at runtime:
|
|
2459
|
-
|
|
2460
|
-
\`\`\`json
|
|
2461
|
-
{ "type": "flow", "flow": { "key": "{{$.input.target}}", "inputs": { "company": "$.input.company" } } }
|
|
2462
|
-
\`\`\`
|
|
2463
|
-
|
|
2464
|
-
Conditional execution: \`"if": "$.steps.prev.response.data.length > 0"\`
|
|
2465
|
-
|
|
2466
|
-
## Connection Resolution \u2014 late-bound by default
|
|
2467
|
-
|
|
2468
|
-
Action steps reference a platform connection in one of two forms:
|
|
2469
|
-
|
|
2470
|
-
\`\`\`json
|
|
2471
|
-
// preferred \u2014 late-bound, survives re-auth
|
|
2472
|
-
"connection": { "platform": "gmail" }
|
|
2473
|
-
|
|
2474
|
-
// multi-account: disambiguate with the connection's tag
|
|
2475
|
-
"connection": { "platform": "gmail", "tag": "work@example.com" }
|
|
2476
|
-
|
|
2477
|
-
// legacy \u2014 works for backwards compat, breaks on re-auth
|
|
2478
|
-
"connectionKey": "live::gmail::default::abc123..."
|
|
2479
|
-
\`\`\`
|
|
2480
|
-
|
|
2481
|
-
The engine resolves the \`connection\` ref once per flow run (cached for the run's lifetime) by calling \`listConnections\` and matching on platform + optional tag. Resolution errors fail the step with a clear message \u2014 \`No connection found for platform "X"\`, \`Multiple "X" connections found (tags: ...). Add a "tag" field\`, or \`No "X" connection has tag "Y"\`.
|
|
2482
|
-
|
|
2483
|
-
Both \`platform\` and \`tag\` accept \`$.input.x\` selectors so a flow can be parameterised per-execution (e.g. multi-tenant orchestrators that pass the user's email as the tag).
|
|
2484
|
-
|
|
2485
|
-
The validator rejects any action that sets both forms or neither, at \`flow validate\` and \`flow execute\` time.
|
|
2486
|
-
|
|
2487
|
-
### Optional input metadata: connection-key auto-resolve (legacy)
|
|
2488
|
-
|
|
2489
|
-
For flows that still use literal \`connectionKey\` strings via inputs, an input declaration can carry a \`"connection": { "platform": "..." }\` hint so \`flow execute\` auto-fills a single matching connection's key. New flows don't need this \u2014 switch the action's connection form to \`{ platform, tag? }\` and skip the input entirely.
|
|
2490
|
-
|
|
2491
|
-
## Complete Example: Fetch Data, Transform, Notify
|
|
2492
|
-
|
|
2493
|
-
\`\`\`json
|
|
2494
|
-
{
|
|
2495
|
-
"key": "contacts-to-slack",
|
|
2496
|
-
"name": "CRM Contacts Summary to Slack",
|
|
2497
|
-
"description": "Fetch recent contacts from CRM, build a summary, post to Slack",
|
|
2498
|
-
"version": "1",
|
|
2499
|
-
"inputs": {
|
|
2500
|
-
"slackChannel": {
|
|
2501
|
-
"type": "string",
|
|
2502
|
-
"required": true,
|
|
2503
|
-
"description": "Slack channel name or ID"
|
|
2504
|
-
}
|
|
2505
|
-
},
|
|
2506
|
-
"steps": [
|
|
2507
|
-
{
|
|
2508
|
-
"id": "fetchContacts",
|
|
2509
|
-
"name": "Fetch recent contacts",
|
|
2510
|
-
"type": "action",
|
|
2511
|
-
"action": {
|
|
2512
|
-
"platform": "attio",
|
|
2513
|
-
"actionId": "ATTIO_LIST_PEOPLE_ACTION_ID",
|
|
2514
|
-
"connection": { "platform": "attio" },
|
|
2515
|
-
"queryParams": { "limit": "10" }
|
|
2690
|
+
async function executeFlow(flow, inputs, api, permissions, allowedActionIds, options = {}, resumeState, flowStack = []) {
|
|
2691
|
+
const resolvedInputs = {};
|
|
2692
|
+
for (const [name, decl] of Object.entries(flow.inputs)) {
|
|
2693
|
+
const provided = inputs[name];
|
|
2694
|
+
const isMissing2 = provided === void 0 || provided === null;
|
|
2695
|
+
if (isMissing2) {
|
|
2696
|
+
if (decl.required !== false && decl.default === void 0) {
|
|
2697
|
+
throw new Error(`Missing required input: "${name}"${decl.description ? ` \u2014 ${decl.description}` : ""}`);
|
|
2516
2698
|
}
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
"id": "buildSummary",
|
|
2520
|
-
"name": "Build formatted summary",
|
|
2521
|
-
"type": "code",
|
|
2522
|
-
"code": {
|
|
2523
|
-
"source": "const contacts = $.steps.fetchContacts.response.data || [];\\nconst lines = contacts.map((c, i) => \`\${i+1}. \${c.name || 'Unknown'} \u2014 \${c.email || 'no email'}\`);\\nreturn { summary: \`Found \${contacts.length} contacts:\\n\${lines.join('\\n')}\` };"
|
|
2699
|
+
if (decl.default !== void 0) {
|
|
2700
|
+
resolvedInputs[name] = decl.default;
|
|
2524
2701
|
}
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
"
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2702
|
+
continue;
|
|
2703
|
+
}
|
|
2704
|
+
let value = provided;
|
|
2705
|
+
switch (decl.type) {
|
|
2706
|
+
case "string":
|
|
2707
|
+
if (typeof value !== "string") value = String(value);
|
|
2708
|
+
break;
|
|
2709
|
+
case "number":
|
|
2710
|
+
if (typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value))) {
|
|
2711
|
+
value = Number(value);
|
|
2712
|
+
}
|
|
2713
|
+
if (typeof value !== "number" || Number.isNaN(value)) {
|
|
2714
|
+
throw new Error(`Input "${name}" must be a number, got ${describe(provided)}`);
|
|
2715
|
+
}
|
|
2716
|
+
break;
|
|
2717
|
+
case "boolean":
|
|
2718
|
+
if (value === "true" || value === "1" || value === 1) value = true;
|
|
2719
|
+
else if (value === "false" || value === "0" || value === 0) value = false;
|
|
2720
|
+
if (typeof value !== "boolean") {
|
|
2721
|
+
throw new Error(`Input "${name}" must be a boolean, got ${describe(provided)}`);
|
|
2722
|
+
}
|
|
2723
|
+
break;
|
|
2724
|
+
case "array":
|
|
2725
|
+
if (typeof value === "string") {
|
|
2726
|
+
try {
|
|
2727
|
+
value = JSON.parse(value);
|
|
2728
|
+
} catch {
|
|
2729
|
+
}
|
|
2730
|
+
}
|
|
2731
|
+
if (!Array.isArray(value)) {
|
|
2732
|
+
throw new Error(`Input "${name}" must be an array, got ${describe(provided)}`);
|
|
2733
|
+
}
|
|
2734
|
+
break;
|
|
2735
|
+
case "object":
|
|
2736
|
+
if (typeof value === "string") {
|
|
2737
|
+
try {
|
|
2738
|
+
value = JSON.parse(value);
|
|
2739
|
+
} catch {
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
2743
|
+
throw new Error(`Input "${name}" must be an object, got ${describe(provided)}`);
|
|
2537
2744
|
}
|
|
2745
|
+
break;
|
|
2746
|
+
}
|
|
2747
|
+
if (Array.isArray(decl.enum) && decl.enum.length > 0) {
|
|
2748
|
+
if (!decl.enum.some((allowed) => allowed === value)) {
|
|
2749
|
+
throw new Error(`Input "${name}" must be one of ${JSON.stringify(decl.enum)}, got ${JSON.stringify(value)}`);
|
|
2538
2750
|
}
|
|
2539
2751
|
}
|
|
2540
|
-
|
|
2541
|
-
}
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2752
|
+
resolvedInputs[name] = value;
|
|
2753
|
+
}
|
|
2754
|
+
const context = resumeState?.context || {
|
|
2755
|
+
input: resolvedInputs,
|
|
2756
|
+
env: process.env,
|
|
2757
|
+
steps: {},
|
|
2758
|
+
loop: {}
|
|
2759
|
+
};
|
|
2760
|
+
context.input = resolvedInputs;
|
|
2761
|
+
context._defaultOnError = flow.defaultOnError;
|
|
2762
|
+
const completedStepIds = resumeState ? new Set(resumeState.completedSteps) : void 0;
|
|
2763
|
+
if (options.dryRun && !options.mock && !options.stopAfter) {
|
|
2764
|
+
options.onEvent?.({
|
|
2765
|
+
event: "flow:dry-run",
|
|
2766
|
+
flowKey: flow.key,
|
|
2767
|
+
resolvedInputs,
|
|
2768
|
+
steps: dryResolveAllSteps(flow.steps, context),
|
|
2769
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2770
|
+
});
|
|
2771
|
+
return context;
|
|
2772
|
+
}
|
|
2773
|
+
options.onEvent?.({
|
|
2774
|
+
event: options.mock ? "flow:mock-start" : "flow:start",
|
|
2775
|
+
flowKey: flow.key,
|
|
2776
|
+
totalSteps: flow.steps.length,
|
|
2777
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2778
|
+
});
|
|
2779
|
+
const flowStart = Date.now();
|
|
2780
|
+
try {
|
|
2781
|
+
await executeSteps(flow.steps, context, api, permissions, allowedActionIds, options, completedStepIds, flowStack);
|
|
2782
|
+
const stepEntries = Object.values(context.steps);
|
|
2783
|
+
const completed = stepEntries.filter((s) => s.status === "success").length;
|
|
2784
|
+
const failed = stepEntries.filter((s) => s.status === "failed").length;
|
|
2785
|
+
const skipped = stepEntries.filter((s) => s.status === "skipped").length;
|
|
2786
|
+
options.onEvent?.({
|
|
2787
|
+
event: "flow:complete",
|
|
2788
|
+
flowKey: flow.key,
|
|
2789
|
+
status: "success",
|
|
2790
|
+
durationMs: Date.now() - flowStart,
|
|
2791
|
+
stepsCompleted: completed,
|
|
2792
|
+
stepsFailed: failed,
|
|
2793
|
+
stepsSkipped: skipped
|
|
2794
|
+
});
|
|
2795
|
+
} catch (error) {
|
|
2796
|
+
if (error instanceof FlowStopSignal) {
|
|
2797
|
+
const stepEntries = Object.values(context.steps);
|
|
2798
|
+
options.onEvent?.({
|
|
2799
|
+
event: "flow:stopped",
|
|
2800
|
+
flowKey: flow.key,
|
|
2801
|
+
stoppedAfter: error.stepId,
|
|
2802
|
+
dryRun: !!options.dryRun && !options.mock,
|
|
2803
|
+
durationMs: Date.now() - flowStart,
|
|
2804
|
+
stepsCompleted: stepEntries.filter((s) => s.status === "success").length,
|
|
2805
|
+
stepsFailed: stepEntries.filter((s) => s.status === "failed").length,
|
|
2806
|
+
stepsSkipped: stepEntries.filter((s) => s.status === "skipped").length,
|
|
2807
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2808
|
+
});
|
|
2809
|
+
return context;
|
|
2810
|
+
}
|
|
2811
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
2812
|
+
options.onEvent?.({
|
|
2813
|
+
event: "flow:error",
|
|
2814
|
+
flowKey: flow.key,
|
|
2815
|
+
status: "failed",
|
|
2816
|
+
error: errorMsg,
|
|
2817
|
+
durationMs: Date.now() - flowStart
|
|
2818
|
+
});
|
|
2819
|
+
throw error;
|
|
2820
|
+
}
|
|
2821
|
+
return context;
|
|
2565
2822
|
}
|
|
2566
2823
|
|
|
2567
2824
|
// src/lib/flow-runner.ts
|
|
@@ -2732,6 +2989,10 @@ var FlowRunner = class _FlowRunner {
|
|
|
2732
2989
|
throw error;
|
|
2733
2990
|
}
|
|
2734
2991
|
}
|
|
2992
|
+
/** Path to a run's persisted state file. The post-mortem artifact surfaced by `one flow inspect`. */
|
|
2993
|
+
static statePathFor(flowKey, runId) {
|
|
2994
|
+
return path3.join(RUNS_DIR, `${flowKey}-${runId}.state.json`);
|
|
2995
|
+
}
|
|
2735
2996
|
static loadRunState(runId) {
|
|
2736
2997
|
ensureDir(RUNS_DIR);
|
|
2737
2998
|
const files = fs3.readdirSync(RUNS_DIR).filter((f) => f.includes(runId) && f.endsWith(".state.json"));
|