pyoco 0.3.0__py3-none-any.whl → 0.5.0__py3-none-any.whl

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.
pyoco/cli/main.py CHANGED
@@ -1,13 +1,16 @@
1
1
  import argparse
2
+ import json
2
3
  import sys
3
4
  import os
4
5
  import signal
6
+ import time
5
7
  from ..schemas.config import PyocoConfig
6
8
  from ..discovery.loader import TaskLoader
7
9
  from ..core.models import Flow
8
10
  from ..core.engine import Engine
9
11
  from ..trace.console import ConsoleTraceBackend
10
12
  from ..client import Client
13
+ from ..discovery.plugins import list_available_plugins
11
14
 
12
15
  def main():
13
16
  parser = argparse.ArgumentParser(description="Pyoco Workflow Engine")
@@ -28,6 +31,8 @@ def main():
28
31
  check_parser = subparsers.add_parser("check", help="Verify a workflow")
29
32
  check_parser.add_argument("--config", required=True, help="Path to flow.yaml")
30
33
  check_parser.add_argument("--flow", default="main", help="Flow name to check")
34
+ check_parser.add_argument("--dry-run", action="store_true", help="Traverse flow without executing tasks")
35
+ check_parser.add_argument("--json", action="store_true", help="Output report as JSON")
31
36
 
32
37
  # List tasks command
33
38
  list_parser = subparsers.add_parser("list-tasks", help="List available tasks")
@@ -55,6 +60,8 @@ def main():
55
60
  runs_list = runs_subparsers.add_parser("list", help="List runs")
56
61
  runs_list.add_argument("--server", default="http://localhost:8000", help="Server URL")
57
62
  runs_list.add_argument("--status", help="Filter by status")
63
+ runs_list.add_argument("--flow", help="Filter by flow name")
64
+ runs_list.add_argument("--limit", type=int, help="Maximum number of runs to show")
58
65
 
59
66
  runs_show = runs_subparsers.add_parser("show", help="Show run details")
60
67
  runs_show.add_argument("run_id", help="Run ID")
@@ -63,6 +70,24 @@ def main():
63
70
  runs_cancel = runs_subparsers.add_parser("cancel", help="Cancel a run")
64
71
  runs_cancel.add_argument("run_id", help="Run ID")
65
72
  runs_cancel.add_argument("--server", default="http://localhost:8000", help="Server URL")
73
+
74
+ runs_inspect = runs_subparsers.add_parser("inspect", help="Inspect run details")
75
+ runs_inspect.add_argument("run_id", help="Run ID")
76
+ runs_inspect.add_argument("--server", default="http://localhost:8000", help="Server URL")
77
+ runs_inspect.add_argument("--json", action="store_true", help="Output JSON payload")
78
+
79
+ runs_logs = runs_subparsers.add_parser("logs", help="Show run logs")
80
+ runs_logs.add_argument("run_id", help="Run ID")
81
+ runs_logs.add_argument("--server", default="http://localhost:8000", help="Server URL")
82
+ runs_logs.add_argument("--task", help="Filter logs by task")
83
+ runs_logs.add_argument("--tail", type=int, help="Show last N log entries")
84
+ runs_logs.add_argument("--follow", action="store_true", help="Stream logs until completion")
85
+ runs_logs.add_argument("--allow-failure", action="store_true", help="Don't exit non-zero when run failed")
86
+
87
+ plugins_parser = subparsers.add_parser("plugins", help="Inspect plug-in entry points")
88
+ plugins_sub = plugins_parser.add_subparsers(dest="plugins_command")
89
+ plugins_list = plugins_sub.add_parser("list", help="List discovered plug-ins")
90
+ plugins_list.add_argument("--json", action="store_true", help="Output JSON payload")
66
91
 
67
92
  args = parser.parse_args()
68
93
 
@@ -94,6 +119,21 @@ def main():
94
119
  print(f" - {name}")
95
120
  return
96
121
 
122
+ if args.command == "plugins":
123
+ infos = list_available_plugins()
124
+ if args.plugins_command == "list":
125
+ if getattr(args, "json", False):
126
+ print(json.dumps(infos, indent=2))
127
+ else:
128
+ if not infos:
129
+ print("No plug-ins registered under group 'pyoco.tasks'.")
130
+ else:
131
+ print("Discovered plug-ins:")
132
+ for info in infos:
133
+ mod = info.get("module") or info.get("value")
134
+ print(f" - {info.get('name')} ({mod})")
135
+ return
136
+
97
137
  if args.command == "server":
98
138
  if args.server_command == "start":
99
139
  import uvicorn
@@ -113,7 +153,7 @@ def main():
113
153
  client = Client(args.server)
114
154
  try:
115
155
  if args.runs_command == "list":
116
- runs = client.list_runs(status=args.status)
156
+ runs = client.list_runs(status=args.status, flow=args.flow, limit=args.limit)
117
157
  print(f"🐇 Active Runs ({len(runs)}):")
118
158
  print(f"{'ID':<36} | {'Status':<12} | {'Flow':<15}")
119
159
  print("-" * 70)
@@ -134,6 +174,31 @@ def main():
134
174
  elif args.runs_command == "cancel":
135
175
  client.cancel_run(args.run_id)
136
176
  print(f"🛑 Cancellation requested for run {args.run_id}")
177
+ elif args.runs_command == "inspect":
178
+ run = client.get_run(args.run_id)
179
+ if args.json:
180
+ print(json.dumps(run, indent=2))
181
+ else:
182
+ print(f"🐇 Run: {run['run_id']} ({run.get('flow_name', 'n/a')})")
183
+ print(f"Status: {run['status']}")
184
+ if run.get("start_time"):
185
+ print(f"Started: {run['start_time']}")
186
+ if run.get("end_time"):
187
+ print(f"Ended: {run['end_time']}")
188
+ print("Tasks:")
189
+ records = run.get("task_records", {})
190
+ for name, info in records.items():
191
+ state = info.get("state", run["tasks"].get(name))
192
+ duration = info.get("duration_ms")
193
+ duration_str = f"{duration:.2f} ms" if duration else "-"
194
+ print(f" - {name}: {state} ({duration_str})")
195
+ if info.get("error"):
196
+ print(f" error: {info['error']}")
197
+ if not records:
198
+ for t_name, t_state in run.get("tasks", {}).items():
199
+ print(f" - {t_name}: {t_state}")
200
+ elif args.runs_command == "logs":
201
+ _stream_logs(client, args)
137
202
  except Exception as e:
138
203
  print(f"Error: {e}")
139
204
  return
@@ -164,14 +229,16 @@ def main():
164
229
  sys.exit(1)
165
230
  return
166
231
  # Build Flow from graph string
167
- from ..dsl.syntax import TaskWrapper
232
+ from ..dsl.syntax import TaskWrapper, switch
168
233
  eval_context = {name: TaskWrapper(task) for name, task in loader.tasks.items()}
234
+ eval_context["switch"] = switch
169
235
 
170
236
  try:
171
237
  # Create Flow and add all loaded tasks
172
238
  flow = Flow(name=args.flow)
173
239
  for t in loader.tasks.values():
174
240
  flow.add_task(t)
241
+ eval_context["flow"] = flow
175
242
 
176
243
  # Evaluate graph to set up dependencies
177
244
  exec(flow_conf.graph, {}, eval_context)
@@ -210,36 +277,36 @@ def main():
210
277
 
211
278
  # 1. Check imports (already done by loader.load(), but we can check for missing tasks in graph)
212
279
  # 2. Build flow to check graph
213
- from ..dsl.syntax import TaskWrapper
280
+ from ..dsl.syntax import TaskWrapper, switch
214
281
  eval_context = {name: TaskWrapper(task) for name, task in loader.tasks.items()}
282
+ eval_context["switch"] = switch
215
283
 
216
284
  try:
217
285
  flow = Flow(name=args.flow)
218
286
  for t in loader.tasks.values():
219
287
  flow.add_task(t)
288
+ eval_context["flow"] = flow
220
289
 
221
290
  eval(flow_conf.graph, {}, eval_context)
222
291
 
223
292
  # 3. Reachability / Orphans
224
- # Nodes with no deps and no dependents (except if single node flow)
225
293
  if len(flow.tasks) > 1:
226
294
  for t in flow.tasks:
227
295
  if not t.dependencies and not t.dependents:
228
296
  warnings.append(f"Task '{t.name}' is orphaned (no dependencies or dependents).")
229
297
 
230
298
  # 4. Cycles
231
- # Simple DFS for cycle detection
232
299
  visited = set()
233
300
  path = set()
301
+
234
302
  def visit(node):
235
303
  if node in path:
236
- return True # Cycle
304
+ return True
237
305
  if node in visited:
238
306
  return False
239
-
240
307
  visited.add(node)
241
308
  path.add(node)
242
- for dep in node.dependencies: # Check upstream
309
+ for dep in node.dependencies:
243
310
  if visit(dep):
244
311
  return True
245
312
  path.remove(node)
@@ -255,29 +322,75 @@ def main():
255
322
  for t in flow.tasks:
256
323
  sig = inspect.signature(t.func)
257
324
  for name, param in sig.parameters.items():
258
- if name == 'ctx': continue
259
- # Check if input provided in task config or defaults
260
- # This is hard because inputs are resolved at runtime.
261
- # But we can check if 'inputs' mapping exists for it.
325
+ if name == 'ctx':
326
+ continue
262
327
  if name not in t.inputs and name not in flow_conf.defaults:
263
- # Warning: might be missing input
264
328
  warnings.append(f"Task '{t.name}' argument '{name}' might be missing input (not in inputs or defaults).")
265
329
 
266
330
  except Exception as e:
267
331
  errors.append(f"Graph evaluation failed: {e}")
268
332
 
269
- # Report
270
- print("\n--- Check Report ---")
271
- if not errors and not warnings:
272
- print("✅ All checks passed!")
333
+ if args.dry_run:
334
+ from ..dsl.validator import FlowValidator
335
+ try:
336
+ validator = FlowValidator(flow)
337
+ dr_report = validator.validate()
338
+ warnings.extend(dr_report.warnings)
339
+ errors.extend(dr_report.errors)
340
+ except Exception as exc:
341
+ print(f"❌ Dry run internal error: {exc}")
342
+ import traceback
343
+ traceback.print_exc()
344
+ sys.exit(3)
345
+
346
+ status = "ok"
347
+ if errors:
348
+ status = "error"
349
+ elif warnings:
350
+ status = "warning"
351
+
352
+ report = {"status": status, "warnings": warnings, "errors": errors}
353
+
354
+ if args.json:
355
+ print(json.dumps(report, indent=2))
273
356
  else:
274
- for w in warnings:
275
- print(f"⚠️ {w}")
276
- for e in errors:
277
- print(f" {e}")
278
-
279
- if errors:
357
+ print("\n--- Check Report ---")
358
+ print(f"Status: {status}")
359
+ if not errors and not warnings:
360
+ print(" All checks passed!")
361
+ else:
362
+ for w in warnings:
363
+ print(f"⚠️ {w}")
364
+ for e in errors:
365
+ print(f"❌ {e}")
366
+
367
+ if errors:
368
+ sys.exit(2 if args.dry_run else 1)
369
+ return
370
+
371
+ def _stream_logs(client, args):
372
+ seen_seq = -1
373
+ follow = args.follow
374
+ while True:
375
+ tail = args.tail if (args.tail and seen_seq == -1 and not follow) else None
376
+ data = client.get_run_logs(args.run_id, task=args.task, tail=tail)
377
+ logs = data.get("logs", [])
378
+ logs.sort(key=lambda entry: entry.get("seq", 0))
379
+ for entry in logs:
380
+ seq = entry.get("seq", 0)
381
+ if seq <= seen_seq:
382
+ continue
383
+ line = entry.get("text", "")
384
+ line = line.rstrip("\n")
385
+ print(f"[{entry.get('task', 'unknown')}][{entry.get('stream', '')}] {line}")
386
+ seen_seq = seq
387
+ status = data.get("run_status", "UNKNOWN")
388
+ if not follow or status in ("COMPLETED", "FAILED", "CANCELLED"):
389
+ if status == "FAILED" and not args.allow_failure:
280
390
  sys.exit(1)
391
+ break
392
+ time.sleep(1)
393
+
281
394
 
282
395
  if __name__ == "__main__":
283
396
  main()
pyoco/client.py CHANGED
@@ -1,6 +1,6 @@
1
1
  import httpx
2
2
  from typing import Dict, List, Optional, Any
3
- from .core.models import RunStatus, TaskState
3
+ from .core.models import RunStatus, TaskState, RunContext
4
4
 
5
5
  class Client:
6
6
  def __init__(self, server_url: str, client_id: str = "cli"):
@@ -17,10 +17,19 @@ class Client:
17
17
  resp.raise_for_status()
18
18
  return resp.json()["run_id"]
19
19
 
20
- def list_runs(self, status: Optional[str] = None) -> List[Dict]:
20
+ def list_runs(
21
+ self,
22
+ status: Optional[str] = None,
23
+ flow: Optional[str] = None,
24
+ limit: Optional[int] = None,
25
+ ) -> List[Dict]:
21
26
  params = {}
22
27
  if status:
23
28
  params["status"] = status
29
+ if flow:
30
+ params["flow"] = flow
31
+ if limit:
32
+ params["limit"] = limit
24
33
  resp = self.client.get("/runs", params=params)
25
34
  resp.raise_for_status()
26
35
  return resp.json()
@@ -49,21 +58,32 @@ class Client:
49
58
  # print(f"Poll failed: {e}")
50
59
  return None
51
60
 
52
- def heartbeat(self, run_id: str, task_states: Dict[str, TaskState], run_status: RunStatus) -> bool:
61
+ def heartbeat(self, run_ctx: RunContext) -> bool:
53
62
  """
54
63
  Sends heartbeat. Returns True if cancellation is requested.
55
64
  """
56
65
  try:
57
- # Convert Enums to values
58
- states_json = {k: v.value if hasattr(v, 'value') else v for k, v in task_states.items()}
59
- status_value = run_status.value if hasattr(run_status, 'value') else run_status
60
-
61
- resp = self.client.post(f"/runs/{run_id}/heartbeat", json={
66
+ states_json = {k: v.value if hasattr(v, 'value') else v for k, v in run_ctx.tasks.items()}
67
+ status_value = run_ctx.status.value if hasattr(run_ctx.status, 'value') else run_ctx.status
68
+ payload = {
62
69
  "task_states": states_json,
70
+ "task_records": run_ctx.serialize_task_records(),
71
+ "logs": run_ctx.drain_logs(),
63
72
  "run_status": status_value
64
- })
73
+ }
74
+ resp = self.client.post(f"/runs/{run_ctx.run_id}/heartbeat", json=payload)
65
75
  resp.raise_for_status()
66
76
  return resp.json().get("cancel_requested", False)
67
77
  except Exception as e:
68
78
  print(f"Heartbeat failed: {e}")
69
79
  return False
80
+
81
+ def get_run_logs(self, run_id: str, task: Optional[str] = None, tail: Optional[int] = None) -> Dict[str, Any]:
82
+ params = {}
83
+ if task:
84
+ params["task"] = task
85
+ if tail:
86
+ params["tail"] = tail
87
+ resp = self.client.get(f"/runs/{run_id}/logs", params=params)
88
+ resp.raise_for_status()
89
+ return resp.json()
pyoco/core/context.py CHANGED
@@ -1,8 +1,46 @@
1
1
  import threading
2
- from typing import Any, Dict, List, Optional
2
+ from typing import Any, Dict, List, Optional, Sequence
3
3
  from dataclasses import dataclass, field
4
4
  from .models import RunContext
5
5
 
6
+
7
+ @dataclass
8
+ class LoopFrame:
9
+ name: str
10
+ type: str
11
+ index: Optional[int] = None
12
+ iteration: Optional[int] = None
13
+ count: Optional[int] = None
14
+ item: Any = None
15
+ condition: Optional[bool] = None
16
+ path: Optional[str] = None
17
+
18
+
19
+ class LoopStack:
20
+ def __init__(self):
21
+ self._frames: List[LoopFrame] = []
22
+
23
+ def push(self, frame: LoopFrame) -> LoopFrame:
24
+ parent_path = self._frames[-1].path if self._frames else ""
25
+ segment = frame.name
26
+ if frame.index is not None:
27
+ segment = f"{segment}[{frame.index}]"
28
+ frame.path = f"{parent_path}.{segment}" if parent_path else segment
29
+ self._frames.append(frame)
30
+ return frame
31
+
32
+ def pop(self) -> LoopFrame:
33
+ if not self._frames:
34
+ raise RuntimeError("Loop stack underflow")
35
+ return self._frames.pop()
36
+
37
+ @property
38
+ def current(self) -> Optional[LoopFrame]:
39
+ return self._frames[-1] if self._frames else None
40
+
41
+ def snapshot(self) -> Sequence[LoopFrame]:
42
+ return tuple(self._frames)
43
+
6
44
  @dataclass
7
45
  class Context:
8
46
  """
@@ -14,11 +52,13 @@ class Context:
14
52
  artifacts: Dict[str, Any] = field(default_factory=dict)
15
53
  env: Dict[str, str] = field(default_factory=dict)
16
54
  artifact_dir: Optional[str] = None
55
+ _vars: Dict[str, Any] = field(default_factory=dict, repr=False)
17
56
 
18
57
  # Reference to the parent run context (v0.2.0+)
19
58
  run_context: Optional[RunContext] = None
20
59
 
21
60
  _lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
61
+ _loop_stack: LoopStack = field(default_factory=LoopStack, repr=False)
22
62
 
23
63
  @property
24
64
  def is_cancelled(self) -> bool:
@@ -27,6 +67,29 @@ class Context:
27
67
  return self.run_context.status in [RunStatus.CANCELLING, RunStatus.CANCELLED]
28
68
  return False
29
69
 
70
+ @property
71
+ def loop(self) -> Optional[LoopFrame]:
72
+ return self._loop_stack.current
73
+
74
+ @property
75
+ def loops(self) -> Sequence[LoopFrame]:
76
+ return self._loop_stack.snapshot()
77
+
78
+ def push_loop(self, frame: LoopFrame) -> LoopFrame:
79
+ return self._loop_stack.push(frame)
80
+
81
+ def pop_loop(self) -> LoopFrame:
82
+ return self._loop_stack.pop()
83
+
84
+ def set_var(self, name: str, value: Any):
85
+ self._vars[name] = value
86
+
87
+ def get_var(self, name: str, default=None):
88
+ return self._vars.get(name, default)
89
+
90
+ def clear_var(self, name: str):
91
+ self._vars.pop(name, None)
92
+
30
93
  def __post_init__(self):
31
94
  # Ensure artifact directory exists
32
95
  if self.artifact_dir is None:
@@ -124,3 +187,20 @@ class Context:
124
187
 
125
188
  return value
126
189
 
190
+ def expression_data(self) -> Dict[str, Any]:
191
+ data: Dict[str, Any] = {}
192
+ data.update(self._vars)
193
+ data["params"] = self.params
194
+ data["results"] = self.results
195
+ data["scratch"] = self.scratch
196
+ data["artifacts"] = self.artifacts
197
+ data["loop"] = self.loop
198
+ data["loops"] = list(self.loops)
199
+ return data
200
+
201
+ def env_data(self) -> Dict[str, str]:
202
+ import os
203
+
204
+ env_data = dict(os.environ)
205
+ env_data.update(self.env)
206
+ return env_data
pyoco/core/engine.py CHANGED
@@ -1,9 +1,32 @@
1
1
  import time
2
+ import io
3
+ import sys
4
+ import traceback
2
5
  from typing import Dict, Any, List, Set, Optional
6
+ import contextlib
3
7
  from .models import Flow, Task, RunContext, TaskState, RunStatus
4
- from .context import Context
8
+ from .context import Context, LoopFrame
9
+ from .exceptions import UntilMaxIterationsExceeded
5
10
  from ..trace.backend import TraceBackend
6
11
  from ..trace.console import ConsoleTraceBackend
12
+ from ..dsl.nodes import TaskNode, RepeatNode, ForEachNode, UntilNode, SwitchNode, DEFAULT_CASE_VALUE
13
+ from ..dsl.expressions import Expression
14
+
15
+ class TeeStream:
16
+ def __init__(self, original):
17
+ self.original = original
18
+ self.buffer = io.StringIO()
19
+
20
+ def write(self, data):
21
+ self.original.write(data)
22
+ self.buffer.write(data)
23
+ return len(data)
24
+
25
+ def flush(self):
26
+ self.original.flush()
27
+
28
+ def getvalue(self):
29
+ return self.buffer.getvalue()
7
30
 
8
31
  class Engine:
9
32
  """
@@ -44,16 +67,31 @@ class Engine:
44
67
  run_context = RunContext()
45
68
 
46
69
  run_ctx = run_context
70
+ run_ctx.flow_name = flow.name
71
+ run_ctx.params = params or {}
47
72
 
48
73
  # Initialize all tasks as PENDING
49
74
  for task in flow.tasks:
50
75
  run_ctx.tasks[task.name] = TaskState.PENDING
76
+ run_ctx.ensure_task_record(task.name)
51
77
 
52
78
  ctx = Context(params=params or {}, run_context=run_ctx)
53
79
  self.trace.on_flow_start(flow.name, run_id=run_ctx.run_id)
54
80
 
55
81
  # Register active run
56
82
  self.active_runs[run_ctx.run_id] = run_ctx
83
+
84
+ if flow.has_control_flow():
85
+ try:
86
+ program = flow.build_program()
87
+ self._execute_subflow(program, ctx)
88
+ run_ctx.status = RunStatus.COMPLETED
89
+ except Exception:
90
+ run_ctx.status = RunStatus.FAILED
91
+ run_ctx.end_time = time.time()
92
+ raise
93
+ run_ctx.end_time = time.time()
94
+ return ctx
57
95
 
58
96
  try:
59
97
  executed: Set[Task] = set()
@@ -264,12 +302,130 @@ class Engine:
264
302
  run_ctx.end_time = time.time()
265
303
  return ctx
266
304
 
305
+ def _execute_subflow(self, subflow, ctx: Context):
306
+ for node in subflow.steps:
307
+ self._execute_node(node, ctx)
308
+
309
+ def _execute_node(self, node, ctx: Context):
310
+ if isinstance(node, TaskNode):
311
+ self._execute_task(node.task, ctx)
312
+ elif isinstance(node, RepeatNode):
313
+ self._execute_repeat(node, ctx)
314
+ elif isinstance(node, ForEachNode):
315
+ self._execute_foreach(node, ctx)
316
+ elif isinstance(node, UntilNode):
317
+ self._execute_until(node, ctx)
318
+ elif isinstance(node, SwitchNode):
319
+ self._execute_switch(node, ctx)
320
+ else:
321
+ raise TypeError(f"Unknown node type: {type(node)}")
322
+
323
+ def _execute_repeat(self, node: RepeatNode, ctx: Context):
324
+ count_value = self._resolve_repeat_count(node.count, ctx)
325
+ for index in range(count_value):
326
+ frame = LoopFrame(name="repeat", type="repeat", index=index, iteration=index + 1, count=count_value)
327
+ ctx.push_loop(frame)
328
+ try:
329
+ self._execute_subflow(node.body, ctx)
330
+ finally:
331
+ ctx.pop_loop()
332
+
333
+ def _execute_foreach(self, node: ForEachNode, ctx: Context):
334
+ sequence = self._eval_expression(node.source, ctx)
335
+ if not isinstance(sequence, (list, tuple)):
336
+ raise TypeError("ForEach source must evaluate to a list or tuple.")
337
+
338
+ total = len(sequence)
339
+ label = node.alias or node.source.source
340
+ for index, item in enumerate(sequence):
341
+ frame = LoopFrame(
342
+ name=f"foreach:{label}",
343
+ type="foreach",
344
+ index=index,
345
+ iteration=index + 1,
346
+ count=total,
347
+ item=item,
348
+ )
349
+ ctx.push_loop(frame)
350
+ if node.alias:
351
+ ctx.set_var(node.alias, item)
352
+ try:
353
+ self._execute_subflow(node.body, ctx)
354
+ finally:
355
+ if node.alias:
356
+ ctx.clear_var(node.alias)
357
+ ctx.pop_loop()
358
+
359
+ def _execute_until(self, node: UntilNode, ctx: Context):
360
+ max_iter = node.max_iter or 1000
361
+ iteration = 0
362
+ last_condition = None
363
+ while True:
364
+ iteration += 1
365
+ frame = LoopFrame(
366
+ name="until",
367
+ type="until",
368
+ index=iteration - 1,
369
+ iteration=iteration,
370
+ condition=last_condition,
371
+ count=max_iter,
372
+ )
373
+ ctx.push_loop(frame)
374
+ try:
375
+ self._execute_subflow(node.body, ctx)
376
+ condition_result = bool(self._eval_expression(node.condition, ctx))
377
+ finally:
378
+ ctx.pop_loop()
379
+
380
+ last_condition = condition_result
381
+ if condition_result:
382
+ break
383
+ if iteration >= max_iter:
384
+ raise UntilMaxIterationsExceeded(node.condition.source, max_iter)
385
+
386
+ def _execute_switch(self, node: SwitchNode, ctx: Context):
387
+ value = self._eval_expression(node.expression, ctx)
388
+ default_case = None
389
+ for case in node.cases:
390
+ if case.value == DEFAULT_CASE_VALUE:
391
+ if default_case is None:
392
+ default_case = case
393
+ continue
394
+ if case.value == value:
395
+ self._execute_subflow(case.target, ctx)
396
+ return
397
+ if default_case:
398
+ self._execute_subflow(default_case.target, ctx)
399
+ def _resolve_repeat_count(self, count_value, ctx: Context) -> int:
400
+ if isinstance(count_value, Expression):
401
+ resolved = self._eval_expression(count_value, ctx)
402
+ else:
403
+ resolved = count_value
404
+ if not isinstance(resolved, int):
405
+ raise TypeError("Repeat count must evaluate to an integer.")
406
+ if resolved < 0:
407
+ raise ValueError("Repeat count cannot be negative.")
408
+ return resolved
409
+
410
+ def _eval_expression(self, expression, ctx: Context):
411
+ if isinstance(expression, Expression):
412
+ return expression.evaluate(ctx=ctx.expression_data(), env=ctx.env_data())
413
+ return expression
414
+
267
415
  def _execute_task(self, task: Task, ctx: Context):
268
416
  # Update state to RUNNING
269
417
  from .models import TaskState
418
+ run_ctx = ctx.run_context
270
419
  if ctx.run_context:
271
420
  ctx.run_context.tasks[task.name] = TaskState.RUNNING
272
-
421
+ record = ctx.run_context.ensure_task_record(task.name)
422
+ record.state = TaskState.RUNNING
423
+ record.started_at = time.time()
424
+ record.error = None
425
+ record.traceback = None
426
+ else:
427
+ record = None
428
+
273
429
  self.trace.on_node_start(task.name)
274
430
  start_time = time.time()
275
431
  # Retry loop
@@ -301,8 +457,17 @@ class Engine:
301
457
  elif param_name in ctx.results:
302
458
  kwargs[param_name] = ctx.results[param_name]
303
459
 
304
- result = task.func(**kwargs)
460
+ if record:
461
+ record.inputs = {k: v for k, v in kwargs.items() if k != "ctx"}
462
+
463
+ stdout_capture = TeeStream(sys.stdout)
464
+ stderr_capture = TeeStream(sys.stderr)
465
+ with contextlib.redirect_stdout(stdout_capture), contextlib.redirect_stderr(stderr_capture):
466
+ result = task.func(**kwargs)
305
467
  ctx.set_result(task.name, result)
468
+ if run_ctx:
469
+ run_ctx.append_log(task.name, "stdout", stdout_capture.getvalue())
470
+ run_ctx.append_log(task.name, "stderr", stderr_capture.getvalue())
306
471
 
307
472
  # Handle outputs saving
308
473
  for target_path in task.outputs:
@@ -333,10 +498,24 @@ class Engine:
333
498
  # Update state to SUCCEEDED
334
499
  if ctx.run_context:
335
500
  ctx.run_context.tasks[task.name] = TaskState.SUCCEEDED
501
+ if record:
502
+ record.state = TaskState.SUCCEEDED
503
+ record.ended_at = time.time()
504
+ record.duration_ms = (record.ended_at - record.started_at) * 1000
505
+ record.output = result
336
506
 
337
507
  return # Success
338
508
 
339
509
  except Exception as e:
510
+ if run_ctx:
511
+ run_ctx.append_log(task.name, "stdout", stdout_capture.getvalue() if 'stdout_capture' in locals() else "")
512
+ run_ctx.append_log(task.name, "stderr", stderr_capture.getvalue() if 'stderr_capture' in locals() else "")
513
+ if record:
514
+ record.state = TaskState.FAILED
515
+ record.ended_at = time.time()
516
+ record.duration_ms = (record.ended_at - record.started_at) * 1000
517
+ record.error = str(e)
518
+ record.traceback = traceback.format_exc()
340
519
  if retries_left > 0:
341
520
  retries_left -= 1
342
521
  # Log retry?