flowyml 1.2.0__py3-none-any.whl → 1.3.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 (91) hide show
  1. flowyml/__init__.py +3 -0
  2. flowyml/assets/base.py +10 -0
  3. flowyml/assets/metrics.py +6 -0
  4. flowyml/cli/main.py +108 -2
  5. flowyml/cli/run.py +9 -2
  6. flowyml/core/execution_status.py +52 -0
  7. flowyml/core/hooks.py +106 -0
  8. flowyml/core/observability.py +210 -0
  9. flowyml/core/orchestrator.py +274 -0
  10. flowyml/core/pipeline.py +193 -231
  11. flowyml/core/project.py +34 -2
  12. flowyml/core/remote_orchestrator.py +109 -0
  13. flowyml/core/resources.py +22 -5
  14. flowyml/core/retry_policy.py +80 -0
  15. flowyml/core/step.py +18 -1
  16. flowyml/core/submission_result.py +53 -0
  17. flowyml/integrations/keras.py +95 -22
  18. flowyml/monitoring/alerts.py +2 -2
  19. flowyml/stacks/__init__.py +15 -0
  20. flowyml/stacks/aws.py +599 -0
  21. flowyml/stacks/azure.py +295 -0
  22. flowyml/stacks/components.py +24 -2
  23. flowyml/stacks/gcp.py +158 -11
  24. flowyml/stacks/local.py +5 -0
  25. flowyml/storage/artifacts.py +15 -5
  26. flowyml/storage/materializers/__init__.py +2 -0
  27. flowyml/storage/materializers/cloudpickle.py +74 -0
  28. flowyml/storage/metadata.py +166 -5
  29. flowyml/ui/backend/main.py +41 -1
  30. flowyml/ui/backend/routers/assets.py +356 -15
  31. flowyml/ui/backend/routers/client.py +46 -0
  32. flowyml/ui/backend/routers/execution.py +13 -2
  33. flowyml/ui/backend/routers/experiments.py +48 -12
  34. flowyml/ui/backend/routers/metrics.py +213 -0
  35. flowyml/ui/backend/routers/pipelines.py +63 -7
  36. flowyml/ui/backend/routers/projects.py +33 -7
  37. flowyml/ui/backend/routers/runs.py +150 -8
  38. flowyml/ui/frontend/dist/assets/index-DcYwrn2j.css +1 -0
  39. flowyml/ui/frontend/dist/assets/index-Dlz_ygOL.js +592 -0
  40. flowyml/ui/frontend/dist/index.html +2 -2
  41. flowyml/ui/frontend/src/App.jsx +4 -1
  42. flowyml/ui/frontend/src/app/assets/page.jsx +260 -230
  43. flowyml/ui/frontend/src/app/dashboard/page.jsx +38 -7
  44. flowyml/ui/frontend/src/app/experiments/page.jsx +61 -314
  45. flowyml/ui/frontend/src/app/observability/page.jsx +277 -0
  46. flowyml/ui/frontend/src/app/pipelines/page.jsx +79 -402
  47. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectArtifactsList.jsx +151 -0
  48. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectExperimentsList.jsx +145 -0
  49. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectHeader.jsx +45 -0
  50. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectHierarchy.jsx +467 -0
  51. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectMetricsPanel.jsx +253 -0
  52. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectPipelinesList.jsx +105 -0
  53. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectRelations.jsx +189 -0
  54. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectRunsList.jsx +136 -0
  55. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectTabs.jsx +95 -0
  56. flowyml/ui/frontend/src/app/projects/[projectId]/page.jsx +326 -0
  57. flowyml/ui/frontend/src/app/projects/page.jsx +13 -3
  58. flowyml/ui/frontend/src/app/runs/[runId]/page.jsx +79 -10
  59. flowyml/ui/frontend/src/app/runs/page.jsx +82 -424
  60. flowyml/ui/frontend/src/app/settings/page.jsx +1 -0
  61. flowyml/ui/frontend/src/app/tokens/page.jsx +62 -16
  62. flowyml/ui/frontend/src/components/AssetDetailsPanel.jsx +373 -0
  63. flowyml/ui/frontend/src/components/AssetLineageGraph.jsx +291 -0
  64. flowyml/ui/frontend/src/components/AssetStatsDashboard.jsx +302 -0
  65. flowyml/ui/frontend/src/components/AssetTreeHierarchy.jsx +477 -0
  66. flowyml/ui/frontend/src/components/ExperimentDetailsPanel.jsx +227 -0
  67. flowyml/ui/frontend/src/components/NavigationTree.jsx +401 -0
  68. flowyml/ui/frontend/src/components/PipelineDetailsPanel.jsx +239 -0
  69. flowyml/ui/frontend/src/components/PipelineGraph.jsx +67 -3
  70. flowyml/ui/frontend/src/components/ProjectSelector.jsx +115 -0
  71. flowyml/ui/frontend/src/components/RunDetailsPanel.jsx +298 -0
  72. flowyml/ui/frontend/src/components/header/Header.jsx +48 -1
  73. flowyml/ui/frontend/src/components/plugins/ZenMLIntegration.jsx +106 -0
  74. flowyml/ui/frontend/src/components/sidebar/Sidebar.jsx +52 -26
  75. flowyml/ui/frontend/src/components/ui/DataView.jsx +35 -17
  76. flowyml/ui/frontend/src/components/ui/ErrorBoundary.jsx +118 -0
  77. flowyml/ui/frontend/src/contexts/ProjectContext.jsx +2 -2
  78. flowyml/ui/frontend/src/contexts/ToastContext.jsx +116 -0
  79. flowyml/ui/frontend/src/layouts/MainLayout.jsx +5 -1
  80. flowyml/ui/frontend/src/router/index.jsx +4 -0
  81. flowyml/ui/frontend/src/utils/date.js +10 -0
  82. flowyml/ui/frontend/src/utils/downloads.js +11 -0
  83. flowyml/utils/config.py +6 -0
  84. flowyml/utils/stack_config.py +45 -3
  85. {flowyml-1.2.0.dist-info → flowyml-1.3.0.dist-info}/METADATA +42 -4
  86. {flowyml-1.2.0.dist-info → flowyml-1.3.0.dist-info}/RECORD +89 -52
  87. {flowyml-1.2.0.dist-info → flowyml-1.3.0.dist-info}/licenses/LICENSE +1 -1
  88. flowyml/ui/frontend/dist/assets/index-DFNQnrUj.js +0 -448
  89. flowyml/ui/frontend/dist/assets/index-pWI271rZ.css +0 -1
  90. {flowyml-1.2.0.dist-info → flowyml-1.3.0.dist-info}/WHEEL +0 -0
  91. {flowyml-1.2.0.dist-info → flowyml-1.3.0.dist-info}/entry_points.txt +0 -0
flowyml/core/pipeline.py CHANGED
@@ -19,11 +19,15 @@ class PipelineResult:
19
19
  self.run_id = run_id
20
20
  self.pipeline_name = pipeline_name
21
21
  self.success = False
22
+ self.state = "pending"
22
23
  self.step_results: dict[str, ExecutionResult] = {}
23
24
  self.outputs: dict[str, Any] = {}
24
25
  self.start_time = datetime.now()
25
26
  self.end_time: datetime | None = None
26
27
  self.duration_seconds: float = 0.0
28
+ self.resource_config: Any | None = None
29
+ self.docker_config: Any | None = None
30
+ self.remote_job_id: str | None = None
27
31
 
28
32
  def add_step_result(self, result: ExecutionResult) -> None:
29
33
  """Add result from a step execution."""
@@ -37,9 +41,21 @@ class PipelineResult:
37
41
  def finalize(self, success: bool) -> None:
38
42
  """Mark pipeline as complete."""
39
43
  self.success = success
44
+ self.state = "completed" if success else "failed"
40
45
  self.end_time = datetime.now()
41
46
  self.duration_seconds = (self.end_time - self.start_time).total_seconds()
42
47
 
48
+ def attach_configs(self, resource_config: Any | None, docker_config: Any | None) -> None:
49
+ """Store execution configs for downstream inspection."""
50
+ self.resource_config = resource_config
51
+ self.docker_config = docker_config
52
+
53
+ def mark_submitted(self, job_id: str) -> None:
54
+ """Mark result as remotely submitted."""
55
+ self.success = True
56
+ self.state = "submitted"
57
+ self.remote_job_id = job_id
58
+
43
59
  def __getitem__(self, key: str) -> Any:
44
60
  """Allow dict-style access to outputs."""
45
61
  return self.outputs.get(key)
@@ -50,9 +66,17 @@ class PipelineResult:
50
66
  "run_id": self.run_id,
51
67
  "pipeline_name": self.pipeline_name,
52
68
  "success": self.success,
69
+ "state": self.state,
53
70
  "start_time": self.start_time.isoformat(),
54
71
  "end_time": self.end_time.isoformat() if self.end_time else None,
55
72
  "duration_seconds": self.duration_seconds,
73
+ "resource_config": self.resource_config.to_dict()
74
+ if hasattr(self.resource_config, "to_dict")
75
+ else self.resource_config,
76
+ "docker_config": self.docker_config.to_dict()
77
+ if hasattr(self.docker_config, "to_dict")
78
+ else self.docker_config,
79
+ "remote_job_id": self.remote_job_id,
56
80
  "steps": {
57
81
  name: {
58
82
  "success": result.success,
@@ -67,10 +91,19 @@ class PipelineResult:
67
91
 
68
92
  def summary(self) -> str:
69
93
  """Generate execution summary."""
94
+ if self.state == "submitted":
95
+ status_line = f"Status: ⏳ SUBMITTED (job: {self.remote_job_id})"
96
+ elif self.success:
97
+ status_line = "Status: ✓ SUCCESS"
98
+ elif self.state == "failed":
99
+ status_line = "Status: ✗ FAILED"
100
+ else:
101
+ status_line = f"Status: {self.state.upper()}"
102
+
70
103
  lines = [
71
104
  f"Pipeline: {self.pipeline_name}",
72
105
  f"Run ID: {self.run_id}",
73
- f"Status: {'✓ SUCCESS' if self.success else '✗ FAILED'}",
106
+ status_line,
74
107
  f"Duration: {self.duration_seconds:.2f}s",
75
108
  "",
76
109
  "Steps:",
@@ -127,7 +160,9 @@ class Pipeline:
127
160
  self.name = name
128
161
  self.context = context or Context()
129
162
  self.enable_cache = enable_cache
130
- self.stack = stack # Store stack instance
163
+ self.stack = None # Will be assigned via _apply_stack
164
+ self._stack_locked = stack is not None
165
+ self._provided_executor = executor
131
166
 
132
167
  self.steps: list[Step] = []
133
168
  self.dag = DAG()
@@ -146,15 +181,14 @@ class Pipeline:
146
181
  self.runs_dir.mkdir(parents=True, exist_ok=True)
147
182
 
148
183
  # Initialize components from stack or defaults
149
- if self.stack:
150
- self.executor = executor or self.stack.executor
151
- self.metadata_store = self.stack.metadata_store
152
- else:
153
- self.executor = executor or LocalExecutor()
154
- # Metadata store for UI integration
155
- from flowyml.storage.metadata import SQLiteMetadataStore
184
+ self.executor = executor or LocalExecutor()
185
+ # Metadata store for UI integration
186
+ from flowyml.storage.metadata import SQLiteMetadataStore
156
187
 
157
- self.metadata_store = SQLiteMetadataStore()
188
+ self.metadata_store = SQLiteMetadataStore()
189
+
190
+ if stack:
191
+ self._apply_stack(stack, locked=True)
158
192
 
159
193
  # Handle Project Attachment
160
194
  if project:
@@ -179,6 +213,18 @@ class Pipeline:
179
213
  self._built = False
180
214
  self.step_groups: list[Any] = [] # Will hold StepGroup objects
181
215
 
216
+ def _apply_stack(self, stack: Any | None, locked: bool) -> None:
217
+ """Attach a stack and update executors/metadata."""
218
+ if not stack:
219
+ return
220
+ self.stack = stack
221
+ self._stack_locked = locked
222
+ if self._provided_executor:
223
+ self.executor = self._provided_executor
224
+ else:
225
+ self.executor = stack.executor
226
+ self.metadata_store = stack.metadata_store
227
+
182
228
  def add_step(self, step: Step) -> "Pipeline":
183
229
  """Add a step to the pipeline.
184
230
 
@@ -234,6 +280,7 @@ class Pipeline:
234
280
  resources: Any | None = None, # ResourceConfig
235
281
  docker_config: Any | None = None, # DockerConfig
236
282
  context: dict[str, Any] | None = None, # Context vars override
283
+ **kwargs,
237
284
  ) -> PipelineResult:
238
285
  """Execute the pipeline.
239
286
 
@@ -244,25 +291,34 @@ class Pipeline:
244
291
  resources: Resource configuration for execution
245
292
  docker_config: Docker configuration for containerized execution
246
293
  context: Context variables override
294
+ **kwargs: Additional arguments passed to the orchestrator
247
295
 
248
296
  Returns:
249
297
  PipelineResult with outputs and execution info
250
298
  """
251
299
  import uuid
300
+ from flowyml.core.orchestrator import LocalOrchestrator
252
301
 
253
302
  run_id = str(uuid.uuid4())
254
303
 
255
- # Use provided stack or instance stack
304
+ # Determine stack for this run
256
305
  if stack is not None:
257
- self.stack = stack
258
- # Update components from new stack
259
- self.executor = self.stack.executor
260
- self.metadata_store = self.stack.metadata_store
261
-
262
- # Determine artifact store
263
- artifact_store = None
264
- if self.stack:
265
- artifact_store = self.stack.artifact_store
306
+ self._apply_stack(stack, locked=True)
307
+ elif not self._stack_locked:
308
+ active_stack = None
309
+ try:
310
+ from flowyml.stacks.registry import get_active_stack
311
+ except ImportError:
312
+ get_active_stack = None
313
+ if get_active_stack:
314
+ active_stack = get_active_stack()
315
+ if active_stack:
316
+ self._apply_stack(active_stack, locked=False)
317
+
318
+ # Determine orchestrator
319
+ orchestrator = getattr(self.stack, "orchestrator", None) if self.stack else None
320
+ if orchestrator is None:
321
+ orchestrator = LocalOrchestrator()
266
322
 
267
323
  # Update context with provided values
268
324
  if context:
@@ -272,218 +328,30 @@ class Pipeline:
272
328
  if not self._built:
273
329
  self.build()
274
330
 
275
- # Initialize result
276
- result = PipelineResult(run_id, self.name)
277
- step_outputs = inputs or {}
278
-
279
- # Map step names to step objects for easier lookup
280
- self.steps_dict = {step.name: step for step in self.steps}
281
- if debug:
282
- pass
283
- else:
284
- # Always print the run URL for better UX
285
- pass
286
-
287
- # Get execution units (individual steps or groups)
288
- from flowyml.core.step_grouping import get_execution_units
289
-
290
- execution_units = get_execution_units(self.dag, self.steps)
291
-
292
- # Execute steps/groups in order
293
- for unit in execution_units:
294
- # Check if unit is a group or individual step
295
- from flowyml.core.step_grouping import StepGroup
296
-
297
- if isinstance(unit, StepGroup):
298
- # Execute entire group
299
- if debug:
300
- pass
301
-
302
- # Get context parameters (use first step's function as representative)
303
- first_step = unit.steps[0]
304
- context_params = self.context.inject_params(first_step.func)
305
-
306
- # Execute the group
307
- group_results = self.executor.execute_step_group(
308
- step_group=unit,
309
- inputs=step_outputs,
310
- context_params=context_params,
311
- cache_store=self.cache_store,
312
- artifact_store=artifact_store,
313
- run_id=run_id,
314
- project_name=self.name,
315
- )
316
-
317
- # Process each step result
318
- for step_result in group_results:
319
- result.add_step_result(step_result)
320
-
321
- if debug:
322
- pass
323
-
324
- # Handle failure
325
- if not step_result.success and not step_result.skipped:
326
- result.finalize(success=False)
327
- self._save_run(result)
328
- return result
329
-
330
- # Store outputs for next steps/groups
331
- if step_result.output is not None:
332
- # Find step definition to get output names
333
- step_def = next((s for s in self.steps if s.name == step_result.step_name), None)
334
- if step_def:
335
- if len(step_def.outputs) == 1:
336
- step_outputs[step_def.outputs[0]] = step_result.output
337
- result.outputs[step_def.outputs[0]] = step_result.output
338
- elif isinstance(step_result.output, (list, tuple)) and len(step_result.output) == len(
339
- step_def.outputs,
340
- ):
341
- for name, val in zip(step_def.outputs, step_result.output, strict=False):
342
- step_outputs[name] = val
343
- result.outputs[name] = val
344
- elif isinstance(step_result.output, dict):
345
- for name in step_def.outputs:
346
- if name in step_result.output:
347
- step_outputs[name] = step_result.output[name]
348
- result.outputs[name] = step_result.output[name]
349
- else:
350
- if step_def.outputs:
351
- step_outputs[step_def.outputs[0]] = step_result.output
352
- result.outputs[step_def.outputs[0]] = step_result.output
353
-
354
- else:
355
- # Execute single ungrouped step
356
- step = unit
357
-
358
- if debug:
359
- pass
360
-
361
- # Prepare step inputs
362
- step_inputs = {}
363
-
364
- # Get function signature to map inputs to parameters
365
- import inspect
366
-
367
- sig = inspect.signature(step.func)
368
- params = list(sig.parameters.values())
369
-
370
- # Filter out self/cls
371
- params = [p for p in params if p.name not in ("self", "cls")]
372
-
373
- # Strategy:
374
- # 1. Map inputs to parameters
375
- # - If input name matches param name, use it
376
- # - If not, use positional mapping (input[i] -> param[i])
377
-
378
- # Track which parameters have been assigned
379
- assigned_params = set()
380
-
381
- if step.inputs:
382
- for i, input_name in enumerate(step.inputs):
383
- if input_name not in step_outputs:
384
- continue
385
-
386
- val = step_outputs[input_name]
387
-
388
- # Check if input name matches a parameter
389
- param_match = next((p for p in params if p.name == input_name), None)
390
-
391
- if param_match:
392
- step_inputs[param_match.name] = val
393
- assigned_params.add(param_match.name)
394
- elif i < len(params):
395
- # Positional fallback
396
- # Only if this parameter hasn't been assigned yet
397
- target_param = params[i]
398
- if target_param.name not in assigned_params:
399
- step_inputs[target_param.name] = val
400
- assigned_params.add(target_param.name)
401
-
402
- # Auto-map parameters from available outputs if they match function signature
403
- # This allows passing inputs to run() without declaring them as asset dependencies
404
- for param in params:
405
- if param.name in step_outputs and param.name not in step_inputs:
406
- step_inputs[param.name] = step_outputs[param.name]
407
- assigned_params.add(param.name)
408
-
409
- # Validate context parameters
410
- # Exclude parameters that are already provided in step_inputs
411
- exclude_params = list(step.inputs) + list(step_inputs.keys())
412
- missing_params = self.context.validate_for_step(step.func, exclude=exclude_params)
413
- if missing_params:
414
- if debug:
415
- pass
416
-
417
- error_msg = f"Missing required parameters: {missing_params}"
418
- step_result = ExecutionResult(
419
- step_name=step.name,
420
- success=False,
421
- error=error_msg,
422
- )
423
- result.add_step_result(step_result)
424
- result.finalize(success=False)
425
- self._save_run(result) # Save run before returning
426
- self._save_pipeline_definition() # Save definition even on failure
427
- print("DEBUG: Pipeline failed at step execution")
428
- return result
429
-
430
- # Get context parameters for this step
431
- context_params = self.context.inject_params(step.func)
432
-
433
- # Execute step
434
- step_result = self.executor.execute_step(
435
- step,
436
- step_inputs,
437
- context_params,
438
- self.cache_store,
439
- artifact_store=artifact_store,
440
- run_id=run_id,
441
- project_name=self.name,
442
- )
443
-
444
- result.add_step_result(step_result)
445
-
446
- if debug:
447
- pass
448
-
449
- # Handle failure
450
- if not step_result.success:
451
- if debug and not step_result.error:
452
- pass
453
- result.finalize(success=False)
454
- self._save_run(result)
455
- self._save_pipeline_definition() # Save definition even on failure
456
- print("DEBUG: Pipeline failed at step execution")
457
- return result
458
-
459
- # Store outputs for next steps
460
- if step_result.output is not None:
461
- if len(step.outputs) == 1:
462
- step_outputs[step.outputs[0]] = step_result.output
463
- result.outputs[step.outputs[0]] = step_result.output
464
- elif isinstance(step_result.output, (list, tuple)) and len(step_result.output) == len(step.outputs):
465
- for name, val in zip(step.outputs, step_result.output, strict=False):
466
- step_outputs[name] = val
467
- result.outputs[name] = val
468
- elif isinstance(step_result.output, dict):
469
- for name in step.outputs:
470
- if name in step_result.output:
471
- step_outputs[name] = step_result.output[name]
472
- result.outputs[name] = step_result.output[name]
473
- else:
474
- # Fallback: assign to first output if available
475
- if step.outputs:
476
- step_outputs[step.outputs[0]] = step_result.output
477
- result.outputs[step.outputs[0]] = step_result.output
478
-
479
- # Success!
480
- result.finalize(success=True)
331
+ resource_config = self._coerce_resource_config(resources)
332
+ docker_cfg = self._coerce_docker_config(docker_config)
333
+
334
+ # Run the pipeline via orchestrator
335
+ result = orchestrator.run_pipeline(
336
+ self,
337
+ run_id=run_id,
338
+ resources=resource_config,
339
+ docker_config=docker_cfg,
340
+ inputs=inputs,
341
+ context=context,
342
+ **kwargs,
343
+ )
481
344
 
482
- if debug:
483
- pass
345
+ # If result is just a job ID (remote execution), wrap it in a basic result
346
+ if isinstance(result, str):
347
+ # Create a submitted result wrapper
348
+ wrapper = PipelineResult(run_id, self.name)
349
+ wrapper.attach_configs(resource_config, docker_cfg)
350
+ wrapper.mark_submitted(result)
351
+ self._save_run(wrapper)
352
+ self._save_pipeline_definition()
353
+ return wrapper
484
354
 
485
- self._save_run(result)
486
- self._save_pipeline_definition() # Save pipeline structure for scheduling
487
355
  return result
488
356
 
489
357
  def to_definition(self) -> dict:
@@ -527,6 +395,36 @@ class Pipeline:
527
395
  # Don't fail the run if definition saving fails
528
396
  print(f"Warning: Failed to save pipeline definition: {e}")
529
397
 
398
+ def _coerce_resource_config(self, resources: Any | None):
399
+ """Convert resources input to ResourceConfig if necessary."""
400
+ if resources is None:
401
+ return None
402
+ try:
403
+ from flowyml.stacks.components import ResourceConfig
404
+ except Exception:
405
+ return resources
406
+
407
+ if isinstance(resources, ResourceConfig):
408
+ return resources
409
+ if isinstance(resources, dict):
410
+ return ResourceConfig(**resources)
411
+ return resources
412
+
413
+ def _coerce_docker_config(self, docker_config: Any | None):
414
+ """Convert docker input to DockerConfig if necessary."""
415
+ if docker_config is None:
416
+ return None
417
+ try:
418
+ from flowyml.stacks.components import DockerConfig
419
+ except Exception:
420
+ return docker_config
421
+
422
+ if isinstance(docker_config, DockerConfig):
423
+ return docker_config
424
+ if isinstance(docker_config, dict):
425
+ return DockerConfig(**docker_config)
426
+ return docker_config
427
+
530
428
  def _save_run(self, result: PipelineResult) -> None:
531
429
  """Save run results to disk and metadata database."""
532
430
  # Save to JSON file
@@ -576,7 +474,7 @@ class Pipeline:
576
474
  metadata = {
577
475
  "run_id": result.run_id,
578
476
  "pipeline_name": result.pipeline_name,
579
- "status": "completed" if result.success else "failed",
477
+ "status": result.state,
580
478
  "start_time": result.start_time.isoformat(),
581
479
  "end_time": result.end_time.isoformat() if result.end_time else None,
582
480
  "duration": result.duration_seconds,
@@ -584,6 +482,13 @@ class Pipeline:
584
482
  "context": self.context._params if hasattr(self.context, "_params") else {},
585
483
  "steps": steps_metadata,
586
484
  "dag": dag_data,
485
+ "resources": result.resource_config.to_dict()
486
+ if hasattr(result.resource_config, "to_dict")
487
+ else result.resource_config,
488
+ "docker": result.docker_config.to_dict()
489
+ if hasattr(result.docker_config, "to_dict")
490
+ else result.docker_config,
491
+ "remote_job_id": result.remote_job_id,
587
492
  }
588
493
  self.metadata_store.save_run(result.run_id, metadata)
589
494
 
@@ -762,3 +667,60 @@ class Pipeline:
762
667
 
763
668
  def __repr__(self) -> str:
764
669
  return f"Pipeline(name='{self.name}', steps={len(self.steps)})"
670
+
671
+ def schedule(
672
+ self,
673
+ schedule_type: str,
674
+ value: str | int,
675
+ **kwargs,
676
+ ) -> Any:
677
+ """Schedule this pipeline to run automatically.
678
+
679
+ Args:
680
+ schedule_type: Type of schedule ('cron', 'interval', 'daily', 'hourly')
681
+ value: Schedule value (cron expression, seconds, 'HH:MM', or minute)
682
+ **kwargs: Additional arguments for scheduler
683
+
684
+ Returns:
685
+ Schedule object
686
+ """
687
+ from flowyml.core.scheduler import PipelineScheduler
688
+
689
+ scheduler = PipelineScheduler()
690
+
691
+ if schedule_type == "cron":
692
+ return scheduler.schedule_cron(self.name, self.run, str(value), **kwargs)
693
+ elif schedule_type == "interval":
694
+ return scheduler.schedule_interval(self.name, self.run, seconds=int(value), **kwargs)
695
+ elif schedule_type == "daily":
696
+ if isinstance(value, str) and ":" in value:
697
+ h, m = map(int, value.split(":"))
698
+ return scheduler.schedule_daily(self.name, self.run, hour=h, minute=m, **kwargs)
699
+ else:
700
+ raise ValueError("Daily schedule value must be 'HH:MM'")
701
+ elif schedule_type == "hourly":
702
+ return scheduler.schedule_hourly(self.name, self.run, minute=int(value), **kwargs)
703
+ else:
704
+ raise ValueError(f"Unknown schedule type: {schedule_type}")
705
+
706
+ def check_cache(self) -> dict[str, Any] | None:
707
+ """Check if a successful run of this pipeline already exists.
708
+
709
+ Returns:
710
+ Metadata of the last successful run, or None if not found.
711
+ """
712
+ # Query metadata store for successful runs of this pipeline
713
+ try:
714
+ runs = self.metadata_store.query(
715
+ pipeline_name=self.name,
716
+ status="completed",
717
+ )
718
+
719
+ if runs:
720
+ # Return the most recent one (query returns ordered by created_at DESC)
721
+ return runs[0]
722
+ except Exception as e:
723
+ # Don't fail if metadata store is not available or errors
724
+ print(f"Warning: Failed to check cache: {e}")
725
+
726
+ return None
flowyml/core/project.py CHANGED
@@ -4,6 +4,7 @@ import json
4
4
  from pathlib import Path
5
5
  from typing import Any, TYPE_CHECKING
6
6
  from datetime import datetime
7
+ from flowyml.utils.config import get_config
7
8
 
8
9
  if TYPE_CHECKING:
9
10
  from flowyml.core.pipeline import Pipeline
@@ -186,6 +187,36 @@ class Project:
186
187
  with open(output_file, "w") as f:
187
188
  json.dump(export_data, f, indent=2)
188
189
 
190
+ def log_model_metrics(
191
+ self,
192
+ model_name: str,
193
+ metrics: dict[str, float],
194
+ run_id: str | None = None,
195
+ environment: str | None = None,
196
+ tags: dict | None = None,
197
+ ) -> None:
198
+ """Log production metrics scoped to this project."""
199
+ self.metadata_store.log_model_metrics(
200
+ project=self.name,
201
+ model_name=model_name,
202
+ metrics=metrics,
203
+ run_id=run_id,
204
+ environment=environment,
205
+ tags=tags,
206
+ )
207
+
208
+ def list_model_metrics(
209
+ self,
210
+ model_name: str | None = None,
211
+ limit: int = 100,
212
+ ) -> list[dict]:
213
+ """List production metrics for this project."""
214
+ return self.metadata_store.list_model_metrics(
215
+ project=self.name,
216
+ model_name=model_name,
217
+ limit=limit,
218
+ )
219
+
189
220
  def __repr__(self) -> str:
190
221
  return f"Project(name='{self.name}', pipelines={len(self.metadata['pipelines'])})"
191
222
 
@@ -205,8 +236,9 @@ class ProjectManager:
205
236
  >>> project = manager.get_project("recommendation_system")
206
237
  """
207
238
 
208
- def __init__(self, projects_dir: str = ".flowyml/projects"):
209
- self.projects_dir = Path(projects_dir)
239
+ def __init__(self, projects_dir: str | None = None):
240
+ config = get_config()
241
+ self.projects_dir = Path(projects_dir or config.projects_dir)
210
242
  self.projects_dir.mkdir(parents=True, exist_ok=True)
211
243
 
212
244
  def create_project(self, name: str, description: str = "") -> Project: