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.
Files changed (36) hide show
  1. {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/.gitignore +1 -0
  2. {api_chain_runner-2.5.3/api_chain_runner.egg-info → api_chain_runner-2.5.5}/PKG-INFO +13 -3
  3. {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/PYPI_README.md +12 -2
  4. {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/README.md +22 -2
  5. {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/__init__.py +1 -1
  6. {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/models.py +23 -2
  7. {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/runner.py +59 -2
  8. {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/ui/server.py +121 -35
  9. {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/ui/static/flow.js +432 -121
  10. api_chain_runner-2.5.5/api_chain_runner/ui/static/sidebar.js +34 -0
  11. {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/ui/static/style.css +578 -52
  12. {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/ui/templates/docs.html +2 -1
  13. {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/ui/templates/flow.html +9 -5
  14. {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/ui/templates/icons.html +3 -1
  15. {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/ui/templates/index.html +3 -2
  16. {api_chain_runner-2.5.3 → api_chain_runner-2.5.5/api_chain_runner.egg-info}/PKG-INFO +13 -3
  17. {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner.egg-info/SOURCES.txt +1 -0
  18. {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/MANIFEST.in +0 -0
  19. {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/__main__.py +0 -0
  20. {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/executor.py +0 -0
  21. {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/generator.py +0 -0
  22. {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/logger.py +0 -0
  23. {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/pause.py +0 -0
  24. {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/resolver.py +0 -0
  25. {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/store.py +0 -0
  26. {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/ui/__init__.py +0 -0
  27. {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/ui/static/create.js +0 -0
  28. {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/ui/static/docs.js +0 -0
  29. {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/ui/static/theme.js +0 -0
  30. {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner/ui/templates/editor.html +0 -0
  31. {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner.egg-info/dependency_links.txt +0 -0
  32. {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner.egg-info/entry_points.txt +0 -0
  33. {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner.egg-info/requires.txt +0 -0
  34. {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/api_chain_runner.egg-info/top_level.txt +0 -0
  35. {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/pyproject.toml +0 -0
  36. {api_chain_runner-2.5.3 → api_chain_runner-2.5.5}/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.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
- 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.5"
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,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": "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", []),
@@ -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
- if value is None or value == "":
438
- step.pop(key, None)
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
- step[key] = value
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(raw, default_flow_style=False, allow_unicode=True, sort_keys=False)
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)