@withone/cli 1.47.7 → 1.47.10

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