flowyml 1.2.0__py3-none-any.whl → 1.4.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 (104) 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 +34 -17
  14. flowyml/core/retry_policy.py +80 -0
  15. flowyml/core/scheduler.py +9 -9
  16. flowyml/core/scheduler_config.py +2 -3
  17. flowyml/core/step.py +18 -1
  18. flowyml/core/submission_result.py +53 -0
  19. flowyml/integrations/keras.py +95 -22
  20. flowyml/monitoring/alerts.py +2 -2
  21. flowyml/stacks/__init__.py +15 -0
  22. flowyml/stacks/aws.py +599 -0
  23. flowyml/stacks/azure.py +295 -0
  24. flowyml/stacks/bridge.py +9 -9
  25. flowyml/stacks/components.py +24 -2
  26. flowyml/stacks/gcp.py +158 -11
  27. flowyml/stacks/local.py +5 -0
  28. flowyml/stacks/plugins.py +2 -2
  29. flowyml/stacks/registry.py +21 -0
  30. flowyml/storage/artifacts.py +15 -5
  31. flowyml/storage/materializers/__init__.py +2 -0
  32. flowyml/storage/materializers/base.py +33 -0
  33. flowyml/storage/materializers/cloudpickle.py +74 -0
  34. flowyml/storage/metadata.py +3 -881
  35. flowyml/storage/remote.py +590 -0
  36. flowyml/storage/sql.py +911 -0
  37. flowyml/ui/backend/dependencies.py +28 -0
  38. flowyml/ui/backend/main.py +43 -80
  39. flowyml/ui/backend/routers/assets.py +483 -17
  40. flowyml/ui/backend/routers/client.py +46 -0
  41. flowyml/ui/backend/routers/execution.py +13 -2
  42. flowyml/ui/backend/routers/experiments.py +97 -14
  43. flowyml/ui/backend/routers/metrics.py +168 -0
  44. flowyml/ui/backend/routers/pipelines.py +77 -12
  45. flowyml/ui/backend/routers/projects.py +33 -7
  46. flowyml/ui/backend/routers/runs.py +221 -12
  47. flowyml/ui/backend/routers/schedules.py +5 -21
  48. flowyml/ui/backend/routers/stats.py +14 -0
  49. flowyml/ui/backend/routers/traces.py +37 -53
  50. flowyml/ui/frontend/dist/assets/index-DcYwrn2j.css +1 -0
  51. flowyml/ui/frontend/dist/assets/index-Dlz_ygOL.js +592 -0
  52. flowyml/ui/frontend/dist/index.html +2 -2
  53. flowyml/ui/frontend/src/App.jsx +4 -1
  54. flowyml/ui/frontend/src/app/assets/page.jsx +260 -230
  55. flowyml/ui/frontend/src/app/dashboard/page.jsx +38 -7
  56. flowyml/ui/frontend/src/app/experiments/page.jsx +61 -314
  57. flowyml/ui/frontend/src/app/observability/page.jsx +277 -0
  58. flowyml/ui/frontend/src/app/pipelines/page.jsx +79 -402
  59. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectArtifactsList.jsx +151 -0
  60. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectExperimentsList.jsx +145 -0
  61. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectHeader.jsx +45 -0
  62. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectHierarchy.jsx +467 -0
  63. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectMetricsPanel.jsx +253 -0
  64. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectPipelinesList.jsx +105 -0
  65. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectRelations.jsx +189 -0
  66. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectRunsList.jsx +136 -0
  67. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectTabs.jsx +95 -0
  68. flowyml/ui/frontend/src/app/projects/[projectId]/page.jsx +326 -0
  69. flowyml/ui/frontend/src/app/projects/page.jsx +13 -3
  70. flowyml/ui/frontend/src/app/runs/[runId]/page.jsx +79 -10
  71. flowyml/ui/frontend/src/app/runs/page.jsx +82 -424
  72. flowyml/ui/frontend/src/app/settings/page.jsx +1 -0
  73. flowyml/ui/frontend/src/app/tokens/page.jsx +62 -16
  74. flowyml/ui/frontend/src/components/AssetDetailsPanel.jsx +373 -0
  75. flowyml/ui/frontend/src/components/AssetLineageGraph.jsx +291 -0
  76. flowyml/ui/frontend/src/components/AssetStatsDashboard.jsx +302 -0
  77. flowyml/ui/frontend/src/components/AssetTreeHierarchy.jsx +477 -0
  78. flowyml/ui/frontend/src/components/ExperimentDetailsPanel.jsx +227 -0
  79. flowyml/ui/frontend/src/components/NavigationTree.jsx +401 -0
  80. flowyml/ui/frontend/src/components/PipelineDetailsPanel.jsx +239 -0
  81. flowyml/ui/frontend/src/components/PipelineGraph.jsx +67 -3
  82. flowyml/ui/frontend/src/components/ProjectSelector.jsx +115 -0
  83. flowyml/ui/frontend/src/components/RunDetailsPanel.jsx +298 -0
  84. flowyml/ui/frontend/src/components/header/Header.jsx +48 -1
  85. flowyml/ui/frontend/src/components/plugins/ZenMLIntegration.jsx +106 -0
  86. flowyml/ui/frontend/src/components/sidebar/Sidebar.jsx +52 -26
  87. flowyml/ui/frontend/src/components/ui/DataView.jsx +35 -17
  88. flowyml/ui/frontend/src/components/ui/ErrorBoundary.jsx +118 -0
  89. flowyml/ui/frontend/src/contexts/ProjectContext.jsx +2 -2
  90. flowyml/ui/frontend/src/contexts/ToastContext.jsx +116 -0
  91. flowyml/ui/frontend/src/layouts/MainLayout.jsx +5 -1
  92. flowyml/ui/frontend/src/router/index.jsx +4 -0
  93. flowyml/ui/frontend/src/utils/date.js +10 -0
  94. flowyml/ui/frontend/src/utils/downloads.js +11 -0
  95. flowyml/utils/config.py +6 -0
  96. flowyml/utils/stack_config.py +45 -3
  97. {flowyml-1.2.0.dist-info → flowyml-1.4.0.dist-info}/METADATA +44 -4
  98. flowyml-1.4.0.dist-info/RECORD +200 -0
  99. {flowyml-1.2.0.dist-info → flowyml-1.4.0.dist-info}/licenses/LICENSE +1 -1
  100. flowyml/ui/frontend/dist/assets/index-DFNQnrUj.js +0 -448
  101. flowyml/ui/frontend/dist/assets/index-pWI271rZ.css +0 -1
  102. flowyml-1.2.0.dist-info/RECORD +0 -159
  103. {flowyml-1.2.0.dist-info → flowyml-1.4.0.dist-info}/WHEEL +0 -0
  104. {flowyml-1.2.0.dist-info → flowyml-1.4.0.dist-info}/entry_points.txt +0 -0
@@ -1,45 +1,511 @@
1
- from fastapi import APIRouter, HTTPException
1
+ from fastapi import APIRouter, HTTPException, UploadFile, File
2
+ from fastapi.responses import FileResponse
3
+ from pydantic import BaseModel, Field
2
4
  from flowyml.storage.metadata import SQLiteMetadataStore
5
+ from flowyml.core.project import ProjectManager
6
+ from pathlib import Path
7
+ from flowyml.ui.backend.dependencies import get_store
8
+ import shutil
9
+ import asyncio
10
+ import contextlib
3
11
 
4
12
  router = APIRouter()
5
13
 
6
14
 
7
- def get_store():
8
- return SQLiteMetadataStore()
15
+ def _save_file_sync(src, dst):
16
+ with open(dst, "wb") as buffer:
17
+ shutil.copyfileobj(src, buffer)
18
+
19
+
20
+ def _iter_metadata_stores():
21
+ stores = [(None, SQLiteMetadataStore())]
22
+ try:
23
+ manager = ProjectManager()
24
+ for project_meta in manager.list_projects():
25
+ name = project_meta.get("name")
26
+ if not name:
27
+ continue
28
+ project = manager.get_project(name)
29
+ if project:
30
+ stores.append((name, project.metadata_store))
31
+ except Exception:
32
+ pass
33
+ return stores
34
+
35
+
36
+ def _dedupe_assets(assets):
37
+ dedup = {}
38
+ for asset, project_name in assets:
39
+ artifact_id = asset.get("artifact_id") or f"{project_name}:{len(dedup)}"
40
+ if artifact_id in dedup:
41
+ continue
42
+ entry = dict(asset)
43
+ if project_name and not entry.get("project"):
44
+ entry["project"] = project_name
45
+ dedup[artifact_id] = entry
46
+ return list(dedup.values())
9
47
 
10
48
 
11
49
  @router.get("/")
12
50
  async def list_assets(limit: int = 50, asset_type: str = None, run_id: str = None, project: str = None):
13
51
  """List all assets, optionally filtered by project."""
52
+ try:
53
+ combined = []
54
+ for project_name, store in _iter_metadata_stores():
55
+ if project and project_name and project != project_name:
56
+ continue
57
+
58
+ filters = {}
59
+ if asset_type:
60
+ filters["type"] = asset_type
61
+ if run_id:
62
+ filters["run_id"] = run_id
63
+
64
+ assets = store.list_assets(limit=limit, **filters)
65
+ for asset in assets:
66
+ combined.append((asset, project_name))
67
+
68
+ assets = _dedupe_assets(combined)
69
+
70
+ if project:
71
+ assets = [a for a in assets if a.get("project") == project]
72
+
73
+ assets = assets[:limit]
74
+
75
+ return {"assets": assets}
76
+ except Exception as e:
77
+ return {"assets": [], "error": str(e)}
78
+
79
+
80
+ class AssetCreate(BaseModel):
81
+ artifact_id: str
82
+ name: str
83
+ asset_type: str = Field(..., alias="type")
84
+ run_id: str
85
+ step: str
86
+ project: str | None = None
87
+ metadata: dict = {}
88
+ value: str | None = None
89
+
90
+
91
+ @router.post("/")
92
+ async def create_asset(asset: AssetCreate):
93
+ """Create or update an asset metadata."""
94
+ try:
95
+ store = get_store()
96
+
97
+ # Prepare metadata
98
+ metadata = asset.metadata.copy()
99
+ metadata.update(
100
+ {
101
+ "name": asset.name,
102
+ "type": asset.asset_type,
103
+ "run_id": asset.run_id,
104
+ "step": asset.step,
105
+ "project": asset.project,
106
+ "value": asset.value,
107
+ },
108
+ )
109
+
110
+ store.save_artifact(asset.artifact_id, metadata)
111
+ return {"status": "success", "artifact_id": asset.artifact_id}
112
+ except Exception as e:
113
+ raise HTTPException(status_code=500, detail=str(e))
114
+
115
+
116
+ @router.post("/{artifact_id}/upload")
117
+ async def upload_asset_content(artifact_id: str, file: UploadFile = File(...)):
118
+ """Upload content for an artifact."""
14
119
  try:
15
120
  store = get_store()
16
121
 
17
- # Build filters
18
- filters = {}
19
- if asset_type:
20
- filters["type"] = asset_type
21
- if run_id:
22
- filters["run_id"] = run_id
122
+ # Get existing metadata to find path or create a new one
123
+ existing = store.load_artifact(artifact_id)
124
+
125
+ if not existing:
126
+ raise HTTPException(status_code=404, detail="Artifact metadata not found. Create metadata first.")
127
+
128
+ # Determine storage path
129
+ # We use the LocalArtifactStore logic here since the backend is running locally relative to itself
130
+ from flowyml.storage.artifacts import LocalArtifactStore
131
+ from flowyml.utils.config import get_config
132
+
133
+ config = get_config()
134
+ artifact_store = LocalArtifactStore(base_path=config.artifacts_dir)
135
+
136
+ # Construct a path if not present
137
+ if not existing.get("path"):
138
+ # Create a path structure: project/run_id/artifact_id/filename
139
+ project = existing.get("project", "default")
140
+ run_id = existing.get("run_id", "unknown")
141
+ filename = file.filename or "content"
142
+ rel_path = f"{project}/{run_id}/{artifact_id}/{filename}"
143
+ else:
144
+ rel_path = existing.get("path")
145
+ # If path is absolute, make it relative to artifacts dir if possible, or just use it
146
+ # But LocalArtifactStore expects relative paths usually, or handles absolute ones
147
+
148
+ # Save the file
149
+ full_path = artifact_store.base_path / rel_path
150
+ full_path.parent.mkdir(parents=True, exist_ok=True)
151
+
152
+ loop = asyncio.get_running_loop()
153
+ await loop.run_in_executor(None, _save_file_sync, file.file, full_path)
154
+
155
+ # Update metadata with path
156
+ existing["path"] = str(rel_path)
157
+ store.save_artifact(artifact_id, existing)
158
+
159
+ return {"status": "success", "path": str(rel_path)}
160
+
161
+ except HTTPException:
162
+ raise
163
+ except Exception as e:
164
+ raise HTTPException(status_code=500, detail=str(e))
165
+
166
+
167
+ @router.delete("/{artifact_id}")
168
+ async def delete_asset(artifact_id: str):
169
+ """Delete an asset and its file."""
170
+ try:
171
+ store = get_store()
172
+
173
+ # Get metadata to find path
174
+ asset = store.load_artifact(artifact_id)
175
+ if not asset:
176
+ raise HTTPException(status_code=404, detail="Asset not found")
177
+
178
+ # Delete file if it exists locally (since backend is local to itself)
179
+ path = asset.get("path")
180
+ if path:
181
+ from flowyml.storage.artifacts import LocalArtifactStore
182
+ from flowyml.utils.config import get_config
183
+
184
+ config = get_config()
185
+ artifact_store = LocalArtifactStore(base_path=config.artifacts_dir)
186
+
187
+ with contextlib.suppress(Exception):
188
+ artifact_store.delete(path)
189
+
190
+ # Delete metadata
191
+ store.delete_artifact(artifact_id)
192
+
193
+ return {"status": "success", "artifact_id": artifact_id}
194
+
195
+ except HTTPException:
196
+ raise
197
+ except Exception as e:
198
+ raise HTTPException(status_code=500, detail=str(e))
199
+
200
+
201
+ @router.get("/stats")
202
+ async def get_asset_stats(project: str | None = None):
203
+ """Get statistics about assets for the dashboard."""
204
+ try:
205
+ combined_assets = []
206
+ for project_name, store in _iter_metadata_stores():
207
+ if project and project_name and project != project_name:
208
+ continue
209
+
210
+ assets = store.list_assets(limit=1000)
211
+ for asset in assets:
212
+ combined_assets.append((asset, project_name))
213
+
214
+ assets = _dedupe_assets(combined_assets)
215
+
216
+ if project:
217
+ assets = [a for a in assets if a.get("project") == project]
218
+
219
+ # Calculate statistics
220
+ total_assets = len(assets)
221
+
222
+ # Count by type
223
+ type_counts = {}
224
+ for asset in assets:
225
+ asset_type = asset.get("type", "unknown")
226
+ type_counts[asset_type] = type_counts.get(asset_type, 0) + 1
227
+
228
+ # Calculate total storage (if size info available)
229
+ total_storage = sum(asset.get("size_bytes", 0) for asset in assets)
230
+
231
+ # Get recent assets (last 10)
232
+ sorted_assets = sorted(
233
+ assets,
234
+ key=lambda x: x.get("created_at", ""),
235
+ reverse=True,
236
+ )
237
+ recent_assets = sorted_assets[:10]
238
+
239
+ # Count by project
240
+ project_counts = {}
241
+ for asset in assets:
242
+ proj = asset.get("project", "default")
243
+ project_counts[proj] = project_counts.get(proj, 0) + 1
244
+
245
+ return {
246
+ "total_assets": total_assets,
247
+ "by_type": type_counts,
248
+ "total_storage_bytes": total_storage,
249
+ "recent_assets": recent_assets,
250
+ "by_project": project_counts,
251
+ }
252
+ except Exception as e:
253
+ raise HTTPException(status_code=500, detail=f"Failed to get stats: {str(e)}")
254
+
255
+
256
+ @router.get("/search")
257
+ async def search_assets(q: str, limit: int = 50, project: str | None = None):
258
+ """Search assets by name or properties."""
259
+ try:
260
+ combined_assets = []
261
+ for project_name, store in _iter_metadata_stores():
262
+ if project and project_name and project != project_name:
263
+ continue
23
264
 
24
- assets = store.list_assets(limit=limit, **filters)
265
+ assets = store.list_assets(limit=1000)
266
+ for asset in assets:
267
+ combined_assets.append((asset, project_name))
268
+
269
+ assets = _dedupe_assets(combined_assets)
25
270
 
26
- # Filter by project if specified
27
271
  if project:
28
- # Get all runs for this project
272
+ assets = [a for a in assets if a.get("project") == project]
273
+
274
+ # Simple fuzzy search in name, type, and step
275
+ query_lower = q.lower()
276
+ matching_assets = []
277
+
278
+ for asset in assets:
279
+ name = asset.get("name", "").lower()
280
+ asset_type = asset.get("type", "").lower()
281
+ step = asset.get("step", "").lower()
282
+
283
+ # Check if query matches any field
284
+ if query_lower in name or query_lower in asset_type or query_lower in step:
285
+ matching_assets.append(asset)
286
+
287
+ return {
288
+ "assets": matching_assets[:limit],
289
+ "total": len(matching_assets),
290
+ "query": q,
291
+ }
292
+ except Exception as e:
293
+ raise HTTPException(status_code=500, detail=f"Failed to search: {str(e)}")
294
+
295
+
296
+ @router.get("/lineage")
297
+ async def get_asset_lineage(
298
+ asset_id: str | None = None,
299
+ project: str | None = None,
300
+ depth: int = 3,
301
+ ):
302
+ """
303
+ Get lineage graph for artifacts.
304
+
305
+ Returns nodes (artifacts, runs, pipelines) and edges (relationships).
306
+ Can be scoped to a specific asset or all assets in a project.
307
+ """
308
+ try:
309
+ store = get_store()
310
+ nodes = []
311
+ edges = []
312
+ visited_artifacts = set()
313
+ visited_runs = set()
314
+
315
+ # Get starting artifacts
316
+ if asset_id:
317
+ artifact = store.load_artifact(asset_id)
318
+ if not artifact:
319
+ raise HTTPException(status_code=404, detail="Asset not found")
320
+ starting_artifacts = [artifact]
321
+ elif project:
322
+ # Get all artifacts for project
29
323
  all_runs = store.list_runs(limit=1000)
30
324
  project_run_ids = {r.get("run_id") for r in all_runs if r.get("project") == project}
31
- assets = [a for a in assets if a.get("run_id") in project_run_ids]
325
+ all_artifacts = store.list_assets(limit=1000)
326
+ starting_artifacts = [a for a in all_artifacts if a.get("run_id") in project_run_ids]
327
+ else:
328
+ # Get recent artifacts
329
+ starting_artifacts = store.list_assets(limit=50)
32
330
 
33
- return {"assets": assets}
331
+ # Build graph
332
+ for artifact in starting_artifacts:
333
+ artifact_id = artifact.get("artifact_id")
334
+ if not artifact_id or artifact_id in visited_artifacts:
335
+ continue
336
+
337
+ visited_artifacts.add(artifact_id)
338
+
339
+ # Add artifact node
340
+ nodes.append(
341
+ {
342
+ "id": artifact_id,
343
+ "type": "artifact",
344
+ "label": artifact.get("name", artifact_id),
345
+ "artifact_type": artifact.get("type", "unknown"),
346
+ "metadata": {
347
+ "created_at": artifact.get("created_at"),
348
+ "properties": artifact.get("properties", {}),
349
+ "size": artifact.get("size"),
350
+ "run_id": artifact.get("run_id"),
351
+ },
352
+ },
353
+ )
354
+
355
+ # Add relationship to run
356
+ run_id = artifact.get("run_id")
357
+ if run_id and run_id not in visited_runs:
358
+ visited_runs.add(run_id)
359
+ run = store.load_run(run_id)
360
+
361
+ if run:
362
+ # Add run node
363
+ nodes.append(
364
+ {
365
+ "id": run_id,
366
+ "type": "run",
367
+ "label": run.get("run_name", run_id[:8]),
368
+ "metadata": {
369
+ "pipeline_name": run.get("pipeline_name"),
370
+ "status": run.get("status"),
371
+ "project": run.get("project"),
372
+ },
373
+ },
374
+ )
375
+
376
+ # Add edge: run produces artifact
377
+ edges.append(
378
+ {
379
+ "id": f"{run_id}->{artifact_id}",
380
+ "source": run_id,
381
+ "target": artifact_id,
382
+ "type": "produces",
383
+ "label": artifact.get("step", ""),
384
+ },
385
+ )
386
+
387
+ # Find input artifacts (from DAG/steps metadata)
388
+ metadata = run.get("metadata", {})
389
+ # dag = metadata.get("dag", {})
390
+ steps = metadata.get("steps", {})
391
+
392
+ # Get step that produced this artifact
393
+ artifact_step = artifact.get("step")
394
+ if artifact_step and artifact_step in steps:
395
+ step_info = steps.get(artifact_step, {})
396
+ inputs = step_info.get("inputs", [])
397
+
398
+ # Find artifacts that were inputs to this step
399
+ for input_name in inputs:
400
+ # Search for artifact with this name from the same run
401
+ input_artifacts = [
402
+ a for a in all_artifacts if a.get("name") == input_name and a.get("run_id") == run_id
403
+ ]
404
+ for input_artifact in input_artifacts:
405
+ input_id = input_artifact.get("artifact_id")
406
+ if input_id and input_id not in visited_artifacts:
407
+ visited_artifacts.add(input_id)
408
+ nodes.append(
409
+ {
410
+ "id": input_id,
411
+ "type": "artifact",
412
+ "label": input_artifact.get("name", input_id),
413
+ "artifact_type": input_artifact.get("type", "unknown"),
414
+ "metadata": {
415
+ "created_at": input_artifact.get("created_at"),
416
+ "properties": input_artifact.get("properties", {}),
417
+ },
418
+ },
419
+ )
420
+
421
+ # Add edge: input artifact consumed by run
422
+ edges.append(
423
+ {
424
+ "id": f"{input_id}->{run_id}",
425
+ "source": input_id,
426
+ "target": run_id,
427
+ "type": "consumes",
428
+ "label": artifact_step,
429
+ },
430
+ )
431
+
432
+ return {
433
+ "nodes": nodes,
434
+ "edges": edges,
435
+ "metadata": {
436
+ "total_artifacts": len([n for n in nodes if n["type"] == "artifact"]),
437
+ "total_runs": len([n for n in nodes if n["type"] == "run"]),
438
+ "depth_used": depth,
439
+ },
440
+ }
441
+
442
+ except HTTPException:
443
+ raise
34
444
  except Exception as e:
35
- return {"assets": [], "error": str(e)}
445
+ raise HTTPException(status_code=500, detail=f"Failed to build lineage: {str(e)}")
36
446
 
37
447
 
38
448
  @router.get("/{artifact_id}")
39
449
  async def get_asset(artifact_id: str):
40
450
  """Get details for a specific asset."""
41
- store = get_store()
42
- asset = store.load_artifact(artifact_id)
451
+ asset = _find_asset(artifact_id)
43
452
  if not asset:
44
453
  raise HTTPException(status_code=404, detail="Asset not found")
45
454
  return asset
455
+
456
+
457
+ @router.get("/{artifact_id}/download")
458
+ async def download_asset(artifact_id: str):
459
+ """Download the artifact file referenced by metadata."""
460
+ asset, _ = _find_asset_with_store(artifact_id)
461
+ if not asset:
462
+ raise HTTPException(status_code=404, detail="Asset not found")
463
+
464
+ artifact_path = asset.get("path")
465
+ if not artifact_path:
466
+ raise HTTPException(status_code=404, detail="Artifact path not available")
467
+
468
+ file_path = Path(artifact_path)
469
+ if not file_path.exists():
470
+ raise HTTPException(status_code=404, detail="Artifact file not found on disk")
471
+
472
+ return FileResponse(
473
+ path=file_path,
474
+ filename=file_path.name,
475
+ media_type="application/octet-stream",
476
+ )
477
+
478
+
479
+ class ProjectUpdate(BaseModel):
480
+ project_name: str
481
+
482
+
483
+ @router.put("/{artifact_id}/project")
484
+ async def update_asset_project(artifact_id: str, update: ProjectUpdate):
485
+ """Update the project for an asset."""
486
+ try:
487
+ _, store = _find_asset_with_store(artifact_id)
488
+ if not store:
489
+ raise HTTPException(status_code=404, detail="Asset not found")
490
+
491
+ store.update_artifact_project(artifact_id, update.project_name)
492
+ return {"status": "success", "project": update.project_name}
493
+ except HTTPException:
494
+ raise
495
+ except Exception as e:
496
+ raise HTTPException(status_code=500, detail=str(e))
497
+
498
+
499
+ def _find_asset(artifact_id: str):
500
+ asset, _ = _find_asset_with_store(artifact_id)
501
+ return asset
502
+
503
+
504
+ def _find_asset_with_store(artifact_id: str):
505
+ for project_name, store in _iter_metadata_stores():
506
+ asset = store.load_artifact(artifact_id)
507
+ if asset:
508
+ if project_name and not asset.get("project"):
509
+ asset["project"] = project_name
510
+ return asset, store
511
+ return None, None
@@ -0,0 +1,46 @@
1
+ from fastapi import APIRouter, Request
2
+ from pydantic import BaseModel
3
+ from typing import Any
4
+ from flowyml.monitoring.alerts import alert_manager, AlertLevel
5
+
6
+ router = APIRouter()
7
+
8
+
9
+ class ClientError(BaseModel):
10
+ message: str
11
+ stack: str | None = None
12
+ component_stack: str | None = None
13
+ url: str | None = None
14
+ user_agent: str | None = None
15
+ additional_info: dict[str, Any] | None = None
16
+
17
+
18
+ @router.post("/errors")
19
+ async def report_client_error(error: ClientError, request: Request):
20
+ """Report an error from the frontend client."""
21
+
22
+ # Construct a detailed message
23
+ details = f"Client Error: {error.message}\n"
24
+ if error.url:
25
+ details += f"URL: {error.url}\n"
26
+ if error.user_agent:
27
+ details += f"User Agent: {error.user_agent}\n"
28
+ if error.component_stack:
29
+ details += f"Component Stack:\n{error.component_stack}\n"
30
+ if error.stack:
31
+ details += f"Stack Trace:\n{error.stack}\n"
32
+
33
+ # Send alert
34
+ alert_manager.send_alert(
35
+ title=f"Frontend Error: {error.message[:50]}...",
36
+ message=details,
37
+ level=AlertLevel.ERROR,
38
+ metadata={
39
+ "source": "frontend",
40
+ "url": error.url,
41
+ "user_agent": error.user_agent,
42
+ "additional_info": error.additional_info,
43
+ },
44
+ )
45
+
46
+ return {"status": "recorded"}
@@ -27,6 +27,7 @@ class PipelineExecutionRequest(BaseModel):
27
27
  parameters: dict[str, Any] = {}
28
28
  project: str | None = None
29
29
  dry_run: bool = False # If True, validate but don't execute
30
+ retry_count: int = 0 # Number of retries on failure (0-5)
30
31
 
31
32
 
32
33
  class TokenRequest(BaseModel):
@@ -92,13 +93,23 @@ async def execute_pipeline(
92
93
 
93
94
  pipeline = getattr(module, request.pipeline_name)
94
95
 
95
- # Execute the pipeline
96
- result = pipeline.run(**request.parameters)
96
+ # Execute the pipeline with retry policy if specified
97
+ run_kwargs = request.parameters.copy()
98
+
99
+ if request.retry_count > 0:
100
+ from flowyml.core.retry import OrchestratorRetryPolicy
101
+
102
+ run_kwargs["retry_policy"] = OrchestratorRetryPolicy(
103
+ max_retries=min(request.retry_count, 5), # Cap at 5
104
+ )
105
+
106
+ result = pipeline.run(**run_kwargs)
97
107
 
98
108
  return {
99
109
  "status": "completed",
100
110
  "run_id": result.run_id if hasattr(result, "run_id") else None,
101
111
  "pipeline": request.pipeline_name,
112
+ "retry_count": request.retry_count,
102
113
  "message": "Pipeline executed successfully",
103
114
  }
104
115