flowyml 1.7.1__py3-none-any.whl → 1.8.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 (137) hide show
  1. flowyml/assets/base.py +15 -0
  2. flowyml/assets/dataset.py +570 -17
  3. flowyml/assets/metrics.py +5 -0
  4. flowyml/assets/model.py +1052 -15
  5. flowyml/cli/main.py +709 -0
  6. flowyml/cli/stack_cli.py +138 -25
  7. flowyml/core/__init__.py +17 -0
  8. flowyml/core/executor.py +231 -37
  9. flowyml/core/image_builder.py +129 -0
  10. flowyml/core/log_streamer.py +227 -0
  11. flowyml/core/orchestrator.py +59 -4
  12. flowyml/core/pipeline.py +65 -13
  13. flowyml/core/routing.py +558 -0
  14. flowyml/core/scheduler.py +88 -5
  15. flowyml/core/step.py +9 -1
  16. flowyml/core/step_grouping.py +49 -35
  17. flowyml/core/types.py +407 -0
  18. flowyml/integrations/keras.py +247 -82
  19. flowyml/monitoring/alerts.py +10 -0
  20. flowyml/monitoring/notifications.py +104 -25
  21. flowyml/monitoring/slack_blocks.py +323 -0
  22. flowyml/plugins/__init__.py +251 -0
  23. flowyml/plugins/alerters/__init__.py +1 -0
  24. flowyml/plugins/alerters/slack.py +168 -0
  25. flowyml/plugins/base.py +752 -0
  26. flowyml/plugins/config.py +478 -0
  27. flowyml/plugins/deployers/__init__.py +22 -0
  28. flowyml/plugins/deployers/gcp_cloud_run.py +200 -0
  29. flowyml/plugins/deployers/sagemaker.py +306 -0
  30. flowyml/plugins/deployers/vertex.py +290 -0
  31. flowyml/plugins/integration.py +369 -0
  32. flowyml/plugins/manager.py +510 -0
  33. flowyml/plugins/model_registries/__init__.py +22 -0
  34. flowyml/plugins/model_registries/mlflow.py +159 -0
  35. flowyml/plugins/model_registries/sagemaker.py +489 -0
  36. flowyml/plugins/model_registries/vertex.py +386 -0
  37. flowyml/plugins/orchestrators/__init__.py +13 -0
  38. flowyml/plugins/orchestrators/sagemaker.py +443 -0
  39. flowyml/plugins/orchestrators/vertex_ai.py +461 -0
  40. flowyml/plugins/registries/__init__.py +13 -0
  41. flowyml/plugins/registries/ecr.py +321 -0
  42. flowyml/plugins/registries/gcr.py +313 -0
  43. flowyml/plugins/registry.py +454 -0
  44. flowyml/plugins/stack.py +494 -0
  45. flowyml/plugins/stack_config.py +537 -0
  46. flowyml/plugins/stores/__init__.py +13 -0
  47. flowyml/plugins/stores/gcs.py +460 -0
  48. flowyml/plugins/stores/s3.py +453 -0
  49. flowyml/plugins/trackers/__init__.py +11 -0
  50. flowyml/plugins/trackers/mlflow.py +316 -0
  51. flowyml/plugins/validators/__init__.py +3 -0
  52. flowyml/plugins/validators/deepchecks.py +119 -0
  53. flowyml/registry/__init__.py +2 -1
  54. flowyml/registry/model_environment.py +109 -0
  55. flowyml/registry/model_registry.py +241 -96
  56. flowyml/serving/__init__.py +17 -0
  57. flowyml/serving/model_server.py +628 -0
  58. flowyml/stacks/__init__.py +60 -0
  59. flowyml/stacks/aws.py +93 -0
  60. flowyml/stacks/base.py +62 -0
  61. flowyml/stacks/components.py +12 -0
  62. flowyml/stacks/gcp.py +44 -9
  63. flowyml/stacks/plugins.py +115 -0
  64. flowyml/stacks/registry.py +2 -1
  65. flowyml/storage/sql.py +401 -12
  66. flowyml/tracking/experiment.py +8 -5
  67. flowyml/ui/backend/Dockerfile +87 -16
  68. flowyml/ui/backend/auth.py +12 -2
  69. flowyml/ui/backend/main.py +149 -5
  70. flowyml/ui/backend/routers/ai_context.py +226 -0
  71. flowyml/ui/backend/routers/assets.py +23 -4
  72. flowyml/ui/backend/routers/auth.py +96 -0
  73. flowyml/ui/backend/routers/deployments.py +660 -0
  74. flowyml/ui/backend/routers/model_explorer.py +597 -0
  75. flowyml/ui/backend/routers/plugins.py +103 -51
  76. flowyml/ui/backend/routers/projects.py +91 -8
  77. flowyml/ui/backend/routers/runs.py +132 -1
  78. flowyml/ui/backend/routers/schedules.py +54 -29
  79. flowyml/ui/backend/routers/templates.py +319 -0
  80. flowyml/ui/backend/routers/websocket.py +2 -2
  81. flowyml/ui/frontend/Dockerfile +55 -6
  82. flowyml/ui/frontend/dist/assets/index-B5AsPTSz.css +1 -0
  83. flowyml/ui/frontend/dist/assets/index-dFbZ8wD8.js +753 -0
  84. flowyml/ui/frontend/dist/index.html +2 -2
  85. flowyml/ui/frontend/dist/logo.png +0 -0
  86. flowyml/ui/frontend/nginx.conf +65 -4
  87. flowyml/ui/frontend/package-lock.json +1415 -74
  88. flowyml/ui/frontend/package.json +4 -0
  89. flowyml/ui/frontend/public/logo.png +0 -0
  90. flowyml/ui/frontend/src/App.jsx +10 -7
  91. flowyml/ui/frontend/src/app/assets/page.jsx +890 -321
  92. flowyml/ui/frontend/src/app/auth/Login.jsx +90 -0
  93. flowyml/ui/frontend/src/app/dashboard/page.jsx +8 -8
  94. flowyml/ui/frontend/src/app/deployments/page.jsx +786 -0
  95. flowyml/ui/frontend/src/app/model-explorer/page.jsx +1031 -0
  96. flowyml/ui/frontend/src/app/pipelines/page.jsx +12 -2
  97. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectExperimentsList.jsx +19 -6
  98. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectMetricsPanel.jsx +1 -1
  99. flowyml/ui/frontend/src/app/runs/[runId]/page.jsx +601 -101
  100. flowyml/ui/frontend/src/app/runs/page.jsx +8 -2
  101. flowyml/ui/frontend/src/app/settings/page.jsx +267 -253
  102. flowyml/ui/frontend/src/components/ArtifactViewer.jsx +62 -2
  103. flowyml/ui/frontend/src/components/AssetDetailsPanel.jsx +424 -29
  104. flowyml/ui/frontend/src/components/AssetTreeHierarchy.jsx +119 -11
  105. flowyml/ui/frontend/src/components/DatasetViewer.jsx +753 -0
  106. flowyml/ui/frontend/src/components/Layout.jsx +6 -0
  107. flowyml/ui/frontend/src/components/PipelineGraph.jsx +79 -29
  108. flowyml/ui/frontend/src/components/RunDetailsPanel.jsx +36 -6
  109. flowyml/ui/frontend/src/components/RunMetaPanel.jsx +113 -0
  110. flowyml/ui/frontend/src/components/TrainingHistoryChart.jsx +514 -0
  111. flowyml/ui/frontend/src/components/TrainingMetricsPanel.jsx +175 -0
  112. flowyml/ui/frontend/src/components/ai/AIAssistantButton.jsx +71 -0
  113. flowyml/ui/frontend/src/components/ai/AIAssistantPanel.jsx +420 -0
  114. flowyml/ui/frontend/src/components/header/Header.jsx +22 -0
  115. flowyml/ui/frontend/src/components/plugins/PluginManager.jsx +4 -4
  116. flowyml/ui/frontend/src/components/plugins/{ZenMLIntegration.jsx → StackImport.jsx} +38 -12
  117. flowyml/ui/frontend/src/components/sidebar/Sidebar.jsx +36 -13
  118. flowyml/ui/frontend/src/contexts/AIAssistantContext.jsx +245 -0
  119. flowyml/ui/frontend/src/contexts/AuthContext.jsx +108 -0
  120. flowyml/ui/frontend/src/hooks/useAIContext.js +156 -0
  121. flowyml/ui/frontend/src/hooks/useWebGPU.js +54 -0
  122. flowyml/ui/frontend/src/layouts/MainLayout.jsx +6 -0
  123. flowyml/ui/frontend/src/router/index.jsx +47 -20
  124. flowyml/ui/frontend/src/services/pluginService.js +3 -1
  125. flowyml/ui/server_manager.py +5 -5
  126. flowyml/ui/utils.py +157 -39
  127. flowyml/utils/config.py +37 -15
  128. flowyml/utils/model_introspection.py +123 -0
  129. flowyml/utils/observability.py +30 -0
  130. flowyml-1.8.0.dist-info/METADATA +174 -0
  131. {flowyml-1.7.1.dist-info → flowyml-1.8.0.dist-info}/RECORD +134 -73
  132. {flowyml-1.7.1.dist-info → flowyml-1.8.0.dist-info}/WHEEL +1 -1
  133. flowyml/ui/frontend/dist/assets/index-BqDQvp63.js +0 -630
  134. flowyml/ui/frontend/dist/assets/index-By4trVyv.css +0 -1
  135. flowyml-1.7.1.dist-info/METADATA +0 -477
  136. {flowyml-1.7.1.dist-info → flowyml-1.8.0.dist-info}/entry_points.txt +0 -0
  137. {flowyml-1.7.1.dist-info → flowyml-1.8.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,558 @@
1
+ """FlowyML Artifact Routing - Automatic Type-Based Artifact Routing.
2
+
3
+ This module provides automatic routing of step outputs to appropriate
4
+ infrastructure based on their Python types. When a step returns a
5
+ `Model`, `Dataset`, `Metrics`, or other artifact type, the runtime
6
+ automatically routes it to the configured stores and registries.
7
+
8
+ Usage:
9
+ from flowyml.core.routing import route_artifact
10
+
11
+ # After step execution
12
+ result = step.func(**inputs)
13
+
14
+ # Route based on type and stack config
15
+ artifact_info = route_artifact(
16
+ output=result,
17
+ step_name="train_model",
18
+ run_id="run-123",
19
+ )
20
+
21
+ The routing is configured via flowyml.yaml:
22
+ stacks:
23
+ gcp-prod:
24
+ artifact_routing:
25
+ Model: { store: gcs, register: true }
26
+ Dataset: { store: gcs }
27
+ Metrics: { log_to_tracker: true }
28
+ """
29
+
30
+ import logging
31
+ from typing import Any, get_type_hints
32
+ from dataclasses import dataclass, field
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ @dataclass
38
+ class RoutingResult:
39
+ """Result of artifact routing.
40
+
41
+ Attributes:
42
+ artifact_type: Name of the artifact type (Model, Dataset, etc.)
43
+ store_uri: URI where the artifact was stored
44
+ registered: Whether the artifact was registered (e.g., in model registry)
45
+ deployed: Whether the artifact was deployed (e.g., to endpoint)
46
+ endpoint_uri: URI of the deployment endpoint
47
+ logged: Whether the artifact was logged (e.g., metrics to tracker)
48
+ metadata: Additional metadata from routing
49
+ """
50
+
51
+ artifact_type: str | None = None
52
+ store_uri: str | None = None
53
+ registered: bool = False
54
+ deployed: bool = False
55
+ endpoint_uri: str | None = None
56
+ logged: bool = False
57
+ metadata: dict[str, Any] = field(default_factory=dict)
58
+
59
+
60
+ def get_step_return_type(step_func: callable) -> type | None:
61
+ """Get the return type annotation from a step function.
62
+
63
+ Args:
64
+ step_func: The step function to inspect.
65
+
66
+ Returns:
67
+ The return type annotation, or None if not annotated.
68
+ """
69
+ try:
70
+ hints = get_type_hints(step_func)
71
+ return hints.get("return")
72
+ except Exception:
73
+ # Fallback to __annotations__ if get_type_hints fails
74
+ try:
75
+ return step_func.__annotations__.get("return")
76
+ except Exception:
77
+ return None
78
+
79
+
80
+ def detect_artifact_type(output: Any) -> str | None:
81
+ """Detect the artifact type from an output value.
82
+
83
+ This checks if the output is an instance of one of our artifact types
84
+ or if it matches specific patterns (like dict for Metrics).
85
+
86
+ Args:
87
+ output: The step output value.
88
+
89
+ Returns:
90
+ Type name string or None.
91
+ """
92
+ # Import types here to avoid circular imports
93
+ from flowyml.core.types import Artifact, Model, Dataset, Metrics, Parameters
94
+
95
+ if isinstance(output, Model):
96
+ return "Model"
97
+ elif isinstance(output, Dataset):
98
+ return "Dataset"
99
+ elif isinstance(output, Metrics):
100
+ return "Metrics"
101
+ elif isinstance(output, Parameters):
102
+ return "Parameters"
103
+ elif isinstance(output, Artifact):
104
+ return type(output).__name__
105
+
106
+ return None
107
+
108
+
109
+ def route_artifact(
110
+ output: Any,
111
+ step_name: str,
112
+ run_id: str,
113
+ return_type: type | None = None,
114
+ project_name: str = "default",
115
+ ) -> RoutingResult:
116
+ """Route a step output to appropriate infrastructure based on type.
117
+
118
+ This is the main entry point for type-based artifact routing.
119
+ It inspects the output type and routes to configured stores/registries.
120
+
121
+ Args:
122
+ output: The step output to route.
123
+ step_name: Name of the step that produced this output.
124
+ run_id: Current run identifier.
125
+ return_type: Optional return type annotation (if known).
126
+ project_name: Project name for namespacing.
127
+
128
+ Returns:
129
+ RoutingResult with routing information.
130
+ """
131
+ result = RoutingResult()
132
+
133
+ # Skip None outputs
134
+ if output is None:
135
+ return result
136
+
137
+ # Detect artifact type
138
+ artifact_type = detect_artifact_type(output)
139
+
140
+ # If not detected from value, try from type annotation
141
+ if artifact_type is None and return_type is not None:
142
+ try:
143
+ type_name = return_type.__name__ if hasattr(return_type, "__name__") else str(return_type)
144
+ if type_name in ("Model", "Dataset", "Metrics", "Parameters"):
145
+ artifact_type = type_name
146
+ except Exception:
147
+ pass
148
+
149
+ if artifact_type is None:
150
+ # Not a routable artifact type
151
+ return result
152
+
153
+ result.artifact_type = artifact_type
154
+ logger.debug(f"Routing {artifact_type} artifact from step '{step_name}'")
155
+
156
+ # Get routing configuration from active stack
157
+ try:
158
+ from flowyml.plugins.stack_config import get_routing_for_type, get_active_stack
159
+
160
+ routing_rule = get_routing_for_type(artifact_type)
161
+ stack = get_active_stack()
162
+
163
+ if routing_rule is None:
164
+ logger.debug(f"No routing rule for {artifact_type}, using defaults")
165
+ return result
166
+
167
+ # Route to artifact store
168
+ if routing_rule.store:
169
+ result.store_uri = _save_to_store(
170
+ output=output,
171
+ artifact_type=artifact_type,
172
+ store_name=routing_rule.store,
173
+ path=routing_rule.format_path(
174
+ run_id=run_id,
175
+ step_name=step_name,
176
+ artifact_name=artifact_type.lower(),
177
+ ),
178
+ stack=stack,
179
+ )
180
+
181
+ # Register model if configured
182
+ if routing_rule.register and artifact_type == "Model":
183
+ result.registered = _register_model(
184
+ output=output,
185
+ step_name=step_name,
186
+ run_id=run_id,
187
+ stack=stack,
188
+ )
189
+
190
+ # Deploy model if configured and conditions are met
191
+ # Note: deploy=True just enables deployment - actual deployment depends on deploy_condition
192
+ if routing_rule.deploy and artifact_type == "Model":
193
+ # Get metrics from model metadata for conditional deployment
194
+ model_metrics = None
195
+ if hasattr(output, "metadata") and output.metadata:
196
+ model_metrics = output.metadata.get("metrics", {})
197
+
198
+ # Check if auto-deployment should proceed
199
+ if routing_rule.should_auto_deploy(model_metrics):
200
+ endpoint_name = routing_rule.endpoint_name or f"{step_name}-endpoint"
201
+ result.deployed, result.endpoint_uri = _deploy_model(
202
+ output=output,
203
+ step_name=step_name,
204
+ run_id=run_id,
205
+ endpoint_name=endpoint_name,
206
+ stack=stack,
207
+ )
208
+ else:
209
+ # Log that deployment is pending approval/manual action
210
+ condition = routing_rule.deploy_condition
211
+ if condition == "manual":
212
+ logger.info(
213
+ f"Model registered but not deployed (deploy_condition='manual'). "
214
+ f"Use 'flowyml model deploy {output.name}' to deploy.",
215
+ )
216
+ elif condition == "on_approval":
217
+ logger.info("Model registered, awaiting approval for deployment.")
218
+ elif condition == "auto" and routing_rule.deploy_min_metrics:
219
+ logger.info(
220
+ f"Model not deployed - metrics did not meet thresholds: " f"{routing_rule.deploy_min_metrics}",
221
+ )
222
+
223
+ # Log metrics if configured
224
+ if routing_rule.log_to_tracker and artifact_type == "Metrics":
225
+ result.logged = _log_metrics(
226
+ output=output,
227
+ step_name=step_name,
228
+ run_id=run_id,
229
+ stack=stack,
230
+ )
231
+
232
+ # Log parameters if configured
233
+ if routing_rule.log_to_tracker and artifact_type == "Parameters":
234
+ result.logged = _log_parameters(
235
+ output=output,
236
+ step_name=step_name,
237
+ run_id=run_id,
238
+ stack=stack,
239
+ )
240
+
241
+ # Add routing metadata
242
+ result.metadata = {
243
+ "store": routing_rule.store,
244
+ "path": routing_rule.path,
245
+ "registered": result.registered,
246
+ "deployed": result.deployed,
247
+ "logged": result.logged,
248
+ }
249
+
250
+ except ImportError:
251
+ logger.debug("Stack config not available, skipping routing")
252
+ except Exception as e:
253
+ logger.warning(f"Error during artifact routing: {e}")
254
+
255
+ return result
256
+
257
+
258
+ def _save_to_store(
259
+ output: Any,
260
+ artifact_type: str,
261
+ store_name: str,
262
+ path: str,
263
+ stack: Any,
264
+ ) -> str | None:
265
+ """Save artifact to the configured store.
266
+
267
+ Args:
268
+ output: The artifact to save.
269
+ artifact_type: Type of the artifact.
270
+ store_name: Name of the store (gcs, s3, local).
271
+ path: Path within the store.
272
+ stack: Stack configuration.
273
+
274
+ Returns:
275
+ URI of the saved artifact or None.
276
+ """
277
+ try:
278
+ # Get artifact store from stack
279
+ if store_name and stack and stack.artifact_stores:
280
+ store_config = stack.artifact_stores.get(store_name)
281
+ if store_config:
282
+ # Instantiate and use the store
283
+ from flowyml.plugins.config import get_artifact_store
284
+
285
+ store = get_artifact_store()
286
+ if store:
287
+ # Extract data if it's an Artifact wrapper
288
+ from flowyml.core.types import Artifact
289
+
290
+ data = output.data if isinstance(output, Artifact) else output
291
+ return store.save(data, path)
292
+
293
+ # Fallback to default artifact store
294
+ from flowyml.plugins.config import get_artifact_store
295
+
296
+ store = get_artifact_store()
297
+ if store:
298
+ from flowyml.core.types import Artifact
299
+
300
+ data = output.data if isinstance(output, Artifact) else output
301
+ return store.save(data, path)
302
+
303
+ except Exception as e:
304
+ logger.warning(f"Failed to save artifact to store: {e}")
305
+
306
+ return None
307
+
308
+
309
+ def _register_model(
310
+ output: Any,
311
+ step_name: str,
312
+ run_id: str,
313
+ stack: Any,
314
+ ) -> bool:
315
+ """Register a model in the model registry.
316
+
317
+ Args:
318
+ output: The Model artifact.
319
+ step_name: Step that produced the model.
320
+ run_id: Current run ID.
321
+ stack: Stack configuration.
322
+
323
+ Returns:
324
+ True if registration was successful.
325
+ """
326
+ try:
327
+ from flowyml.core.types import Model
328
+
329
+ if not isinstance(output, Model):
330
+ return False
331
+
332
+ # Get model registry from plugins
333
+ from flowyml.plugins.config import get_config
334
+
335
+ config = get_config()
336
+ registry = config._get_plugin("model_registry")
337
+
338
+ if registry:
339
+ model_name = output.name or f"{step_name}_model"
340
+ model_uri = output.uri or f"runs/{run_id}/models/{step_name}"
341
+
342
+ registry.register_model(
343
+ name=model_name,
344
+ model_uri=model_uri,
345
+ version=output.version,
346
+ metadata={
347
+ "framework": output.framework,
348
+ "step_name": step_name,
349
+ "run_id": run_id,
350
+ **output.metadata,
351
+ },
352
+ )
353
+ logger.info(f"Registered model '{model_name}' to registry")
354
+ return True
355
+
356
+ except Exception as e:
357
+ logger.warning(f"Failed to register model: {e}")
358
+
359
+ return False
360
+
361
+
362
+ def _deploy_model(
363
+ output: Any,
364
+ step_name: str,
365
+ run_id: str,
366
+ endpoint_name: str,
367
+ stack: Any,
368
+ ) -> tuple[bool, str | None]:
369
+ """Deploy a model to an endpoint.
370
+
371
+ Args:
372
+ output: The Model artifact.
373
+ step_name: Step that produced the model.
374
+ run_id: Current run ID.
375
+ endpoint_name: Name for the endpoint.
376
+ stack: Stack configuration.
377
+
378
+ Returns:
379
+ Tuple of (success, endpoint_uri).
380
+ """
381
+ try:
382
+ from flowyml.core.types import Model
383
+
384
+ if not isinstance(output, Model):
385
+ return False, None
386
+
387
+ # Get model deployer from stack config
388
+ if stack and stack.model_deployer:
389
+ deployer_config = stack.model_deployer
390
+ deployer_type = deployer_config.get("type", "")
391
+
392
+ deployer = None
393
+
394
+ # Instantiate the appropriate deployer
395
+ if "vertex" in deployer_type:
396
+ from flowyml.plugins.deployers.vertex import VertexEndpointDeployer
397
+
398
+ deployer = VertexEndpointDeployer(
399
+ project=deployer_config.get("project"),
400
+ location=deployer_config.get("location", "us-central1"),
401
+ )
402
+ elif "sagemaker" in deployer_type:
403
+ from flowyml.plugins.deployers.sagemaker import SageMakerEndpointDeployer
404
+
405
+ deployer = SageMakerEndpointDeployer(
406
+ region=deployer_config.get("region"),
407
+ role_arn=deployer_config.get("role_arn"),
408
+ )
409
+
410
+ if deployer:
411
+ deployer.initialize()
412
+
413
+ # Get model URI (from artifact store or output)
414
+ model_uri = output.uri or f"runs/{run_id}/models/{step_name}"
415
+
416
+ endpoint_uri = deployer.deploy(
417
+ model_uri=model_uri,
418
+ endpoint_name=endpoint_name,
419
+ )
420
+
421
+ logger.info(f"Deployed model to endpoint: {endpoint_uri}")
422
+ return True, endpoint_uri
423
+
424
+ # No deployer configured
425
+ logger.debug("No model deployer configured in stack")
426
+ return False, None
427
+
428
+ except Exception as e:
429
+ logger.warning(f"Failed to deploy model: {e}")
430
+
431
+ return False, None
432
+
433
+
434
+ def _log_metrics(
435
+ output: Any,
436
+ step_name: str,
437
+ run_id: str,
438
+ stack: Any,
439
+ ) -> bool:
440
+ """Log metrics to the experiment tracker.
441
+
442
+ Args:
443
+ output: The Metrics artifact (dict-like).
444
+ step_name: Step that produced the metrics.
445
+ run_id: Current run ID.
446
+ stack: Stack configuration.
447
+
448
+ Returns:
449
+ True if logging was successful.
450
+ """
451
+ try:
452
+ from flowyml.core.types import Metrics
453
+ from flowyml.plugins.config import get_tracker
454
+
455
+ tracker = get_tracker()
456
+ if tracker:
457
+ # Get metrics values
458
+ if isinstance(output, Metrics):
459
+ metrics_dict = dict(output)
460
+ step_num = output._step
461
+ else:
462
+ metrics_dict = dict(output)
463
+ step_num = None
464
+
465
+ tracker.log_metrics(metrics_dict, step=step_num)
466
+ logger.debug(f"Logged metrics from step '{step_name}': {list(metrics_dict.keys())}")
467
+ return True
468
+
469
+ except Exception as e:
470
+ logger.warning(f"Failed to log metrics: {e}")
471
+
472
+ return False
473
+
474
+
475
+ def _log_parameters(
476
+ output: Any,
477
+ step_name: str,
478
+ run_id: str,
479
+ stack: Any,
480
+ ) -> bool:
481
+ """Log parameters to the experiment tracker.
482
+
483
+ Args:
484
+ output: The Parameters artifact (dict-like).
485
+ step_name: Step that uses the parameters.
486
+ run_id: Current run ID.
487
+ stack: Stack configuration.
488
+
489
+ Returns:
490
+ True if logging was successful.
491
+ """
492
+ try:
493
+ from flowyml.core.types import Parameters
494
+ from flowyml.plugins.config import get_tracker
495
+
496
+ tracker = get_tracker()
497
+ if tracker:
498
+ # Get parameter values
499
+ if isinstance(output, Parameters):
500
+ params_dict = dict(output)
501
+ else:
502
+ params_dict = dict(output)
503
+
504
+ # Log parameters (with step prefix for clarity)
505
+ prefixed_params = {f"{step_name}/{k}": v for k, v in params_dict.items()}
506
+ tracker.log_params(prefixed_params)
507
+ logger.debug(f"Logged parameters from step '{step_name}': {list(params_dict.keys())}")
508
+ return True
509
+
510
+ except Exception as e:
511
+ logger.warning(f"Failed to log parameters: {e}")
512
+
513
+ return False
514
+
515
+
516
+ def should_route(output: Any) -> bool:
517
+ """Check if an output should be routed.
518
+
519
+ Args:
520
+ output: The step output.
521
+
522
+ Returns:
523
+ True if the output should be routed.
524
+ """
525
+ if output is None:
526
+ return False
527
+
528
+ from flowyml.core.types import is_artifact_type
529
+
530
+ return is_artifact_type(output)
531
+
532
+
533
+ def auto_route_metrics_and_params(
534
+ output: Any,
535
+ step_name: str,
536
+ run_id: str,
537
+ ) -> bool:
538
+ """Automatically route Metrics and Parameters without explicit config.
539
+
540
+ This is a convenience function that can be called to log Metrics
541
+ and Parameters even when no routing rule is configured.
542
+
543
+ Args:
544
+ output: The step output.
545
+ step_name: Step name.
546
+ run_id: Run ID.
547
+
548
+ Returns:
549
+ True if logging was successful.
550
+ """
551
+ from flowyml.core.types import Metrics, Parameters
552
+
553
+ if isinstance(output, Metrics):
554
+ return _log_metrics(output, step_name, run_id, None)
555
+ elif isinstance(output, Parameters):
556
+ return _log_parameters(output, step_name, run_id, None)
557
+
558
+ return False
flowyml/core/scheduler.py CHANGED
@@ -224,6 +224,36 @@ class SchedulerPersistence:
224
224
  logger.error(f"Failed to load schedule {name}: {e}")
225
225
  return schedules
226
226
 
227
+ def load_schedule(self, name: str) -> Schedule | None:
228
+ """Load a single schedule from database by name.
229
+
230
+ Returns None if not found. Creates a Schedule without a pipeline_func
231
+ (the schedule will be enabled/disabled but won't actually run until
232
+ a pipeline function is registered).
233
+ """
234
+ with self.engine.connect() as conn:
235
+ stmt = select(self.schedules.c.name, self.schedules.c.data).where(
236
+ self.schedules.c.name == name,
237
+ )
238
+ result = conn.execute(stmt)
239
+ row = result.fetchone()
240
+ if row:
241
+ try:
242
+ data = json.loads(row.data)
243
+ # Create a minimal Schedule for enable/disable operations
244
+ # without requiring the pipeline function
245
+ return Schedule(
246
+ pipeline_name=data.get("pipeline_name", name),
247
+ pipeline_func=lambda: None, # Placeholder - not for execution
248
+ schedule_type=data.get("schedule_type", ""),
249
+ schedule_value=data.get("schedule_value", ""),
250
+ timezone=data.get("timezone", "UTC"),
251
+ enabled=data.get("enabled", True),
252
+ )
253
+ except Exception as e:
254
+ logger.error(f"Failed to load schedule {name}: {e}")
255
+ return None
256
+
227
257
  def delete_schedule(self, name: str) -> None:
228
258
  """Delete schedule from database using SQLAlchemy."""
229
259
  with self.engine.connect() as conn:
@@ -251,6 +281,29 @@ class SchedulerPersistence:
251
281
  conn.execute(stmt)
252
282
  conn.commit()
253
283
 
284
+ def list_all_schedules(self) -> list[dict[str, Any]]:
285
+ """List all schedules from database without requiring pipeline functions.
286
+
287
+ This is useful for displaying schedules in the UI regardless of whether
288
+ the pipeline code is loaded.
289
+ """
290
+ schedules = []
291
+ with self.engine.connect() as conn:
292
+ stmt = select(self.schedules.c.name, self.schedules.c.data, self.schedules.c.updated_at)
293
+ result = conn.execute(stmt)
294
+ for row in result:
295
+ try:
296
+ data = json.loads(row.data)
297
+ data["name"] = row.name
298
+ if row.updated_at:
299
+ data["updated_at"] = (
300
+ row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else str(row.updated_at)
301
+ )
302
+ schedules.append(data)
303
+ except Exception as e:
304
+ logger.error(f"Failed to parse schedule {row.name}: {e}")
305
+ return schedules
306
+
254
307
  def get_history(self, schedule_name: str, limit: int = 50) -> list[dict[str, Any]]:
255
308
  """Get execution history for a schedule using SQLAlchemy."""
256
309
  history = []
@@ -533,11 +586,17 @@ class PipelineScheduler:
533
586
  return schedule
534
587
 
535
588
  def unschedule(self, name: str) -> None:
536
- """Remove a scheduled pipeline."""
589
+ """Remove a scheduled pipeline.
590
+
591
+ Handles both in-memory schedules and persisted schedules.
592
+ """
593
+ # Remove from in-memory schedules if present
537
594
  if name in self.schedules:
538
595
  del self.schedules[name]
539
- if self._persistence:
540
- self._persistence.delete_schedule(name)
596
+
597
+ # Always try to remove from persistence (handles schedules created by other processes)
598
+ if self._persistence:
599
+ self._persistence.delete_schedule(name)
541
600
 
542
601
  def clear(self) -> None:
543
602
  """Remove all schedules."""
@@ -550,18 +609,42 @@ class PipelineScheduler:
550
609
  conn.commit()
551
610
 
552
611
  def enable(self, name: str) -> None:
553
- """Enable a schedule."""
612
+ """Enable a schedule.
613
+
614
+ Handles both in-memory schedules and persisted schedules.
615
+ """
554
616
  if name in self.schedules:
555
617
  self.schedules[name].enabled = True
556
618
  if self._persistence:
557
619
  self._persistence.save_schedule(self.schedules[name])
620
+ elif self._persistence:
621
+ # Schedule might be in persistence but not loaded in memory
622
+ # Load it, update, and save back
623
+ schedule = self._persistence.load_schedule(name)
624
+ if schedule:
625
+ schedule.enabled = True
626
+ self._persistence.save_schedule(schedule)
627
+ # Also add to in-memory schedules
628
+ self.schedules[name] = schedule
558
629
 
559
630
  def disable(self, name: str) -> None:
560
- """Disable a schedule."""
631
+ """Disable a schedule.
632
+
633
+ Handles both in-memory schedules and persisted schedules.
634
+ """
561
635
  if name in self.schedules:
562
636
  self.schedules[name].enabled = False
563
637
  if self._persistence:
564
638
  self._persistence.save_schedule(self.schedules[name])
639
+ elif self._persistence:
640
+ # Schedule might be in persistence but not loaded in memory
641
+ # Load it, update, and save back
642
+ schedule = self._persistence.load_schedule(name)
643
+ if schedule:
644
+ schedule.enabled = False
645
+ self._persistence.save_schedule(schedule)
646
+ # Also add to in-memory schedules
647
+ self.schedules[name] = schedule
565
648
 
566
649
  def _run_pipeline(self, schedule: Schedule) -> None:
567
650
  """Run a scheduled pipeline."""