flowyml 1.1.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 (92) 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/core/versioning.py +2 -2
  18. flowyml/integrations/keras.py +95 -22
  19. flowyml/monitoring/alerts.py +2 -2
  20. flowyml/stacks/__init__.py +15 -0
  21. flowyml/stacks/aws.py +599 -0
  22. flowyml/stacks/azure.py +295 -0
  23. flowyml/stacks/components.py +24 -2
  24. flowyml/stacks/gcp.py +158 -11
  25. flowyml/stacks/local.py +5 -0
  26. flowyml/storage/artifacts.py +15 -5
  27. flowyml/storage/materializers/__init__.py +2 -0
  28. flowyml/storage/materializers/cloudpickle.py +74 -0
  29. flowyml/storage/metadata.py +166 -5
  30. flowyml/ui/backend/main.py +41 -1
  31. flowyml/ui/backend/routers/assets.py +356 -15
  32. flowyml/ui/backend/routers/client.py +46 -0
  33. flowyml/ui/backend/routers/execution.py +13 -2
  34. flowyml/ui/backend/routers/experiments.py +48 -12
  35. flowyml/ui/backend/routers/metrics.py +213 -0
  36. flowyml/ui/backend/routers/pipelines.py +63 -7
  37. flowyml/ui/backend/routers/projects.py +33 -7
  38. flowyml/ui/backend/routers/runs.py +150 -8
  39. flowyml/ui/frontend/dist/assets/index-DcYwrn2j.css +1 -0
  40. flowyml/ui/frontend/dist/assets/index-Dlz_ygOL.js +592 -0
  41. flowyml/ui/frontend/dist/index.html +2 -2
  42. flowyml/ui/frontend/src/App.jsx +4 -1
  43. flowyml/ui/frontend/src/app/assets/page.jsx +260 -230
  44. flowyml/ui/frontend/src/app/dashboard/page.jsx +38 -7
  45. flowyml/ui/frontend/src/app/experiments/page.jsx +61 -314
  46. flowyml/ui/frontend/src/app/observability/page.jsx +277 -0
  47. flowyml/ui/frontend/src/app/pipelines/page.jsx +79 -402
  48. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectArtifactsList.jsx +151 -0
  49. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectExperimentsList.jsx +145 -0
  50. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectHeader.jsx +45 -0
  51. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectHierarchy.jsx +467 -0
  52. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectMetricsPanel.jsx +253 -0
  53. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectPipelinesList.jsx +105 -0
  54. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectRelations.jsx +189 -0
  55. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectRunsList.jsx +136 -0
  56. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectTabs.jsx +95 -0
  57. flowyml/ui/frontend/src/app/projects/[projectId]/page.jsx +326 -0
  58. flowyml/ui/frontend/src/app/projects/page.jsx +13 -3
  59. flowyml/ui/frontend/src/app/runs/[runId]/page.jsx +79 -10
  60. flowyml/ui/frontend/src/app/runs/page.jsx +82 -424
  61. flowyml/ui/frontend/src/app/settings/page.jsx +1 -0
  62. flowyml/ui/frontend/src/app/tokens/page.jsx +62 -16
  63. flowyml/ui/frontend/src/components/AssetDetailsPanel.jsx +373 -0
  64. flowyml/ui/frontend/src/components/AssetLineageGraph.jsx +291 -0
  65. flowyml/ui/frontend/src/components/AssetStatsDashboard.jsx +302 -0
  66. flowyml/ui/frontend/src/components/AssetTreeHierarchy.jsx +477 -0
  67. flowyml/ui/frontend/src/components/ExperimentDetailsPanel.jsx +227 -0
  68. flowyml/ui/frontend/src/components/NavigationTree.jsx +401 -0
  69. flowyml/ui/frontend/src/components/PipelineDetailsPanel.jsx +239 -0
  70. flowyml/ui/frontend/src/components/PipelineGraph.jsx +67 -3
  71. flowyml/ui/frontend/src/components/ProjectSelector.jsx +115 -0
  72. flowyml/ui/frontend/src/components/RunDetailsPanel.jsx +298 -0
  73. flowyml/ui/frontend/src/components/header/Header.jsx +48 -1
  74. flowyml/ui/frontend/src/components/plugins/ZenMLIntegration.jsx +106 -0
  75. flowyml/ui/frontend/src/components/sidebar/Sidebar.jsx +52 -26
  76. flowyml/ui/frontend/src/components/ui/DataView.jsx +35 -17
  77. flowyml/ui/frontend/src/components/ui/ErrorBoundary.jsx +118 -0
  78. flowyml/ui/frontend/src/contexts/ProjectContext.jsx +2 -2
  79. flowyml/ui/frontend/src/contexts/ToastContext.jsx +116 -0
  80. flowyml/ui/frontend/src/layouts/MainLayout.jsx +5 -1
  81. flowyml/ui/frontend/src/router/index.jsx +4 -0
  82. flowyml/ui/frontend/src/utils/date.js +10 -0
  83. flowyml/ui/frontend/src/utils/downloads.js +11 -0
  84. flowyml/utils/config.py +6 -0
  85. flowyml/utils/stack_config.py +45 -3
  86. {flowyml-1.1.0.dist-info → flowyml-1.3.0.dist-info}/METADATA +113 -12
  87. {flowyml-1.1.0.dist-info → flowyml-1.3.0.dist-info}/RECORD +90 -53
  88. {flowyml-1.1.0.dist-info → flowyml-1.3.0.dist-info}/licenses/LICENSE +1 -1
  89. flowyml/ui/frontend/dist/assets/index-DFNQnrUj.js +0 -448
  90. flowyml/ui/frontend/dist/assets/index-pWI271rZ.css +0 -1
  91. {flowyml-1.1.0.dist-info → flowyml-1.3.0.dist-info}/WHEEL +0 -0
  92. {flowyml-1.1.0.dist-info → flowyml-1.3.0.dist-info}/entry_points.txt +0 -0
@@ -1,5 +1,10 @@
1
1
  from fastapi import APIRouter, HTTPException
2
+ from fastapi.responses import FileResponse
3
+ from pydantic import BaseModel
2
4
  from flowyml.storage.metadata import SQLiteMetadataStore
5
+ from flowyml.core.project import ProjectManager
6
+ from typing import Optional
7
+ from pathlib import Path
3
8
 
4
9
  router = APIRouter()
5
10
 
@@ -8,38 +13,374 @@ def get_store():
8
13
  return SQLiteMetadataStore()
9
14
 
10
15
 
16
+ def _iter_metadata_stores():
17
+ stores = [(None, SQLiteMetadataStore())]
18
+ try:
19
+ manager = ProjectManager()
20
+ for project_meta in manager.list_projects():
21
+ name = project_meta.get("name")
22
+ if not name:
23
+ continue
24
+ project = manager.get_project(name)
25
+ if project:
26
+ stores.append((name, project.metadata_store))
27
+ except Exception:
28
+ pass
29
+ return stores
30
+
31
+
32
+ def _dedupe_assets(assets):
33
+ dedup = {}
34
+ for asset, project_name in assets:
35
+ artifact_id = asset.get("artifact_id") or f"{project_name}:{len(dedup)}"
36
+ if artifact_id in dedup:
37
+ continue
38
+ entry = dict(asset)
39
+ if project_name and not entry.get("project"):
40
+ entry["project"] = project_name
41
+ dedup[artifact_id] = entry
42
+ return list(dedup.values())
43
+
44
+
11
45
  @router.get("/")
12
46
  async def list_assets(limit: int = 50, asset_type: str = None, run_id: str = None, project: str = None):
13
47
  """List all assets, optionally filtered by project."""
14
48
  try:
15
- store = get_store()
49
+ combined = []
50
+ for project_name, store in _iter_metadata_stores():
51
+ if project and project_name and project != project_name:
52
+ continue
53
+
54
+ filters = {}
55
+ if asset_type:
56
+ filters["type"] = asset_type
57
+ if run_id:
58
+ filters["run_id"] = run_id
16
59
 
17
- # Build filters
18
- filters = {}
19
- if asset_type:
20
- filters["type"] = asset_type
21
- if run_id:
22
- filters["run_id"] = run_id
60
+ assets = store.list_assets(limit=limit, **filters)
61
+ for asset in assets:
62
+ combined.append((asset, project_name))
23
63
 
24
- assets = store.list_assets(limit=limit, **filters)
64
+ assets = _dedupe_assets(combined)
25
65
 
26
- # Filter by project if specified
27
66
  if project:
28
- # Get all runs for this project
29
- all_runs = store.list_runs(limit=1000)
30
- 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]
67
+ assets = [a for a in assets if a.get("project") == project]
68
+
69
+ assets = assets[:limit]
32
70
 
33
71
  return {"assets": assets}
34
72
  except Exception as e:
35
73
  return {"assets": [], "error": str(e)}
36
74
 
37
75
 
76
+ @router.get("/stats")
77
+ async def get_asset_stats(project: Optional[str] = None):
78
+ """Get statistics about assets for the dashboard."""
79
+ try:
80
+ combined_assets = []
81
+ for project_name, store in _iter_metadata_stores():
82
+ if project and project_name and project != project_name:
83
+ continue
84
+
85
+ assets = store.list_assets(limit=1000)
86
+ for asset in assets:
87
+ combined_assets.append((asset, project_name))
88
+
89
+ assets = _dedupe_assets(combined_assets)
90
+
91
+ if project:
92
+ assets = [a for a in assets if a.get("project") == project]
93
+
94
+ # Calculate statistics
95
+ total_assets = len(assets)
96
+
97
+ # Count by type
98
+ type_counts = {}
99
+ for asset in assets:
100
+ asset_type = asset.get("type", "unknown")
101
+ type_counts[asset_type] = type_counts.get(asset_type, 0) + 1
102
+
103
+ # Calculate total storage (if size info available)
104
+ total_storage = sum(asset.get("size_bytes", 0) for asset in assets)
105
+
106
+ # Get recent assets (last 10)
107
+ sorted_assets = sorted(
108
+ assets,
109
+ key=lambda x: x.get("created_at", ""),
110
+ reverse=True,
111
+ )
112
+ recent_assets = sorted_assets[:10]
113
+
114
+ # Count by project
115
+ project_counts = {}
116
+ for asset in assets:
117
+ proj = asset.get("project", "default")
118
+ project_counts[proj] = project_counts.get(proj, 0) + 1
119
+
120
+ return {
121
+ "total_assets": total_assets,
122
+ "by_type": type_counts,
123
+ "total_storage_bytes": total_storage,
124
+ "recent_assets": recent_assets,
125
+ "by_project": project_counts,
126
+ }
127
+ except Exception as e:
128
+ raise HTTPException(status_code=500, detail=f"Failed to get stats: {str(e)}")
129
+
130
+
131
+ @router.get("/search")
132
+ async def search_assets(q: str, limit: int = 50, project: Optional[str] = None):
133
+ """Search assets by name or properties."""
134
+ try:
135
+ combined_assets = []
136
+ for project_name, store in _iter_metadata_stores():
137
+ if project and project_name and project != project_name:
138
+ continue
139
+
140
+ assets = store.list_assets(limit=1000)
141
+ for asset in assets:
142
+ combined_assets.append((asset, project_name))
143
+
144
+ assets = _dedupe_assets(combined_assets)
145
+
146
+ if project:
147
+ assets = [a for a in assets if a.get("project") == project]
148
+
149
+ # Simple fuzzy search in name, type, and step
150
+ query_lower = q.lower()
151
+ matching_assets = []
152
+
153
+ for asset in assets:
154
+ name = asset.get("name", "").lower()
155
+ asset_type = asset.get("type", "").lower()
156
+ step = asset.get("step", "").lower()
157
+
158
+ # Check if query matches any field
159
+ if query_lower in name or query_lower in asset_type or query_lower in step:
160
+ matching_assets.append(asset)
161
+
162
+ return {
163
+ "assets": matching_assets[:limit],
164
+ "total": len(matching_assets),
165
+ "query": q,
166
+ }
167
+ except Exception as e:
168
+ raise HTTPException(status_code=500, detail=f"Failed to search: {str(e)}")
169
+
170
+
171
+ @router.get("/lineage")
172
+ async def get_asset_lineage(
173
+ asset_id: Optional[str] = None,
174
+ project: Optional[str] = None,
175
+ depth: int = 3,
176
+ ):
177
+ """
178
+ Get lineage graph for artifacts.
179
+
180
+ Returns nodes (artifacts, runs, pipelines) and edges (relationships).
181
+ Can be scoped to a specific asset or all assets in a project.
182
+ """
183
+ try:
184
+ store = get_store()
185
+ nodes = []
186
+ edges = []
187
+ visited_artifacts = set()
188
+ visited_runs = set()
189
+
190
+ # Get starting artifacts
191
+ if asset_id:
192
+ artifact = store.load_artifact(asset_id)
193
+ if not artifact:
194
+ raise HTTPException(status_code=404, detail="Asset not found")
195
+ starting_artifacts = [artifact]
196
+ elif project:
197
+ # Get all artifacts for project
198
+ all_runs = store.list_runs(limit=1000)
199
+ project_run_ids = {r.get("run_id") for r in all_runs if r.get("project") == project}
200
+ all_artifacts = store.list_assets(limit=1000)
201
+ starting_artifacts = [a for a in all_artifacts if a.get("run_id") in project_run_ids]
202
+ else:
203
+ # Get recent artifacts
204
+ starting_artifacts = store.list_assets(limit=50)
205
+
206
+ # Build graph
207
+ for artifact in starting_artifacts:
208
+ artifact_id = artifact.get("artifact_id")
209
+ if not artifact_id or artifact_id in visited_artifacts:
210
+ continue
211
+
212
+ visited_artifacts.add(artifact_id)
213
+
214
+ # Add artifact node
215
+ nodes.append(
216
+ {
217
+ "id": artifact_id,
218
+ "type": "artifact",
219
+ "label": artifact.get("name", artifact_id),
220
+ "artifact_type": artifact.get("type", "unknown"),
221
+ "metadata": {
222
+ "created_at": artifact.get("created_at"),
223
+ "properties": artifact.get("properties", {}),
224
+ "size": artifact.get("size"),
225
+ "run_id": artifact.get("run_id"),
226
+ },
227
+ },
228
+ )
229
+
230
+ # Add relationship to run
231
+ run_id = artifact.get("run_id")
232
+ if run_id and run_id not in visited_runs:
233
+ visited_runs.add(run_id)
234
+ run = store.load_run(run_id)
235
+
236
+ if run:
237
+ # Add run node
238
+ nodes.append(
239
+ {
240
+ "id": run_id,
241
+ "type": "run",
242
+ "label": run.get("run_name", run_id[:8]),
243
+ "metadata": {
244
+ "pipeline_name": run.get("pipeline_name"),
245
+ "status": run.get("status"),
246
+ "project": run.get("project"),
247
+ },
248
+ },
249
+ )
250
+
251
+ # Add edge: run produces artifact
252
+ edges.append(
253
+ {
254
+ "id": f"{run_id}->{artifact_id}",
255
+ "source": run_id,
256
+ "target": artifact_id,
257
+ "type": "produces",
258
+ "label": artifact.get("step", ""),
259
+ },
260
+ )
261
+
262
+ # Find input artifacts (from DAG/steps metadata)
263
+ metadata = run.get("metadata", {})
264
+ # dag = metadata.get("dag", {})
265
+ steps = metadata.get("steps", {})
266
+
267
+ # Get step that produced this artifact
268
+ artifact_step = artifact.get("step")
269
+ if artifact_step and artifact_step in steps:
270
+ step_info = steps.get(artifact_step, {})
271
+ inputs = step_info.get("inputs", [])
272
+
273
+ # Find artifacts that were inputs to this step
274
+ for input_name in inputs:
275
+ # Search for artifact with this name from the same run
276
+ input_artifacts = [
277
+ a for a in all_artifacts if a.get("name") == input_name and a.get("run_id") == run_id
278
+ ]
279
+ for input_artifact in input_artifacts:
280
+ input_id = input_artifact.get("artifact_id")
281
+ if input_id and input_id not in visited_artifacts:
282
+ visited_artifacts.add(input_id)
283
+ nodes.append(
284
+ {
285
+ "id": input_id,
286
+ "type": "artifact",
287
+ "label": input_artifact.get("name", input_id),
288
+ "artifact_type": input_artifact.get("type", "unknown"),
289
+ "metadata": {
290
+ "created_at": input_artifact.get("created_at"),
291
+ "properties": input_artifact.get("properties", {}),
292
+ },
293
+ },
294
+ )
295
+
296
+ # Add edge: input artifact consumed by run
297
+ edges.append(
298
+ {
299
+ "id": f"{input_id}->{run_id}",
300
+ "source": input_id,
301
+ "target": run_id,
302
+ "type": "consumes",
303
+ "label": artifact_step,
304
+ },
305
+ )
306
+
307
+ return {
308
+ "nodes": nodes,
309
+ "edges": edges,
310
+ "metadata": {
311
+ "total_artifacts": len([n for n in nodes if n["type"] == "artifact"]),
312
+ "total_runs": len([n for n in nodes if n["type"] == "run"]),
313
+ "depth_used": depth,
314
+ },
315
+ }
316
+
317
+ except HTTPException:
318
+ raise
319
+ except Exception as e:
320
+ raise HTTPException(status_code=500, detail=f"Failed to build lineage: {str(e)}")
321
+
322
+
38
323
  @router.get("/{artifact_id}")
39
324
  async def get_asset(artifact_id: str):
40
325
  """Get details for a specific asset."""
41
- store = get_store()
42
- asset = store.load_artifact(artifact_id)
326
+ asset = _find_asset(artifact_id)
43
327
  if not asset:
44
328
  raise HTTPException(status_code=404, detail="Asset not found")
45
329
  return asset
330
+
331
+
332
+ @router.get("/{artifact_id}/download")
333
+ async def download_asset(artifact_id: str):
334
+ """Download the artifact file referenced by metadata."""
335
+ asset, _ = _find_asset_with_store(artifact_id)
336
+ if not asset:
337
+ raise HTTPException(status_code=404, detail="Asset not found")
338
+
339
+ artifact_path = asset.get("path")
340
+ if not artifact_path:
341
+ raise HTTPException(status_code=404, detail="Artifact path not available")
342
+
343
+ file_path = Path(artifact_path)
344
+ if not file_path.exists():
345
+ raise HTTPException(status_code=404, detail="Artifact file not found on disk")
346
+
347
+ return FileResponse(
348
+ path=file_path,
349
+ filename=file_path.name,
350
+ media_type="application/octet-stream",
351
+ )
352
+
353
+
354
+ class ProjectUpdate(BaseModel):
355
+ project_name: str
356
+
357
+
358
+ @router.put("/{artifact_id}/project")
359
+ async def update_asset_project(artifact_id: str, update: ProjectUpdate):
360
+ """Update the project for an asset."""
361
+ try:
362
+ _, store = _find_asset_with_store(artifact_id)
363
+ if not store:
364
+ raise HTTPException(status_code=404, detail="Asset not found")
365
+
366
+ store.update_artifact_project(artifact_id, update.project_name)
367
+ return {"status": "success", "project": update.project_name}
368
+ except HTTPException:
369
+ raise
370
+ except Exception as e:
371
+ raise HTTPException(status_code=500, detail=str(e))
372
+
373
+
374
+ def _find_asset(artifact_id: str):
375
+ asset, _ = _find_asset_with_store(artifact_id)
376
+ return asset
377
+
378
+
379
+ def _find_asset_with_store(artifact_id: str):
380
+ for project_name, store in _iter_metadata_stores():
381
+ asset = store.load_artifact(artifact_id)
382
+ if asset:
383
+ if project_name and not asset.get("project"):
384
+ asset["project"] = project_name
385
+ return asset, store
386
+ return None, None
@@ -0,0 +1,46 @@
1
+ from fastapi import APIRouter, Request
2
+ from pydantic import BaseModel
3
+ from typing import Any, Optional
4
+ from flowyml.monitoring.alerts import alert_manager, AlertLevel
5
+
6
+ router = APIRouter()
7
+
8
+
9
+ class ClientError(BaseModel):
10
+ message: str
11
+ stack: Optional[str] = None
12
+ component_stack: Optional[str] = None
13
+ url: Optional[str] = None
14
+ user_agent: Optional[str] = None
15
+ additional_info: Optional[dict[str, Any]] = 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
 
@@ -1,5 +1,7 @@
1
1
  from fastapi import APIRouter, HTTPException
2
2
  from flowyml.storage.metadata import SQLiteMetadataStore
3
+ from flowyml.core.project import ProjectManager
4
+ from typing import Optional
3
5
 
4
6
  router = APIRouter()
5
7
 
@@ -8,18 +10,48 @@ def get_store():
8
10
  return SQLiteMetadataStore()
9
11
 
10
12
 
13
+ def _iter_metadata_stores():
14
+ """Yield tuples of (project_name, store) including global and project stores."""
15
+ stores = [(None, SQLiteMetadataStore())]
16
+ try:
17
+ manager = ProjectManager()
18
+ for project_meta in manager.list_projects():
19
+ name = project_meta.get("name")
20
+ if not name:
21
+ continue
22
+ project = manager.get_project(name)
23
+ if project:
24
+ stores.append((name, project.metadata_store))
25
+ except Exception:
26
+ pass
27
+ return stores
28
+
29
+
11
30
  @router.get("/")
12
- async def list_experiments(project: str = None):
31
+ async def list_experiments(project: Optional[str] = None):
13
32
  """List all experiments, optionally filtered by project."""
14
33
  try:
15
- store = get_store()
16
- experiments = store.list_experiments()
34
+ combined_experiments = []
35
+
36
+ for project_name, store in _iter_metadata_stores():
37
+ # Skip other projects if filtering
38
+ if project and project_name and project != project_name:
39
+ continue
17
40
 
18
- # Filter by project if specified
19
- if project:
20
- experiments = [e for e in experiments if e.get("project") == project]
41
+ store_experiments = store.list_experiments()
21
42
 
22
- return {"experiments": experiments}
43
+ for exp in store_experiments:
44
+ # Add project info if not already present
45
+ if project_name and not exp.get("project"):
46
+ exp["project"] = project_name
47
+
48
+ # Skip if filtering by project and doesn't match
49
+ if project and exp.get("project") != project:
50
+ continue
51
+
52
+ combined_experiments.append(exp)
53
+
54
+ return {"experiments": combined_experiments}
23
55
  except Exception as e:
24
56
  return {"experiments": [], "error": str(e)}
25
57
 
@@ -27,11 +59,15 @@ async def list_experiments(project: str = None):
27
59
  @router.get("/{experiment_id}")
28
60
  async def get_experiment(experiment_id: str):
29
61
  """Get experiment details."""
30
- store = get_store()
31
- experiment = store.get_experiment(experiment_id)
32
- if not experiment:
33
- raise HTTPException(status_code=404, detail="Experiment not found")
34
- return experiment
62
+ # Try to find experiment in any store
63
+ for project_name, store in _iter_metadata_stores():
64
+ experiment = store.get_experiment(experiment_id)
65
+ if experiment:
66
+ if project_name and not experiment.get("project"):
67
+ experiment["project"] = project_name
68
+ return experiment
69
+
70
+ raise HTTPException(status_code=404, detail="Experiment not found")
35
71
 
36
72
 
37
73
  @router.put("/{experiment_name}/project")