api-chain-runner 2.5.3__tar.gz → 2.5.5__tar.gz
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.
- {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/.gitignore +1 -0
- {api_chain_runner-2.5.3/api_chain_runner.egg-info → api_chain_runner-2.5.5}/PKG-INFO +13 -3
- {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/PYPI_README.md +12 -2
- {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/README.md +22 -2
- {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/__init__.py +1 -1
- {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/models.py +23 -2
- {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/runner.py +59 -2
- {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/ui/server.py +121 -35
- {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/ui/static/flow.js +432 -121
- api_chain_runner-2.5.5/api_chain_runner/ui/static/sidebar.js +34 -0
- {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/ui/static/style.css +578 -52
- {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/ui/templates/docs.html +2 -1
- {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/ui/templates/flow.html +9 -5
- {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/ui/templates/icons.html +3 -1
- {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/ui/templates/index.html +3 -2
- {api_chain_runner-2.5.3 → api_chain_runner-2.5.5/api_chain_runner.egg-info}/PKG-INFO +13 -3
- {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner.egg-info/SOURCES.txt +1 -0
- {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/MANIFEST.in +0 -0
- {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/__main__.py +0 -0
- {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/executor.py +0 -0
- {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/generator.py +0 -0
- {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/logger.py +0 -0
- {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/pause.py +0 -0
- {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/resolver.py +0 -0
- {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/store.py +0 -0
- {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/ui/__init__.py +0 -0
- {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/ui/static/create.js +0 -0
- {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/ui/static/docs.js +0 -0
- {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/ui/static/theme.js +0 -0
- {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/ui/templates/editor.html +0 -0
- {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner.egg-info/dependency_links.txt +0 -0
- {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner.egg-info/entry_points.txt +0 -0
- {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner.egg-info/requires.txt +0 -0
- {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner.egg-info/top_level.txt +0 -0
- {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/pyproject.toml +0 -0
- {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: api-chain-runner
|
|
3
|
-
Version: 2.5.
|
|
3
|
+
Version: 2.5.5
|
|
4
4
|
Summary: Execute chained API calls defined in YAML configuration files
|
|
5
5
|
Author-email: Sambit Saha <creativesambit@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -330,10 +330,10 @@ files:
|
|
|
330
330
|
|
|
331
331
|
### Conditional Steps
|
|
332
332
|
|
|
333
|
-
|
|
333
|
+
Condition operators include `equals` (the default), `not_equals`, `contains`, `not_contains`, `greater_than`, `greater_than_or_equal`, `less_than`, `less_than_or_equal`, `starts_with`, `ends_with`, `is_null`, and `is_not_null`. Multiple conditions are ANDed. Use `contains` for list membership; unlike `eval_condition`, a `condition` controls whether the dependent step runs.
|
|
334
334
|
|
|
335
335
|
```yaml
|
|
336
|
-
# Single condition
|
|
336
|
+
# Single condition (operator defaults to `equals`)
|
|
337
337
|
- name: generate-link
|
|
338
338
|
url: "https://api.example.com/link"
|
|
339
339
|
method: POST
|
|
@@ -342,6 +342,16 @@ Skip steps based on previous responses:
|
|
|
342
342
|
key_path: "status"
|
|
343
343
|
expected_value: "PENDING"
|
|
344
344
|
|
|
345
|
+
# List membership
|
|
346
|
+
- name: next-step
|
|
347
|
+
url: "https://api.example.com/next"
|
|
348
|
+
method: POST
|
|
349
|
+
condition:
|
|
350
|
+
step: get-lead-status
|
|
351
|
+
key_path: "lead_status"
|
|
352
|
+
operator: contains
|
|
353
|
+
expected_value: "okyc"
|
|
354
|
+
|
|
345
355
|
# Multiple conditions (ALL must pass)
|
|
346
356
|
- name: ready-to-sanction
|
|
347
357
|
url: "https://api.example.com/sanction"
|
|
@@ -302,10 +302,10 @@ files:
|
|
|
302
302
|
|
|
303
303
|
### Conditional Steps
|
|
304
304
|
|
|
305
|
-
|
|
305
|
+
Condition operators include `equals` (the default), `not_equals`, `contains`, `not_contains`, `greater_than`, `greater_than_or_equal`, `less_than`, `less_than_or_equal`, `starts_with`, `ends_with`, `is_null`, and `is_not_null`. Multiple conditions are ANDed. Use `contains` for list membership; unlike `eval_condition`, a `condition` controls whether the dependent step runs.
|
|
306
306
|
|
|
307
307
|
```yaml
|
|
308
|
-
# Single condition
|
|
308
|
+
# Single condition (operator defaults to `equals`)
|
|
309
309
|
- name: generate-link
|
|
310
310
|
url: "https://api.example.com/link"
|
|
311
311
|
method: POST
|
|
@@ -314,6 +314,16 @@ Skip steps based on previous responses:
|
|
|
314
314
|
key_path: "status"
|
|
315
315
|
expected_value: "PENDING"
|
|
316
316
|
|
|
317
|
+
# List membership
|
|
318
|
+
- name: next-step
|
|
319
|
+
url: "https://api.example.com/next"
|
|
320
|
+
method: POST
|
|
321
|
+
condition:
|
|
322
|
+
step: get-lead-status
|
|
323
|
+
key_path: "lead_status"
|
|
324
|
+
operator: contains
|
|
325
|
+
expected_value: "okyc"
|
|
326
|
+
|
|
317
327
|
# Multiple conditions (ALL must pass)
|
|
318
328
|
- name: ready-to-sanction
|
|
319
329
|
url: "https://api.example.com/sanction"
|
|
@@ -467,7 +467,7 @@ Manual steps are logged to CSV as `MANUAL — completed by user`.
|
|
|
467
467
|
Only execute a step if a previous step's response matches a specific value. You can use a single condition or multiple conditions (all must pass):
|
|
468
468
|
|
|
469
469
|
```yaml
|
|
470
|
-
# Single condition
|
|
470
|
+
# Single condition (operator defaults to equals for backward compatibility)
|
|
471
471
|
- name: generate-registration-link
|
|
472
472
|
url: "https://api.example.com/registration-link"
|
|
473
473
|
method: POST
|
|
@@ -475,6 +475,16 @@ Only execute a step if a previous step's response matches a specific value. You
|
|
|
475
475
|
step: check-status
|
|
476
476
|
key_path: "businessProofVerification"
|
|
477
477
|
expected_value: "PENDING"
|
|
478
|
+
|
|
479
|
+
# List membership — runs only when the value is present in the list
|
|
480
|
+
- name: next-step
|
|
481
|
+
url: "https://api.example.com/next"
|
|
482
|
+
method: POST
|
|
483
|
+
condition:
|
|
484
|
+
step: get-lead-status
|
|
485
|
+
key_path: "lead_status"
|
|
486
|
+
operator: contains
|
|
487
|
+
expected_value: "okyc"
|
|
478
488
|
headers:
|
|
479
489
|
Authorization: "Bearer ${auth.token}"
|
|
480
490
|
payload:
|
|
@@ -497,7 +507,17 @@ Only execute a step if a previous step's response matches a specific value. You
|
|
|
497
507
|
leadId: "${lead.id}"
|
|
498
508
|
```
|
|
499
509
|
|
|
500
|
-
|
|
510
|
+
Conditions support these operators:
|
|
511
|
+
|
|
512
|
+
- `equals` (default) — exact value match; omitting `operator` preserves existing behavior
|
|
513
|
+
- `not_equals` — value does not equal the expected value
|
|
514
|
+
- `contains` / `not_contains` — expected value is (or is not) in a list, string, or mapping
|
|
515
|
+
- `greater_than`, `greater_than_or_equal`, `less_than`, `less_than_or_equal` — numeric or string comparisons
|
|
516
|
+
- `starts_with` / `ends_with` — string prefix/suffix checks
|
|
517
|
+
- `is_null` / `is_not_null` — check whether the response value is null; expected value may be blank
|
|
518
|
+
|
|
519
|
+
Multiple conditions are ANDed together. If you need to check whether `okyc` appears anywhere in `lead_status`, use `operator: contains` rather than `eval_condition`; `eval_condition` reports a result but does not control later-step execution.
|
|
520
|
+
|
|
501
521
|
|
|
502
522
|
```
|
|
503
523
|
[9/12] ⏭ generate-registration-link — skipped (condition not met: businessProofVerification='COMPLETED', expected 'PENDING')
|
|
@@ -30,13 +30,34 @@ class PollingConfig:
|
|
|
30
30
|
expected_values: list[str] | None = None # list of acceptable values (e.g. ["APPROVED", "COMPLETED"])
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
VALID_CONDITION_OPERATORS = frozenset({
|
|
34
|
+
"equals",
|
|
35
|
+
"not_equals",
|
|
36
|
+
"contains",
|
|
37
|
+
"not_contains",
|
|
38
|
+
"greater_than",
|
|
39
|
+
"greater_than_or_equal",
|
|
40
|
+
"less_than",
|
|
41
|
+
"less_than_or_equal",
|
|
42
|
+
"starts_with",
|
|
43
|
+
"ends_with",
|
|
44
|
+
"is_null",
|
|
45
|
+
"is_not_null",
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
|
|
33
49
|
@dataclass
|
|
34
50
|
class ConditionConfig:
|
|
35
|
-
"""Condition to check before executing a step.
|
|
51
|
+
"""Condition to check before executing a step.
|
|
52
|
+
|
|
53
|
+
``operator`` defaults to ``equals`` so existing condition configurations
|
|
54
|
+
retain their original exact-string comparison behavior.
|
|
55
|
+
"""
|
|
36
56
|
|
|
37
57
|
step: str # name of the previous step whose response to check
|
|
38
58
|
key_path: str # dot-notation path in that step's response
|
|
39
|
-
expected_value: str # value
|
|
59
|
+
expected_value: str # value used by the operator
|
|
60
|
+
operator: str = "equals"
|
|
40
61
|
|
|
41
62
|
|
|
42
63
|
# Default retry: 3 attempts on timeout/connection/5xx errors
|
|
@@ -18,6 +18,7 @@ from api_chain_runner.models import (
|
|
|
18
18
|
PollingConfig,
|
|
19
19
|
StepDefinition,
|
|
20
20
|
StepResult,
|
|
21
|
+
VALID_CONDITION_OPERATORS,
|
|
21
22
|
validate_steps,
|
|
22
23
|
)
|
|
23
24
|
from api_chain_runner.pause import PauseController
|
|
@@ -189,10 +190,17 @@ class ChainRunner:
|
|
|
189
190
|
raise ConfigurationError(
|
|
190
191
|
f"Step '{entry['name']}': condition missing required field '{req_field}'."
|
|
191
192
|
)
|
|
193
|
+
operator = str(ci.get("operator", "equals")).strip().lower()
|
|
194
|
+
if operator not in VALID_CONDITION_OPERATORS:
|
|
195
|
+
raise ConfigurationError(
|
|
196
|
+
f"Step '{entry['name']}': invalid condition operator '{operator}'. "
|
|
197
|
+
f"Must be one of {sorted(VALID_CONDITION_OPERATORS)}."
|
|
198
|
+
)
|
|
192
199
|
conditions.append(ConditionConfig(
|
|
193
200
|
step=ci["step"],
|
|
194
201
|
key_path=ci["key_path"],
|
|
195
202
|
expected_value=str(ci["expected_value"]),
|
|
203
|
+
operator=operator,
|
|
196
204
|
))
|
|
197
205
|
condition = conditions
|
|
198
206
|
|
|
@@ -314,6 +322,55 @@ class ChainRunner:
|
|
|
314
322
|
if variables and isinstance(variables, dict):
|
|
315
323
|
self.store.save("vars", variables)
|
|
316
324
|
|
|
325
|
+
@staticmethod
|
|
326
|
+
def condition_matches(actual, condition: ConditionConfig) -> bool:
|
|
327
|
+
"""Return whether an extracted value satisfies a configured condition."""
|
|
328
|
+
operator = condition.operator or "equals"
|
|
329
|
+
expected = condition.expected_value
|
|
330
|
+
|
|
331
|
+
if operator == "equals":
|
|
332
|
+
return str(actual) == str(expected)
|
|
333
|
+
if operator == "not_equals":
|
|
334
|
+
return str(actual) != str(expected)
|
|
335
|
+
if operator == "contains":
|
|
336
|
+
try:
|
|
337
|
+
return expected in actual
|
|
338
|
+
except TypeError:
|
|
339
|
+
return False
|
|
340
|
+
if operator == "not_contains":
|
|
341
|
+
try:
|
|
342
|
+
return expected not in actual
|
|
343
|
+
except TypeError:
|
|
344
|
+
return True
|
|
345
|
+
if operator == "is_null":
|
|
346
|
+
return actual is None
|
|
347
|
+
if operator == "is_not_null":
|
|
348
|
+
return actual is not None
|
|
349
|
+
if operator in {"starts_with", "ends_with"}:
|
|
350
|
+
if not isinstance(actual, str):
|
|
351
|
+
return False
|
|
352
|
+
return (actual.startswith if operator == "starts_with" else actual.endswith)(str(expected))
|
|
353
|
+
|
|
354
|
+
try:
|
|
355
|
+
if isinstance(actual, bool):
|
|
356
|
+
comparable_expected = str(expected).lower() == "true"
|
|
357
|
+
elif isinstance(actual, (int, float)):
|
|
358
|
+
comparable_expected = float(expected)
|
|
359
|
+
else:
|
|
360
|
+
comparable_expected = str(expected)
|
|
361
|
+
if operator == "greater_than":
|
|
362
|
+
return actual > comparable_expected
|
|
363
|
+
if operator == "greater_than_or_equal":
|
|
364
|
+
return actual >= comparable_expected
|
|
365
|
+
if operator == "less_than":
|
|
366
|
+
return actual < comparable_expected
|
|
367
|
+
if operator == "less_than_or_equal":
|
|
368
|
+
return actual <= comparable_expected
|
|
369
|
+
except (TypeError, ValueError):
|
|
370
|
+
return False
|
|
371
|
+
|
|
372
|
+
return False
|
|
373
|
+
|
|
317
374
|
def run(self) -> ChainResult:
|
|
318
375
|
"""Execute all steps in the chain sequentially.
|
|
319
376
|
|
|
@@ -350,9 +407,9 @@ class ChainRunner:
|
|
|
350
407
|
if self.store.has(cond.step):
|
|
351
408
|
stored = self.store.get_raw(cond.step)
|
|
352
409
|
actual = StepExecutor._get_nested(stored, cond.key_path)
|
|
353
|
-
if
|
|
410
|
+
if not self.condition_matches(actual, cond):
|
|
354
411
|
print(f"[{idx}/{total}] ⏭ {step.name} — skipped (condition not met: "
|
|
355
|
-
f"{cond.key_path}='{actual}',
|
|
412
|
+
f"{cond.key_path}='{actual}', {cond.operator} '{cond.expected_value}')")
|
|
356
413
|
skip = True
|
|
357
414
|
break
|
|
358
415
|
else:
|
|
@@ -7,12 +7,14 @@ import json
|
|
|
7
7
|
import os
|
|
8
8
|
import threading
|
|
9
9
|
import webbrowser
|
|
10
|
+
from datetime import datetime
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
|
|
12
13
|
import yaml
|
|
13
14
|
from flask import Flask, jsonify, render_template, request
|
|
14
15
|
|
|
15
16
|
from api_chain_runner.runner import ChainRunner
|
|
17
|
+
from api_chain_runner.models import VALID_CONDITION_OPERATORS
|
|
16
18
|
|
|
17
19
|
app = Flask(
|
|
18
20
|
__name__,
|
|
@@ -24,6 +26,11 @@ app = Flask(
|
|
|
24
26
|
_flow_dir: str = "."
|
|
25
27
|
|
|
26
28
|
|
|
29
|
+
def _local_timestamp() -> str:
|
|
30
|
+
"""Return the local timezone timestamp used by the UI response timeline."""
|
|
31
|
+
return datetime.now().astimezone().isoformat(timespec="seconds")
|
|
32
|
+
|
|
33
|
+
|
|
27
34
|
def _discover_flows(base_dir: str) -> list[dict]:
|
|
28
35
|
"""Recursively find all .yaml/.yml files and extract chain metadata."""
|
|
29
36
|
flows = []
|
|
@@ -55,6 +62,51 @@ def _discover_flows(base_dir: str) -> list[dict]:
|
|
|
55
62
|
return flows
|
|
56
63
|
|
|
57
64
|
|
|
65
|
+
def _normalize_conditions(value, *, field_name="condition") -> list[dict[str, str]]:
|
|
66
|
+
"""Return condition data in the UI's ordered-list representation.
|
|
67
|
+
|
|
68
|
+
ChainRunner accepts both the legacy single mapping form and the current
|
|
69
|
+
list form. The UI always receives and persists a list, with values
|
|
70
|
+
normalized to strings so the result can be loaded by ChainRunner without
|
|
71
|
+
changing condition comparison semantics.
|
|
72
|
+
"""
|
|
73
|
+
if value is None:
|
|
74
|
+
return []
|
|
75
|
+
entries = [value] if isinstance(value, dict) else value
|
|
76
|
+
if not isinstance(entries, list):
|
|
77
|
+
raise ValueError(f"'{field_name}' must be a mapping or list of mappings")
|
|
78
|
+
|
|
79
|
+
normalized = []
|
|
80
|
+
required = ("step", "key_path", "expected_value")
|
|
81
|
+
for index, entry in enumerate(entries):
|
|
82
|
+
if not isinstance(entry, dict):
|
|
83
|
+
raise ValueError(f"condition at index {index} must be a mapping")
|
|
84
|
+
missing = [name for name in required if name not in entry]
|
|
85
|
+
if missing:
|
|
86
|
+
raise ValueError(
|
|
87
|
+
f"condition at index {index} missing required field(s): {', '.join(missing)}"
|
|
88
|
+
)
|
|
89
|
+
if not isinstance(entry["step"], str) or not entry["step"].strip():
|
|
90
|
+
raise ValueError(f"condition at index {index} 'step' must be a non-empty string")
|
|
91
|
+
if not isinstance(entry["key_path"], str) or not entry["key_path"].strip():
|
|
92
|
+
raise ValueError(f"condition at index {index} 'key_path' must be a non-empty string")
|
|
93
|
+
operator = str(entry.get("operator", "equals")).strip().lower()
|
|
94
|
+
if operator not in VALID_CONDITION_OPERATORS:
|
|
95
|
+
raise ValueError(
|
|
96
|
+
f"condition at index {index} has invalid operator '{operator}'"
|
|
97
|
+
)
|
|
98
|
+
normalized_entry = {
|
|
99
|
+
"step": entry["step"],
|
|
100
|
+
"key_path": entry["key_path"],
|
|
101
|
+
"expected_value": str(entry["expected_value"]),
|
|
102
|
+
}
|
|
103
|
+
# Keep legacy YAML/UI output unchanged when operator was omitted.
|
|
104
|
+
if "operator" in entry:
|
|
105
|
+
normalized_entry["operator"] = operator
|
|
106
|
+
normalized.append(normalized_entry)
|
|
107
|
+
return normalized
|
|
108
|
+
|
|
109
|
+
|
|
58
110
|
def _parse_chain(filepath: str) -> dict:
|
|
59
111
|
"""Parse a YAML chain file and return structured data for visualization."""
|
|
60
112
|
with open(filepath, "r", encoding="utf-8") as f:
|
|
@@ -65,6 +117,7 @@ def _parse_chain(filepath: str) -> dict:
|
|
|
65
117
|
for step in chain:
|
|
66
118
|
if not isinstance(step, dict):
|
|
67
119
|
continue
|
|
120
|
+
condition = _normalize_conditions(step.get("condition"))
|
|
68
121
|
step_info = {
|
|
69
122
|
"name": step.get("name", "unnamed"),
|
|
70
123
|
"method": step.get("method", "MANUAL") if not step.get("manual") else "MANUAL",
|
|
@@ -75,7 +128,8 @@ def _parse_chain(filepath: str) -> dict:
|
|
|
75
128
|
"has_payload": "payload" in step,
|
|
76
129
|
"has_files": "files" in step,
|
|
77
130
|
"has_unique_fields": "unique_fields" in step,
|
|
78
|
-
"has_condition":
|
|
131
|
+
"has_condition": bool(condition),
|
|
132
|
+
"condition": condition,
|
|
79
133
|
"manual": step.get("manual", False),
|
|
80
134
|
"instruction": step.get("instruction", ""),
|
|
81
135
|
"print_ref": step.get("print_ref", []),
|
|
@@ -164,7 +218,37 @@ def _run_chain_thread(run_id: str, filepath: str):
|
|
|
164
218
|
runner.pause_controller.wait_if_paused()
|
|
165
219
|
|
|
166
220
|
try:
|
|
221
|
+
execution_time = None
|
|
222
|
+
if step.condition:
|
|
223
|
+
execution_time = _local_timestamp()
|
|
224
|
+
skip = False
|
|
225
|
+
for cond in step.condition:
|
|
226
|
+
if runner.store.has(cond.step):
|
|
227
|
+
from api_chain_runner.executor import StepExecutor
|
|
228
|
+
stored = runner.store.get_raw(cond.step)
|
|
229
|
+
actual = StepExecutor._get_nested(stored, cond.key_path)
|
|
230
|
+
if not runner.condition_matches(actual, cond):
|
|
231
|
+
skip = True
|
|
232
|
+
break
|
|
233
|
+
else:
|
|
234
|
+
skip = True
|
|
235
|
+
break
|
|
236
|
+
if skip:
|
|
237
|
+
step_result = {
|
|
238
|
+
"step_name": step.name,
|
|
239
|
+
"status_code": 0,
|
|
240
|
+
"success": True,
|
|
241
|
+
"duration_ms": 0,
|
|
242
|
+
"executed_at": execution_time or _local_timestamp(),
|
|
243
|
+
"error": None,
|
|
244
|
+
"skipped": True,
|
|
245
|
+
}
|
|
246
|
+
with _run_lock:
|
|
247
|
+
_active_runs[run_id]["results"].append(step_result)
|
|
248
|
+
continue
|
|
249
|
+
|
|
167
250
|
if step.manual:
|
|
251
|
+
execution_time = _local_timestamp()
|
|
168
252
|
# Resolve print_ref values from previous steps
|
|
169
253
|
resolved_refs = {}
|
|
170
254
|
if step.print_ref:
|
|
@@ -207,42 +291,16 @@ def _run_chain_thread(run_id: str, filepath: str):
|
|
|
207
291
|
"status_code": 0,
|
|
208
292
|
"success": True,
|
|
209
293
|
"duration_ms": 0,
|
|
294
|
+
"executed_at": execution_time,
|
|
210
295
|
"error": None,
|
|
211
296
|
"manual": True,
|
|
212
297
|
}
|
|
213
|
-
if resolved_refs:
|
|
214
|
-
step_result["printed_keys"] = resolved_refs
|
|
215
298
|
else:
|
|
216
|
-
if step.condition:
|
|
217
|
-
skip = False
|
|
218
|
-
for cond in step.condition:
|
|
219
|
-
if runner.store.has(cond.step):
|
|
220
|
-
from api_chain_runner.executor import StepExecutor
|
|
221
|
-
stored = runner.store.get_raw(cond.step)
|
|
222
|
-
actual = StepExecutor._get_nested(stored, cond.key_path)
|
|
223
|
-
if str(actual) != cond.expected_value:
|
|
224
|
-
skip = True
|
|
225
|
-
break
|
|
226
|
-
else:
|
|
227
|
-
skip = True
|
|
228
|
-
break
|
|
229
|
-
if skip:
|
|
230
|
-
step_result = {
|
|
231
|
-
"step_name": step.name,
|
|
232
|
-
"status_code": 0,
|
|
233
|
-
"success": True,
|
|
234
|
-
"duration_ms": 0,
|
|
235
|
-
"error": None,
|
|
236
|
-
"skipped": True,
|
|
237
|
-
}
|
|
238
|
-
with _run_lock:
|
|
239
|
-
_active_runs[run_id]["results"].append(step_result)
|
|
240
|
-
continue
|
|
241
|
-
|
|
242
299
|
if step.delay > 0:
|
|
243
300
|
import time
|
|
244
301
|
time.sleep(step.delay)
|
|
245
302
|
|
|
303
|
+
execution_time = _local_timestamp()
|
|
246
304
|
result = runner.executor.execute(step)
|
|
247
305
|
resp_preview = json.dumps(result.response_body, indent=2, default=str) if isinstance(result.response_body, (dict, list)) else str(result.response_body)
|
|
248
306
|
|
|
@@ -251,6 +309,7 @@ def _run_chain_thread(run_id: str, filepath: str):
|
|
|
251
309
|
"status_code": result.status_code,
|
|
252
310
|
"success": result.success,
|
|
253
311
|
"duration_ms": round(result.duration_ms, 1),
|
|
312
|
+
"executed_at": execution_time,
|
|
254
313
|
"error": result.error,
|
|
255
314
|
"response_body": resp_preview,
|
|
256
315
|
}
|
|
@@ -298,6 +357,7 @@ def _run_chain_thread(run_id: str, filepath: str):
|
|
|
298
357
|
"status_code": 0,
|
|
299
358
|
"success": False,
|
|
300
359
|
"duration_ms": 0,
|
|
360
|
+
"executed_at": "",
|
|
301
361
|
"error": "Not executed (chain aborted)",
|
|
302
362
|
"skipped": True,
|
|
303
363
|
}
|
|
@@ -314,6 +374,7 @@ def _run_chain_thread(run_id: str, filepath: str):
|
|
|
314
374
|
"status_code": -1,
|
|
315
375
|
"success": False,
|
|
316
376
|
"duration_ms": 0,
|
|
377
|
+
"executed_at": execution_time or "",
|
|
317
378
|
"error": str(exc),
|
|
318
379
|
"response_body": "",
|
|
319
380
|
}
|
|
@@ -430,17 +491,42 @@ def api_step_update(flow_path, step_index):
|
|
|
430
491
|
return jsonify({"error": "step index out of range"}), 400
|
|
431
492
|
|
|
432
493
|
step = chain[step_index]
|
|
494
|
+
# Validate conditions before mutating any other field so malformed
|
|
495
|
+
# editor submissions cannot partially save a step.
|
|
496
|
+
normalized_condition = None
|
|
497
|
+
if "condition" in updates:
|
|
498
|
+
submitted_condition = updates["condition"]
|
|
499
|
+
if not isinstance(submitted_condition, list):
|
|
500
|
+
return jsonify({"error": "'condition' must be a list of mappings"}), 400
|
|
501
|
+
try:
|
|
502
|
+
normalized_condition = _normalize_conditions(submitted_condition)
|
|
503
|
+
except ValueError as exc:
|
|
504
|
+
return jsonify({"error": str(exc)}), 400
|
|
505
|
+
|
|
433
506
|
# Apply updates to allowed fields
|
|
434
|
-
allowed = {"payload", "headers", "url", "unique_fields", "delay", "continue_on_error", "method", "files", "print_keys", "polling", "eval_keys", "eval_condition", "success_message", "failure_message", "manual", "instruction", "print_ref", "retry"}
|
|
507
|
+
allowed = {"payload", "headers", "url", "unique_fields", "delay", "continue_on_error", "method", "files", "print_keys", "polling", "eval_keys", "eval_condition", "success_message", "failure_message", "manual", "instruction", "print_ref", "retry", "condition"}
|
|
435
508
|
for key, value in updates.items():
|
|
436
|
-
if key in allowed:
|
|
437
|
-
|
|
438
|
-
|
|
509
|
+
if key not in allowed:
|
|
510
|
+
continue
|
|
511
|
+
if key == "condition":
|
|
512
|
+
if normalized_condition:
|
|
513
|
+
step[key] = normalized_condition
|
|
439
514
|
else:
|
|
440
|
-
|
|
515
|
+
# An explicit empty list is the clearing contract.
|
|
516
|
+
step.pop(key, None)
|
|
517
|
+
elif value is None or value == "":
|
|
518
|
+
step.pop(key, None)
|
|
519
|
+
else:
|
|
520
|
+
step[key] = value
|
|
441
521
|
|
|
442
522
|
# Write back preserving original formatting as much as possible
|
|
443
|
-
dumped = yaml.dump(
|
|
523
|
+
dumped = yaml.dump(
|
|
524
|
+
raw,
|
|
525
|
+
default_flow_style=False,
|
|
526
|
+
allow_unicode=True,
|
|
527
|
+
sort_keys=False,
|
|
528
|
+
default_style='"',
|
|
529
|
+
)
|
|
444
530
|
formatted = _format_yaml_for_readability(dumped)
|
|
445
531
|
with open(abs_path, "w", encoding="utf-8") as f:
|
|
446
532
|
f.write(formatted)
|