api-chain-runner 2.5.3__tar.gz → 2.5.6__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.
Files changed (36) hide show
  1. {api_chain_runner-2.5.3 → api_chain_runner-2.5.6}/.gitignore +1 -0
  2. {api_chain_runner-2.5.3/api_chain_runner.egg-info → api_chain_runner-2.5.6}/PKG-INFO +13 -3
  3. {api_chain_runner-2.5.3 → api_chain_runner-2.5.6}/PYPI_README.md +12 -2
  4. {api_chain_runner-2.5.3 → api_chain_runner-2.5.6}/README.md +22 -2
  5. {api_chain_runner-2.5.3 → api_chain_runner-2.5.6}/api_chain_runner/__init__.py +1 -1
  6. {api_chain_runner-2.5.3 → api_chain_runner-2.5.6}/api_chain_runner/models.py +23 -2
  7. {api_chain_runner-2.5.3 → api_chain_runner-2.5.6}/api_chain_runner/runner.py +59 -2
  8. {api_chain_runner-2.5.3 → api_chain_runner-2.5.6}/api_chain_runner/ui/server.py +217 -37
  9. {api_chain_runner-2.5.3 → api_chain_runner-2.5.6}/api_chain_runner/ui/static/flow.js +519 -128
  10. api_chain_runner-2.5.6/api_chain_runner/ui/static/sidebar.js +34 -0
  11. {api_chain_runner-2.5.3 → api_chain_runner-2.5.6}/api_chain_runner/ui/static/style.css +622 -52
  12. {api_chain_runner-2.5.3 → api_chain_runner-2.5.6}/api_chain_runner/ui/templates/docs.html +2 -1
  13. {api_chain_runner-2.5.3 → api_chain_runner-2.5.6}/api_chain_runner/ui/templates/flow.html +33 -5
  14. {api_chain_runner-2.5.3 → api_chain_runner-2.5.6}/api_chain_runner/ui/templates/icons.html +7 -2
  15. {api_chain_runner-2.5.3 → api_chain_runner-2.5.6}/api_chain_runner/ui/templates/index.html +3 -2
  16. {api_chain_runner-2.5.3 → api_chain_runner-2.5.6/api_chain_runner.egg-info}/PKG-INFO +13 -3
  17. {api_chain_runner-2.5.3 → api_chain_runner-2.5.6}/api_chain_runner.egg-info/SOURCES.txt +1 -0
  18. {api_chain_runner-2.5.3 → api_chain_runner-2.5.6}/MANIFEST.in +0 -0
  19. {api_chain_runner-2.5.3 → api_chain_runner-2.5.6}/api_chain_runner/__main__.py +0 -0
  20. {api_chain_runner-2.5.3 → api_chain_runner-2.5.6}/api_chain_runner/executor.py +0 -0
  21. {api_chain_runner-2.5.3 → api_chain_runner-2.5.6}/api_chain_runner/generator.py +0 -0
  22. {api_chain_runner-2.5.3 → api_chain_runner-2.5.6}/api_chain_runner/logger.py +0 -0
  23. {api_chain_runner-2.5.3 → api_chain_runner-2.5.6}/api_chain_runner/pause.py +0 -0
  24. {api_chain_runner-2.5.3 → api_chain_runner-2.5.6}/api_chain_runner/resolver.py +0 -0
  25. {api_chain_runner-2.5.3 → api_chain_runner-2.5.6}/api_chain_runner/store.py +0 -0
  26. {api_chain_runner-2.5.3 → api_chain_runner-2.5.6}/api_chain_runner/ui/__init__.py +0 -0
  27. {api_chain_runner-2.5.3 → api_chain_runner-2.5.6}/api_chain_runner/ui/static/create.js +0 -0
  28. {api_chain_runner-2.5.3 → api_chain_runner-2.5.6}/api_chain_runner/ui/static/docs.js +0 -0
  29. {api_chain_runner-2.5.3 → api_chain_runner-2.5.6}/api_chain_runner/ui/static/theme.js +0 -0
  30. {api_chain_runner-2.5.3 → api_chain_runner-2.5.6}/api_chain_runner/ui/templates/editor.html +0 -0
  31. {api_chain_runner-2.5.3 → api_chain_runner-2.5.6}/api_chain_runner.egg-info/dependency_links.txt +0 -0
  32. {api_chain_runner-2.5.3 → api_chain_runner-2.5.6}/api_chain_runner.egg-info/entry_points.txt +0 -0
  33. {api_chain_runner-2.5.3 → api_chain_runner-2.5.6}/api_chain_runner.egg-info/requires.txt +0 -0
  34. {api_chain_runner-2.5.3 → api_chain_runner-2.5.6}/api_chain_runner.egg-info/top_level.txt +0 -0
  35. {api_chain_runner-2.5.3 → api_chain_runner-2.5.6}/pyproject.toml +0 -0
  36. {api_chain_runner-2.5.3 → api_chain_runner-2.5.6}/setup.cfg +0 -0
@@ -18,6 +18,7 @@ env/
18
18
  .kiro/
19
19
  *.swp
20
20
  *.swo
21
+ .hypothesis
21
22
 
22
23
  # OS
23
24
  .DS_Store
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: api-chain-runner
3
- Version: 2.5.3
3
+ Version: 2.5.6
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
- Skip steps based on previous responses:
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
- Skip steps based on previous responses:
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
- If any condition is not met, the step is skipped with a message:
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')
@@ -1,6 +1,6 @@
1
1
  """API Chain Runner — execute chained API calls with dynamic reference resolution."""
2
2
 
3
- __version__ = "2.5.3"
3
+ __version__ = "2.5.6"
4
4
 
5
5
  from api_chain_runner.runner import ChainRunner
6
6
  from api_chain_runner.generator import UniqueDataGenerator
@@ -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 that must match for this step to run
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 str(actual) != cond.expected_value:
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}', expected '{cond.expected_value}')")
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,17 +117,19 @@ 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",
71
124
  "url": step.get("url", ""),
72
125
  "continue_on_error": step.get("continue_on_error", True),
73
126
  "delay": step.get("delay", 0),
74
- "has_polling": "polling" in step,
127
+ "has_polling": isinstance(step.get("polling"), 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": "condition" in step,
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", []),
@@ -90,7 +144,7 @@ def _parse_chain(filepath: str) -> dict:
90
144
  "failure_message": step.get("failure_message", ""),
91
145
  "retry": step.get("retry"),
92
146
  }
93
- if "polling" in step:
147
+ if isinstance(step.get("polling"), dict):
94
148
  p = step["polling"]
95
149
  step_info["polling"] = {
96
150
  "key_path": p.get("key_path", ""),
@@ -113,6 +167,25 @@ def _read_raw_yaml(filepath: str) -> str:
113
167
  return f.read()
114
168
 
115
169
 
170
+ def _validate_step_names(raw: dict) -> None:
171
+ """Validate only the required and unique names in a candidate chain."""
172
+ chain = raw.get("chain") if isinstance(raw, dict) else None
173
+ if not isinstance(chain, list) or not chain:
174
+ raise ValueError("Configuration must contain a non-empty 'chain' list.")
175
+
176
+ seen = set()
177
+ for index, step in enumerate(chain):
178
+ if not isinstance(step, dict):
179
+ raise ValueError(f"Step at index {index} must be a mapping.")
180
+ name = step.get("name")
181
+ if not isinstance(name, str) or not name.strip():
182
+ raise ValueError(f"Step at index {index} must have a non-empty name.")
183
+ normalized = name.strip()
184
+ if normalized in seen:
185
+ raise ValueError(f"Duplicate step name '{normalized}'. Step names must be unique.")
186
+ seen.add(normalized)
187
+
188
+
116
189
  def _format_yaml_for_readability(yaml_str: str) -> str:
117
190
  """Post-process YAML to add blank lines before each step for better readability."""
118
191
  # Add newline before '- name:' if it's not already preceded by a blank line
@@ -164,7 +237,37 @@ def _run_chain_thread(run_id: str, filepath: str):
164
237
  runner.pause_controller.wait_if_paused()
165
238
 
166
239
  try:
240
+ execution_time = None
241
+ if step.condition:
242
+ execution_time = _local_timestamp()
243
+ skip = False
244
+ for cond in step.condition:
245
+ if runner.store.has(cond.step):
246
+ from api_chain_runner.executor import StepExecutor
247
+ stored = runner.store.get_raw(cond.step)
248
+ actual = StepExecutor._get_nested(stored, cond.key_path)
249
+ if not runner.condition_matches(actual, cond):
250
+ skip = True
251
+ break
252
+ else:
253
+ skip = True
254
+ break
255
+ if skip:
256
+ step_result = {
257
+ "step_name": step.name,
258
+ "status_code": 0,
259
+ "success": True,
260
+ "duration_ms": 0,
261
+ "executed_at": execution_time or _local_timestamp(),
262
+ "error": None,
263
+ "skipped": True,
264
+ }
265
+ with _run_lock:
266
+ _active_runs[run_id]["results"].append(step_result)
267
+ continue
268
+
167
269
  if step.manual:
270
+ execution_time = _local_timestamp()
168
271
  # Resolve print_ref values from previous steps
169
272
  resolved_refs = {}
170
273
  if step.print_ref:
@@ -207,42 +310,16 @@ def _run_chain_thread(run_id: str, filepath: str):
207
310
  "status_code": 0,
208
311
  "success": True,
209
312
  "duration_ms": 0,
313
+ "executed_at": execution_time,
210
314
  "error": None,
211
315
  "manual": True,
212
316
  }
213
- if resolved_refs:
214
- step_result["printed_keys"] = resolved_refs
215
317
  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
318
  if step.delay > 0:
243
319
  import time
244
320
  time.sleep(step.delay)
245
321
 
322
+ execution_time = _local_timestamp()
246
323
  result = runner.executor.execute(step)
247
324
  resp_preview = json.dumps(result.response_body, indent=2, default=str) if isinstance(result.response_body, (dict, list)) else str(result.response_body)
248
325
 
@@ -251,6 +328,7 @@ def _run_chain_thread(run_id: str, filepath: str):
251
328
  "status_code": result.status_code,
252
329
  "success": result.success,
253
330
  "duration_ms": round(result.duration_ms, 1),
331
+ "executed_at": execution_time,
254
332
  "error": result.error,
255
333
  "response_body": resp_preview,
256
334
  }
@@ -298,6 +376,7 @@ def _run_chain_thread(run_id: str, filepath: str):
298
376
  "status_code": 0,
299
377
  "success": False,
300
378
  "duration_ms": 0,
379
+ "executed_at": "",
301
380
  "error": "Not executed (chain aborted)",
302
381
  "skipped": True,
303
382
  }
@@ -314,6 +393,7 @@ def _run_chain_thread(run_id: str, filepath: str):
314
393
  "status_code": -1,
315
394
  "success": False,
316
395
  "duration_ms": 0,
396
+ "executed_at": execution_time or "",
317
397
  "error": str(exc),
318
398
  "response_body": "",
319
399
  }
@@ -430,17 +510,47 @@ def api_step_update(flow_path, step_index):
430
510
  return jsonify({"error": "step index out of range"}), 400
431
511
 
432
512
  step = chain[step_index]
513
+ # Validate conditions before mutating any other field so malformed
514
+ # editor submissions cannot partially save a step.
515
+ normalized_condition = None
516
+ if "condition" in updates:
517
+ submitted_condition = updates["condition"]
518
+ if not isinstance(submitted_condition, list):
519
+ return jsonify({"error": "'condition' must be a list of mappings"}), 400
520
+ try:
521
+ normalized_condition = _normalize_conditions(submitted_condition)
522
+ except ValueError as exc:
523
+ return jsonify({"error": str(exc)}), 400
524
+
433
525
  # 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"}
526
+ allowed = {"name", "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
527
  for key, value in updates.items():
436
- if key in allowed:
437
- if value is None or value == "":
438
- step.pop(key, None)
528
+ if key not in allowed:
529
+ continue
530
+ if key == "condition":
531
+ if normalized_condition:
532
+ step[key] = normalized_condition
439
533
  else:
440
- step[key] = value
534
+ # An explicit empty list is the clearing contract.
535
+ step.pop(key, None)
536
+ elif value is None or value == "":
537
+ step.pop(key, None)
538
+ else:
539
+ step[key] = value
540
+
541
+ try:
542
+ _validate_step_names(raw)
543
+ except ValueError as exc:
544
+ return jsonify({"error": str(exc)}), 400
441
545
 
442
546
  # Write back preserving original formatting as much as possible
443
- dumped = yaml.dump(raw, default_flow_style=False, allow_unicode=True, sort_keys=False)
547
+ dumped = yaml.dump(
548
+ raw,
549
+ default_flow_style=False,
550
+ allow_unicode=True,
551
+ sort_keys=False,
552
+ default_style='"',
553
+ )
444
554
  formatted = _format_yaml_for_readability(dumped)
445
555
  with open(abs_path, "w", encoding="utf-8") as f:
446
556
  f.write(formatted)
@@ -450,6 +560,76 @@ def api_step_update(flow_path, step_index):
450
560
  return jsonify({"error": str(exc)}), 500
451
561
 
452
562
 
563
+ @app.route("/api/flow/<path:flow_path>/step/<int:step_index>", methods=["DELETE"])
564
+ def api_step_delete(flow_path, step_index):
565
+ """Delete one step after checking the remaining step names."""
566
+ abs_path = os.path.join(_flow_dir, flow_path)
567
+ if not os.path.isfile(abs_path):
568
+ return jsonify({"error": "not found"}), 404
569
+
570
+ try:
571
+ with open(abs_path, "r", encoding="utf-8") as f:
572
+ raw = yaml.safe_load(f)
573
+ chain = raw.get("chain", []) if isinstance(raw, dict) else []
574
+ if step_index < 0 or step_index >= len(chain):
575
+ return jsonify({"error": "step index out of range"}), 400
576
+
577
+ candidate = dict(raw)
578
+ candidate["chain"] = list(chain)
579
+ candidate["chain"].pop(step_index)
580
+ _validate_step_names(candidate)
581
+
582
+ dumped = yaml.dump(
583
+ candidate,
584
+ default_flow_style=False,
585
+ allow_unicode=True,
586
+ sort_keys=False,
587
+ default_style='"',
588
+ )
589
+ with open(abs_path, "w", encoding="utf-8") as f:
590
+ f.write(_format_yaml_for_readability(dumped))
591
+ return jsonify({"success": True})
592
+ except ValueError as exc:
593
+ return jsonify({"error": str(exc)}), 400
594
+ except Exception as exc:
595
+ return jsonify({"error": str(exc)}), 500
596
+
597
+
598
+ @app.route("/api/flow/<path:flow_path>/step", methods=["POST"])
599
+ def api_step_add(flow_path):
600
+ """Append one validated step to an existing flow."""
601
+ abs_path = os.path.join(_flow_dir, flow_path)
602
+ if not os.path.isfile(abs_path):
603
+ return jsonify({"error": "not found"}), 404
604
+
605
+ data = request.get_json(silent=True) or {}
606
+ new_step = data.get("step", data.get("updates"))
607
+ if not isinstance(new_step, dict):
608
+ return jsonify({"error": "step must be a mapping"}), 400
609
+
610
+ try:
611
+ with open(abs_path, "r", encoding="utf-8") as f:
612
+ raw = yaml.safe_load(f)
613
+ if not isinstance(raw, dict) or not isinstance(raw.get("chain"), list):
614
+ return jsonify({"error": "Configuration must contain a top-level 'chain' list."}), 400
615
+ candidate = dict(raw)
616
+ candidate["chain"] = list(raw["chain"]) + [dict(new_step)]
617
+ _validate_step_names(candidate)
618
+
619
+ dumped = yaml.dump(
620
+ candidate,
621
+ default_flow_style=False,
622
+ allow_unicode=True,
623
+ sort_keys=False,
624
+ default_style='"',
625
+ )
626
+ with open(abs_path, "w", encoding="utf-8") as f:
627
+ f.write(_format_yaml_for_readability(dumped))
628
+ return jsonify({"success": True})
629
+ except Exception as exc:
630
+ return jsonify({"error": str(exc)}), 400
631
+
632
+
453
633
  @app.route("/api/flow/create", methods=["POST"])
454
634
  def api_flow_create():
455
635
  """Create a new flow YAML file with initial steps."""