flowyml 1.4.0__py3-none-any.whl → 1.6.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.
Files changed (51) hide show
  1. flowyml/__init__.py +2 -1
  2. flowyml/assets/featureset.py +30 -5
  3. flowyml/assets/metrics.py +47 -4
  4. flowyml/cli/main.py +21 -0
  5. flowyml/cli/models.py +444 -0
  6. flowyml/cli/rich_utils.py +95 -0
  7. flowyml/core/checkpoint.py +6 -1
  8. flowyml/core/conditional.py +104 -0
  9. flowyml/core/display.py +525 -0
  10. flowyml/core/execution_status.py +1 -0
  11. flowyml/core/executor.py +201 -8
  12. flowyml/core/orchestrator.py +500 -7
  13. flowyml/core/pipeline.py +301 -11
  14. flowyml/core/project.py +4 -1
  15. flowyml/core/scheduler.py +225 -81
  16. flowyml/core/versioning.py +13 -4
  17. flowyml/registry/model_registry.py +1 -1
  18. flowyml/storage/sql.py +53 -13
  19. flowyml/ui/backend/main.py +2 -0
  20. flowyml/ui/backend/routers/assets.py +36 -0
  21. flowyml/ui/backend/routers/execution.py +2 -2
  22. flowyml/ui/backend/routers/runs.py +211 -0
  23. flowyml/ui/backend/routers/stats.py +2 -2
  24. flowyml/ui/backend/routers/websocket.py +121 -0
  25. flowyml/ui/frontend/dist/assets/index-By4trVyv.css +1 -0
  26. flowyml/ui/frontend/dist/assets/index-CX5RV2C9.js +630 -0
  27. flowyml/ui/frontend/dist/index.html +2 -2
  28. flowyml/ui/frontend/package-lock.json +289 -0
  29. flowyml/ui/frontend/package.json +1 -0
  30. flowyml/ui/frontend/src/app/compare/page.jsx +213 -0
  31. flowyml/ui/frontend/src/app/experiments/compare/page.jsx +289 -0
  32. flowyml/ui/frontend/src/app/experiments/page.jsx +61 -1
  33. flowyml/ui/frontend/src/app/runs/[runId]/page.jsx +418 -203
  34. flowyml/ui/frontend/src/app/runs/page.jsx +64 -3
  35. flowyml/ui/frontend/src/app/settings/page.jsx +1 -1
  36. flowyml/ui/frontend/src/app/tokens/page.jsx +8 -6
  37. flowyml/ui/frontend/src/components/ArtifactViewer.jsx +159 -0
  38. flowyml/ui/frontend/src/components/NavigationTree.jsx +26 -9
  39. flowyml/ui/frontend/src/components/PipelineGraph.jsx +69 -28
  40. flowyml/ui/frontend/src/components/RunDetailsPanel.jsx +42 -14
  41. flowyml/ui/frontend/src/router/index.jsx +4 -0
  42. flowyml/ui/server_manager.py +181 -0
  43. flowyml/ui/utils.py +63 -1
  44. flowyml/utils/config.py +7 -0
  45. {flowyml-1.4.0.dist-info → flowyml-1.6.0.dist-info}/METADATA +5 -3
  46. {flowyml-1.4.0.dist-info → flowyml-1.6.0.dist-info}/RECORD +49 -41
  47. flowyml/ui/frontend/dist/assets/index-DcYwrn2j.css +0 -1
  48. flowyml/ui/frontend/dist/assets/index-Dlz_ygOL.js +0 -592
  49. {flowyml-1.4.0.dist-info → flowyml-1.6.0.dist-info}/WHEEL +0 -0
  50. {flowyml-1.4.0.dist-info → flowyml-1.6.0.dist-info}/entry_points.txt +0 -0
  51. {flowyml-1.4.0.dist-info → flowyml-1.6.0.dist-info}/licenses/LICENSE +0 -0
@@ -17,7 +17,9 @@ from flowyml.core.observability import get_metrics_collector
17
17
  from flowyml.core.retry_policy import with_retry
18
18
 
19
19
  if TYPE_CHECKING:
20
- from flowyml.core.pipeline import Pipeline
20
+ from flowyml.core.pipeline import Pipeline, PipelineResult
21
+ from flowyml.core.executor import ExecutionResult
22
+ from flowyml.core.step import Step
21
23
 
22
24
 
23
25
  class LocalOrchestrator(Orchestrator):
@@ -89,21 +91,26 @@ class LocalOrchestrator(Orchestrator):
89
91
  # Get execution units (individual steps or groups)
90
92
  execution_units = get_execution_units(pipeline.dag, pipeline.steps)
91
93
 
94
+ # Check if we're resuming from checkpoint
95
+ resume_from_checkpoint = getattr(pipeline, "_resume_from_checkpoint", False)
96
+ completed_steps_from_checkpoint = getattr(pipeline, "_completed_steps_from_checkpoint", set())
97
+ checkpoint = getattr(pipeline, "_checkpoint", None)
98
+
92
99
  # Execute steps/groups in order
93
100
  for unit in execution_units:
94
101
  # Check if unit is a group or individual step
95
102
  if isinstance(unit, StepGroup):
96
103
  # Execute entire group
104
+ # Show group execution start
105
+ if hasattr(pipeline, "_display") and pipeline._display:
106
+ for step in unit.steps:
107
+ pipeline._display.update_step_status(step_name=step.name, status="running")
97
108
 
98
- # Get context parameters (use first step's function as representative)
99
- first_step = unit.steps[0]
100
- context_params = pipeline.context.inject_params(first_step.func)
101
-
102
- # Execute the group
109
+ # Pass pipeline context so each step can get its own injected params
103
110
  group_results = pipeline.executor.execute_step_group(
104
111
  step_group=unit,
105
112
  inputs=step_outputs,
106
- context_params=context_params,
113
+ context=pipeline.context, # Pass full context object
107
114
  cache_store=pipeline.cache_store,
108
115
  artifact_store=pipeline.stack.artifact_store if pipeline.stack else None,
109
116
  run_id=run_id,
@@ -112,6 +119,16 @@ class LocalOrchestrator(Orchestrator):
112
119
 
113
120
  # Process each step result
114
121
  for step_result in group_results:
122
+ # Update display
123
+ if hasattr(pipeline, "_display") and pipeline._display:
124
+ pipeline._display.update_step_status(
125
+ step_name=step_result.step_name,
126
+ status="success" if step_result.success else "failed",
127
+ duration=step_result.duration_seconds,
128
+ cached=step_result.cached,
129
+ error=step_result.error,
130
+ )
131
+
115
132
  result.add_step_result(step_result)
116
133
 
117
134
  # Handle failure
@@ -124,10 +141,76 @@ class LocalOrchestrator(Orchestrator):
124
141
  if step_result.output is not None:
125
142
  self._process_step_output(pipeline, step_result, step_outputs, result)
126
143
 
144
+ # Save checkpoint after successful step
145
+ checkpoint = getattr(pipeline, "_checkpoint", None)
146
+ if checkpoint and step_result.success:
147
+ try:
148
+ # Save step outputs to checkpoint
149
+ checkpoint.save_step_state(
150
+ step_name=step_result.step_name,
151
+ outputs=step_outputs,
152
+ metadata={
153
+ "duration": step_result.duration_seconds,
154
+ "cached": step_result.cached,
155
+ },
156
+ )
157
+ except Exception as e:
158
+ # Don't fail pipeline if checkpoint save fails
159
+ import warnings
160
+
161
+ warnings.warn(
162
+ f"Failed to save checkpoint for step {step_result.step_name}: {e}",
163
+ stacklevel=2,
164
+ )
165
+
166
+ # Check for control flows that need to be evaluated after this step
167
+ self._evaluate_control_flows(pipeline, step_result, step_outputs, result, run_id)
168
+
127
169
  else:
128
170
  # Execute single ungrouped step
129
171
  step = unit
130
172
 
173
+ # Skip step if already completed in checkpoint
174
+ if resume_from_checkpoint and step.name in completed_steps_from_checkpoint:
175
+ if hasattr(pipeline, "_display") and pipeline._display:
176
+ pipeline._display.update_step_status(
177
+ step_name=step.name,
178
+ status="success",
179
+ cached=True,
180
+ )
181
+
182
+ # Load step outputs from checkpoint
183
+ try:
184
+ if checkpoint:
185
+ step_outputs_from_checkpoint = checkpoint.load_step_state(step.name)
186
+
187
+ # Process checkpoint outputs
188
+ if isinstance(step_outputs_from_checkpoint, dict):
189
+ for output_name, output_value in step_outputs_from_checkpoint.items():
190
+ step_outputs[output_name] = output_value
191
+ result.outputs[output_name] = output_value
192
+
193
+ # Create a mock ExecutionResult for checkpointed step
194
+ step_result = ExecutionResult(
195
+ step_name=step.name,
196
+ success=True,
197
+ output=step_outputs_from_checkpoint,
198
+ duration_seconds=0.0,
199
+ cached=True,
200
+ )
201
+ result.add_step_result(step_result)
202
+
203
+ # Continue to next step
204
+ continue
205
+ except Exception as e:
206
+ # If checkpoint load fails, execute the step normally
207
+ import warnings
208
+
209
+ warnings.warn(
210
+ f"Failed to load checkpoint for step {step.name}: {e}. Executing step normally.",
211
+ stacklevel=2,
212
+ )
213
+
131
214
  # Prepare step inputs
132
215
  step_inputs = {}
133
216
 
@@ -186,6 +269,10 @@ class LocalOrchestrator(Orchestrator):
186
269
  # Get context parameters for this step
187
270
  context_params = pipeline.context.inject_params(step.func)
188
271
 
272
+ # Update display - step starting
273
+ if hasattr(pipeline, "_display") and pipeline._display:
274
+ pipeline._display.update_step_status(step_name=step.name, status="running")
275
+
189
276
  # Run step start hooks
190
277
  hooks.run_step_start_hooks(step, step_inputs)
191
278
 
@@ -203,6 +290,16 @@ class LocalOrchestrator(Orchestrator):
203
290
  # Run step end hooks
204
291
  hooks.run_step_end_hooks(step, step_result)
205
292
 
293
+ # Update display - step completed
294
+ if hasattr(pipeline, "_display") and pipeline._display:
295
+ pipeline._display.update_step_status(
296
+ step_name=step.name,
297
+ status="success" if step_result.success else "failed",
298
+ duration=step_result.duration_seconds,
299
+ cached=step_result.cached,
300
+ error=step_result.error,
301
+ )
302
+
206
303
  result.add_step_result(step_result)
207
304
 
208
305
  # Handle failure
@@ -216,9 +313,48 @@ class LocalOrchestrator(Orchestrator):
216
313
  if step_result.output is not None:
217
314
  self._process_step_output(pipeline, step_result, step_outputs, result)
218
315
 
316
+ # Save checkpoint after successful step
317
+ checkpoint = getattr(pipeline, "_checkpoint", None)
318
+ if checkpoint and step_result.success:
319
+ try:
320
+ # Save step outputs to checkpoint
321
+ checkpoint.save_step_state(
322
+ step_name=step.name,
323
+ outputs=step_outputs,
324
+ metadata={
325
+ "duration": step_result.duration_seconds,
326
+ "cached": step_result.cached,
327
+ },
328
+ )
329
+ except Exception as e:
330
+ # Don't fail pipeline if checkpoint save fails
331
+ import warnings
332
+
333
+ warnings.warn(f"Failed to save checkpoint for step {step.name}: {e}", stacklevel=2)
334
+
335
+ # Check for control flows that need to be evaluated after this step
336
+ self._evaluate_control_flows(pipeline, step_result, step_outputs, result, run_id)
337
+
219
338
  # Success! Finalize and return
220
339
  result.finalize(success=True)
221
340
 
341
+ # Save final checkpoint if checkpointing is enabled
342
+ checkpoint = getattr(pipeline, "_checkpoint", None)
343
+ if checkpoint and result.success:
344
+ try:
345
+ checkpoint.save_step_state(
346
+ "pipeline_complete",
347
+ result.outputs,
348
+ metadata={
349
+ "duration": result.duration_seconds,
350
+ "success": True,
351
+ },
352
+ )
353
+ except Exception as e:
354
+ import warnings
355
+
356
+ warnings.warn(f"Failed to save final checkpoint: {e}", stacklevel=2)
357
+
222
358
  # Run pipeline end hooks
223
359
  hooks.run_pipeline_end_hooks(pipeline, result)
224
360
 
@@ -230,6 +366,363 @@ class LocalOrchestrator(Orchestrator):
230
366
  pipeline._save_pipeline_definition()
231
367
  return result
232
368
 
369
+ def _evaluate_control_flows(
370
+ self,
371
+ pipeline: "Pipeline",
372
+ step_result: "ExecutionResult",
373
+ step_outputs: dict[str, Any],
374
+ result: "PipelineResult",
375
+ run_id: str,
376
+ ) -> None:
377
+ """Evaluate control flows after a step completes.
378
+
379
+ Args:
380
+ pipeline: Pipeline instance
381
+ step_result: Result of the step that just completed
382
+ step_outputs: Current step outputs dictionary
383
+ result: Pipeline result object
384
+ run_id: Run identifier
385
+ """
386
+ from flowyml.core.conditional import If
387
+
388
+ # Create a context object for condition evaluation
389
+ class ExecutionContext:
390
+ """Context object for conditional evaluation.
391
+
392
+ Provides access to step outputs via ctx.steps['step_name'].outputs['output_name']
393
+ """
394
+
395
+ def __init__(self, result: "PipelineResult", pipeline: "Pipeline"):
396
+ self.result = result
397
+ self.pipeline = pipeline
398
+ self._steps_cache = None
399
+
400
+ @property
401
+ def steps(self):
402
+ """Lazy-load steps dictionary with outputs."""
403
+ if self._steps_cache is None:
404
+ self._steps_cache = {}
405
+ # Build steps dictionary with outputs
406
+ for step_name, step_res in self.result.step_results.items():
407
+ if step_res.success and step_res.output is not None:
408
+ step_def = next((s for s in self.pipeline.steps if s.name == step_name), None)
409
+ if step_def:
410
+ # Create step outputs dictionary
411
+ step_outputs = {}
412
+ if len(step_def.outputs) == 1:
413
+ step_outputs[step_def.outputs[0]] = step_res.output
414
+ elif isinstance(step_res.output, dict):
415
+ step_outputs = step_res.output
416
+ elif step_def.outputs:
417
+ # Try to map tuple/list outputs
418
+ if isinstance(step_res.output, (list, tuple)) and len(step_res.output) == len(
419
+ step_def.outputs,
420
+ ):
421
+ for name, val in zip(step_def.outputs, step_res.output, strict=False):
422
+ step_outputs[name] = val
423
+ else:
424
+ step_outputs[step_def.outputs[0]] = step_res.output
425
+
426
+ # Create step object with outputs attribute that supports Asset objects
427
+ class StepContext:
428
+ def __init__(self, outputs):
429
+ # Wrap outputs to support Asset object access
430
+ self._raw_outputs = outputs
431
+ self.outputs = self._wrap_outputs(outputs)
432
+
433
+ def _wrap_outputs(self, outputs):
434
+ """Wrap outputs to support Asset object property access."""
435
+ wrapped = {}
436
+ for key, value in outputs.items():
437
+ wrapped[key] = self._wrap_asset(value)
438
+ return wrapped
439
+
440
+ def _wrap_asset(self, value):
441
+ """Wrap Asset objects to expose their properties."""
442
+ # Check if it's an Asset object
443
+ from flowyml.assets.base import Asset
444
+ from flowyml.assets.metrics import Metrics
445
+ from flowyml.assets.featureset import FeatureSet
446
+
447
+ if isinstance(value, Asset):
448
+ # Create a wrapper that exposes Asset properties
449
+ class AssetWrapper:
450
+ def __init__(self, asset):
451
+ self._asset = asset
452
+ # Expose the asset itself
453
+ self._self = asset
454
+
455
+ def __getattr__(self, name): # noqa: B023
456
+ # Handle special cases FIRST before generic hasattr check
457
+ # This is important because Asset has a 'metadata' attribute
458
+ # that is an AssetMetadata dataclass, not a dict
459
+
460
+ # Expose metadata as a merged dict of properties + tags
461
+ # This MUST come before hasattr check because Asset.metadata
462
+ # is an AssetMetadata dataclass, not a dict
463
+ if name == "metadata": # noqa: B023
464
+ # Create a dict that merges properties and tags
465
+ # Tags take precedence if there's a conflict
466
+ metadata_dict = (
467
+ dict(self._asset.properties)
468
+ if self._asset.properties
469
+ else {}
470
+ )
471
+ if self._asset.tags:
472
+ metadata_dict.update(self._asset.tags)
473
+ return metadata_dict
474
+
475
+ # For Metrics, map .metrics to .data or .get_all_metrics()
476
+ if isinstance(self._asset, Metrics):
477
+ if name == "metrics": # noqa: B023
478
+ return (
479
+ self._asset.get_all_metrics() or self._asset.data or {} # noqa: B023
480
+ )
481
+
482
+ # Now try to get from asset (handles all Asset properties)
483
+ try:
484
+ if hasattr(self._asset, name): # noqa: B023
485
+ attr = getattr(self._asset, name) # noqa: B023
486
+ # If it's a property/method, return it
487
+ # If it's callable but we want the value, call it
488
+ if callable(attr) and not isinstance(attr, type):
489
+ # It's a method, not a property - return as-is
490
+ return attr
491
+ return attr
492
+ except Exception as e:
493
+ # If accessing the attribute fails, log and continue to fallback logic
494
+ # This can happen if a property raises an exception
495
+ import warnings
496
+
497
+ warnings.warn(
498
+ f"Failed to access attribute '{name}' on {type(self._asset).__name__}: {e}", # noqa: B023
499
+ stacklevel=3,
500
+ )
501
+ pass
502
+
503
+ # Fallback: expose common properties/tags/data
504
+ if name == "data": # noqa: B023
505
+ return self._asset.data
506
+
507
+ if name == "properties": # noqa: B023
508
+ return self._asset.properties
509
+
510
+ if name == "tags": # noqa: B023
511
+ return self._asset.tags
512
+
513
+ raise AttributeError( # noqa: B023
514
+ f"'{type(self).__name__}' object has no attribute '{name}'", # noqa: B023
515
+ )
516
+
517
+ def __getitem__(self, key):
518
+ """Allow dict-like access for Metrics.data and Asset.data."""
519
+ # For Metrics, access via get_all_metrics()
520
+ if isinstance(self._asset, Metrics):
521
+ metrics = (
522
+ self._asset.get_all_metrics() or self._asset.data or {}
523
+ )
524
+ if isinstance(metrics, dict):
525
+ return metrics[key] # noqa: B023
526
+
527
+ # For all Assets, allow dict access to .data if it's a dict
528
+ if isinstance(self._asset.data, dict):
529
+ return self._asset.data[key]
530
+
531
+ # For FeatureSet, allow access to statistics
532
+ if isinstance(self._asset, FeatureSet):
533
+ if key in self._asset.statistics:
534
+ return self._asset.statistics[key]
535
+
536
+ raise KeyError(f"'{key}' not found in {type(self._asset).__name__}")
537
+
538
+ def __contains__(self, key):
539
+ """Support 'in' operator."""
540
+ # For Metrics, check in metrics dict
541
+ if isinstance(self._asset, Metrics):
542
+ metrics = (
543
+ self._asset.get_all_metrics() or self._asset.data or {}
544
+ )
545
+ if isinstance(metrics, dict):
546
+ return key in metrics
547
+
548
+ # For all Assets, check in .data if it's a dict
549
+ if isinstance(self._asset.data, dict):
550
+ return key in self._asset.data
551
+
552
+ # For FeatureSet, check in statistics
553
+ if isinstance(self._asset, FeatureSet):
554
+ return key in self._asset.statistics
555
+
556
+ return False
557
+
558
+ def __repr__(self):
559
+ return f"<AssetWrapper({type(self._asset).__name__})>"
560
+
561
+ return AssetWrapper(value)
562
+ # For dict values, return as-is but allow attribute access
563
+ elif isinstance(value, dict):
564
+
565
+ class DictWrapper(dict):
566
+ """Dict wrapper that allows attribute access."""
567
+
568
+ def __getattr__(self, name): # noqa: B023
569
+ if name in self: # noqa: B023
570
+ return self[name] # noqa: B023
571
+ raise AttributeError( # noqa: B023
572
+ f"'{type(self).__name__}' object has no attribute '{name}'", # noqa: B023
573
+ )
574
+
575
+ return DictWrapper(value)
576
+ # For other types, return as-is
577
+ return value
578
+
579
+ self._steps_cache[step_name] = StepContext(step_outputs)
580
+ return self._steps_cache
581
+
582
+ context = ExecutionContext(result, pipeline)
583
+
584
+ # Evaluate each control flow
585
+ for control_flow in pipeline.control_flows:
586
+ if isinstance(control_flow, If):
587
+ try:
588
+ selected_step = control_flow.evaluate(context)
589
+ except Exception as e:
590
+ # If condition evaluation fails, log the error with full traceback for debugging
591
+ import warnings
592
+ import traceback
593
+
594
+ warnings.warn(
595
+ f"Failed to evaluate control flow condition: {e}\n{traceback.format_exc()}",
596
+ stacklevel=2,
597
+ )
598
+ # If condition evaluation fails, try to execute else_step as fallback
599
+ # This ensures we don't silently skip execution
600
+ selected_step = control_flow.else_step
601
+
602
+ # Execute selected_step if it exists (could be then_step, else_step, or None)
603
+ if selected_step:
604
+ from flowyml.core.step import Step
605
+
606
+ # Check if selected_step is already a Step object or a function
607
+ if isinstance(selected_step, Step):
608
+ # Already a Step object (e.g., from @step decorator), use it directly
609
+ step_obj = selected_step
610
+ else:
611
+ # It's a function, try to find existing Step in pipeline.steps
612
+ # or check if any step has this function
613
+ step_obj = next((s for s in pipeline.steps if s.func == selected_step), None)
614
+
615
+ # If step not found in pipeline.steps, it's a conditional step - create Step object on the fly
616
+ if step_obj is None:
617
+ # Get function name safely
618
+ func_name = getattr(selected_step, "__name__", "conditional_step")
619
+ # Create a Step object for the conditional step function
620
+ step_obj = Step(
621
+ func=selected_step,
622
+ name=func_name,
623
+ inputs=[], # Conditional steps may not have explicit inputs
624
+ outputs=[], # Conditional steps may not have explicit outputs
625
+ )
626
+
627
+ if step_obj.name not in result.step_results:
628
+ # Execute the selected step
629
+ # The check above prevents re-execution of the same step
630
+ self._execute_conditional_step(
631
+ pipeline,
632
+ step_obj,
633
+ step_outputs,
634
+ result,
635
+ run_id,
636
+ )
637
+ # Note: Control flows will be re-evaluated after conditional step completes
638
+
639
+ def _execute_conditional_step(
640
+ self,
641
+ pipeline: "Pipeline",
642
+ step: "Step",
643
+ step_outputs: dict[str, Any],
644
+ result: "PipelineResult",
645
+ run_id: str,
646
+ ) -> None:
647
+ """Execute a step that was selected by conditional logic.
648
+
649
+ Args:
650
+ pipeline: Pipeline instance
651
+ step: Step to execute
652
+ step_outputs: Current step outputs
653
+ result: Pipeline result object
654
+ run_id: Run identifier
655
+ """
656
+ # Prepare step inputs (similar to regular step execution)
657
+ import inspect
658
+
659
+ step_inputs = {}
660
+ sig = inspect.signature(step.func)
661
+ params = [p for p in sig.parameters.values() if p.name not in ("self", "cls")]
662
+
663
+ for param in params:
664
+ if param.name in step_outputs:
665
+ step_inputs[param.name] = step_outputs[param.name]
666
+
667
+ # Get context parameters
668
+ context_params = pipeline.context.inject_params(step.func)
669
+
670
+ # Update display
671
+ if hasattr(pipeline, "_display") and pipeline._display:
672
+ pipeline._display.update_step_status(step_name=step.name, status="running")
673
+
674
+ # Execute step
675
+ step_result = pipeline.executor.execute_step(
676
+ step,
677
+ step_inputs,
678
+ context_params,
679
+ pipeline.cache_store,
680
+ artifact_store=pipeline.stack.artifact_store if pipeline.stack else None,
681
+ run_id=run_id,
682
+ project_name=pipeline.name,
683
+ )
684
+
685
+ # Update display
686
+ if hasattr(pipeline, "_display") and pipeline._display:
687
+ pipeline._display.update_step_status(
688
+ step_name=step.name,
689
+ status="success" if step_result.success else "failed",
690
+ duration=step_result.duration_seconds,
691
+ cached=step_result.cached,
692
+ error=step_result.error,
693
+ )
694
+
695
+ result.add_step_result(step_result)
696
+
697
+ # Handle failure
698
+ if not step_result.success:
699
+ result.finalize(success=False)
700
+ return
701
+
702
+ # Process outputs
703
+ if step_result.output is not None:
704
+ self._process_step_output(pipeline, step_result, step_outputs, result)
705
+
706
+ # Save checkpoint after successful conditional step
707
+ checkpoint = getattr(pipeline, "_checkpoint", None)
708
+ if checkpoint and step_result.success:
709
+ try:
710
+ checkpoint.save_step_state(
711
+ step_name=step.name,
712
+ outputs=step_outputs,
713
+ metadata={
714
+ "duration": step_result.duration_seconds,
715
+ "cached": step_result.cached,
716
+ },
717
+ )
718
+ except Exception as e:
719
+ import warnings
720
+
721
+ warnings.warn(f"Failed to save checkpoint for conditional step {step.name}: {e}", stacklevel=2)
722
+
723
+ # Check for control flows that need to be evaluated after conditional step
724
+ self._evaluate_control_flows(pipeline, step_result, step_outputs, result, run_id)
725
+
233
726
  def _process_step_output(self, pipeline, step_result, step_outputs, result):
234
727
  """Helper to process step outputs and update state."""
235
728
  from pathlib import Path