flowyml 1.3.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.
@@ -0,0 +1,28 @@
1
+ """Backend dependencies."""
2
+
3
+ import os
4
+ from flowyml.storage.sql import SQLMetadataStore
5
+ from flowyml.utils.config import get_config
6
+
7
+ _store = None
8
+
9
+
10
+ def get_store() -> SQLMetadataStore:
11
+ """Get the metadata store instance.
12
+
13
+ Uses FLOWYML_DATABASE_URL if set, otherwise defaults to local SQLite.
14
+ """
15
+ global _store
16
+ if _store is None:
17
+ config = get_config()
18
+ db_url = os.environ.get("FLOWYML_DATABASE_URL")
19
+
20
+ # If no explicit URL, use the config's metadata_db path
21
+ if not db_url:
22
+ db_path = config.metadata_db
23
+ # Ensure it's a string path for SQLMetadataStore
24
+ _store = SQLMetadataStore(db_path=str(db_path))
25
+ else:
26
+ _store = SQLMetadataStore(db_url=db_url)
27
+
28
+ return _store
@@ -23,6 +23,7 @@ from flowyml.ui.backend.routers import (
23
23
  plugins,
24
24
  metrics,
25
25
  client,
26
+ stats,
26
27
  )
27
28
 
28
29
  app = FastAPI(
@@ -75,85 +76,7 @@ app.include_router(execution.router, prefix="/api/execution", tags=["execution"]
75
76
  app.include_router(metrics.router, prefix="/api/metrics", tags=["metrics"])
76
77
  app.include_router(plugins.router, prefix="/api", tags=["plugins"])
77
78
  app.include_router(client.router, prefix="/api/client", tags=["client"])
78
-
79
-
80
- # Stats endpoint for dashboard
81
- @app.get("/api/stats")
82
- async def get_stats(project: str = None):
83
- """Get overall statistics for the dashboard, optionally filtered by project."""
84
- try:
85
- from flowyml.storage.metadata import SQLiteMetadataStore
86
-
87
- store = SQLiteMetadataStore()
88
-
89
- # Get base stats
90
- stats = store.get_statistics()
91
-
92
- # Get run status counts (not in get_statistics yet)
93
- # We can add this to get_statistics later, but for now let's query efficiently
94
- import sqlite3
95
-
96
- conn = sqlite3.connect(store.db_path)
97
- cursor = conn.cursor()
98
-
99
- if project:
100
- cursor.execute(
101
- "SELECT COUNT(*) FROM runs WHERE project = ? AND status = 'completed'",
102
- [project],
103
- )
104
- completed_runs = cursor.fetchone()[0]
105
-
106
- cursor.execute(
107
- "SELECT COUNT(*) FROM runs WHERE project = ? AND status = 'failed'",
108
- [project],
109
- )
110
- failed_runs = cursor.fetchone()[0]
111
-
112
- cursor.execute(
113
- "SELECT AVG(duration) FROM runs WHERE project = ? AND duration IS NOT NULL",
114
- [project],
115
- )
116
- avg_duration = cursor.fetchone()[0] or 0
117
-
118
- cursor.execute(
119
- "SELECT COUNT(*) FROM runs WHERE project = ?",
120
- [project],
121
- )
122
- total_runs = cursor.fetchone()[0]
123
- else:
124
- cursor.execute("SELECT COUNT(*) FROM runs WHERE status = 'completed'")
125
- completed_runs = cursor.fetchone()[0]
126
-
127
- cursor.execute("SELECT COUNT(*) FROM runs WHERE status = 'failed'")
128
- failed_runs = cursor.fetchone()[0]
129
-
130
- cursor.execute("SELECT AVG(duration) FROM runs WHERE duration IS NOT NULL")
131
- avg_duration = cursor.fetchone()[0] or 0
132
-
133
- cursor.execute("SELECT COUNT(*) FROM runs")
134
- total_runs = cursor.fetchone()[0]
135
-
136
- conn.close()
137
-
138
- return {
139
- "runs": total_runs if project else stats.get("total_runs", 0),
140
- "completed_runs": completed_runs,
141
- "failed_runs": failed_runs,
142
- "pipelines": stats.get("total_pipelines", 0), # TODO: filter by project
143
- "artifacts": stats.get("total_artifacts", 0), # TODO: filter by project
144
- "avg_duration": avg_duration,
145
- }
146
- except Exception as e:
147
- # Return default stats if there's an error
148
- return {
149
- "runs": 0,
150
- "completed_runs": 0,
151
- "failed_runs": 0,
152
- "pipelines": 0,
153
- "artifacts": 0,
154
- "avg_duration": 0,
155
- "error": str(e),
156
- }
79
+ app.include_router(stats.router, prefix="/api/stats", tags=["stats"])
157
80
 
158
81
 
159
82
  # Static file serving for frontend
@@ -1,16 +1,20 @@
1
- from fastapi import APIRouter, HTTPException
1
+ from fastapi import APIRouter, HTTPException, UploadFile, File
2
2
  from fastapi.responses import FileResponse
3
- from pydantic import BaseModel
3
+ from pydantic import BaseModel, Field
4
4
  from flowyml.storage.metadata import SQLiteMetadataStore
5
5
  from flowyml.core.project import ProjectManager
6
- from typing import Optional
7
6
  from pathlib import Path
7
+ from flowyml.ui.backend.dependencies import get_store
8
+ import shutil
9
+ import asyncio
10
+ import contextlib
8
11
 
9
12
  router = APIRouter()
10
13
 
11
14
 
12
- def get_store():
13
- return SQLiteMetadataStore()
15
+ def _save_file_sync(src, dst):
16
+ with open(dst, "wb") as buffer:
17
+ shutil.copyfileobj(src, buffer)
14
18
 
15
19
 
16
20
  def _iter_metadata_stores():
@@ -73,8 +77,129 @@ async def list_assets(limit: int = 50, asset_type: str = None, run_id: str = Non
73
77
  return {"assets": [], "error": str(e)}
74
78
 
75
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."""
119
+ try:
120
+ store = get_store()
121
+
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
+
76
201
  @router.get("/stats")
77
- async def get_asset_stats(project: Optional[str] = None):
202
+ async def get_asset_stats(project: str | None = None):
78
203
  """Get statistics about assets for the dashboard."""
79
204
  try:
80
205
  combined_assets = []
@@ -129,7 +254,7 @@ async def get_asset_stats(project: Optional[str] = None):
129
254
 
130
255
 
131
256
  @router.get("/search")
132
- async def search_assets(q: str, limit: int = 50, project: Optional[str] = None):
257
+ async def search_assets(q: str, limit: int = 50, project: str | None = None):
133
258
  """Search assets by name or properties."""
134
259
  try:
135
260
  combined_assets = []
@@ -170,8 +295,8 @@ async def search_assets(q: str, limit: int = 50, project: Optional[str] = None):
170
295
 
171
296
  @router.get("/lineage")
172
297
  async def get_asset_lineage(
173
- asset_id: Optional[str] = None,
174
- project: Optional[str] = None,
298
+ asset_id: str | None = None,
299
+ project: str | None = None,
175
300
  depth: int = 3,
176
301
  ):
177
302
  """
@@ -1,6 +1,6 @@
1
1
  from fastapi import APIRouter, Request
2
2
  from pydantic import BaseModel
3
- from typing import Any, Optional
3
+ from typing import Any
4
4
  from flowyml.monitoring.alerts import alert_manager, AlertLevel
5
5
 
6
6
  router = APIRouter()
@@ -8,11 +8,11 @@ router = APIRouter()
8
8
 
9
9
  class ClientError(BaseModel):
10
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
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
16
 
17
17
 
18
18
  @router.post("/errors")
@@ -1,15 +1,12 @@
1
1
  from fastapi import APIRouter, HTTPException
2
2
  from flowyml.storage.metadata import SQLiteMetadataStore
3
3
  from flowyml.core.project import ProjectManager
4
- from typing import Optional
4
+ from flowyml.ui.backend.dependencies import get_store
5
+ from pydantic import BaseModel
5
6
 
6
7
  router = APIRouter()
7
8
 
8
9
 
9
- def get_store():
10
- return SQLiteMetadataStore()
11
-
12
-
13
10
  def _iter_metadata_stores():
14
11
  """Yield tuples of (project_name, store) including global and project stores."""
15
12
  stores = [(None, SQLiteMetadataStore())]
@@ -28,7 +25,7 @@ def _iter_metadata_stores():
28
25
 
29
26
 
30
27
  @router.get("/")
31
- async def list_experiments(project: Optional[str] = None):
28
+ async def list_experiments(project: str | None = None):
32
29
  """List all experiments, optionally filtered by project."""
33
30
  try:
34
31
  combined_experiments = []
@@ -83,3 +80,53 @@ async def update_experiment_project(experiment_name: str, project_update: dict):
83
80
  return {"message": f"Updated experiment {experiment_name} to project {project_name}"}
84
81
  except Exception as e:
85
82
  raise HTTPException(status_code=500, detail=str(e))
83
+
84
+
85
+ class ExperimentCreate(BaseModel):
86
+ experiment_id: str
87
+ name: str
88
+ description: str = ""
89
+ tags: dict = {}
90
+ project: str | None = None
91
+
92
+
93
+ @router.post("/")
94
+ async def create_experiment(experiment: ExperimentCreate):
95
+ """Create or update an experiment."""
96
+ try:
97
+ store = get_store()
98
+ store.save_experiment(
99
+ experiment_id=experiment.experiment_id,
100
+ name=experiment.name,
101
+ description=experiment.description,
102
+ tags=experiment.tags,
103
+ )
104
+
105
+ if experiment.project:
106
+ store.update_experiment_project(experiment.name, experiment.project)
107
+
108
+ return {"status": "success", "experiment_id": experiment.experiment_id}
109
+ except Exception as e:
110
+ raise HTTPException(status_code=500, detail=str(e))
111
+
112
+
113
+ class ExperimentRunLog(BaseModel):
114
+ run_id: str
115
+ metrics: dict | None = None
116
+ parameters: dict | None = None
117
+
118
+
119
+ @router.post("/{experiment_id}/runs")
120
+ async def log_experiment_run(experiment_id: str, log: ExperimentRunLog):
121
+ """Log a run to an experiment."""
122
+ try:
123
+ store = get_store()
124
+ store.log_experiment_run(
125
+ experiment_id=experiment_id,
126
+ run_id=log.run_id,
127
+ metrics=log.metrics,
128
+ parameters=log.parameters,
129
+ )
130
+ return {"status": "success"}
131
+ except Exception as e:
132
+ raise HTTPException(status_code=500, detail=str(e))
@@ -6,6 +6,7 @@ from pydantic import BaseModel, Field
6
6
  from typing import Any
7
7
 
8
8
  from flowyml.ui.backend.auth import verify_api_token, security
9
+ from flowyml.ui.backend.dependencies import get_store
9
10
  from flowyml.storage.metadata import SQLiteMetadataStore
10
11
  from flowyml.core.project import ProjectManager
11
12
  from flowyml.utils.config import get_config
@@ -45,6 +46,24 @@ class MetricsLogRequest(BaseModel):
45
46
  tags: dict[str, Any] | None = Field(default_factory=dict, description="Optional metadata tags")
46
47
 
47
48
 
49
+ class MetricLog(BaseModel):
50
+ run_id: str
51
+ name: str
52
+ value: float
53
+ step: int = 0
54
+
55
+
56
+ @router.post("/")
57
+ async def log_metric(metric: MetricLog):
58
+ """Log a single metric."""
59
+ try:
60
+ store = get_global_store()
61
+ store.save_metric(metric.run_id, metric.name, metric.value, metric.step)
62
+ return {"status": "success"}
63
+ except Exception as e:
64
+ raise HTTPException(status_code=500, detail=str(e))
65
+
66
+
48
67
  @router.post("/log")
49
68
  async def log_model_metrics(
50
69
  payload: MetricsLogRequest,
@@ -138,76 +157,12 @@ async def list_model_metrics(
138
157
  @router.get("/observability/orchestrator")
139
158
  async def get_orchestrator_metrics():
140
159
  """Get orchestrator-level performance metrics."""
141
- from datetime import datetime, timedelta
142
- import sqlite3
143
-
144
- store = get_global_store()
145
- conn = sqlite3.connect(store.db_path)
146
- cursor = conn.cursor()
147
-
148
- thirty_days_ago = (datetime.now() - timedelta(days=30)).isoformat()
149
- cursor.execute("SELECT COUNT(*) FROM runs WHERE created_at >= ?", (thirty_days_ago,))
150
- total_runs = cursor.fetchone()[0]
151
-
152
- cursor.execute(
153
- "SELECT status, COUNT(*) FROM runs WHERE created_at >= ? GROUP BY status",
154
- (thirty_days_ago,),
155
- )
156
- status_counts = dict(cursor.fetchall())
157
-
158
- cursor.execute(
159
- "SELECT AVG(duration) FROM runs WHERE created_at >= ? AND duration IS NOT NULL",
160
- (thirty_days_ago,),
161
- )
162
- avg_duration = cursor.fetchone()[0] or 0
163
-
164
- conn.close()
165
-
166
- completed = status_counts.get("completed", 0)
167
- success_rate = completed / total_runs if total_runs > 0 else 0
168
-
169
- return {
170
- "total_runs": total_runs,
171
- "success_rate": success_rate,
172
- "avg_duration_seconds": avg_duration,
173
- "status_distribution": status_counts,
174
- "period_days": 30,
175
- }
160
+ store = get_store()
161
+ return store.get_orchestrator_metrics(days=30)
176
162
 
177
163
 
178
164
  @router.get("/observability/cache")
179
165
  async def get_cache_metrics():
180
166
  """Get cache performance metrics."""
181
- from datetime import datetime, timedelta
182
- import sqlite3
183
- import json as json_lib
184
-
185
- store = get_global_store()
186
- conn = sqlite3.connect(store.db_path)
187
- cursor = conn.cursor()
188
-
189
- thirty_days_ago = (datetime.now() - timedelta(days=30)).isoformat()
190
- cursor.execute("SELECT metadata FROM runs WHERE created_at >= ?", (thirty_days_ago,))
191
-
192
- total_steps, cached_steps = 0, 0
193
- for row in cursor.fetchall():
194
- if not row[0]:
195
- continue
196
- try:
197
- metadata = json_lib.loads(row[0])
198
- for step_data in metadata.get("steps", {}).values():
199
- total_steps += 1
200
- if step_data.get("cached"):
201
- cached_steps += 1
202
- except Exception:
203
- continue
204
-
205
- conn.close()
206
- cache_hit_rate = cached_steps / total_steps if total_steps > 0 else 0
207
-
208
- return {
209
- "total_steps": total_steps,
210
- "cached_steps": cached_steps,
211
- "cache_hit_rate": cache_hit_rate,
212
- "period_days": 30,
213
- }
167
+ store = get_store()
168
+ return store.get_cache_metrics(days=30)
@@ -1,21 +1,14 @@
1
1
  from fastapi import APIRouter, HTTPException
2
2
  from pydantic import BaseModel
3
- from flowyml.storage.metadata import SQLiteMetadataStore
3
+ from flowyml.ui.backend.dependencies import get_store
4
4
  from flowyml.core.project import ProjectManager
5
- from flowyml.utils.config import get_config
6
- from typing import Optional
7
5
 
8
6
  router = APIRouter()
9
7
 
10
8
 
11
- def get_store():
12
- get_config()
13
- return SQLiteMetadataStore()
14
-
15
-
16
9
  def _iter_metadata_stores():
17
10
  """Yield tuples of (project_name, store) including global and project stores."""
18
- stores = [(None, SQLiteMetadataStore())]
11
+ stores = [(None, get_store())]
19
12
  try:
20
13
  manager = ProjectManager()
21
14
  for project_meta in manager.list_projects():
@@ -31,7 +24,7 @@ def _iter_metadata_stores():
31
24
 
32
25
 
33
26
  @router.get("/")
34
- async def list_pipelines(project: Optional[str] = None, limit: int = 100):
27
+ async def list_pipelines(project: str | None = None, limit: int = 100):
35
28
  """List all unique pipelines with details, optionally filtered by project."""
36
29
  try:
37
30
  pipeline_map = {} # pipeline_name -> data
@@ -164,3 +157,19 @@ async def update_pipeline_project(pipeline_name: str, update: ProjectUpdate):
164
157
  return {"status": "success", "project": update.project_name}
165
158
  except Exception as e:
166
159
  raise HTTPException(status_code=500, detail=str(e))
160
+
161
+
162
+ class PipelineDefinitionCreate(BaseModel):
163
+ pipeline_name: str
164
+ definition: dict
165
+
166
+
167
+ @router.post("/")
168
+ async def save_pipeline_definition(data: PipelineDefinitionCreate):
169
+ """Save a pipeline definition."""
170
+ try:
171
+ store = get_store()
172
+ store.save_pipeline_definition(data.pipeline_name, data.definition)
173
+ return {"status": "success", "pipeline_name": data.pipeline_name}
174
+ except Exception as e:
175
+ raise HTTPException(status_code=500, detail=str(e))
@@ -2,19 +2,15 @@ from fastapi import APIRouter, HTTPException
2
2
  from pydantic import BaseModel
3
3
  from flowyml.storage.metadata import SQLiteMetadataStore
4
4
  from flowyml.core.project import ProjectManager
5
- from typing import Optional
6
5
  import json
6
+ from flowyml.ui.backend.dependencies import get_store
7
7
 
8
8
  router = APIRouter()
9
9
 
10
10
 
11
- def get_store():
12
- return SQLiteMetadataStore()
13
-
14
-
15
11
  def _iter_metadata_stores():
16
12
  """Yield tuples of (project_name, store) including global and project stores."""
17
- stores: list[tuple[Optional[str], SQLiteMetadataStore]] = [(None, SQLiteMetadataStore())]
13
+ stores: list[tuple[str | None, SQLiteMetadataStore]] = [(None, get_store())]
18
14
  try:
19
15
  manager = ProjectManager()
20
16
  for project_meta in manager.list_projects():
@@ -50,16 +46,43 @@ def _sort_runs(runs):
50
46
 
51
47
 
52
48
  @router.get("/")
53
- async def list_runs(limit: int = 20, project: str = None):
54
- """List all runs, optionally filtered by project."""
49
+ async def list_runs(
50
+ limit: int = 20,
51
+ project: str = None,
52
+ pipeline_name: str = None,
53
+ status: str = None,
54
+ ):
55
+ """List all runs, optionally filtered by project, pipeline_name, and status."""
55
56
  try:
56
57
  combined = []
57
58
  for project_name, store in _iter_metadata_stores():
58
59
  # Skip other projects if filtering by project name
59
60
  if project and project_name and project != project_name:
60
61
  continue
61
- store_runs = store.list_runs(limit=limit)
62
+
63
+ # Use store's query method if available for better performance, or list_runs
64
+ # SQLMetadataStore has query method.
65
+ if hasattr(store, "query"):
66
+ filters = {}
67
+ if pipeline_name:
68
+ filters["pipeline_name"] = pipeline_name
69
+ if status:
70
+ filters["status"] = status
71
+
72
+ # We can't pass limit to query easily if it doesn't support it,
73
+ # but SQLMetadataStore.query usually returns all matching.
74
+ # We'll slice later.
75
+ store_runs = store.query(**filters)
76
+ else:
77
+ store_runs = store.list_runs(limit=limit)
78
+
62
79
  for run in store_runs:
80
+ # Apply filters if store didn't (e.g. if we used list_runs or store doesn't support query)
81
+ if pipeline_name and run.get("pipeline_name") != pipeline_name:
82
+ continue
83
+ if status and run.get("status") != status:
84
+ continue
85
+
63
86
  combined.append((run, project_name))
64
87
 
65
88
  runs = _deduplicate_runs(combined)
@@ -73,6 +96,50 @@ async def list_runs(limit: int = 20, project: str = None):
73
96
  return {"runs": [], "error": str(e)}
74
97
 
75
98
 
99
+ class RunCreate(BaseModel):
100
+ run_id: str
101
+ pipeline_name: str
102
+ status: str = "pending"
103
+ start_time: str
104
+ end_time: str | None = None
105
+ duration: float | None = None
106
+ metadata: dict = {}
107
+ project: str | None = None
108
+ metrics: dict | None = None
109
+ parameters: dict | None = None
110
+
111
+
112
+ @router.post("/")
113
+ async def create_run(run: RunCreate):
114
+ """Create or update a run."""
115
+ try:
116
+ store = get_store()
117
+
118
+ # Prepare metadata dict
119
+ metadata = run.metadata.copy()
120
+ metadata.update(
121
+ {
122
+ "pipeline_name": run.pipeline_name,
123
+ "status": run.status,
124
+ "start_time": run.start_time,
125
+ "end_time": run.end_time,
126
+ "duration": run.duration,
127
+ "project": run.project,
128
+ },
129
+ )
130
+
131
+ if run.metrics:
132
+ metadata["metrics"] = run.metrics
133
+
134
+ if run.parameters:
135
+ metadata["parameters"] = run.parameters
136
+
137
+ store.save_run(run.run_id, metadata)
138
+ return {"status": "success", "run_id": run.run_id}
139
+ except Exception as e:
140
+ raise HTTPException(status_code=500, detail=str(e))
141
+
142
+
76
143
  @router.get("/{run_id}")
77
144
  async def get_run(run_id: str):
78
145
  """Get details for a specific run."""