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