Spaces:
Running
Running
Commit ·
a23ff80
1
Parent(s): e8ad71f
Deploy 2026-01-28 16:51:38
Browse files- src/flow/ui/api/__init__.py +2 -0
- src/flow/ui/api/configs.py +45 -6
- src/flow/ui/api/jobs.py +88 -26
- src/flow/ui/api/runs.py +41 -5
- src/flow/ui/api/tasks.py +51 -5
- src/flow/ui/api/tests.py +282 -0
- src/flow/ui/auth/__init__.py +4 -0
- src/flow/ui/auth/user_context.py +48 -0
- src/flow/ui/database.py +56 -3
- src/flow/ui/main.py +2 -1
- src/flow/ui/models/__init__.py +3 -0
- src/flow/ui/models/config.py +3 -0
- src/flow/ui/models/job.py +3 -0
- src/flow/ui/models/task.py +3 -0
- src/flow/ui/models/test_run.py +62 -0
- src/flow/ui/schemas/__init__.py +5 -0
- src/flow/ui/schemas/config.py +1 -0
- src/flow/ui/schemas/job.py +1 -0
- src/flow/ui/schemas/task.py +1 -0
- src/flow/ui/schemas/test.py +107 -0
- src/flow/ui/services/test_service.py +308 -0
- src/flow/ui/tests/test_test_service.py +152 -0
- src/flow/ui/tests/test_tests_api.py +239 -0
- src/flow/ui/ui/assets/index-B2HaxLdE.css +1 -0
- src/flow/ui/ui/assets/index-BeLhM5TW.js +0 -0
- src/flow/ui/ui/assets/index-CHxtV6Si.js +0 -0
- src/flow/ui/ui/assets/index-CIxCoVSG.css +1 -0
- src/flow/ui/ui/assets/index-CcGRa0_M.css +1 -0
- src/flow/ui/ui/assets/index-CsLpRsjU.js +0 -0
- src/flow/ui/ui/flow.svg +2 -2
- src/flow/ui/ui/index.html +2 -2
src/flow/ui/api/__init__.py
CHANGED
|
@@ -5,10 +5,12 @@ from .configs import router as configs_router
|
|
| 5 |
from .tasks import router as tasks_router
|
| 6 |
from .jobs import router as jobs_router
|
| 7 |
from .runs import router as runs_router
|
|
|
|
| 8 |
|
| 9 |
__all__ = [
|
| 10 |
"configs_router",
|
| 11 |
"tasks_router",
|
| 12 |
"jobs_router",
|
| 13 |
"runs_router",
|
|
|
|
| 14 |
]
|
|
|
|
| 5 |
from .tasks import router as tasks_router
|
| 6 |
from .jobs import router as jobs_router
|
| 7 |
from .runs import router as runs_router
|
| 8 |
+
from .tests import router as tests_router
|
| 9 |
|
| 10 |
__all__ = [
|
| 11 |
"configs_router",
|
| 12 |
"tasks_router",
|
| 13 |
"jobs_router",
|
| 14 |
"runs_router",
|
| 15 |
+
"tests_router",
|
| 16 |
]
|
src/flow/ui/api/configs.py
CHANGED
|
@@ -1,18 +1,20 @@
|
|
| 1 |
# Copyright (c) Microsoft. All rights reserved.
|
| 2 |
"""Agent config API routes."""
|
| 3 |
|
|
|
|
| 4 |
from uuid import UUID
|
| 5 |
|
| 6 |
from fastapi import APIRouter, Depends, HTTPException
|
| 7 |
from pydantic import BaseModel
|
| 8 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 9 |
-
from sqlmodel import
|
| 10 |
|
| 11 |
from flow.experiments.models import Agent, CompactionConfig, GridSearchStrategy
|
| 12 |
|
|
|
|
| 13 |
from ..database import get_session
|
| 14 |
from ..models.config import AgentConfig
|
| 15 |
-
from ..schemas import AgentCreate,
|
| 16 |
|
| 17 |
router = APIRouter(prefix="/configs", tags=["configs"])
|
| 18 |
|
|
@@ -54,11 +56,18 @@ def parse_uuid(id_str: str) -> UUID:
|
|
| 54 |
async def list_configs(
|
| 55 |
include_auto_generated: bool = False,
|
| 56 |
session: AsyncSession = Depends(get_session),
|
|
|
|
| 57 |
) -> list[AgentConfig]:
|
| 58 |
-
"""List agent configurations."""
|
| 59 |
query = select(AgentConfig)
|
| 60 |
if not include_auto_generated:
|
| 61 |
query = query.where(AgentConfig.is_auto_generated == False) # noqa: E712
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
query = query.order_by(desc(AgentConfig.created_at))
|
| 63 |
result = await session.execute(query)
|
| 64 |
return list(result.scalars().all())
|
|
@@ -68,12 +77,14 @@ async def list_configs(
|
|
| 68 |
async def create_config(
|
| 69 |
data: AgentCreate,
|
| 70 |
session: AsyncSession = Depends(get_session),
|
|
|
|
| 71 |
) -> AgentConfig:
|
| 72 |
"""Create a new agent configuration."""
|
| 73 |
config = AgentConfig(
|
| 74 |
name=data.name,
|
| 75 |
description=data.description,
|
| 76 |
config_json=data.to_config_json(),
|
|
|
|
| 77 |
)
|
| 78 |
session.add(config)
|
| 79 |
await session.commit()
|
|
@@ -85,10 +96,18 @@ async def create_config(
|
|
| 85 |
async def get_config(
|
| 86 |
config_id: str,
|
| 87 |
session: AsyncSession = Depends(get_session),
|
|
|
|
| 88 |
) -> AgentConfig:
|
| 89 |
"""Get a specific agent configuration."""
|
| 90 |
uuid_id = parse_uuid(config_id)
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
config = result.scalar_one_or_none()
|
| 93 |
if not config:
|
| 94 |
raise HTTPException(status_code=404, detail="Config not found")
|
|
@@ -100,10 +119,18 @@ async def update_config(
|
|
| 100 |
config_id: str,
|
| 101 |
data: AgentUpdate,
|
| 102 |
session: AsyncSession = Depends(get_session),
|
|
|
|
| 103 |
) -> AgentConfig:
|
| 104 |
"""Update an agent configuration."""
|
| 105 |
uuid_id = parse_uuid(config_id)
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
config = result.scalar_one_or_none()
|
| 108 |
if not config:
|
| 109 |
raise HTTPException(status_code=404, detail="Config not found")
|
|
@@ -142,10 +169,18 @@ async def update_config(
|
|
| 142 |
async def delete_config(
|
| 143 |
config_id: str,
|
| 144 |
session: AsyncSession = Depends(get_session),
|
|
|
|
| 145 |
) -> None:
|
| 146 |
"""Delete an agent configuration."""
|
| 147 |
uuid_id = parse_uuid(config_id)
|
| 148 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
config = result.scalar_one_or_none()
|
| 150 |
if not config:
|
| 151 |
raise HTTPException(status_code=404, detail="Config not found")
|
|
@@ -158,12 +193,14 @@ async def delete_config(
|
|
| 158 |
async def generate_candidates(
|
| 159 |
data: CandidateRequest,
|
| 160 |
session: AsyncSession = Depends(get_session),
|
|
|
|
| 161 |
) -> list[AgentConfig]:
|
| 162 |
"""Generate candidate agents for optimization.
|
| 163 |
|
| 164 |
Uses GridSearchStrategy to generate candidate variants from a base agent.
|
| 165 |
Each candidate is stored as an AgentConfig in the database.
|
| 166 |
"""
|
|
|
|
| 167 |
variations: dict[str, list] = {}
|
| 168 |
|
| 169 |
if data.vary_compaction:
|
|
@@ -212,6 +249,7 @@ async def generate_candidates(
|
|
| 212 |
},
|
| 213 |
is_auto_generated=True,
|
| 214 |
job_id=job_uuid,
|
|
|
|
| 215 |
)
|
| 216 |
session.add(config)
|
| 217 |
await session.commit()
|
|
@@ -244,6 +282,7 @@ async def generate_candidates(
|
|
| 244 |
},
|
| 245 |
is_auto_generated=True,
|
| 246 |
job_id=job_uuid,
|
|
|
|
| 247 |
)
|
| 248 |
session.add(config)
|
| 249 |
configs.append(config)
|
|
|
|
| 1 |
# Copyright (c) Microsoft. All rights reserved.
|
| 2 |
"""Agent config API routes."""
|
| 3 |
|
| 4 |
+
from typing import Annotated
|
| 5 |
from uuid import UUID
|
| 6 |
|
| 7 |
from fastapi import APIRouter, Depends, HTTPException
|
| 8 |
from pydantic import BaseModel
|
| 9 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 10 |
+
from sqlmodel import desc, select
|
| 11 |
|
| 12 |
from flow.experiments.models import Agent, CompactionConfig, GridSearchStrategy
|
| 13 |
|
| 14 |
+
from ..auth import TokenData, get_current_user, get_effective_user_id, should_filter_by_user
|
| 15 |
from ..database import get_session
|
| 16 |
from ..models.config import AgentConfig
|
| 17 |
+
from ..schemas import AgentCreate, AgentResponse, AgentUpdate
|
| 18 |
|
| 19 |
router = APIRouter(prefix="/configs", tags=["configs"])
|
| 20 |
|
|
|
|
| 56 |
async def list_configs(
|
| 57 |
include_auto_generated: bool = False,
|
| 58 |
session: AsyncSession = Depends(get_session),
|
| 59 |
+
user: Annotated[TokenData | None, Depends(get_current_user)] = None,
|
| 60 |
) -> list[AgentConfig]:
|
| 61 |
+
"""List agent configurations for the current user."""
|
| 62 |
query = select(AgentConfig)
|
| 63 |
if not include_auto_generated:
|
| 64 |
query = query.where(AgentConfig.is_auto_generated == False) # noqa: E712
|
| 65 |
+
|
| 66 |
+
# Filter by user if auth is enabled
|
| 67 |
+
if should_filter_by_user():
|
| 68 |
+
effective_user_id = get_effective_user_id(user)
|
| 69 |
+
query = query.where(AgentConfig.user_id == effective_user_id)
|
| 70 |
+
|
| 71 |
query = query.order_by(desc(AgentConfig.created_at))
|
| 72 |
result = await session.execute(query)
|
| 73 |
return list(result.scalars().all())
|
|
|
|
| 77 |
async def create_config(
|
| 78 |
data: AgentCreate,
|
| 79 |
session: AsyncSession = Depends(get_session),
|
| 80 |
+
user: Annotated[TokenData | None, Depends(get_current_user)] = None,
|
| 81 |
) -> AgentConfig:
|
| 82 |
"""Create a new agent configuration."""
|
| 83 |
config = AgentConfig(
|
| 84 |
name=data.name,
|
| 85 |
description=data.description,
|
| 86 |
config_json=data.to_config_json(),
|
| 87 |
+
user_id=get_effective_user_id(user),
|
| 88 |
)
|
| 89 |
session.add(config)
|
| 90 |
await session.commit()
|
|
|
|
| 96 |
async def get_config(
|
| 97 |
config_id: str,
|
| 98 |
session: AsyncSession = Depends(get_session),
|
| 99 |
+
user: Annotated[TokenData | None, Depends(get_current_user)] = None,
|
| 100 |
) -> AgentConfig:
|
| 101 |
"""Get a specific agent configuration."""
|
| 102 |
uuid_id = parse_uuid(config_id)
|
| 103 |
+
query = select(AgentConfig).where(AgentConfig.id == uuid_id)
|
| 104 |
+
|
| 105 |
+
# Filter by user if auth is enabled
|
| 106 |
+
if should_filter_by_user():
|
| 107 |
+
effective_user_id = get_effective_user_id(user)
|
| 108 |
+
query = query.where(AgentConfig.user_id == effective_user_id)
|
| 109 |
+
|
| 110 |
+
result = await session.execute(query)
|
| 111 |
config = result.scalar_one_or_none()
|
| 112 |
if not config:
|
| 113 |
raise HTTPException(status_code=404, detail="Config not found")
|
|
|
|
| 119 |
config_id: str,
|
| 120 |
data: AgentUpdate,
|
| 121 |
session: AsyncSession = Depends(get_session),
|
| 122 |
+
user: Annotated[TokenData | None, Depends(get_current_user)] = None,
|
| 123 |
) -> AgentConfig:
|
| 124 |
"""Update an agent configuration."""
|
| 125 |
uuid_id = parse_uuid(config_id)
|
| 126 |
+
query = select(AgentConfig).where(AgentConfig.id == uuid_id)
|
| 127 |
+
|
| 128 |
+
# Filter by user if auth is enabled
|
| 129 |
+
if should_filter_by_user():
|
| 130 |
+
effective_user_id = get_effective_user_id(user)
|
| 131 |
+
query = query.where(AgentConfig.user_id == effective_user_id)
|
| 132 |
+
|
| 133 |
+
result = await session.execute(query)
|
| 134 |
config = result.scalar_one_or_none()
|
| 135 |
if not config:
|
| 136 |
raise HTTPException(status_code=404, detail="Config not found")
|
|
|
|
| 169 |
async def delete_config(
|
| 170 |
config_id: str,
|
| 171 |
session: AsyncSession = Depends(get_session),
|
| 172 |
+
user: Annotated[TokenData | None, Depends(get_current_user)] = None,
|
| 173 |
) -> None:
|
| 174 |
"""Delete an agent configuration."""
|
| 175 |
uuid_id = parse_uuid(config_id)
|
| 176 |
+
query = select(AgentConfig).where(AgentConfig.id == uuid_id)
|
| 177 |
+
|
| 178 |
+
# Filter by user if auth is enabled
|
| 179 |
+
if should_filter_by_user():
|
| 180 |
+
effective_user_id = get_effective_user_id(user)
|
| 181 |
+
query = query.where(AgentConfig.user_id == effective_user_id)
|
| 182 |
+
|
| 183 |
+
result = await session.execute(query)
|
| 184 |
config = result.scalar_one_or_none()
|
| 185 |
if not config:
|
| 186 |
raise HTTPException(status_code=404, detail="Config not found")
|
|
|
|
| 193 |
async def generate_candidates(
|
| 194 |
data: CandidateRequest,
|
| 195 |
session: AsyncSession = Depends(get_session),
|
| 196 |
+
user: Annotated[TokenData | None, Depends(get_current_user)] = None,
|
| 197 |
) -> list[AgentConfig]:
|
| 198 |
"""Generate candidate agents for optimization.
|
| 199 |
|
| 200 |
Uses GridSearchStrategy to generate candidate variants from a base agent.
|
| 201 |
Each candidate is stored as an AgentConfig in the database.
|
| 202 |
"""
|
| 203 |
+
effective_user_id = get_effective_user_id(user)
|
| 204 |
variations: dict[str, list] = {}
|
| 205 |
|
| 206 |
if data.vary_compaction:
|
|
|
|
| 249 |
},
|
| 250 |
is_auto_generated=True,
|
| 251 |
job_id=job_uuid,
|
| 252 |
+
user_id=effective_user_id,
|
| 253 |
)
|
| 254 |
session.add(config)
|
| 255 |
await session.commit()
|
|
|
|
| 282 |
},
|
| 283 |
is_auto_generated=True,
|
| 284 |
job_id=job_uuid,
|
| 285 |
+
user_id=effective_user_id,
|
| 286 |
)
|
| 287 |
session.add(config)
|
| 288 |
configs.append(config)
|
src/flow/ui/api/jobs.py
CHANGED
|
@@ -3,17 +3,19 @@
|
|
| 3 |
|
| 4 |
import asyncio
|
| 5 |
import logging
|
| 6 |
-
from
|
|
|
|
| 7 |
from uuid import UUID
|
| 8 |
|
| 9 |
-
from fastapi import APIRouter,
|
| 10 |
from fastapi.responses import StreamingResponse
|
| 11 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 12 |
-
from sqlmodel import
|
| 13 |
|
| 14 |
-
from ..
|
| 15 |
-
from ..
|
| 16 |
from ..models.config import AgentConfig
|
|
|
|
| 17 |
from ..models.task import TaskModel
|
| 18 |
from ..schemas import JobCreate, JobResponse
|
| 19 |
from ..services.optimizer_service import OptimizerService
|
|
@@ -37,11 +39,18 @@ def parse_uuid(id_str: str) -> UUID:
|
|
| 37 |
async def list_jobs(
|
| 38 |
status: JobStatus | None = None,
|
| 39 |
session: AsyncSession = Depends(get_session),
|
|
|
|
| 40 |
) -> list[OptimizationJob]:
|
| 41 |
-
"""List all optimization jobs."""
|
| 42 |
query = select(OptimizationJob)
|
| 43 |
if status:
|
| 44 |
query = query.where(OptimizationJob.status == status)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
query = query.order_by(desc(OptimizationJob.created_at))
|
| 46 |
result = await session.execute(query)
|
| 47 |
return list(result.scalars().all())
|
|
@@ -51,19 +60,33 @@ async def list_jobs(
|
|
| 51 |
async def create_job(
|
| 52 |
data: JobCreate,
|
| 53 |
session: AsyncSession = Depends(get_session),
|
|
|
|
| 54 |
) -> OptimizationJob:
|
| 55 |
"""Create a new optimization job."""
|
| 56 |
-
|
|
|
|
|
|
|
| 57 |
for candidate_id in data.candidate_ids:
|
| 58 |
uuid_id = parse_uuid(candidate_id)
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
| 60 |
if not result.scalar_one_or_none():
|
| 61 |
raise HTTPException(status_code=400, detail=f"Candidate {candidate_id} not found")
|
| 62 |
|
| 63 |
-
# Validate task_ids exist
|
| 64 |
for task_id in data.task_ids:
|
| 65 |
uuid_id = parse_uuid(task_id)
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
if not result.scalar_one_or_none():
|
| 68 |
raise HTTPException(status_code=400, detail=f"Task {task_id} not found")
|
| 69 |
|
|
@@ -74,6 +97,7 @@ async def create_job(
|
|
| 74 |
parallel=data.parallel,
|
| 75 |
use_llm_eval=data.use_llm_eval,
|
| 76 |
total_experiments=len(data.candidate_ids) * len(data.task_ids),
|
|
|
|
| 77 |
)
|
| 78 |
session.add(job)
|
| 79 |
await session.commit()
|
|
@@ -85,10 +109,17 @@ async def create_job(
|
|
| 85 |
async def get_job(
|
| 86 |
job_id: str,
|
| 87 |
session: AsyncSession = Depends(get_session),
|
|
|
|
| 88 |
) -> OptimizationJob:
|
| 89 |
"""Get a specific optimization job."""
|
| 90 |
uuid_id = parse_uuid(job_id)
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
job = result.scalar_one_or_none()
|
| 93 |
if not job:
|
| 94 |
raise HTTPException(status_code=404, detail="Job not found")
|
|
@@ -122,8 +153,7 @@ async def _run_job_background(job_id: str) -> None:
|
|
| 122 |
await session.commit()
|
| 123 |
finally:
|
| 124 |
# Remove from running jobs tracker
|
| 125 |
-
|
| 126 |
-
del _running_jobs[job_id]
|
| 127 |
|
| 128 |
|
| 129 |
@router.post("/{job_id}/start")
|
|
@@ -131,6 +161,7 @@ async def start_job(
|
|
| 131 |
job_id: str,
|
| 132 |
background_tasks: BackgroundTasks,
|
| 133 |
session: AsyncSession = Depends(get_session),
|
|
|
|
| 134 |
) -> StreamingResponse:
|
| 135 |
"""Start an optimization job and stream progress via SSE.
|
| 136 |
|
|
@@ -139,7 +170,13 @@ async def start_job(
|
|
| 139 |
for progress updates.
|
| 140 |
"""
|
| 141 |
uuid_id = parse_uuid(job_id)
|
| 142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
job = result.scalar_one_or_none()
|
| 144 |
if not job:
|
| 145 |
raise HTTPException(status_code=404, detail="Job not found")
|
|
@@ -208,10 +245,17 @@ async def start_job(
|
|
| 208 |
async def cancel_job(
|
| 209 |
job_id: str,
|
| 210 |
session: AsyncSession = Depends(get_session),
|
|
|
|
| 211 |
) -> OptimizationJob:
|
| 212 |
"""Cancel a running optimization job."""
|
| 213 |
uuid_id = parse_uuid(job_id)
|
| 214 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
job = result.scalar_one_or_none()
|
| 216 |
if not job:
|
| 217 |
raise HTTPException(status_code=404, detail="Job not found")
|
|
@@ -234,10 +278,17 @@ async def cancel_job(
|
|
| 234 |
async def delete_job(
|
| 235 |
job_id: str,
|
| 236 |
session: AsyncSession = Depends(get_session),
|
|
|
|
| 237 |
) -> None:
|
| 238 |
"""Delete an optimization job and its runs."""
|
| 239 |
uuid_id = parse_uuid(job_id)
|
| 240 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
job = result.scalar_one_or_none()
|
| 242 |
if not job:
|
| 243 |
raise HTTPException(status_code=404, detail="Job not found")
|
|
@@ -254,16 +305,21 @@ async def delete_job(
|
|
| 254 |
async def reset_job(
|
| 255 |
job_id: str,
|
| 256 |
session: AsyncSession = Depends(get_session),
|
|
|
|
| 257 |
) -> OptimizationJob:
|
| 258 |
"""Reset a stuck job back to pending state.
|
| 259 |
|
| 260 |
Use this to recover jobs that got stuck in 'running' state
|
| 261 |
due to connection drops or server restarts.
|
| 262 |
"""
|
| 263 |
-
from datetime import datetime, timezone
|
| 264 |
-
|
| 265 |
uuid_id = parse_uuid(job_id)
|
| 266 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
job = result.scalar_one_or_none()
|
| 268 |
if not job:
|
| 269 |
raise HTTPException(status_code=404, detail="Job not found")
|
|
@@ -292,24 +348,30 @@ async def reset_job(
|
|
| 292 |
async def cleanup_stuck_jobs(
|
| 293 |
max_age_hours: int = 2,
|
| 294 |
session: AsyncSession = Depends(get_session),
|
|
|
|
| 295 |
) -> list[OptimizationJob]:
|
| 296 |
-
"""Mark stuck 'running' jobs as failed.
|
| 297 |
|
| 298 |
Jobs that have been 'running' for longer than max_age_hours without
|
| 299 |
any progress update are assumed to be stuck (e.g., due to server
|
| 300 |
restart or connection drop).
|
| 301 |
"""
|
| 302 |
-
from datetime import datetime,
|
| 303 |
|
| 304 |
cutoff = datetime.now(timezone.utc) - timedelta(hours=max_age_hours)
|
| 305 |
|
| 306 |
# Find jobs that are running and started before cutoff
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
OptimizationJob.started_at < cutoff,
|
| 311 |
-
)
|
| 312 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
stuck_jobs = list(result.scalars().all())
|
| 314 |
|
| 315 |
# Mark them as failed
|
|
|
|
| 3 |
|
| 4 |
import asyncio
|
| 5 |
import logging
|
| 6 |
+
from collections.abc import AsyncGenerator
|
| 7 |
+
from typing import Annotated, Any
|
| 8 |
from uuid import UUID
|
| 9 |
|
| 10 |
+
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
| 11 |
from fastapi.responses import StreamingResponse
|
| 12 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 13 |
+
from sqlmodel import desc, or_, select
|
| 14 |
|
| 15 |
+
from ..auth import TokenData, get_current_user, get_effective_user_id, should_filter_by_user
|
| 16 |
+
from ..database import async_session, get_session
|
| 17 |
from ..models.config import AgentConfig
|
| 18 |
+
from ..models.job import JobStatus, OptimizationJob
|
| 19 |
from ..models.task import TaskModel
|
| 20 |
from ..schemas import JobCreate, JobResponse
|
| 21 |
from ..services.optimizer_service import OptimizerService
|
|
|
|
| 39 |
async def list_jobs(
|
| 40 |
status: JobStatus | None = None,
|
| 41 |
session: AsyncSession = Depends(get_session),
|
| 42 |
+
user: Annotated[TokenData | None, Depends(get_current_user)] = None,
|
| 43 |
) -> list[OptimizationJob]:
|
| 44 |
+
"""List all optimization jobs for the current user."""
|
| 45 |
query = select(OptimizationJob)
|
| 46 |
if status:
|
| 47 |
query = query.where(OptimizationJob.status == status)
|
| 48 |
+
|
| 49 |
+
# Filter by user if auth is enabled
|
| 50 |
+
if should_filter_by_user():
|
| 51 |
+
effective_user_id = get_effective_user_id(user)
|
| 52 |
+
query = query.where(OptimizationJob.user_id == effective_user_id)
|
| 53 |
+
|
| 54 |
query = query.order_by(desc(OptimizationJob.created_at))
|
| 55 |
result = await session.execute(query)
|
| 56 |
return list(result.scalars().all())
|
|
|
|
| 60 |
async def create_job(
|
| 61 |
data: JobCreate,
|
| 62 |
session: AsyncSession = Depends(get_session),
|
| 63 |
+
user: Annotated[TokenData | None, Depends(get_current_user)] = None,
|
| 64 |
) -> OptimizationJob:
|
| 65 |
"""Create a new optimization job."""
|
| 66 |
+
effective_user_id = get_effective_user_id(user)
|
| 67 |
+
|
| 68 |
+
# Validate candidate_ids exist AND belong to user
|
| 69 |
for candidate_id in data.candidate_ids:
|
| 70 |
uuid_id = parse_uuid(candidate_id)
|
| 71 |
+
query = select(AgentConfig).where(AgentConfig.id == uuid_id)
|
| 72 |
+
if should_filter_by_user():
|
| 73 |
+
query = query.where(AgentConfig.user_id == effective_user_id)
|
| 74 |
+
result = await session.execute(query)
|
| 75 |
if not result.scalar_one_or_none():
|
| 76 |
raise HTTPException(status_code=400, detail=f"Candidate {candidate_id} not found")
|
| 77 |
|
| 78 |
+
# Validate task_ids exist AND are accessible (shared or user's own)
|
| 79 |
for task_id in data.task_ids:
|
| 80 |
uuid_id = parse_uuid(task_id)
|
| 81 |
+
query = select(TaskModel).where(TaskModel.id == uuid_id)
|
| 82 |
+
if should_filter_by_user():
|
| 83 |
+
query = query.where(
|
| 84 |
+
or_(
|
| 85 |
+
TaskModel.user_id == None, # noqa: E711 - Shared tasks
|
| 86 |
+
TaskModel.user_id == effective_user_id,
|
| 87 |
+
)
|
| 88 |
+
)
|
| 89 |
+
result = await session.execute(query)
|
| 90 |
if not result.scalar_one_or_none():
|
| 91 |
raise HTTPException(status_code=400, detail=f"Task {task_id} not found")
|
| 92 |
|
|
|
|
| 97 |
parallel=data.parallel,
|
| 98 |
use_llm_eval=data.use_llm_eval,
|
| 99 |
total_experiments=len(data.candidate_ids) * len(data.task_ids),
|
| 100 |
+
user_id=effective_user_id,
|
| 101 |
)
|
| 102 |
session.add(job)
|
| 103 |
await session.commit()
|
|
|
|
| 109 |
async def get_job(
|
| 110 |
job_id: str,
|
| 111 |
session: AsyncSession = Depends(get_session),
|
| 112 |
+
user: Annotated[TokenData | None, Depends(get_current_user)] = None,
|
| 113 |
) -> OptimizationJob:
|
| 114 |
"""Get a specific optimization job."""
|
| 115 |
uuid_id = parse_uuid(job_id)
|
| 116 |
+
query = select(OptimizationJob).where(OptimizationJob.id == uuid_id)
|
| 117 |
+
|
| 118 |
+
if should_filter_by_user():
|
| 119 |
+
effective_user_id = get_effective_user_id(user)
|
| 120 |
+
query = query.where(OptimizationJob.user_id == effective_user_id)
|
| 121 |
+
|
| 122 |
+
result = await session.execute(query)
|
| 123 |
job = result.scalar_one_or_none()
|
| 124 |
if not job:
|
| 125 |
raise HTTPException(status_code=404, detail="Job not found")
|
|
|
|
| 153 |
await session.commit()
|
| 154 |
finally:
|
| 155 |
# Remove from running jobs tracker
|
| 156 |
+
_running_jobs.pop(job_id, None)
|
|
|
|
| 157 |
|
| 158 |
|
| 159 |
@router.post("/{job_id}/start")
|
|
|
|
| 161 |
job_id: str,
|
| 162 |
background_tasks: BackgroundTasks,
|
| 163 |
session: AsyncSession = Depends(get_session),
|
| 164 |
+
user: Annotated[TokenData | None, Depends(get_current_user)] = None,
|
| 165 |
) -> StreamingResponse:
|
| 166 |
"""Start an optimization job and stream progress via SSE.
|
| 167 |
|
|
|
|
| 170 |
for progress updates.
|
| 171 |
"""
|
| 172 |
uuid_id = parse_uuid(job_id)
|
| 173 |
+
query = select(OptimizationJob).where(OptimizationJob.id == uuid_id)
|
| 174 |
+
|
| 175 |
+
if should_filter_by_user():
|
| 176 |
+
effective_user_id = get_effective_user_id(user)
|
| 177 |
+
query = query.where(OptimizationJob.user_id == effective_user_id)
|
| 178 |
+
|
| 179 |
+
result = await session.execute(query)
|
| 180 |
job = result.scalar_one_or_none()
|
| 181 |
if not job:
|
| 182 |
raise HTTPException(status_code=404, detail="Job not found")
|
|
|
|
| 245 |
async def cancel_job(
|
| 246 |
job_id: str,
|
| 247 |
session: AsyncSession = Depends(get_session),
|
| 248 |
+
user: Annotated[TokenData | None, Depends(get_current_user)] = None,
|
| 249 |
) -> OptimizationJob:
|
| 250 |
"""Cancel a running optimization job."""
|
| 251 |
uuid_id = parse_uuid(job_id)
|
| 252 |
+
query = select(OptimizationJob).where(OptimizationJob.id == uuid_id)
|
| 253 |
+
|
| 254 |
+
if should_filter_by_user():
|
| 255 |
+
effective_user_id = get_effective_user_id(user)
|
| 256 |
+
query = query.where(OptimizationJob.user_id == effective_user_id)
|
| 257 |
+
|
| 258 |
+
result = await session.execute(query)
|
| 259 |
job = result.scalar_one_or_none()
|
| 260 |
if not job:
|
| 261 |
raise HTTPException(status_code=404, detail="Job not found")
|
|
|
|
| 278 |
async def delete_job(
|
| 279 |
job_id: str,
|
| 280 |
session: AsyncSession = Depends(get_session),
|
| 281 |
+
user: Annotated[TokenData | None, Depends(get_current_user)] = None,
|
| 282 |
) -> None:
|
| 283 |
"""Delete an optimization job and its runs."""
|
| 284 |
uuid_id = parse_uuid(job_id)
|
| 285 |
+
query = select(OptimizationJob).where(OptimizationJob.id == uuid_id)
|
| 286 |
+
|
| 287 |
+
if should_filter_by_user():
|
| 288 |
+
effective_user_id = get_effective_user_id(user)
|
| 289 |
+
query = query.where(OptimizationJob.user_id == effective_user_id)
|
| 290 |
+
|
| 291 |
+
result = await session.execute(query)
|
| 292 |
job = result.scalar_one_or_none()
|
| 293 |
if not job:
|
| 294 |
raise HTTPException(status_code=404, detail="Job not found")
|
|
|
|
| 305 |
async def reset_job(
|
| 306 |
job_id: str,
|
| 307 |
session: AsyncSession = Depends(get_session),
|
| 308 |
+
user: Annotated[TokenData | None, Depends(get_current_user)] = None,
|
| 309 |
) -> OptimizationJob:
|
| 310 |
"""Reset a stuck job back to pending state.
|
| 311 |
|
| 312 |
Use this to recover jobs that got stuck in 'running' state
|
| 313 |
due to connection drops or server restarts.
|
| 314 |
"""
|
|
|
|
|
|
|
| 315 |
uuid_id = parse_uuid(job_id)
|
| 316 |
+
query = select(OptimizationJob).where(OptimizationJob.id == uuid_id)
|
| 317 |
+
|
| 318 |
+
if should_filter_by_user():
|
| 319 |
+
effective_user_id = get_effective_user_id(user)
|
| 320 |
+
query = query.where(OptimizationJob.user_id == effective_user_id)
|
| 321 |
+
|
| 322 |
+
result = await session.execute(query)
|
| 323 |
job = result.scalar_one_or_none()
|
| 324 |
if not job:
|
| 325 |
raise HTTPException(status_code=404, detail="Job not found")
|
|
|
|
| 348 |
async def cleanup_stuck_jobs(
|
| 349 |
max_age_hours: int = 2,
|
| 350 |
session: AsyncSession = Depends(get_session),
|
| 351 |
+
user: Annotated[TokenData | None, Depends(get_current_user)] = None,
|
| 352 |
) -> list[OptimizationJob]:
|
| 353 |
+
"""Mark stuck 'running' jobs as failed (only user's own jobs).
|
| 354 |
|
| 355 |
Jobs that have been 'running' for longer than max_age_hours without
|
| 356 |
any progress update are assumed to be stuck (e.g., due to server
|
| 357 |
restart or connection drop).
|
| 358 |
"""
|
| 359 |
+
from datetime import datetime, timedelta, timezone
|
| 360 |
|
| 361 |
cutoff = datetime.now(timezone.utc) - timedelta(hours=max_age_hours)
|
| 362 |
|
| 363 |
# Find jobs that are running and started before cutoff
|
| 364 |
+
query = select(OptimizationJob).where(
|
| 365 |
+
OptimizationJob.status == JobStatus.RUNNING,
|
| 366 |
+
OptimizationJob.started_at < cutoff,
|
|
|
|
|
|
|
| 367 |
)
|
| 368 |
+
|
| 369 |
+
# Only affect user's own jobs
|
| 370 |
+
if should_filter_by_user():
|
| 371 |
+
effective_user_id = get_effective_user_id(user)
|
| 372 |
+
query = query.where(OptimizationJob.user_id == effective_user_id)
|
| 373 |
+
|
| 374 |
+
result = await session.execute(query)
|
| 375 |
stuck_jobs = list(result.scalars().all())
|
| 376 |
|
| 377 |
# Mark them as failed
|
src/flow/ui/api/runs.py
CHANGED
|
@@ -1,16 +1,18 @@
|
|
| 1 |
# Copyright (c) Microsoft. All rights reserved.
|
| 2 |
"""Run API routes."""
|
| 3 |
|
| 4 |
-
from typing import Any
|
| 5 |
from uuid import UUID
|
| 6 |
|
| 7 |
from fastapi import APIRouter, Depends, HTTPException
|
| 8 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 9 |
-
from sqlmodel import
|
| 10 |
|
|
|
|
| 11 |
from ..database import get_session
|
|
|
|
| 12 |
from ..models.run import ExperimentRun
|
| 13 |
-
from ..schemas import
|
| 14 |
|
| 15 |
router = APIRouter(prefix="/runs", tags=["runs"])
|
| 16 |
|
|
@@ -30,8 +32,12 @@ async def list_runs(
|
|
| 30 |
task_name: str | None = None,
|
| 31 |
is_pareto: bool | None = None,
|
| 32 |
session: AsyncSession = Depends(get_session),
|
|
|
|
| 33 |
) -> list[ExperimentRun]:
|
| 34 |
-
"""List experiment runs with optional filters.
|
|
|
|
|
|
|
|
|
|
| 35 |
query = select(ExperimentRun)
|
| 36 |
|
| 37 |
if job_id:
|
|
@@ -44,6 +50,13 @@ async def list_runs(
|
|
| 44 |
if is_pareto is not None:
|
| 45 |
query = query.where(ExperimentRun.is_pareto == is_pareto)
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
query = query.order_by(desc(ExperimentRun.created_at))
|
| 48 |
result = await session.execute(query)
|
| 49 |
return list(result.scalars().all())
|
|
@@ -53,10 +66,20 @@ async def list_runs(
|
|
| 53 |
async def get_run(
|
| 54 |
run_id: str,
|
| 55 |
session: AsyncSession = Depends(get_session),
|
|
|
|
| 56 |
) -> dict[str, Any]:
|
| 57 |
"""Get detailed information about a specific run."""
|
| 58 |
uuid_id = parse_uuid(run_id)
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
run = result.scalar_one_or_none()
|
| 61 |
if not run:
|
| 62 |
raise HTTPException(status_code=404, detail="Run not found")
|
|
@@ -99,9 +122,22 @@ async def get_run(
|
|
| 99 |
async def get_job_summary(
|
| 100 |
job_id: str,
|
| 101 |
session: AsyncSession = Depends(get_session),
|
|
|
|
| 102 |
) -> dict[str, Any]:
|
| 103 |
"""Get aggregated summary for a job's runs."""
|
| 104 |
uuid_id = parse_uuid(job_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
result = await session.execute(
|
| 106 |
select(ExperimentRun).where(ExperimentRun.job_id == uuid_id)
|
| 107 |
)
|
|
|
|
| 1 |
# Copyright (c) Microsoft. All rights reserved.
|
| 2 |
"""Run API routes."""
|
| 3 |
|
| 4 |
+
from typing import Annotated, Any
|
| 5 |
from uuid import UUID
|
| 6 |
|
| 7 |
from fastapi import APIRouter, Depends, HTTPException
|
| 8 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 9 |
+
from sqlmodel import desc, select
|
| 10 |
|
| 11 |
+
from ..auth import TokenData, get_current_user, get_effective_user_id, should_filter_by_user
|
| 12 |
from ..database import get_session
|
| 13 |
+
from ..models.job import OptimizationJob
|
| 14 |
from ..models.run import ExperimentRun
|
| 15 |
+
from ..schemas import CriterionResultSchema, RunDetailResponse, RunResponse
|
| 16 |
|
| 17 |
router = APIRouter(prefix="/runs", tags=["runs"])
|
| 18 |
|
|
|
|
| 32 |
task_name: str | None = None,
|
| 33 |
is_pareto: bool | None = None,
|
| 34 |
session: AsyncSession = Depends(get_session),
|
| 35 |
+
user: Annotated[TokenData | None, Depends(get_current_user)] = None,
|
| 36 |
) -> list[ExperimentRun]:
|
| 37 |
+
"""List experiment runs with optional filters.
|
| 38 |
+
|
| 39 |
+
Runs are filtered by their parent job's user_id.
|
| 40 |
+
"""
|
| 41 |
query = select(ExperimentRun)
|
| 42 |
|
| 43 |
if job_id:
|
|
|
|
| 50 |
if is_pareto is not None:
|
| 51 |
query = query.where(ExperimentRun.is_pareto == is_pareto)
|
| 52 |
|
| 53 |
+
# Filter by user via parent job
|
| 54 |
+
if should_filter_by_user():
|
| 55 |
+
effective_user_id = get_effective_user_id(user)
|
| 56 |
+
query = query.join(OptimizationJob).where(
|
| 57 |
+
OptimizationJob.user_id == effective_user_id
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
query = query.order_by(desc(ExperimentRun.created_at))
|
| 61 |
result = await session.execute(query)
|
| 62 |
return list(result.scalars().all())
|
|
|
|
| 66 |
async def get_run(
|
| 67 |
run_id: str,
|
| 68 |
session: AsyncSession = Depends(get_session),
|
| 69 |
+
user: Annotated[TokenData | None, Depends(get_current_user)] = None,
|
| 70 |
) -> dict[str, Any]:
|
| 71 |
"""Get detailed information about a specific run."""
|
| 72 |
uuid_id = parse_uuid(run_id)
|
| 73 |
+
query = select(ExperimentRun).where(ExperimentRun.id == uuid_id)
|
| 74 |
+
|
| 75 |
+
# Filter by user via parent job
|
| 76 |
+
if should_filter_by_user():
|
| 77 |
+
effective_user_id = get_effective_user_id(user)
|
| 78 |
+
query = query.join(OptimizationJob).where(
|
| 79 |
+
OptimizationJob.user_id == effective_user_id
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
result = await session.execute(query)
|
| 83 |
run = result.scalar_one_or_none()
|
| 84 |
if not run:
|
| 85 |
raise HTTPException(status_code=404, detail="Run not found")
|
|
|
|
| 122 |
async def get_job_summary(
|
| 123 |
job_id: str,
|
| 124 |
session: AsyncSession = Depends(get_session),
|
| 125 |
+
user: Annotated[TokenData | None, Depends(get_current_user)] = None,
|
| 126 |
) -> dict[str, Any]:
|
| 127 |
"""Get aggregated summary for a job's runs."""
|
| 128 |
uuid_id = parse_uuid(job_id)
|
| 129 |
+
|
| 130 |
+
# First verify the job belongs to the user
|
| 131 |
+
job_query = select(OptimizationJob).where(OptimizationJob.id == uuid_id)
|
| 132 |
+
if should_filter_by_user():
|
| 133 |
+
effective_user_id = get_effective_user_id(user)
|
| 134 |
+
job_query = job_query.where(OptimizationJob.user_id == effective_user_id)
|
| 135 |
+
|
| 136 |
+
job_result = await session.execute(job_query)
|
| 137 |
+
if not job_result.scalar_one_or_none():
|
| 138 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 139 |
+
|
| 140 |
+
# Then get runs (no need to filter again since we verified job ownership)
|
| 141 |
result = await session.execute(
|
| 142 |
select(ExperimentRun).where(ExperimentRun.job_id == uuid_id)
|
| 143 |
)
|
src/flow/ui/api/tasks.py
CHANGED
|
@@ -1,12 +1,14 @@
|
|
| 1 |
# Copyright (c) Microsoft. All rights reserved.
|
| 2 |
"""Task API routes."""
|
| 3 |
|
|
|
|
| 4 |
from uuid import UUID
|
| 5 |
|
| 6 |
from fastapi import APIRouter, Depends, HTTPException
|
| 7 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 8 |
-
from sqlmodel import
|
| 9 |
|
|
|
|
| 10 |
from ..database import get_session
|
| 11 |
from ..models.task import TaskModel
|
| 12 |
from ..schemas import TaskCreate, TaskResponse
|
|
@@ -27,13 +29,31 @@ async def list_tasks(
|
|
| 27 |
category: str | None = None,
|
| 28 |
suite: str | None = None,
|
| 29 |
session: AsyncSession = Depends(get_session),
|
|
|
|
| 30 |
) -> list[TaskModel]:
|
| 31 |
-
"""List all tasks, optionally filtered by category or suite.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
query = select(TaskModel)
|
| 33 |
if category:
|
| 34 |
query = query.where(TaskModel.category == category)
|
| 35 |
if suite:
|
| 36 |
query = query.where(TaskModel.suite == suite)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
query = query.order_by(desc(TaskModel.created_at))
|
| 38 |
result = await session.execute(query)
|
| 39 |
return list(result.scalars().all())
|
|
@@ -43,6 +63,7 @@ async def list_tasks(
|
|
| 43 |
async def create_task(
|
| 44 |
data: TaskCreate,
|
| 45 |
session: AsyncSession = Depends(get_session),
|
|
|
|
| 46 |
) -> TaskModel:
|
| 47 |
"""Create a new task."""
|
| 48 |
task = TaskModel(
|
|
@@ -50,6 +71,7 @@ async def create_task(
|
|
| 50 |
prompt=data.prompt,
|
| 51 |
criteria_json=data.to_criteria_json(),
|
| 52 |
category=data.category,
|
|
|
|
| 53 |
)
|
| 54 |
session.add(task)
|
| 55 |
await session.commit()
|
|
@@ -61,10 +83,23 @@ async def create_task(
|
|
| 61 |
async def get_task(
|
| 62 |
task_id: str,
|
| 63 |
session: AsyncSession = Depends(get_session),
|
|
|
|
| 64 |
) -> TaskModel:
|
| 65 |
"""Get a specific task."""
|
| 66 |
uuid_id = parse_uuid(task_id)
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
task = result.scalar_one_or_none()
|
| 69 |
if not task:
|
| 70 |
raise HTTPException(status_code=404, detail="Task not found")
|
|
@@ -75,10 +110,21 @@ async def get_task(
|
|
| 75 |
async def delete_task(
|
| 76 |
task_id: str,
|
| 77 |
session: AsyncSession = Depends(get_session),
|
|
|
|
| 78 |
) -> None:
|
| 79 |
-
"""Delete a task.
|
|
|
|
|
|
|
|
|
|
| 80 |
uuid_id = parse_uuid(task_id)
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
task = result.scalar_one_or_none()
|
| 83 |
if not task:
|
| 84 |
raise HTTPException(status_code=404, detail="Task not found")
|
|
|
|
| 1 |
# Copyright (c) Microsoft. All rights reserved.
|
| 2 |
"""Task API routes."""
|
| 3 |
|
| 4 |
+
from typing import Annotated
|
| 5 |
from uuid import UUID
|
| 6 |
|
| 7 |
from fastapi import APIRouter, Depends, HTTPException
|
| 8 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 9 |
+
from sqlmodel import desc, or_, select
|
| 10 |
|
| 11 |
+
from ..auth import TokenData, get_current_user, get_effective_user_id, should_filter_by_user
|
| 12 |
from ..database import get_session
|
| 13 |
from ..models.task import TaskModel
|
| 14 |
from ..schemas import TaskCreate, TaskResponse
|
|
|
|
| 29 |
category: str | None = None,
|
| 30 |
suite: str | None = None,
|
| 31 |
session: AsyncSession = Depends(get_session),
|
| 32 |
+
user: Annotated[TokenData | None, Depends(get_current_user)] = None,
|
| 33 |
) -> list[TaskModel]:
|
| 34 |
+
"""List all tasks, optionally filtered by category or suite.
|
| 35 |
+
|
| 36 |
+
Shows:
|
| 37 |
+
- Shared tasks (user_id is None, e.g., built-in suites)
|
| 38 |
+
- User's own tasks (when auth is enabled)
|
| 39 |
+
"""
|
| 40 |
query = select(TaskModel)
|
| 41 |
if category:
|
| 42 |
query = query.where(TaskModel.category == category)
|
| 43 |
if suite:
|
| 44 |
query = query.where(TaskModel.suite == suite)
|
| 45 |
+
|
| 46 |
+
# Filter by user if auth is enabled
|
| 47 |
+
# Show both shared tasks (user_id=None) AND user's own tasks
|
| 48 |
+
if should_filter_by_user():
|
| 49 |
+
effective_user_id = get_effective_user_id(user)
|
| 50 |
+
query = query.where(
|
| 51 |
+
or_(
|
| 52 |
+
TaskModel.user_id == None, # noqa: E711 - Shared tasks
|
| 53 |
+
TaskModel.user_id == effective_user_id, # User's own tasks
|
| 54 |
+
)
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
query = query.order_by(desc(TaskModel.created_at))
|
| 58 |
result = await session.execute(query)
|
| 59 |
return list(result.scalars().all())
|
|
|
|
| 63 |
async def create_task(
|
| 64 |
data: TaskCreate,
|
| 65 |
session: AsyncSession = Depends(get_session),
|
| 66 |
+
user: Annotated[TokenData | None, Depends(get_current_user)] = None,
|
| 67 |
) -> TaskModel:
|
| 68 |
"""Create a new task."""
|
| 69 |
task = TaskModel(
|
|
|
|
| 71 |
prompt=data.prompt,
|
| 72 |
criteria_json=data.to_criteria_json(),
|
| 73 |
category=data.category,
|
| 74 |
+
user_id=get_effective_user_id(user),
|
| 75 |
)
|
| 76 |
session.add(task)
|
| 77 |
await session.commit()
|
|
|
|
| 83 |
async def get_task(
|
| 84 |
task_id: str,
|
| 85 |
session: AsyncSession = Depends(get_session),
|
| 86 |
+
user: Annotated[TokenData | None, Depends(get_current_user)] = None,
|
| 87 |
) -> TaskModel:
|
| 88 |
"""Get a specific task."""
|
| 89 |
uuid_id = parse_uuid(task_id)
|
| 90 |
+
query = select(TaskModel).where(TaskModel.id == uuid_id)
|
| 91 |
+
|
| 92 |
+
# Allow access to shared tasks (user_id=None) OR user's own tasks
|
| 93 |
+
if should_filter_by_user():
|
| 94 |
+
effective_user_id = get_effective_user_id(user)
|
| 95 |
+
query = query.where(
|
| 96 |
+
or_(
|
| 97 |
+
TaskModel.user_id == None, # noqa: E711
|
| 98 |
+
TaskModel.user_id == effective_user_id,
|
| 99 |
+
)
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
result = await session.execute(query)
|
| 103 |
task = result.scalar_one_or_none()
|
| 104 |
if not task:
|
| 105 |
raise HTTPException(status_code=404, detail="Task not found")
|
|
|
|
| 110 |
async def delete_task(
|
| 111 |
task_id: str,
|
| 112 |
session: AsyncSession = Depends(get_session),
|
| 113 |
+
user: Annotated[TokenData | None, Depends(get_current_user)] = None,
|
| 114 |
) -> None:
|
| 115 |
+
"""Delete a task.
|
| 116 |
+
|
| 117 |
+
Only allows deleting user-owned tasks, not shared/built-in tasks.
|
| 118 |
+
"""
|
| 119 |
uuid_id = parse_uuid(task_id)
|
| 120 |
+
query = select(TaskModel).where(TaskModel.id == uuid_id)
|
| 121 |
+
|
| 122 |
+
if should_filter_by_user():
|
| 123 |
+
effective_user_id = get_effective_user_id(user)
|
| 124 |
+
# Can only delete user's own tasks (not shared ones with user_id=None)
|
| 125 |
+
query = query.where(TaskModel.user_id == effective_user_id)
|
| 126 |
+
|
| 127 |
+
result = await session.execute(query)
|
| 128 |
task = result.scalar_one_or_none()
|
| 129 |
if not task:
|
| 130 |
raise HTTPException(status_code=404, detail="Task not found")
|
src/flow/ui/api/tests.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Microsoft. All rights reserved.
|
| 2 |
+
"""Test run API routes for interactive agent testing."""
|
| 3 |
+
|
| 4 |
+
import asyncio
|
| 5 |
+
import logging
|
| 6 |
+
from collections.abc import AsyncGenerator
|
| 7 |
+
from typing import Annotated, Any
|
| 8 |
+
from uuid import UUID
|
| 9 |
+
|
| 10 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 11 |
+
from fastapi.responses import StreamingResponse
|
| 12 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 13 |
+
from sqlmodel import desc, select
|
| 14 |
+
|
| 15 |
+
from ..auth import TokenData, get_current_user, get_effective_user_id, should_filter_by_user
|
| 16 |
+
from ..database import async_session, get_session
|
| 17 |
+
from ..models.config import AgentConfig
|
| 18 |
+
from ..models.test_run import TestRun, TestRunStatus
|
| 19 |
+
from ..schemas.test import TestRunCreate, TestRunResponse, TestRunDetailResponse
|
| 20 |
+
from ..services.test_service import TestService
|
| 21 |
+
|
| 22 |
+
router = APIRouter(prefix="/tests", tags=["tests"])
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
# Store running tests for cancellation
|
| 26 |
+
_running_tests: dict[str, asyncio.Task[Any]] = {}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def parse_uuid(id_str: str) -> UUID:
|
| 30 |
+
"""Parse a string to UUID, raising 400 if invalid."""
|
| 31 |
+
try:
|
| 32 |
+
return UUID(id_str)
|
| 33 |
+
except ValueError as e:
|
| 34 |
+
raise HTTPException(status_code=400, detail=f"Invalid UUID: {id_str}") from e
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@router.get("", response_model=list[TestRunResponse])
|
| 38 |
+
async def list_tests(
|
| 39 |
+
agent_id: str | None = None,
|
| 40 |
+
limit: int = 50,
|
| 41 |
+
session: AsyncSession = Depends(get_session),
|
| 42 |
+
user: Annotated[TokenData | None, Depends(get_current_user)] = None,
|
| 43 |
+
) -> list[TestRun]:
|
| 44 |
+
"""List test runs, optionally filtered by agent."""
|
| 45 |
+
query = select(TestRun)
|
| 46 |
+
|
| 47 |
+
if agent_id:
|
| 48 |
+
uuid_id = parse_uuid(agent_id)
|
| 49 |
+
query = query.where(TestRun.agent_id == uuid_id)
|
| 50 |
+
|
| 51 |
+
# Filter by user if auth is enabled
|
| 52 |
+
if should_filter_by_user():
|
| 53 |
+
effective_user_id = get_effective_user_id(user)
|
| 54 |
+
query = query.where(TestRun.user_id == effective_user_id)
|
| 55 |
+
|
| 56 |
+
query = query.order_by(desc(TestRun.created_at)).limit(limit)
|
| 57 |
+
result = await session.execute(query)
|
| 58 |
+
return list(result.scalars().all())
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@router.post("", response_model=TestRunResponse, status_code=201)
|
| 62 |
+
async def create_test(
|
| 63 |
+
data: TestRunCreate,
|
| 64 |
+
session: AsyncSession = Depends(get_session),
|
| 65 |
+
user: Annotated[TokenData | None, Depends(get_current_user)] = None,
|
| 66 |
+
) -> TestRun:
|
| 67 |
+
"""Create a new test run for an agent."""
|
| 68 |
+
effective_user_id = get_effective_user_id(user)
|
| 69 |
+
|
| 70 |
+
# Validate agent exists and belongs to user
|
| 71 |
+
agent_uuid = parse_uuid(data.agent_id)
|
| 72 |
+
query = select(AgentConfig).where(AgentConfig.id == agent_uuid)
|
| 73 |
+
if should_filter_by_user():
|
| 74 |
+
query = query.where(AgentConfig.user_id == effective_user_id)
|
| 75 |
+
result = await session.execute(query)
|
| 76 |
+
if not result.scalar_one_or_none():
|
| 77 |
+
raise HTTPException(status_code=400, detail="Agent not found")
|
| 78 |
+
|
| 79 |
+
# Validate task exists if provided
|
| 80 |
+
task_uuid: UUID | None = None
|
| 81 |
+
if data.task_id:
|
| 82 |
+
task_uuid = parse_uuid(data.task_id)
|
| 83 |
+
from ..models.task import TaskModel
|
| 84 |
+
from sqlmodel import or_
|
| 85 |
+
query = select(TaskModel).where(TaskModel.id == task_uuid)
|
| 86 |
+
if should_filter_by_user():
|
| 87 |
+
query = query.where(
|
| 88 |
+
or_(
|
| 89 |
+
TaskModel.user_id == None, # noqa: E711 - Shared tasks
|
| 90 |
+
TaskModel.user_id == effective_user_id,
|
| 91 |
+
)
|
| 92 |
+
)
|
| 93 |
+
result = await session.execute(query)
|
| 94 |
+
if not result.scalar_one_or_none():
|
| 95 |
+
raise HTTPException(status_code=400, detail="Task not found")
|
| 96 |
+
|
| 97 |
+
test_run = TestRun(
|
| 98 |
+
agent_id=agent_uuid,
|
| 99 |
+
prompt=data.prompt,
|
| 100 |
+
task_id=task_uuid,
|
| 101 |
+
user_id=effective_user_id,
|
| 102 |
+
)
|
| 103 |
+
session.add(test_run)
|
| 104 |
+
await session.commit()
|
| 105 |
+
await session.refresh(test_run)
|
| 106 |
+
return test_run
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
@router.get("/{test_id}", response_model=TestRunDetailResponse)
|
| 110 |
+
async def get_test(
|
| 111 |
+
test_id: str,
|
| 112 |
+
session: AsyncSession = Depends(get_session),
|
| 113 |
+
user: Annotated[TokenData | None, Depends(get_current_user)] = None,
|
| 114 |
+
) -> dict[str, Any]:
|
| 115 |
+
"""Get detailed test run info including trace."""
|
| 116 |
+
uuid_id = parse_uuid(test_id)
|
| 117 |
+
query = select(TestRun).where(TestRun.id == uuid_id)
|
| 118 |
+
|
| 119 |
+
if should_filter_by_user():
|
| 120 |
+
effective_user_id = get_effective_user_id(user)
|
| 121 |
+
query = query.where(TestRun.user_id == effective_user_id)
|
| 122 |
+
|
| 123 |
+
result = await session.execute(query)
|
| 124 |
+
test_run = result.scalar_one_or_none()
|
| 125 |
+
if not test_run:
|
| 126 |
+
raise HTTPException(status_code=404, detail="Test run not found")
|
| 127 |
+
|
| 128 |
+
# Convert to response with trace field renamed
|
| 129 |
+
return {
|
| 130 |
+
"id": str(test_run.id),
|
| 131 |
+
"agent_id": str(test_run.agent_id),
|
| 132 |
+
"prompt": test_run.prompt,
|
| 133 |
+
"task_id": str(test_run.task_id) if test_run.task_id else None,
|
| 134 |
+
"status": test_run.status.value if isinstance(test_run.status, TestRunStatus) else test_run.status,
|
| 135 |
+
"tokens_total": test_run.tokens_total,
|
| 136 |
+
"tokens_input": test_run.tokens_input,
|
| 137 |
+
"tokens_output": test_run.tokens_output,
|
| 138 |
+
"duration_seconds": test_run.duration_seconds,
|
| 139 |
+
"score": test_run.score,
|
| 140 |
+
"passed": test_run.passed,
|
| 141 |
+
"reasoning": test_run.reasoning,
|
| 142 |
+
"output": test_run.output,
|
| 143 |
+
"files_created": test_run.files_created,
|
| 144 |
+
"trace": test_run.trace_json,
|
| 145 |
+
"error": test_run.error,
|
| 146 |
+
"created_at": test_run.created_at,
|
| 147 |
+
"started_at": test_run.started_at,
|
| 148 |
+
"completed_at": test_run.completed_at,
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
async def _run_test_background(test_id: str) -> None:
|
| 153 |
+
"""Run test in background, updating DB with progress."""
|
| 154 |
+
service = TestService()
|
| 155 |
+
try:
|
| 156 |
+
async for progress in service.run_test(test_id):
|
| 157 |
+
logger.debug(f"Test {test_id[:8]} progress: {progress.event} - {progress.message}")
|
| 158 |
+
except Exception as e:
|
| 159 |
+
logger.error(f"Background test {test_id[:8]} failed: {e}")
|
| 160 |
+
# Ensure test is marked as failed
|
| 161 |
+
async with async_session() as session:
|
| 162 |
+
from datetime import datetime, timezone
|
| 163 |
+
result = await session.execute(
|
| 164 |
+
select(TestRun).where(TestRun.id == UUID(test_id))
|
| 165 |
+
)
|
| 166 |
+
test_run = result.scalar_one_or_none()
|
| 167 |
+
if test_run and test_run.status == TestRunStatus.RUNNING:
|
| 168 |
+
test_run.status = TestRunStatus.FAILED
|
| 169 |
+
test_run.error = f"Background execution failed: {e}"
|
| 170 |
+
test_run.completed_at = datetime.now(timezone.utc)
|
| 171 |
+
await session.commit()
|
| 172 |
+
finally:
|
| 173 |
+
_running_tests.pop(test_id, None)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
@router.post("/{test_id}/start")
|
| 177 |
+
async def start_test(
|
| 178 |
+
test_id: str,
|
| 179 |
+
session: AsyncSession = Depends(get_session),
|
| 180 |
+
user: Annotated[TokenData | None, Depends(get_current_user)] = None,
|
| 181 |
+
) -> StreamingResponse:
|
| 182 |
+
"""Start a test run and stream progress via SSE.
|
| 183 |
+
|
| 184 |
+
Events streamed:
|
| 185 |
+
- started: Test execution started
|
| 186 |
+
- execution: Agent execution events (text_delta, tool_call_start, tool_result)
|
| 187 |
+
- span: OTEL span completed
|
| 188 |
+
- complete: Test finished
|
| 189 |
+
- error: Error occurred
|
| 190 |
+
"""
|
| 191 |
+
uuid_id = parse_uuid(test_id)
|
| 192 |
+
query = select(TestRun).where(TestRun.id == uuid_id)
|
| 193 |
+
|
| 194 |
+
if should_filter_by_user():
|
| 195 |
+
effective_user_id = get_effective_user_id(user)
|
| 196 |
+
query = query.where(TestRun.user_id == effective_user_id)
|
| 197 |
+
|
| 198 |
+
result = await session.execute(query)
|
| 199 |
+
test_run = result.scalar_one_or_none()
|
| 200 |
+
if not test_run:
|
| 201 |
+
raise HTTPException(status_code=404, detail="Test run not found")
|
| 202 |
+
|
| 203 |
+
if test_run.status != TestRunStatus.PENDING:
|
| 204 |
+
raise HTTPException(status_code=400, detail=f"Test is already {test_run.status}")
|
| 205 |
+
|
| 206 |
+
async def event_stream() -> AsyncGenerator[str, None]:
|
| 207 |
+
"""Stream test progress directly from the service."""
|
| 208 |
+
service = TestService()
|
| 209 |
+
try:
|
| 210 |
+
async for progress in service.run_test(test_id):
|
| 211 |
+
yield f"data: {progress.model_dump_json()}\n\n"
|
| 212 |
+
except Exception as e:
|
| 213 |
+
from ..schemas.test import TestProgress
|
| 214 |
+
yield f"data: {TestProgress(event='error', test_run_id=test_id, message=str(e)).model_dump_json()}\n\n"
|
| 215 |
+
|
| 216 |
+
return StreamingResponse(
|
| 217 |
+
event_stream(),
|
| 218 |
+
media_type="text/event-stream",
|
| 219 |
+
headers={
|
| 220 |
+
"Cache-Control": "no-cache",
|
| 221 |
+
"Connection": "keep-alive",
|
| 222 |
+
},
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
@router.post("/{test_id}/cancel", response_model=TestRunResponse)
|
| 227 |
+
async def cancel_test(
|
| 228 |
+
test_id: str,
|
| 229 |
+
session: AsyncSession = Depends(get_session),
|
| 230 |
+
user: Annotated[TokenData | None, Depends(get_current_user)] = None,
|
| 231 |
+
) -> TestRun:
|
| 232 |
+
"""Cancel a running test."""
|
| 233 |
+
uuid_id = parse_uuid(test_id)
|
| 234 |
+
query = select(TestRun).where(TestRun.id == uuid_id)
|
| 235 |
+
|
| 236 |
+
if should_filter_by_user():
|
| 237 |
+
effective_user_id = get_effective_user_id(user)
|
| 238 |
+
query = query.where(TestRun.user_id == effective_user_id)
|
| 239 |
+
|
| 240 |
+
result = await session.execute(query)
|
| 241 |
+
test_run = result.scalar_one_or_none()
|
| 242 |
+
if not test_run:
|
| 243 |
+
raise HTTPException(status_code=404, detail="Test run not found")
|
| 244 |
+
|
| 245 |
+
if test_run.status != TestRunStatus.RUNNING:
|
| 246 |
+
raise HTTPException(status_code=400, detail=f"Test is not running (status: {test_run.status})")
|
| 247 |
+
|
| 248 |
+
# Cancel the running task if it exists
|
| 249 |
+
if test_id in _running_tests:
|
| 250 |
+
_running_tests[test_id].cancel()
|
| 251 |
+
del _running_tests[test_id]
|
| 252 |
+
|
| 253 |
+
test_run.status = TestRunStatus.CANCELLED
|
| 254 |
+
await session.commit()
|
| 255 |
+
await session.refresh(test_run)
|
| 256 |
+
return test_run
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
@router.delete("/{test_id}", status_code=204)
|
| 260 |
+
async def delete_test(
|
| 261 |
+
test_id: str,
|
| 262 |
+
session: AsyncSession = Depends(get_session),
|
| 263 |
+
user: Annotated[TokenData | None, Depends(get_current_user)] = None,
|
| 264 |
+
) -> None:
|
| 265 |
+
"""Delete a test run."""
|
| 266 |
+
uuid_id = parse_uuid(test_id)
|
| 267 |
+
query = select(TestRun).where(TestRun.id == uuid_id)
|
| 268 |
+
|
| 269 |
+
if should_filter_by_user():
|
| 270 |
+
effective_user_id = get_effective_user_id(user)
|
| 271 |
+
query = query.where(TestRun.user_id == effective_user_id)
|
| 272 |
+
|
| 273 |
+
result = await session.execute(query)
|
| 274 |
+
test_run = result.scalar_one_or_none()
|
| 275 |
+
if not test_run:
|
| 276 |
+
raise HTTPException(status_code=404, detail="Test run not found")
|
| 277 |
+
|
| 278 |
+
if test_run.status == TestRunStatus.RUNNING:
|
| 279 |
+
raise HTTPException(status_code=400, detail="Cannot delete a running test")
|
| 280 |
+
|
| 281 |
+
await session.delete(test_run)
|
| 282 |
+
await session.commit()
|
src/flow/ui/auth/__init__.py
CHANGED
|
@@ -5,6 +5,7 @@ from .config import AuthSettings, AuthMode, get_auth_settings, init_auth_setting
|
|
| 5 |
from .tokens import create_access_token, verify_access_token, TokenData
|
| 6 |
from .middleware import get_current_user, require_auth
|
| 7 |
from .router import router as auth_router
|
|
|
|
| 8 |
|
| 9 |
__all__ = [
|
| 10 |
"AuthSettings",
|
|
@@ -17,4 +18,7 @@ __all__ = [
|
|
| 17 |
"get_current_user",
|
| 18 |
"require_auth",
|
| 19 |
"auth_router",
|
|
|
|
|
|
|
|
|
|
| 20 |
]
|
|
|
|
| 5 |
from .tokens import create_access_token, verify_access_token, TokenData
|
| 6 |
from .middleware import get_current_user, require_auth
|
| 7 |
from .router import router as auth_router
|
| 8 |
+
from .user_context import get_effective_user_id, should_filter_by_user, ANONYMOUS_USER_ID
|
| 9 |
|
| 10 |
__all__ = [
|
| 11 |
"AuthSettings",
|
|
|
|
| 18 |
"get_current_user",
|
| 19 |
"require_auth",
|
| 20 |
"auth_router",
|
| 21 |
+
"get_effective_user_id",
|
| 22 |
+
"should_filter_by_user",
|
| 23 |
+
"ANONYMOUS_USER_ID",
|
| 24 |
]
|
src/flow/ui/auth/user_context.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Microsoft. All rights reserved.
|
| 2 |
+
"""User context utilities for access control."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
from typing import TYPE_CHECKING
|
| 7 |
+
|
| 8 |
+
from .config import get_auth_settings
|
| 9 |
+
|
| 10 |
+
if TYPE_CHECKING:
|
| 11 |
+
from .tokens import TokenData
|
| 12 |
+
|
| 13 |
+
# Constant for anonymous/unauthenticated users
|
| 14 |
+
ANONYMOUS_USER_ID = "anonymous"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def get_effective_user_id(user: TokenData | None) -> str:
|
| 18 |
+
"""Get the effective user ID for database queries.
|
| 19 |
+
|
| 20 |
+
Returns the user's subject (sub) from the token if authenticated,
|
| 21 |
+
or ANONYMOUS_USER_ID if auth is disabled or no user is provided.
|
| 22 |
+
|
| 23 |
+
Args:
|
| 24 |
+
user: TokenData from authentication, or None
|
| 25 |
+
|
| 26 |
+
Returns:
|
| 27 |
+
User ID string to use for database operations
|
| 28 |
+
"""
|
| 29 |
+
settings = get_auth_settings()
|
| 30 |
+
|
| 31 |
+
# If auth is disabled, everyone is "anonymous"
|
| 32 |
+
if not settings.enabled:
|
| 33 |
+
return ANONYMOUS_USER_ID
|
| 34 |
+
|
| 35 |
+
# If auth is enabled but no user (shouldn't happen in protected routes)
|
| 36 |
+
if user is None:
|
| 37 |
+
return ANONYMOUS_USER_ID
|
| 38 |
+
|
| 39 |
+
# Return the user's subject identifier
|
| 40 |
+
return user.sub
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def should_filter_by_user() -> bool:
|
| 44 |
+
"""Check if queries should filter by user_id.
|
| 45 |
+
|
| 46 |
+
Returns True only when auth is enabled.
|
| 47 |
+
"""
|
| 48 |
+
return get_auth_settings().enabled
|
src/flow/ui/database.py
CHANGED
|
@@ -2,9 +2,10 @@
|
|
| 2 |
"""Database setup with SQLModel and SQLite."""
|
| 3 |
|
| 4 |
import logging
|
|
|
|
| 5 |
from pathlib import Path
|
| 6 |
-
from typing import AsyncGenerator
|
| 7 |
|
|
|
|
| 8 |
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
| 9 |
from sqlmodel import SQLModel
|
| 10 |
|
|
@@ -22,12 +23,14 @@ async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit
|
|
| 22 |
|
| 23 |
|
| 24 |
async def init_db() -> None:
|
| 25 |
-
"""Initialize database tables."""
|
| 26 |
-
from flow.ui.models import AgentConfig,
|
| 27 |
|
| 28 |
try:
|
| 29 |
async with engine.begin() as conn:
|
| 30 |
await conn.run_sync(SQLModel.metadata.create_all)
|
|
|
|
|
|
|
| 31 |
except Exception as e:
|
| 32 |
if "already exists" in str(e).lower():
|
| 33 |
logger.debug("Tables already exist (race condition handled)")
|
|
@@ -35,6 +38,56 @@ async def init_db() -> None:
|
|
| 35 |
raise
|
| 36 |
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
| 39 |
"""Get database session."""
|
| 40 |
async with async_session() as session:
|
|
|
|
| 2 |
"""Database setup with SQLModel and SQLite."""
|
| 3 |
|
| 4 |
import logging
|
| 5 |
+
from collections.abc import AsyncGenerator
|
| 6 |
from pathlib import Path
|
|
|
|
| 7 |
|
| 8 |
+
from sqlalchemy import text
|
| 9 |
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
| 10 |
from sqlmodel import SQLModel
|
| 11 |
|
|
|
|
| 23 |
|
| 24 |
|
| 25 |
async def init_db() -> None:
|
| 26 |
+
"""Initialize database tables and run migrations."""
|
| 27 |
+
from flow.ui.models import AgentConfig, ExperimentRun, OptimizationJob, TaskModel, TestRun # noqa: F401
|
| 28 |
|
| 29 |
try:
|
| 30 |
async with engine.begin() as conn:
|
| 31 |
await conn.run_sync(SQLModel.metadata.create_all)
|
| 32 |
+
# Run migration for user_id columns (safe to run multiple times)
|
| 33 |
+
await _migrate_user_id_columns(conn)
|
| 34 |
except Exception as e:
|
| 35 |
if "already exists" in str(e).lower():
|
| 36 |
logger.debug("Tables already exist (race condition handled)")
|
|
|
|
| 38 |
raise
|
| 39 |
|
| 40 |
|
| 41 |
+
async def _migrate_user_id_columns(conn) -> None: # type: ignore[no-untyped-def]
|
| 42 |
+
"""Add user_id columns if they don't exist and backfill existing data.
|
| 43 |
+
|
| 44 |
+
This migration is idempotent - safe to run multiple times.
|
| 45 |
+
"""
|
| 46 |
+
from flow.ui.auth.user_context import ANONYMOUS_USER_ID
|
| 47 |
+
|
| 48 |
+
# Check and migrate agent_configs
|
| 49 |
+
try:
|
| 50 |
+
await conn.execute(text("SELECT user_id FROM agent_configs LIMIT 1"))
|
| 51 |
+
logger.debug("agent_configs.user_id column exists")
|
| 52 |
+
except Exception:
|
| 53 |
+
# Column doesn't exist, add it
|
| 54 |
+
logger.info("Adding user_id column to agent_configs")
|
| 55 |
+
await conn.execute(text("ALTER TABLE agent_configs ADD COLUMN user_id TEXT"))
|
| 56 |
+
await conn.execute(
|
| 57 |
+
text(f"UPDATE agent_configs SET user_id = '{ANONYMOUS_USER_ID}' WHERE user_id IS NULL")
|
| 58 |
+
)
|
| 59 |
+
await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_agent_configs_user_id ON agent_configs(user_id)"))
|
| 60 |
+
|
| 61 |
+
# Check and migrate tasks
|
| 62 |
+
try:
|
| 63 |
+
await conn.execute(text("SELECT user_id FROM tasks LIMIT 1"))
|
| 64 |
+
logger.debug("tasks.user_id column exists")
|
| 65 |
+
except Exception:
|
| 66 |
+
# Column doesn't exist, add it
|
| 67 |
+
logger.info("Adding user_id column to tasks")
|
| 68 |
+
await conn.execute(text("ALTER TABLE tasks ADD COLUMN user_id TEXT"))
|
| 69 |
+
# For tasks: only backfill non-suite tasks (suite tasks remain shared with user_id=NULL)
|
| 70 |
+
await conn.execute(
|
| 71 |
+
text(f"UPDATE tasks SET user_id = '{ANONYMOUS_USER_ID}' WHERE user_id IS NULL AND suite IS NULL")
|
| 72 |
+
)
|
| 73 |
+
await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_tasks_user_id ON tasks(user_id)"))
|
| 74 |
+
|
| 75 |
+
# Check and migrate optimization_jobs
|
| 76 |
+
try:
|
| 77 |
+
await conn.execute(text("SELECT user_id FROM optimization_jobs LIMIT 1"))
|
| 78 |
+
logger.debug("optimization_jobs.user_id column exists")
|
| 79 |
+
except Exception:
|
| 80 |
+
# Column doesn't exist, add it
|
| 81 |
+
logger.info("Adding user_id column to optimization_jobs")
|
| 82 |
+
await conn.execute(text("ALTER TABLE optimization_jobs ADD COLUMN user_id TEXT"))
|
| 83 |
+
await conn.execute(
|
| 84 |
+
text(f"UPDATE optimization_jobs SET user_id = '{ANONYMOUS_USER_ID}' WHERE user_id IS NULL")
|
| 85 |
+
)
|
| 86 |
+
await conn.execute(
|
| 87 |
+
text("CREATE INDEX IF NOT EXISTS ix_optimization_jobs_user_id ON optimization_jobs(user_id)")
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
| 92 |
"""Get database session."""
|
| 93 |
async with async_session() as session:
|
src/flow/ui/main.py
CHANGED
|
@@ -12,7 +12,7 @@ from fastapi.responses import FileResponse
|
|
| 12 |
from starlette.middleware.base import BaseHTTPMiddleware
|
| 13 |
|
| 14 |
from .database import init_db
|
| 15 |
-
from .api import configs_router, tasks_router, jobs_router, runs_router
|
| 16 |
from .auth import auth_router, AuthSettings, get_auth_settings, init_auth_settings
|
| 17 |
from .auth.middleware import AuthMiddleware
|
| 18 |
|
|
@@ -66,6 +66,7 @@ app.include_router(configs_router, prefix="/api")
|
|
| 66 |
app.include_router(tasks_router, prefix="/api")
|
| 67 |
app.include_router(jobs_router, prefix="/api")
|
| 68 |
app.include_router(runs_router, prefix="/api")
|
|
|
|
| 69 |
|
| 70 |
|
| 71 |
# Health check (public, not protected by auth)
|
|
|
|
| 12 |
from starlette.middleware.base import BaseHTTPMiddleware
|
| 13 |
|
| 14 |
from .database import init_db
|
| 15 |
+
from .api import configs_router, tasks_router, jobs_router, runs_router, tests_router
|
| 16 |
from .auth import auth_router, AuthSettings, get_auth_settings, init_auth_settings
|
| 17 |
from .auth.middleware import AuthMiddleware
|
| 18 |
|
|
|
|
| 66 |
app.include_router(tasks_router, prefix="/api")
|
| 67 |
app.include_router(jobs_router, prefix="/api")
|
| 68 |
app.include_router(runs_router, prefix="/api")
|
| 69 |
+
app.include_router(tests_router, prefix="/api")
|
| 70 |
|
| 71 |
|
| 72 |
# Health check (public, not protected by auth)
|
src/flow/ui/models/__init__.py
CHANGED
|
@@ -5,6 +5,7 @@ from .config import AgentConfig
|
|
| 5 |
from .task import TaskModel
|
| 6 |
from .job import OptimizationJob, JobStatus
|
| 7 |
from .run import ExperimentRun
|
|
|
|
| 8 |
|
| 9 |
__all__ = [
|
| 10 |
"AgentConfig",
|
|
@@ -12,4 +13,6 @@ __all__ = [
|
|
| 12 |
"OptimizationJob",
|
| 13 |
"JobStatus",
|
| 14 |
"ExperimentRun",
|
|
|
|
|
|
|
| 15 |
]
|
|
|
|
| 5 |
from .task import TaskModel
|
| 6 |
from .job import OptimizationJob, JobStatus
|
| 7 |
from .run import ExperimentRun
|
| 8 |
+
from .test_run import TestRun, TestRunStatus
|
| 9 |
|
| 10 |
__all__ = [
|
| 11 |
"AgentConfig",
|
|
|
|
| 13 |
"OptimizationJob",
|
| 14 |
"JobStatus",
|
| 15 |
"ExperimentRun",
|
| 16 |
+
"TestRun",
|
| 17 |
+
"TestRunStatus",
|
| 18 |
]
|
src/flow/ui/models/config.py
CHANGED
|
@@ -25,6 +25,9 @@ class AgentConfig(SQLModel, table=True):
|
|
| 25 |
# Link to the job that created this candidate (if auto-generated)
|
| 26 |
job_id: UUID | None = Field(default=None, index=True)
|
| 27 |
|
|
|
|
|
|
|
|
|
|
| 28 |
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
| 29 |
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
| 30 |
|
|
|
|
| 25 |
# Link to the job that created this candidate (if auto-generated)
|
| 26 |
job_id: UUID | None = Field(default=None, index=True)
|
| 27 |
|
| 28 |
+
# Owner of this config (None for legacy data, will be backfilled)
|
| 29 |
+
user_id: str | None = Field(default=None, index=True)
|
| 30 |
+
|
| 31 |
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
| 32 |
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
| 33 |
|
src/flow/ui/models/job.py
CHANGED
|
@@ -26,6 +26,9 @@ class OptimizationJob(SQLModel, table=True):
|
|
| 26 |
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
| 27 |
name: str = Field(default="")
|
| 28 |
|
|
|
|
|
|
|
|
|
|
| 29 |
status: JobStatus = Field(default=JobStatus.PENDING)
|
| 30 |
|
| 31 |
# Job configuration
|
|
|
|
| 26 |
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
| 27 |
name: str = Field(default="")
|
| 28 |
|
| 29 |
+
# Owner of this job
|
| 30 |
+
user_id: str | None = Field(default=None, index=True)
|
| 31 |
+
|
| 32 |
status: JobStatus = Field(default=JobStatus.PENDING)
|
| 33 |
|
| 34 |
# Job configuration
|
src/flow/ui/models/task.py
CHANGED
|
@@ -24,6 +24,9 @@ class TaskModel(SQLModel, table=True):
|
|
| 24 |
category: str = "default"
|
| 25 |
suite: str | None = None # If part of a built-in suite
|
| 26 |
|
|
|
|
|
|
|
|
|
|
| 27 |
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
| 28 |
|
| 29 |
@property
|
|
|
|
| 24 |
category: str = "default"
|
| 25 |
suite: str | None = None # If part of a built-in suite
|
| 26 |
|
| 27 |
+
# Owner of this task (None = shared/built-in suite task, visible to all)
|
| 28 |
+
user_id: str | None = Field(default=None, index=True)
|
| 29 |
+
|
| 30 |
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
| 31 |
|
| 32 |
@property
|
src/flow/ui/models/test_run.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Microsoft. All rights reserved.
|
| 2 |
+
"""Test run model for interactive agent testing."""
|
| 3 |
+
|
| 4 |
+
from datetime import datetime, timezone
|
| 5 |
+
from enum import Enum
|
| 6 |
+
from typing import Any
|
| 7 |
+
from uuid import UUID, uuid4
|
| 8 |
+
|
| 9 |
+
from sqlmodel import Column, Field, JSON, SQLModel
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class TestRunStatus(str, Enum):
|
| 13 |
+
"""Status of a test run."""
|
| 14 |
+
|
| 15 |
+
PENDING = "pending"
|
| 16 |
+
RUNNING = "running"
|
| 17 |
+
COMPLETED = "completed"
|
| 18 |
+
FAILED = "failed"
|
| 19 |
+
CANCELLED = "cancelled"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class TestRun(SQLModel, table=True):
|
| 23 |
+
"""Individual test run for interactive agent testing."""
|
| 24 |
+
|
| 25 |
+
__tablename__ = "test_runs" # type: ignore[assignment]
|
| 26 |
+
|
| 27 |
+
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
| 28 |
+
agent_id: UUID = Field(foreign_key="agent_configs.id", index=True)
|
| 29 |
+
|
| 30 |
+
# Input
|
| 31 |
+
prompt: str
|
| 32 |
+
task_id: UUID | None = Field(default=None, foreign_key="tasks.id")
|
| 33 |
+
|
| 34 |
+
# Status
|
| 35 |
+
status: TestRunStatus = Field(default=TestRunStatus.PENDING)
|
| 36 |
+
|
| 37 |
+
# Results
|
| 38 |
+
output: str = ""
|
| 39 |
+
trace_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
|
| 40 |
+
files_created: list[str] = Field(default_factory=list, sa_column=Column(JSON))
|
| 41 |
+
|
| 42 |
+
# Metrics
|
| 43 |
+
tokens_total: int = 0
|
| 44 |
+
tokens_input: int = 0
|
| 45 |
+
tokens_output: int = 0
|
| 46 |
+
duration_seconds: float = 0.0
|
| 47 |
+
|
| 48 |
+
# Evaluation (if task linked)
|
| 49 |
+
score: float | None = None
|
| 50 |
+
passed: bool | None = None
|
| 51 |
+
reasoning: str = ""
|
| 52 |
+
|
| 53 |
+
# Error info
|
| 54 |
+
error: str | None = None
|
| 55 |
+
|
| 56 |
+
# Timestamps
|
| 57 |
+
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
| 58 |
+
started_at: datetime | None = None
|
| 59 |
+
completed_at: datetime | None = None
|
| 60 |
+
|
| 61 |
+
# Multi-tenancy
|
| 62 |
+
user_id: str | None = Field(default=None, index=True)
|
src/flow/ui/schemas/__init__.py
CHANGED
|
@@ -5,6 +5,7 @@ from .config import AgentCreate, AgentUpdate, AgentResponse
|
|
| 5 |
from .task import TaskCreate, TaskResponse, CriterionSchema
|
| 6 |
from .job import JobCreate, JobResponse, JobProgress
|
| 7 |
from .run import RunResponse, RunDetailResponse, CriterionResultSchema
|
|
|
|
| 8 |
|
| 9 |
__all__ = [
|
| 10 |
"AgentCreate",
|
|
@@ -19,4 +20,8 @@ __all__ = [
|
|
| 19 |
"RunResponse",
|
| 20 |
"RunDetailResponse",
|
| 21 |
"CriterionResultSchema",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
]
|
|
|
|
| 5 |
from .task import TaskCreate, TaskResponse, CriterionSchema
|
| 6 |
from .job import JobCreate, JobResponse, JobProgress
|
| 7 |
from .run import RunResponse, RunDetailResponse, CriterionResultSchema
|
| 8 |
+
from .test import TestRunCreate, TestRunResponse, TestRunDetailResponse, TestProgress
|
| 9 |
|
| 10 |
__all__ = [
|
| 11 |
"AgentCreate",
|
|
|
|
| 20 |
"RunResponse",
|
| 21 |
"RunDetailResponse",
|
| 22 |
"CriterionResultSchema",
|
| 23 |
+
"TestRunCreate",
|
| 24 |
+
"TestRunResponse",
|
| 25 |
+
"TestRunDetailResponse",
|
| 26 |
+
"TestProgress",
|
| 27 |
]
|
src/flow/ui/schemas/config.py
CHANGED
|
@@ -63,6 +63,7 @@ class AgentResponse(BaseModel):
|
|
| 63 |
config: dict[str, Any]
|
| 64 |
is_auto_generated: bool = False
|
| 65 |
job_id: str | None = None
|
|
|
|
| 66 |
created_at: datetime
|
| 67 |
updated_at: datetime
|
| 68 |
|
|
|
|
| 63 |
config: dict[str, Any]
|
| 64 |
is_auto_generated: bool = False
|
| 65 |
job_id: str | None = None
|
| 66 |
+
user_id: str | None = None
|
| 67 |
created_at: datetime
|
| 68 |
updated_at: datetime
|
| 69 |
|
src/flow/ui/schemas/job.py
CHANGED
|
@@ -36,6 +36,7 @@ class JobResponse(BaseModel):
|
|
| 36 |
error: str | None
|
| 37 |
total_experiments: int
|
| 38 |
completed_experiments: int
|
|
|
|
| 39 |
created_at: datetime
|
| 40 |
started_at: datetime | None
|
| 41 |
completed_at: datetime | None
|
|
|
|
| 36 |
error: str | None
|
| 37 |
total_experiments: int
|
| 38 |
completed_experiments: int
|
| 39 |
+
user_id: str | None = None
|
| 40 |
created_at: datetime
|
| 41 |
started_at: datetime | None
|
| 42 |
completed_at: datetime | None
|
src/flow/ui/schemas/task.py
CHANGED
|
@@ -40,6 +40,7 @@ class TaskResponse(BaseModel):
|
|
| 40 |
criteria: list[CriterionSchema]
|
| 41 |
category: str
|
| 42 |
suite: str | None
|
|
|
|
| 43 |
created_at: datetime
|
| 44 |
|
| 45 |
@field_validator("id", mode="before")
|
|
|
|
| 40 |
criteria: list[CriterionSchema]
|
| 41 |
category: str
|
| 42 |
suite: str | None
|
| 43 |
+
user_id: str | None = None
|
| 44 |
created_at: datetime
|
| 45 |
|
| 46 |
@field_validator("id", mode="before")
|
src/flow/ui/schemas/test.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Microsoft. All rights reserved.
|
| 2 |
+
"""Test run schemas."""
|
| 3 |
+
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from typing import Any, Literal
|
| 6 |
+
from uuid import UUID
|
| 7 |
+
|
| 8 |
+
from pydantic import BaseModel, ConfigDict, field_validator
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class TestRunCreate(BaseModel):
|
| 12 |
+
"""Request schema for creating a test run."""
|
| 13 |
+
|
| 14 |
+
agent_id: str
|
| 15 |
+
prompt: str
|
| 16 |
+
task_id: str | None = None
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class TestRunResponse(BaseModel):
|
| 20 |
+
"""Response schema for a test run (summary)."""
|
| 21 |
+
|
| 22 |
+
model_config = ConfigDict(from_attributes=True)
|
| 23 |
+
|
| 24 |
+
id: str
|
| 25 |
+
agent_id: str
|
| 26 |
+
prompt: str
|
| 27 |
+
task_id: str | None
|
| 28 |
+
status: str
|
| 29 |
+
tokens_total: int
|
| 30 |
+
duration_seconds: float
|
| 31 |
+
score: float | None
|
| 32 |
+
passed: bool | None
|
| 33 |
+
error: str | None
|
| 34 |
+
created_at: datetime
|
| 35 |
+
started_at: datetime | None
|
| 36 |
+
completed_at: datetime | None
|
| 37 |
+
|
| 38 |
+
@field_validator("id", "agent_id", "task_id", mode="before")
|
| 39 |
+
@classmethod
|
| 40 |
+
def convert_uuid(cls, v: UUID | str | None) -> str | None:
|
| 41 |
+
"""Convert UUID to string."""
|
| 42 |
+
if isinstance(v, UUID):
|
| 43 |
+
return str(v)
|
| 44 |
+
return v
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class TestRunDetailResponse(BaseModel):
|
| 48 |
+
"""Response schema for test run details."""
|
| 49 |
+
|
| 50 |
+
model_config = ConfigDict(from_attributes=True)
|
| 51 |
+
|
| 52 |
+
id: str
|
| 53 |
+
agent_id: str
|
| 54 |
+
prompt: str
|
| 55 |
+
task_id: str | None
|
| 56 |
+
status: str
|
| 57 |
+
|
| 58 |
+
# Metrics
|
| 59 |
+
tokens_total: int
|
| 60 |
+
tokens_input: int
|
| 61 |
+
tokens_output: int
|
| 62 |
+
duration_seconds: float
|
| 63 |
+
|
| 64 |
+
# Evaluation (if task linked)
|
| 65 |
+
score: float | None
|
| 66 |
+
passed: bool | None
|
| 67 |
+
reasoning: str
|
| 68 |
+
|
| 69 |
+
# Output
|
| 70 |
+
output: str
|
| 71 |
+
files_created: list[str]
|
| 72 |
+
trace: dict[str, Any]
|
| 73 |
+
|
| 74 |
+
# Error
|
| 75 |
+
error: str | None
|
| 76 |
+
|
| 77 |
+
# Timestamps
|
| 78 |
+
created_at: datetime
|
| 79 |
+
started_at: datetime | None
|
| 80 |
+
completed_at: datetime | None
|
| 81 |
+
|
| 82 |
+
@field_validator("id", "agent_id", "task_id", mode="before")
|
| 83 |
+
@classmethod
|
| 84 |
+
def convert_uuid(cls, v: UUID | str | None) -> str | None:
|
| 85 |
+
"""Convert UUID to string."""
|
| 86 |
+
if isinstance(v, UUID):
|
| 87 |
+
return str(v)
|
| 88 |
+
return v
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class TestProgress(BaseModel):
|
| 92 |
+
"""SSE event for test streaming."""
|
| 93 |
+
|
| 94 |
+
event: Literal["started", "execution", "span", "complete", "error"]
|
| 95 |
+
test_run_id: str
|
| 96 |
+
message: str = ""
|
| 97 |
+
|
| 98 |
+
# For execution events
|
| 99 |
+
execution_event: str | None = None # text_delta, tool_call_start, etc.
|
| 100 |
+
content: str | None = None
|
| 101 |
+
tool_name: str | None = None
|
| 102 |
+
|
| 103 |
+
# For span events
|
| 104 |
+
span: dict[str, Any] | None = None
|
| 105 |
+
|
| 106 |
+
# For complete event
|
| 107 |
+
result: TestRunResponse | None = None
|
src/flow/ui/services/test_service.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Microsoft. All rights reserved.
|
| 2 |
+
"""Service for running interactive agent tests with real-time trace streaming."""
|
| 3 |
+
|
| 4 |
+
import asyncio
|
| 5 |
+
import logging
|
| 6 |
+
import os
|
| 7 |
+
import tempfile
|
| 8 |
+
import time
|
| 9 |
+
from datetime import datetime, timezone
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Any, AsyncGenerator
|
| 12 |
+
from uuid import UUID
|
| 13 |
+
|
| 14 |
+
from opentelemetry import trace
|
| 15 |
+
from opentelemetry.sdk.trace import TracerProvider
|
| 16 |
+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor, SpanExporter, SpanExportResult
|
| 17 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 18 |
+
from sqlmodel import select
|
| 19 |
+
|
| 20 |
+
from flow.experiments.models import CompactionConfig
|
| 21 |
+
from flow.experiments.metrics import extract_metrics
|
| 22 |
+
from flow.experiments.trace_collector import FlowTraceCollector
|
| 23 |
+
from flow.harness.base import EventType
|
| 24 |
+
from flow.harness.maf import MAFHarness
|
| 25 |
+
from flow.harness.maf.agent import create_agent
|
| 26 |
+
|
| 27 |
+
from ..database import async_session
|
| 28 |
+
from ..models.config import AgentConfig
|
| 29 |
+
from ..models.test_run import TestRun, TestRunStatus
|
| 30 |
+
from ..schemas.test import TestProgress
|
| 31 |
+
|
| 32 |
+
logger = logging.getLogger(__name__)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class StreamingTraceCollector(FlowTraceCollector):
|
| 36 |
+
"""Trace collector that pushes spans to a queue for real-time streaming."""
|
| 37 |
+
|
| 38 |
+
def __init__(self, span_queue: asyncio.Queue[dict[str, Any]]) -> None:
|
| 39 |
+
"""Initialize with a queue for span streaming.
|
| 40 |
+
|
| 41 |
+
Args:
|
| 42 |
+
span_queue: Queue to push spans to for SSE streaming
|
| 43 |
+
"""
|
| 44 |
+
super().__init__()
|
| 45 |
+
self._queue = span_queue
|
| 46 |
+
|
| 47 |
+
def export(self, spans: Any) -> SpanExportResult:
|
| 48 |
+
"""Export spans and push to queue for streaming."""
|
| 49 |
+
# Get count before export
|
| 50 |
+
count_before = len(self.spans)
|
| 51 |
+
|
| 52 |
+
# Call parent export which populates self.spans
|
| 53 |
+
result = super().export(spans)
|
| 54 |
+
|
| 55 |
+
# Push newly added spans to queue
|
| 56 |
+
for span in self.spans[count_before:]:
|
| 57 |
+
try:
|
| 58 |
+
self._queue.put_nowait(span)
|
| 59 |
+
except asyncio.QueueFull:
|
| 60 |
+
logger.debug("Span queue full, dropping span")
|
| 61 |
+
|
| 62 |
+
return result
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class TestService:
|
| 66 |
+
"""Service for running interactive agent tests."""
|
| 67 |
+
|
| 68 |
+
async def run_test(self, test_run_id: str | UUID) -> AsyncGenerator[TestProgress, None]:
|
| 69 |
+
"""Run an agent test and yield real-time progress with spans.
|
| 70 |
+
|
| 71 |
+
Args:
|
| 72 |
+
test_run_id: ID of the test run to execute
|
| 73 |
+
|
| 74 |
+
Yields:
|
| 75 |
+
TestProgress events for SSE streaming
|
| 76 |
+
"""
|
| 77 |
+
if isinstance(test_run_id, str):
|
| 78 |
+
test_run_id = UUID(test_run_id)
|
| 79 |
+
|
| 80 |
+
async with async_session() as session:
|
| 81 |
+
# Load test run
|
| 82 |
+
result = await session.execute(
|
| 83 |
+
select(TestRun).where(TestRun.id == test_run_id)
|
| 84 |
+
)
|
| 85 |
+
test_run = result.scalar_one_or_none()
|
| 86 |
+
if not test_run:
|
| 87 |
+
yield TestProgress(
|
| 88 |
+
event="error",
|
| 89 |
+
test_run_id=str(test_run_id),
|
| 90 |
+
message="Test run not found",
|
| 91 |
+
)
|
| 92 |
+
return
|
| 93 |
+
|
| 94 |
+
# Load agent config
|
| 95 |
+
agent_result = await session.execute(
|
| 96 |
+
select(AgentConfig).where(AgentConfig.id == test_run.agent_id)
|
| 97 |
+
)
|
| 98 |
+
agent_config = agent_result.scalar_one_or_none()
|
| 99 |
+
if not agent_config:
|
| 100 |
+
yield TestProgress(
|
| 101 |
+
event="error",
|
| 102 |
+
test_run_id=str(test_run_id),
|
| 103 |
+
message="Agent config not found",
|
| 104 |
+
)
|
| 105 |
+
return
|
| 106 |
+
|
| 107 |
+
# Update status to running
|
| 108 |
+
test_run.status = TestRunStatus.RUNNING
|
| 109 |
+
test_run.started_at = datetime.now(timezone.utc)
|
| 110 |
+
await session.commit()
|
| 111 |
+
|
| 112 |
+
yield TestProgress(
|
| 113 |
+
event="started",
|
| 114 |
+
test_run_id=str(test_run_id),
|
| 115 |
+
message="Starting test execution...",
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
# Set up span queue for streaming
|
| 119 |
+
span_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=1000)
|
| 120 |
+
|
| 121 |
+
# Create workspace
|
| 122 |
+
workspace = Path(tempfile.mkdtemp(prefix="flow_test_"))
|
| 123 |
+
|
| 124 |
+
try:
|
| 125 |
+
# Set up streaming trace collection
|
| 126 |
+
collector = StreamingTraceCollector(span_queue)
|
| 127 |
+
processor: SimpleSpanProcessor | None = None
|
| 128 |
+
|
| 129 |
+
provider = trace.get_tracer_provider()
|
| 130 |
+
if isinstance(provider, TracerProvider):
|
| 131 |
+
processor = SimpleSpanProcessor(collector)
|
| 132 |
+
provider.add_span_processor(processor)
|
| 133 |
+
|
| 134 |
+
# Create agent from config
|
| 135 |
+
harness = self._create_harness_from_config(agent_config, workspace)
|
| 136 |
+
|
| 137 |
+
# Execute agent with streaming
|
| 138 |
+
start_time = time.time()
|
| 139 |
+
output_chunks: list[str] = []
|
| 140 |
+
error: str | None = None
|
| 141 |
+
|
| 142 |
+
original_cwd = os.getcwd()
|
| 143 |
+
os.chdir(workspace)
|
| 144 |
+
|
| 145 |
+
try:
|
| 146 |
+
async for event in harness.run_stream(test_run.prompt):
|
| 147 |
+
# Yield execution events
|
| 148 |
+
if event.type == EventType.TEXT_DELTA:
|
| 149 |
+
output_chunks.append(event.content)
|
| 150 |
+
yield TestProgress(
|
| 151 |
+
event="execution",
|
| 152 |
+
test_run_id=str(test_run_id),
|
| 153 |
+
execution_event="text_delta",
|
| 154 |
+
content=event.content,
|
| 155 |
+
)
|
| 156 |
+
elif event.type == EventType.TOOL_CALL_START:
|
| 157 |
+
yield TestProgress(
|
| 158 |
+
event="execution",
|
| 159 |
+
test_run_id=str(test_run_id),
|
| 160 |
+
execution_event="tool_call_start",
|
| 161 |
+
tool_name=event.tool_name,
|
| 162 |
+
)
|
| 163 |
+
elif event.type == EventType.TOOL_RESULT:
|
| 164 |
+
yield TestProgress(
|
| 165 |
+
event="execution",
|
| 166 |
+
test_run_id=str(test_run_id),
|
| 167 |
+
execution_event="tool_result",
|
| 168 |
+
content=event.content[:500] if event.content else "", # Truncate long results
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
# Drain span queue and yield span events
|
| 172 |
+
while not span_queue.empty():
|
| 173 |
+
try:
|
| 174 |
+
span_data = span_queue.get_nowait()
|
| 175 |
+
yield TestProgress(
|
| 176 |
+
event="span",
|
| 177 |
+
test_run_id=str(test_run_id),
|
| 178 |
+
span=span_data,
|
| 179 |
+
)
|
| 180 |
+
except asyncio.QueueEmpty:
|
| 181 |
+
break
|
| 182 |
+
|
| 183 |
+
except Exception as e:
|
| 184 |
+
error = str(e)
|
| 185 |
+
logger.error(f"Test execution failed: {e}")
|
| 186 |
+
finally:
|
| 187 |
+
os.chdir(original_cwd)
|
| 188 |
+
|
| 189 |
+
end_time = time.time()
|
| 190 |
+
duration_seconds = end_time - start_time
|
| 191 |
+
|
| 192 |
+
# Force flush and get all traces
|
| 193 |
+
if processor:
|
| 194 |
+
try:
|
| 195 |
+
processor.force_flush()
|
| 196 |
+
except Exception as e:
|
| 197 |
+
logger.debug(f"Error flushing processor: {e}")
|
| 198 |
+
|
| 199 |
+
# Drain remaining spans
|
| 200 |
+
while not span_queue.empty():
|
| 201 |
+
try:
|
| 202 |
+
span_data = span_queue.get_nowait()
|
| 203 |
+
yield TestProgress(
|
| 204 |
+
event="span",
|
| 205 |
+
test_run_id=str(test_run_id),
|
| 206 |
+
span=span_data,
|
| 207 |
+
)
|
| 208 |
+
except asyncio.QueueEmpty:
|
| 209 |
+
break
|
| 210 |
+
|
| 211 |
+
# Get final trace data
|
| 212 |
+
trace_data = collector.get_traces()
|
| 213 |
+
|
| 214 |
+
# Clean up processor
|
| 215 |
+
if processor:
|
| 216 |
+
try:
|
| 217 |
+
processor.shutdown()
|
| 218 |
+
except Exception as e:
|
| 219 |
+
logger.debug(f"Error shutting down processor: {e}")
|
| 220 |
+
|
| 221 |
+
# Extract metrics from trace
|
| 222 |
+
metrics = extract_metrics(trace_data)
|
| 223 |
+
|
| 224 |
+
# Update test run with results
|
| 225 |
+
test_run.output = "".join(output_chunks)
|
| 226 |
+
test_run.trace_json = {"spans": trace_data}
|
| 227 |
+
test_run.tokens_total = metrics.total_tokens
|
| 228 |
+
test_run.tokens_input = metrics.input_tokens
|
| 229 |
+
test_run.tokens_output = metrics.output_tokens
|
| 230 |
+
test_run.duration_seconds = duration_seconds
|
| 231 |
+
|
| 232 |
+
if error:
|
| 233 |
+
test_run.status = TestRunStatus.FAILED
|
| 234 |
+
test_run.error = error
|
| 235 |
+
else:
|
| 236 |
+
test_run.status = TestRunStatus.COMPLETED
|
| 237 |
+
|
| 238 |
+
test_run.completed_at = datetime.now(timezone.utc)
|
| 239 |
+
await session.commit()
|
| 240 |
+
|
| 241 |
+
# Yield completion
|
| 242 |
+
from ..schemas.test import TestRunResponse
|
| 243 |
+
yield TestProgress(
|
| 244 |
+
event="complete",
|
| 245 |
+
test_run_id=str(test_run_id),
|
| 246 |
+
message="Test completed",
|
| 247 |
+
result=TestRunResponse.model_validate(test_run),
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
except Exception as e:
|
| 251 |
+
test_run.status = TestRunStatus.FAILED
|
| 252 |
+
test_run.error = str(e)
|
| 253 |
+
test_run.completed_at = datetime.now(timezone.utc)
|
| 254 |
+
await session.commit()
|
| 255 |
+
|
| 256 |
+
yield TestProgress(
|
| 257 |
+
event="error",
|
| 258 |
+
test_run_id=str(test_run_id),
|
| 259 |
+
message=f"Test failed: {e}",
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
finally:
|
| 263 |
+
# Clean up workspace
|
| 264 |
+
try:
|
| 265 |
+
import shutil
|
| 266 |
+
shutil.rmtree(workspace)
|
| 267 |
+
except Exception as e:
|
| 268 |
+
logger.debug(f"Failed to clean up workspace: {e}")
|
| 269 |
+
|
| 270 |
+
def _create_harness_from_config(
|
| 271 |
+
self, agent_config: AgentConfig, workspace: Path
|
| 272 |
+
) -> MAFHarness:
|
| 273 |
+
"""Create a MAFHarness from an agent config.
|
| 274 |
+
|
| 275 |
+
Args:
|
| 276 |
+
agent_config: The agent configuration from database
|
| 277 |
+
workspace: Workspace directory for agent execution
|
| 278 |
+
|
| 279 |
+
Returns:
|
| 280 |
+
Configured MAFHarness
|
| 281 |
+
"""
|
| 282 |
+
cfg = agent_config.config_json
|
| 283 |
+
|
| 284 |
+
# Extract compaction config
|
| 285 |
+
compaction_data = cfg.get("compaction", {})
|
| 286 |
+
enable_compaction = compaction_data.get("strategy", "head_tail") != "none"
|
| 287 |
+
params = compaction_data.get("params", {})
|
| 288 |
+
compaction_head_size = params.get("head_size", 10)
|
| 289 |
+
compaction_tail_size = params.get("tail_size", 40)
|
| 290 |
+
|
| 291 |
+
# Get tools configuration
|
| 292 |
+
tools = cfg.get("tools", "standard")
|
| 293 |
+
|
| 294 |
+
# Get instructions
|
| 295 |
+
instructions = cfg.get("instructions")
|
| 296 |
+
|
| 297 |
+
# Create agent
|
| 298 |
+
agent = create_agent(
|
| 299 |
+
name=agent_config.name,
|
| 300 |
+
instructions=instructions,
|
| 301 |
+
tools=tools,
|
| 302 |
+
workspace=workspace,
|
| 303 |
+
enable_compaction=enable_compaction,
|
| 304 |
+
compaction_head_size=compaction_head_size,
|
| 305 |
+
compaction_tail_size=compaction_tail_size,
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
return MAFHarness(agent)
|
src/flow/ui/tests/test_test_service.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Microsoft. All rights reserved.
|
| 2 |
+
"""Unit tests for the TestService and StreamingTraceCollector."""
|
| 3 |
+
|
| 4 |
+
import asyncio
|
| 5 |
+
from typing import Any
|
| 6 |
+
from unittest.mock import MagicMock
|
| 7 |
+
|
| 8 |
+
import pytest
|
| 9 |
+
from opentelemetry.sdk.trace.export import SpanExportResult
|
| 10 |
+
|
| 11 |
+
from flow.ui.services.test_service import StreamingTraceCollector
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def create_mock_span(name: str, span_id: int = 12345, trace_id: int = 67890) -> MagicMock:
|
| 15 |
+
"""Create a mock OpenTelemetry span with proper structure."""
|
| 16 |
+
mock_span = MagicMock()
|
| 17 |
+
# The FlowTraceCollector accesses span.context, not span.get_span_context()
|
| 18 |
+
mock_span.context = MagicMock()
|
| 19 |
+
mock_span.context.span_id = span_id
|
| 20 |
+
mock_span.context.trace_id = trace_id
|
| 21 |
+
mock_span.name = name
|
| 22 |
+
mock_span.start_time = 1000000000000000000 # nanoseconds
|
| 23 |
+
mock_span.end_time = 2000000000000000000 # nanoseconds
|
| 24 |
+
mock_span.status = MagicMock()
|
| 25 |
+
mock_span.status.status_code = MagicMock()
|
| 26 |
+
mock_span.status.status_code.name = "OK"
|
| 27 |
+
mock_span.parent = None
|
| 28 |
+
mock_span.attributes = {"key": "value"}
|
| 29 |
+
mock_span.events = []
|
| 30 |
+
return mock_span
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class TestStreamingTraceCollector:
|
| 34 |
+
"""Tests for the StreamingTraceCollector class."""
|
| 35 |
+
|
| 36 |
+
@pytest.mark.asyncio
|
| 37 |
+
async def test_span_queue_receives_spans(self):
|
| 38 |
+
"""Test that spans are pushed to the queue when exported."""
|
| 39 |
+
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=100)
|
| 40 |
+
collector = StreamingTraceCollector(queue)
|
| 41 |
+
|
| 42 |
+
mock_span = create_mock_span("test_span")
|
| 43 |
+
|
| 44 |
+
# Export the span
|
| 45 |
+
result = collector.export([mock_span])
|
| 46 |
+
assert result == SpanExportResult.SUCCESS
|
| 47 |
+
|
| 48 |
+
# Verify span was added to collector
|
| 49 |
+
assert len(collector.spans) == 1
|
| 50 |
+
|
| 51 |
+
# Verify span was pushed to queue
|
| 52 |
+
assert not queue.empty()
|
| 53 |
+
span_data = await queue.get()
|
| 54 |
+
# StreamingTraceCollector pushes the span dict from parent class
|
| 55 |
+
assert "data" in span_data
|
| 56 |
+
assert span_data["data"]["operation_name"] == "test_span"
|
| 57 |
+
|
| 58 |
+
@pytest.mark.asyncio
|
| 59 |
+
async def test_multiple_spans_exported(self):
|
| 60 |
+
"""Test exporting multiple spans in sequence."""
|
| 61 |
+
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=100)
|
| 62 |
+
collector = StreamingTraceCollector(queue)
|
| 63 |
+
|
| 64 |
+
# Create and export multiple mock spans
|
| 65 |
+
for i in range(5):
|
| 66 |
+
mock_span = create_mock_span(f"span_{i}", span_id=i, trace_id=1000)
|
| 67 |
+
collector.export([mock_span])
|
| 68 |
+
|
| 69 |
+
# Verify all spans were collected
|
| 70 |
+
assert len(collector.spans) == 5
|
| 71 |
+
|
| 72 |
+
# Verify all spans were pushed to queue
|
| 73 |
+
received_spans = []
|
| 74 |
+
while not queue.empty():
|
| 75 |
+
received_spans.append(await queue.get())
|
| 76 |
+
assert len(received_spans) == 5
|
| 77 |
+
|
| 78 |
+
@pytest.mark.asyncio
|
| 79 |
+
async def test_queue_full_does_not_block(self):
|
| 80 |
+
"""Test that a full queue doesn't block the export."""
|
| 81 |
+
# Create a very small queue
|
| 82 |
+
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=1)
|
| 83 |
+
collector = StreamingTraceCollector(queue)
|
| 84 |
+
|
| 85 |
+
# Fill the queue first
|
| 86 |
+
await queue.put({"dummy": "span"})
|
| 87 |
+
assert queue.full()
|
| 88 |
+
|
| 89 |
+
mock_span = create_mock_span("blocked_span")
|
| 90 |
+
|
| 91 |
+
# This should complete without blocking
|
| 92 |
+
result = collector.export([mock_span])
|
| 93 |
+
assert result == SpanExportResult.SUCCESS
|
| 94 |
+
|
| 95 |
+
# Span should still be in collector even if not in queue
|
| 96 |
+
assert len(collector.spans) == 1
|
| 97 |
+
|
| 98 |
+
@pytest.mark.asyncio
|
| 99 |
+
async def test_get_traces_returns_all_spans(self):
|
| 100 |
+
"""Test that get_traces returns all collected spans."""
|
| 101 |
+
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=100)
|
| 102 |
+
collector = StreamingTraceCollector(queue)
|
| 103 |
+
|
| 104 |
+
# Export some spans
|
| 105 |
+
for i in range(3):
|
| 106 |
+
mock_span = create_mock_span(f"span_{i}", span_id=i, trace_id=1000)
|
| 107 |
+
collector.export([mock_span])
|
| 108 |
+
|
| 109 |
+
# Get all traces - note this clears the spans
|
| 110 |
+
traces = collector.get_traces()
|
| 111 |
+
assert len(traces) == 3
|
| 112 |
+
|
| 113 |
+
# Verify spans are cleared after get_traces
|
| 114 |
+
traces_after = collector.get_traces()
|
| 115 |
+
assert len(traces_after) == 0
|
| 116 |
+
|
| 117 |
+
@pytest.mark.asyncio
|
| 118 |
+
async def test_inherits_from_flow_trace_collector(self):
|
| 119 |
+
"""Test that StreamingTraceCollector properly inherits from FlowTraceCollector."""
|
| 120 |
+
from flow.experiments.trace_collector import FlowTraceCollector
|
| 121 |
+
|
| 122 |
+
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=100)
|
| 123 |
+
collector = StreamingTraceCollector(queue)
|
| 124 |
+
|
| 125 |
+
assert isinstance(collector, FlowTraceCollector)
|
| 126 |
+
assert hasattr(collector, "get_traces")
|
| 127 |
+
assert hasattr(collector, "clear")
|
| 128 |
+
assert hasattr(collector, "force_flush")
|
| 129 |
+
assert hasattr(collector, "shutdown")
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
class TestTestServiceHelpers:
|
| 133 |
+
"""Tests for TestService helper methods."""
|
| 134 |
+
|
| 135 |
+
def test_parse_uuid_valid(self):
|
| 136 |
+
"""Test parsing a valid UUID string."""
|
| 137 |
+
from flow.ui.api.tests import parse_uuid
|
| 138 |
+
from uuid import UUID
|
| 139 |
+
|
| 140 |
+
result = parse_uuid("12345678-1234-1234-1234-123456789abc")
|
| 141 |
+
assert isinstance(result, UUID)
|
| 142 |
+
assert str(result) == "12345678-1234-1234-1234-123456789abc"
|
| 143 |
+
|
| 144 |
+
def test_parse_uuid_invalid(self):
|
| 145 |
+
"""Test parsing an invalid UUID string raises HTTPException."""
|
| 146 |
+
from flow.ui.api.tests import parse_uuid
|
| 147 |
+
from fastapi import HTTPException
|
| 148 |
+
|
| 149 |
+
with pytest.raises(HTTPException) as exc_info:
|
| 150 |
+
parse_uuid("not-a-uuid")
|
| 151 |
+
assert exc_info.value.status_code == 400
|
| 152 |
+
assert "Invalid UUID" in str(exc_info.value.detail)
|
src/flow/ui/tests/test_tests_api.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Microsoft. All rights reserved.
|
| 2 |
+
"""Unit tests for the tests API routes."""
|
| 3 |
+
|
| 4 |
+
from typing import AsyncGenerator
|
| 5 |
+
|
| 6 |
+
import httpx
|
| 7 |
+
import pytest
|
| 8 |
+
|
| 9 |
+
from flow.ui.main import app
|
| 10 |
+
from flow.ui.database import init_db
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@pytest.fixture
|
| 14 |
+
async def client() -> AsyncGenerator[httpx.AsyncClient, None]:
|
| 15 |
+
"""Create an ASGI test client."""
|
| 16 |
+
await init_db()
|
| 17 |
+
|
| 18 |
+
async with httpx.AsyncClient(
|
| 19 |
+
transport=httpx.ASGITransport(app=app),
|
| 20 |
+
base_url="http://test",
|
| 21 |
+
timeout=30.0,
|
| 22 |
+
) as client:
|
| 23 |
+
yield client
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class TestTestsAPI:
|
| 27 |
+
"""Tests for the /api/tests endpoints."""
|
| 28 |
+
|
| 29 |
+
@pytest.mark.asyncio
|
| 30 |
+
async def test_create_test_run_requires_valid_agent(self, client: httpx.AsyncClient):
|
| 31 |
+
"""Test that creating a test run requires a valid agent ID."""
|
| 32 |
+
# Try to create test with non-existent agent
|
| 33 |
+
resp = await client.post(
|
| 34 |
+
"/api/tests",
|
| 35 |
+
json={
|
| 36 |
+
"agent_id": "00000000-0000-0000-0000-000000000000",
|
| 37 |
+
"prompt": "test prompt",
|
| 38 |
+
},
|
| 39 |
+
)
|
| 40 |
+
assert resp.status_code == 400
|
| 41 |
+
assert "Agent not found" in resp.json()["detail"]
|
| 42 |
+
|
| 43 |
+
@pytest.mark.asyncio
|
| 44 |
+
async def test_create_test_run_invalid_uuid(self, client: httpx.AsyncClient):
|
| 45 |
+
"""Test that invalid UUID returns 400."""
|
| 46 |
+
resp = await client.post(
|
| 47 |
+
"/api/tests",
|
| 48 |
+
json={
|
| 49 |
+
"agent_id": "not-a-uuid",
|
| 50 |
+
"prompt": "test prompt",
|
| 51 |
+
},
|
| 52 |
+
)
|
| 53 |
+
assert resp.status_code == 400
|
| 54 |
+
assert "Invalid UUID" in resp.json()["detail"]
|
| 55 |
+
|
| 56 |
+
@pytest.mark.asyncio
|
| 57 |
+
async def test_list_tests_empty(self, client: httpx.AsyncClient):
|
| 58 |
+
"""Test listing tests when none exist."""
|
| 59 |
+
resp = await client.get("/api/tests")
|
| 60 |
+
assert resp.status_code == 200
|
| 61 |
+
assert isinstance(resp.json(), list)
|
| 62 |
+
|
| 63 |
+
@pytest.mark.asyncio
|
| 64 |
+
async def test_get_test_not_found(self, client: httpx.AsyncClient):
|
| 65 |
+
"""Test getting a non-existent test returns 404."""
|
| 66 |
+
resp = await client.get("/api/tests/00000000-0000-0000-0000-000000000000")
|
| 67 |
+
assert resp.status_code == 404
|
| 68 |
+
assert "not found" in resp.json()["detail"].lower()
|
| 69 |
+
|
| 70 |
+
@pytest.mark.asyncio
|
| 71 |
+
async def test_cancel_test_not_found(self, client: httpx.AsyncClient):
|
| 72 |
+
"""Test cancelling a non-existent test returns 404."""
|
| 73 |
+
resp = await client.post("/api/tests/00000000-0000-0000-0000-000000000000/cancel")
|
| 74 |
+
assert resp.status_code == 404
|
| 75 |
+
|
| 76 |
+
@pytest.mark.asyncio
|
| 77 |
+
async def test_delete_test_not_found(self, client: httpx.AsyncClient):
|
| 78 |
+
"""Test deleting a non-existent test returns 404."""
|
| 79 |
+
resp = await client.delete("/api/tests/00000000-0000-0000-0000-000000000000")
|
| 80 |
+
assert resp.status_code == 404
|
| 81 |
+
|
| 82 |
+
@pytest.mark.asyncio
|
| 83 |
+
async def test_start_test_not_found(self, client: httpx.AsyncClient):
|
| 84 |
+
"""Test starting a non-existent test returns 404."""
|
| 85 |
+
resp = await client.post("/api/tests/00000000-0000-0000-0000-000000000000/start")
|
| 86 |
+
assert resp.status_code == 404
|
| 87 |
+
|
| 88 |
+
@pytest.mark.asyncio
|
| 89 |
+
async def test_full_test_run_lifecycle(self, client: httpx.AsyncClient):
|
| 90 |
+
"""Test the complete lifecycle: create agent -> create test -> list tests."""
|
| 91 |
+
# Step 1: Create an agent
|
| 92 |
+
agent_data = {
|
| 93 |
+
"name": "test-agent-for-tests",
|
| 94 |
+
"description": "Agent for testing the tests API",
|
| 95 |
+
"enable_message_compaction": False,
|
| 96 |
+
"enable_memory_tool": False,
|
| 97 |
+
"enable_sub_agent": False,
|
| 98 |
+
"bash_timeout": 60,
|
| 99 |
+
}
|
| 100 |
+
agent_resp = await client.post("/api/configs", json=agent_data)
|
| 101 |
+
assert agent_resp.status_code == 201, f"Failed to create agent: {agent_resp.text}"
|
| 102 |
+
agent = agent_resp.json()
|
| 103 |
+
agent_id = agent["id"]
|
| 104 |
+
|
| 105 |
+
try:
|
| 106 |
+
# Step 2: Create a test run
|
| 107 |
+
test_data = {
|
| 108 |
+
"agent_id": agent_id,
|
| 109 |
+
"prompt": "Write hello world",
|
| 110 |
+
}
|
| 111 |
+
test_resp = await client.post("/api/tests", json=test_data)
|
| 112 |
+
assert test_resp.status_code == 201, f"Failed to create test: {test_resp.text}"
|
| 113 |
+
test_run = test_resp.json()
|
| 114 |
+
test_id = test_run["id"]
|
| 115 |
+
|
| 116 |
+
# Verify test run fields
|
| 117 |
+
assert test_run["agent_id"] == agent_id
|
| 118 |
+
assert test_run["prompt"] == "Write hello world"
|
| 119 |
+
assert test_run["status"] == "pending"
|
| 120 |
+
assert test_run["tokens_total"] == 0
|
| 121 |
+
|
| 122 |
+
# Step 3: List tests - should see our test
|
| 123 |
+
list_resp = await client.get("/api/tests")
|
| 124 |
+
assert list_resp.status_code == 200
|
| 125 |
+
tests = list_resp.json()
|
| 126 |
+
assert any(t["id"] == test_id for t in tests)
|
| 127 |
+
|
| 128 |
+
# Step 4: List tests filtered by agent
|
| 129 |
+
list_agent_resp = await client.get(f"/api/tests?agent_id={agent_id}")
|
| 130 |
+
assert list_agent_resp.status_code == 200
|
| 131 |
+
agent_tests = list_agent_resp.json()
|
| 132 |
+
assert len(agent_tests) >= 1
|
| 133 |
+
assert all(t["agent_id"] == agent_id for t in agent_tests)
|
| 134 |
+
|
| 135 |
+
# Step 5: Get test details
|
| 136 |
+
detail_resp = await client.get(f"/api/tests/{test_id}")
|
| 137 |
+
assert detail_resp.status_code == 200
|
| 138 |
+
detail = detail_resp.json()
|
| 139 |
+
assert detail["id"] == test_id
|
| 140 |
+
assert detail["prompt"] == "Write hello world"
|
| 141 |
+
assert "trace" in detail # Detail response includes trace
|
| 142 |
+
|
| 143 |
+
# Step 6: Delete the test
|
| 144 |
+
delete_resp = await client.delete(f"/api/tests/{test_id}")
|
| 145 |
+
assert delete_resp.status_code == 204
|
| 146 |
+
|
| 147 |
+
# Verify test is deleted
|
| 148 |
+
get_deleted = await client.get(f"/api/tests/{test_id}")
|
| 149 |
+
assert get_deleted.status_code == 404
|
| 150 |
+
|
| 151 |
+
finally:
|
| 152 |
+
# Clean up: delete agent
|
| 153 |
+
await client.delete(f"/api/configs/{agent_id}")
|
| 154 |
+
|
| 155 |
+
@pytest.mark.asyncio
|
| 156 |
+
async def test_create_test_with_task(self, client: httpx.AsyncClient):
|
| 157 |
+
"""Test creating a test run linked to a task."""
|
| 158 |
+
# Create agent
|
| 159 |
+
agent_resp = await client.post(
|
| 160 |
+
"/api/configs",
|
| 161 |
+
json={
|
| 162 |
+
"name": "test-agent-with-task",
|
| 163 |
+
"description": "Test agent",
|
| 164 |
+
"enable_message_compaction": False,
|
| 165 |
+
"enable_memory_tool": False,
|
| 166 |
+
"enable_sub_agent": False,
|
| 167 |
+
"bash_timeout": 60,
|
| 168 |
+
},
|
| 169 |
+
)
|
| 170 |
+
assert agent_resp.status_code == 201
|
| 171 |
+
agent_id = agent_resp.json()["id"]
|
| 172 |
+
|
| 173 |
+
# Create task
|
| 174 |
+
task_resp = await client.post(
|
| 175 |
+
"/api/tasks",
|
| 176 |
+
json={
|
| 177 |
+
"name": "test-task",
|
| 178 |
+
"prompt": "Write a function",
|
| 179 |
+
"criteria": [{"name": "correctness", "instruction": "Check if code works correctly"}],
|
| 180 |
+
},
|
| 181 |
+
)
|
| 182 |
+
assert task_resp.status_code == 201
|
| 183 |
+
task_id = task_resp.json()["id"]
|
| 184 |
+
|
| 185 |
+
try:
|
| 186 |
+
# Create test with task
|
| 187 |
+
test_resp = await client.post(
|
| 188 |
+
"/api/tests",
|
| 189 |
+
json={
|
| 190 |
+
"agent_id": agent_id,
|
| 191 |
+
"prompt": "Write a function",
|
| 192 |
+
"task_id": task_id,
|
| 193 |
+
},
|
| 194 |
+
)
|
| 195 |
+
assert test_resp.status_code == 201
|
| 196 |
+
test_run = test_resp.json()
|
| 197 |
+
assert test_run["task_id"] == task_id
|
| 198 |
+
|
| 199 |
+
# Clean up test
|
| 200 |
+
await client.delete(f"/api/tests/{test_run['id']}")
|
| 201 |
+
|
| 202 |
+
finally:
|
| 203 |
+
# Clean up
|
| 204 |
+
await client.delete(f"/api/tasks/{task_id}")
|
| 205 |
+
await client.delete(f"/api/configs/{agent_id}")
|
| 206 |
+
|
| 207 |
+
@pytest.mark.asyncio
|
| 208 |
+
async def test_create_test_with_invalid_task(self, client: httpx.AsyncClient):
|
| 209 |
+
"""Test creating a test run with non-existent task fails."""
|
| 210 |
+
# Create agent
|
| 211 |
+
agent_resp = await client.post(
|
| 212 |
+
"/api/configs",
|
| 213 |
+
json={
|
| 214 |
+
"name": "test-agent-invalid-task",
|
| 215 |
+
"description": "Test agent",
|
| 216 |
+
"enable_message_compaction": False,
|
| 217 |
+
"enable_memory_tool": False,
|
| 218 |
+
"enable_sub_agent": False,
|
| 219 |
+
"bash_timeout": 60,
|
| 220 |
+
},
|
| 221 |
+
)
|
| 222 |
+
assert agent_resp.status_code == 201
|
| 223 |
+
agent_id = agent_resp.json()["id"]
|
| 224 |
+
|
| 225 |
+
try:
|
| 226 |
+
# Create test with non-existent task
|
| 227 |
+
test_resp = await client.post(
|
| 228 |
+
"/api/tests",
|
| 229 |
+
json={
|
| 230 |
+
"agent_id": agent_id,
|
| 231 |
+
"prompt": "Write a function",
|
| 232 |
+
"task_id": "00000000-0000-0000-0000-000000000000",
|
| 233 |
+
},
|
| 234 |
+
)
|
| 235 |
+
assert test_resp.status_code == 400
|
| 236 |
+
assert "Task not found" in test_resp.json()["detail"]
|
| 237 |
+
|
| 238 |
+
finally:
|
| 239 |
+
await client.delete(f"/api/configs/{agent_id}")
|
src/flow/ui/ui/assets/index-B2HaxLdE.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.pointer-events-none{pointer-events:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{top:0;right:0;bottom:0;left:0}.bottom-0{bottom:0}.left-0{left:0}.left-3{left:.75rem}.top-0{top:0}.top-1\/2{top:50%}.z-10{z-index:10}.z-50{z-index:50}.mx-0\.5{margin-left:.125rem;margin-right:.125rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.-mt-1{margin-top:-.25rem}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.ml-6{margin-left:1.5rem}.mr-1{margin-right:.25rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-8{margin-top:2rem}.mt-auto{margin-top:auto}.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.line-clamp-3{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-1\.5{height:.375rem}.h-12{height:3rem}.h-2{height:.5rem}.h-3{height:.75rem}.h-32{height:8rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-80{height:20rem}.h-full{height:100%}.max-h-32{max-height:8rem}.max-h-40{max-height:10rem}.max-h-48{max-height:12rem}.max-h-96{max-height:24rem}.max-h-\[80vh\]{max-height:80vh}.min-h-\[100px\]{min-height:100px}.min-h-screen{min-height:100vh}.w-12{width:3rem}.w-2{width:.5rem}.w-20{width:5rem}.w-24{width:6rem}.w-3{width:.75rem}.w-32{width:8rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-full{width:100%}.min-w-0{min-width:0px}.min-w-\[90px\]{min-width:90px}.max-w-7xl{max-width:80rem}.max-w-full{max-width:100%}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.-translate-y-1\/2{--tw-translate-y: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.rotate-180{--tw-rotate: 180deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize-none{resize:none}.resize-y{resize:vertical}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-1{row-gap:.25rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l-2{border-left-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-\[var\(--accent\)\]{border-color:var(--accent)}.border-\[var\(--border\)\]{border-color:var(--border)}.border-blue-500\/30{border-color:#3b82f64d}.border-green-500\/20{border-color:#22c55e33}.border-green-500\/30{border-color:#22c55e4d}.border-red-500\/20{border-color:#ef444433}.border-red-500\/30{border-color:#ef44444d}.border-red-500\/50{border-color:#ef444480}.border-transparent{border-color:transparent}.bg-\[var\(--accent\)\]{background-color:var(--accent)}.bg-\[var\(--bg-primary\)\]{background-color:var(--bg-primary)}.bg-\[var\(--bg-secondary\)\]{background-color:var(--bg-secondary)}.bg-\[var\(--bg-tertiary\)\]{background-color:var(--bg-tertiary)}.bg-\[var\(--error\)\]{background-color:var(--error)}.bg-black\/80{background-color:#000c}.bg-blue-100{--tw-bg-opacity: 1;background-color:rgb(219 234 254 / var(--tw-bg-opacity, 1))}.bg-blue-400{--tw-bg-opacity: 1;background-color:rgb(96 165 250 / var(--tw-bg-opacity, 1))}.bg-blue-500{--tw-bg-opacity: 1;background-color:rgb(59 130 246 / var(--tw-bg-opacity, 1))}.bg-blue-500\/10{background-color:#3b82f61a}.bg-blue-600{--tw-bg-opacity: 1;background-color:rgb(37 99 235 / var(--tw-bg-opacity, 1))}.bg-emerald-500{--tw-bg-opacity: 1;background-color:rgb(16 185 129 / var(--tw-bg-opacity, 1))}.bg-green-100{--tw-bg-opacity: 1;background-color:rgb(220 252 231 / var(--tw-bg-opacity, 1))}.bg-green-400{--tw-bg-opacity: 1;background-color:rgb(74 222 128 / var(--tw-bg-opacity, 1))}.bg-green-500{--tw-bg-opacity: 1;background-color:rgb(34 197 94 / var(--tw-bg-opacity, 1))}.bg-green-500\/10{background-color:#22c55e1a}.bg-green-500\/20{background-color:#22c55e33}.bg-green-600{--tw-bg-opacity: 1;background-color:rgb(22 163 74 / var(--tw-bg-opacity, 1))}.bg-orange-100{--tw-bg-opacity: 1;background-color:rgb(255 237 213 / var(--tw-bg-opacity, 1))}.bg-purple-100{--tw-bg-opacity: 1;background-color:rgb(243 232 255 / var(--tw-bg-opacity, 1))}.bg-red-100{--tw-bg-opacity: 1;background-color:rgb(254 226 226 / var(--tw-bg-opacity, 1))}.bg-red-500{--tw-bg-opacity: 1;background-color:rgb(239 68 68 / var(--tw-bg-opacity, 1))}.bg-red-500\/10{background-color:#ef44441a}.bg-red-500\/5{background-color:#ef44440d}.bg-red-600{--tw-bg-opacity: 1;background-color:rgb(220 38 38 / var(--tw-bg-opacity, 1))}.bg-yellow-500{--tw-bg-opacity: 1;background-color:rgb(234 179 8 / var(--tw-bg-opacity, 1))}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-2{padding-bottom:.5rem}.pl-10{padding-left:2.5rem}.pr-3{padding-right:.75rem}.pr-4{padding-right:1rem}.pt-2{padding-top:.5rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:JetBrains Mono,ui-monospace,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10px\]{font-size:10px}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.tracking-wide{letter-spacing:.025em}.tracking-wider{letter-spacing:.05em}.text-\[var\(--accent\)\]{color:var(--accent)}.text-\[var\(--error\)\]{color:var(--error)}.text-\[var\(--text-primary\)\]{color:var(--text-primary)}.text-\[var\(--text-secondary\)\]{color:var(--text-secondary)}.text-\[var\(--text-tertiary\)\]{color:var(--text-tertiary)}.text-black{--tw-text-opacity: 1;color:rgb(0 0 0 / var(--tw-text-opacity, 1))}.text-blue-400{--tw-text-opacity: 1;color:rgb(96 165 250 / var(--tw-text-opacity, 1))}.text-blue-800{--tw-text-opacity: 1;color:rgb(30 64 175 / var(--tw-text-opacity, 1))}.text-emerald-400{--tw-text-opacity: 1;color:rgb(52 211 153 / var(--tw-text-opacity, 1))}.text-green-400{--tw-text-opacity: 1;color:rgb(74 222 128 / var(--tw-text-opacity, 1))}.text-green-500{--tw-text-opacity: 1;color:rgb(34 197 94 / var(--tw-text-opacity, 1))}.text-green-800{--tw-text-opacity: 1;color:rgb(22 101 52 / var(--tw-text-opacity, 1))}.text-orange-800{--tw-text-opacity: 1;color:rgb(154 52 18 / var(--tw-text-opacity, 1))}.text-purple-400{--tw-text-opacity: 1;color:rgb(192 132 252 / var(--tw-text-opacity, 1))}.text-purple-800{--tw-text-opacity: 1;color:rgb(107 33 168 / var(--tw-text-opacity, 1))}.text-red-300{--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity, 1))}.text-red-400{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.text-red-500{--tw-text-opacity: 1;color:rgb(239 68 68 / var(--tw-text-opacity, 1))}.text-red-800{--tw-text-opacity: 1;color:rgb(153 27 27 / var(--tw-text-opacity, 1))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.accent-\[var\(--accent\)\]{accent-color:var(--accent)}.shadow-lg{--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-300{transition-duration:.3s}:root{--bg-primary: #0a0a0a;--bg-secondary: #141414;--bg-tertiary: #1a1a1a;--text-primary: #f5f5f5;--text-secondary: #a3a3a3;--accent: #22c55e;--accent-dim: #166534;--border: #262626;--error: #ef4444}[data-theme=light]{--bg-primary: #ffffff;--bg-secondary: #f7f8f9;--bg-tertiary: #eef0f2;--text-primary: #1a1a1a;--text-secondary: #4a4a4a;--accent: #16a34a;--accent-dim: #dcfce7;--border: #d1d5db;--error: #dc2626}*{box-sizing:border-box}body{margin:0;background-color:var(--bg-primary);color:var(--text-primary);font-family:JetBrains Mono,ui-monospace,monospace;font-size:14px;line-height:1.6}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:var(--bg-secondary)}::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px}::-webkit-scrollbar-thumb:hover{background:#404040}[data-theme=light] ::-webkit-scrollbar-thumb:hover{background:silver}.last\:border-0:last-child{border-width:0px}.hover\:border-\[var\(--accent-dim\)\]:hover{border-color:var(--accent-dim)}.hover\:bg-\[\#16a34a\]:hover{--tw-bg-opacity: 1;background-color:rgb(22 163 74 / var(--tw-bg-opacity, 1))}.hover\:bg-\[var\(--bg-primary\)\]:hover{background-color:var(--bg-primary)}.hover\:bg-\[var\(--bg-tertiary\)\]:hover{background-color:var(--bg-tertiary)}.hover\:bg-\[var\(--border\)\]:hover{background-color:var(--border)}.hover\:bg-red-600:hover{--tw-bg-opacity: 1;background-color:rgb(220 38 38 / var(--tw-bg-opacity, 1))}.hover\:text-\[var\(--accent\)\]:hover{color:var(--accent)}.hover\:text-\[var\(--text-primary\)\]:hover{color:var(--text-primary)}.hover\:opacity-80:hover{opacity:.8}.focus\:border-\[var\(--accent\)\]:focus{border-color:var(--accent)}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-\[var\(--accent\)\]:focus{--tw-ring-color: var(--accent)}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}@media (min-width: 768px){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width: 1024px){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width: 1280px){.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (prefers-color-scheme: dark){.dark\:bg-blue-900{--tw-bg-opacity: 1;background-color:rgb(30 58 138 / var(--tw-bg-opacity, 1))}.dark\:bg-green-900{--tw-bg-opacity: 1;background-color:rgb(20 83 45 / var(--tw-bg-opacity, 1))}.dark\:bg-orange-900{--tw-bg-opacity: 1;background-color:rgb(124 45 18 / var(--tw-bg-opacity, 1))}.dark\:bg-purple-900{--tw-bg-opacity: 1;background-color:rgb(88 28 135 / var(--tw-bg-opacity, 1))}.dark\:bg-red-900{--tw-bg-opacity: 1;background-color:rgb(127 29 29 / var(--tw-bg-opacity, 1))}.dark\:text-blue-200{--tw-text-opacity: 1;color:rgb(191 219 254 / var(--tw-text-opacity, 1))}.dark\:text-green-200{--tw-text-opacity: 1;color:rgb(187 247 208 / var(--tw-text-opacity, 1))}.dark\:text-orange-200{--tw-text-opacity: 1;color:rgb(254 215 170 / var(--tw-text-opacity, 1))}.dark\:text-purple-200{--tw-text-opacity: 1;color:rgb(233 213 255 / var(--tw-text-opacity, 1))}.dark\:text-red-200{--tw-text-opacity: 1;color:rgb(254 202 202 / var(--tw-text-opacity, 1))}}
|
src/flow/ui/ui/assets/index-BeLhM5TW.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
src/flow/ui/ui/assets/index-CHxtV6Si.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
src/flow/ui/ui/assets/index-CIxCoVSG.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.pointer-events-none{pointer-events:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{top:0;right:0;bottom:0;left:0}.bottom-0{bottom:0}.left-0{left:0}.left-3{left:.75rem}.top-0{top:0}.top-1\/2{top:50%}.z-10{z-index:10}.z-50{z-index:50}.mx-0\.5{margin-left:.125rem;margin-right:.125rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.-mt-1{margin-top:-.25rem}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.ml-6{margin-left:1.5rem}.mr-1{margin-right:.25rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-8{margin-top:2rem}.mt-auto{margin-top:auto}.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.line-clamp-3{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-1\.5{height:.375rem}.h-12{height:3rem}.h-2{height:.5rem}.h-3{height:.75rem}.h-32{height:8rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-full{height:100%}.max-h-32{max-height:8rem}.max-h-40{max-height:10rem}.max-h-48{max-height:12rem}.max-h-96{max-height:24rem}.max-h-\[80vh\]{max-height:80vh}.min-h-\[100px\]{min-height:100px}.min-h-screen{min-height:100vh}.w-12{width:3rem}.w-2{width:.5rem}.w-20{width:5rem}.w-24{width:6rem}.w-3{width:.75rem}.w-32{width:8rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-full{width:100%}.min-w-0{min-width:0px}.min-w-\[90px\]{min-width:90px}.max-w-7xl{max-width:80rem}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.-translate-y-1\/2{--tw-translate-y: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.rotate-180{--tw-rotate: 180deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize-y{resize:vertical}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-1{row-gap:.25rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-l-2{border-left-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-\[var\(--accent\)\]{border-color:var(--accent)}.border-\[var\(--border\)\]{border-color:var(--border)}.border-blue-500\/30{border-color:#3b82f64d}.border-green-500\/30{border-color:#22c55e4d}.border-red-500\/30{border-color:#ef44444d}.border-red-500\/50{border-color:#ef444480}.bg-\[var\(--accent\)\]{background-color:var(--accent)}.bg-\[var\(--bg-primary\)\]{background-color:var(--bg-primary)}.bg-\[var\(--bg-secondary\)\]{background-color:var(--bg-secondary)}.bg-\[var\(--bg-tertiary\)\]{background-color:var(--bg-tertiary)}.bg-\[var\(--error\)\]{background-color:var(--error)}.bg-black\/80{background-color:#000c}.bg-blue-100{--tw-bg-opacity: 1;background-color:rgb(219 234 254 / var(--tw-bg-opacity, 1))}.bg-blue-400{--tw-bg-opacity: 1;background-color:rgb(96 165 250 / var(--tw-bg-opacity, 1))}.bg-blue-500{--tw-bg-opacity: 1;background-color:rgb(59 130 246 / var(--tw-bg-opacity, 1))}.bg-blue-500\/10{background-color:#3b82f61a}.bg-blue-600{--tw-bg-opacity: 1;background-color:rgb(37 99 235 / var(--tw-bg-opacity, 1))}.bg-emerald-500{--tw-bg-opacity: 1;background-color:rgb(16 185 129 / var(--tw-bg-opacity, 1))}.bg-green-100{--tw-bg-opacity: 1;background-color:rgb(220 252 231 / var(--tw-bg-opacity, 1))}.bg-green-400{--tw-bg-opacity: 1;background-color:rgb(74 222 128 / var(--tw-bg-opacity, 1))}.bg-green-500{--tw-bg-opacity: 1;background-color:rgb(34 197 94 / var(--tw-bg-opacity, 1))}.bg-green-500\/10{background-color:#22c55e1a}.bg-green-500\/20{background-color:#22c55e33}.bg-green-600{--tw-bg-opacity: 1;background-color:rgb(22 163 74 / var(--tw-bg-opacity, 1))}.bg-orange-100{--tw-bg-opacity: 1;background-color:rgb(255 237 213 / var(--tw-bg-opacity, 1))}.bg-purple-100{--tw-bg-opacity: 1;background-color:rgb(243 232 255 / var(--tw-bg-opacity, 1))}.bg-red-100{--tw-bg-opacity: 1;background-color:rgb(254 226 226 / var(--tw-bg-opacity, 1))}.bg-red-500{--tw-bg-opacity: 1;background-color:rgb(239 68 68 / var(--tw-bg-opacity, 1))}.bg-red-500\/10{background-color:#ef44441a}.bg-red-600{--tw-bg-opacity: 1;background-color:rgb(220 38 38 / var(--tw-bg-opacity, 1))}.bg-yellow-500{--tw-bg-opacity: 1;background-color:rgb(234 179 8 / var(--tw-bg-opacity, 1))}.p-1{padding:.25rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-2{padding-bottom:.5rem}.pl-10{padding-left:2.5rem}.pr-3{padding-right:.75rem}.pr-4{padding-right:1rem}.pt-2{padding-top:.5rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:JetBrains Mono,ui-monospace,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.tracking-wide{letter-spacing:.025em}.tracking-wider{letter-spacing:.05em}.text-\[var\(--accent\)\]{color:var(--accent)}.text-\[var\(--error\)\]{color:var(--error)}.text-\[var\(--text-primary\)\]{color:var(--text-primary)}.text-\[var\(--text-secondary\)\]{color:var(--text-secondary)}.text-\[var\(--text-tertiary\)\]{color:var(--text-tertiary)}.text-black{--tw-text-opacity: 1;color:rgb(0 0 0 / var(--tw-text-opacity, 1))}.text-blue-400{--tw-text-opacity: 1;color:rgb(96 165 250 / var(--tw-text-opacity, 1))}.text-blue-800{--tw-text-opacity: 1;color:rgb(30 64 175 / var(--tw-text-opacity, 1))}.text-emerald-400{--tw-text-opacity: 1;color:rgb(52 211 153 / var(--tw-text-opacity, 1))}.text-green-400{--tw-text-opacity: 1;color:rgb(74 222 128 / var(--tw-text-opacity, 1))}.text-green-500{--tw-text-opacity: 1;color:rgb(34 197 94 / var(--tw-text-opacity, 1))}.text-green-800{--tw-text-opacity: 1;color:rgb(22 101 52 / var(--tw-text-opacity, 1))}.text-orange-800{--tw-text-opacity: 1;color:rgb(154 52 18 / var(--tw-text-opacity, 1))}.text-purple-400{--tw-text-opacity: 1;color:rgb(192 132 252 / var(--tw-text-opacity, 1))}.text-purple-800{--tw-text-opacity: 1;color:rgb(107 33 168 / var(--tw-text-opacity, 1))}.text-red-400{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.text-red-800{--tw-text-opacity: 1;color:rgb(153 27 27 / var(--tw-text-opacity, 1))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.accent-\[var\(--accent\)\]{accent-color:var(--accent)}.shadow-lg{--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-300{transition-duration:.3s}:root{--bg-primary: #0a0a0a;--bg-secondary: #141414;--bg-tertiary: #1a1a1a;--text-primary: #f5f5f5;--text-secondary: #a3a3a3;--accent: #22c55e;--accent-dim: #166534;--border: #262626;--error: #ef4444}[data-theme=light]{--bg-primary: #ffffff;--bg-secondary: #f7f8f9;--bg-tertiary: #eef0f2;--text-primary: #1a1a1a;--text-secondary: #4a4a4a;--accent: #16a34a;--accent-dim: #dcfce7;--border: #d1d5db;--error: #dc2626}*{box-sizing:border-box}body{margin:0;background-color:var(--bg-primary);color:var(--text-primary);font-family:JetBrains Mono,ui-monospace,monospace;font-size:14px;line-height:1.6}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:var(--bg-secondary)}::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px}::-webkit-scrollbar-thumb:hover{background:#404040}[data-theme=light] ::-webkit-scrollbar-thumb:hover{background:silver}.last\:border-0:last-child{border-width:0px}.hover\:border-\[var\(--accent-dim\)\]:hover{border-color:var(--accent-dim)}.hover\:bg-\[\#16a34a\]:hover{--tw-bg-opacity: 1;background-color:rgb(22 163 74 / var(--tw-bg-opacity, 1))}.hover\:bg-\[var\(--bg-primary\)\]:hover{background-color:var(--bg-primary)}.hover\:bg-\[var\(--bg-tertiary\)\]:hover{background-color:var(--bg-tertiary)}.hover\:bg-\[var\(--border\)\]:hover{background-color:var(--border)}.hover\:bg-red-600:hover{--tw-bg-opacity: 1;background-color:rgb(220 38 38 / var(--tw-bg-opacity, 1))}.hover\:text-\[var\(--accent\)\]:hover{color:var(--accent)}.hover\:text-\[var\(--text-primary\)\]:hover{color:var(--text-primary)}.hover\:opacity-80:hover{opacity:.8}.focus\:border-\[var\(--accent\)\]:focus{border-color:var(--accent)}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-\[var\(--accent\)\]:focus{--tw-ring-color: var(--accent)}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}@media (min-width: 768px){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width: 1024px){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width: 1280px){.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (prefers-color-scheme: dark){.dark\:bg-blue-900{--tw-bg-opacity: 1;background-color:rgb(30 58 138 / var(--tw-bg-opacity, 1))}.dark\:bg-green-900{--tw-bg-opacity: 1;background-color:rgb(20 83 45 / var(--tw-bg-opacity, 1))}.dark\:bg-orange-900{--tw-bg-opacity: 1;background-color:rgb(124 45 18 / var(--tw-bg-opacity, 1))}.dark\:bg-purple-900{--tw-bg-opacity: 1;background-color:rgb(88 28 135 / var(--tw-bg-opacity, 1))}.dark\:bg-red-900{--tw-bg-opacity: 1;background-color:rgb(127 29 29 / var(--tw-bg-opacity, 1))}.dark\:text-blue-200{--tw-text-opacity: 1;color:rgb(191 219 254 / var(--tw-text-opacity, 1))}.dark\:text-green-200{--tw-text-opacity: 1;color:rgb(187 247 208 / var(--tw-text-opacity, 1))}.dark\:text-orange-200{--tw-text-opacity: 1;color:rgb(254 215 170 / var(--tw-text-opacity, 1))}.dark\:text-purple-200{--tw-text-opacity: 1;color:rgb(233 213 255 / var(--tw-text-opacity, 1))}.dark\:text-red-200{--tw-text-opacity: 1;color:rgb(254 202 202 / var(--tw-text-opacity, 1))}}
|
src/flow/ui/ui/assets/index-CcGRa0_M.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.pointer-events-none{pointer-events:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{top:0;right:0;bottom:0;left:0}.bottom-0{bottom:0}.left-0{left:0}.left-3{left:.75rem}.top-0{top:0}.top-1\/2{top:50%}.z-10{z-index:10}.z-50{z-index:50}.col-span-2{grid-column:span 2 / span 2}.mx-0\.5{margin-left:.125rem;margin-right:.125rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.-mt-1{margin-top:-.25rem}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.ml-6{margin-left:1.5rem}.mr-1{margin-right:.25rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-8{margin-top:2rem}.mt-auto{margin-top:auto}.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.line-clamp-3{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-1\.5{height:.375rem}.h-12{height:3rem}.h-2{height:.5rem}.h-3{height:.75rem}.h-32{height:8rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-full{height:100%}.max-h-32{max-height:8rem}.max-h-40{max-height:10rem}.max-h-48{max-height:12rem}.max-h-96{max-height:24rem}.max-h-\[80vh\]{max-height:80vh}.min-h-0{min-height:0px}.min-h-\[100px\]{min-height:100px}.min-h-screen{min-height:100vh}.w-12{width:3rem}.w-2{width:.5rem}.w-20{width:5rem}.w-24{width:6rem}.w-3{width:.75rem}.w-32{width:8rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-full{width:100%}.min-w-0{min-width:0px}.min-w-\[90px\]{min-width:90px}.max-w-7xl{max-width:80rem}.max-w-\[200px\]{max-width:200px}.max-w-full{max-width:100%}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.-translate-y-1\/2{--tw-translate-y: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.rotate-180{--tw-rotate: 180deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize-none{resize:none}.resize-y{resize:vertical}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-1{row-gap:.25rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l-2{border-left-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-\[var\(--accent\)\]{border-color:var(--accent)}.border-\[var\(--border\)\]{border-color:var(--border)}.border-blue-500\/30{border-color:#3b82f64d}.border-green-500\/20{border-color:#22c55e33}.border-green-500\/30{border-color:#22c55e4d}.border-red-500\/20{border-color:#ef444433}.border-red-500\/30{border-color:#ef44444d}.border-red-500\/50{border-color:#ef444480}.border-transparent{border-color:transparent}.bg-\[var\(--accent\)\]{background-color:var(--accent)}.bg-\[var\(--bg-primary\)\]{background-color:var(--bg-primary)}.bg-\[var\(--bg-secondary\)\]{background-color:var(--bg-secondary)}.bg-\[var\(--bg-tertiary\)\]{background-color:var(--bg-tertiary)}.bg-\[var\(--error\)\]{background-color:var(--error)}.bg-black\/80{background-color:#000c}.bg-blue-100{--tw-bg-opacity: 1;background-color:rgb(219 234 254 / var(--tw-bg-opacity, 1))}.bg-blue-400{--tw-bg-opacity: 1;background-color:rgb(96 165 250 / var(--tw-bg-opacity, 1))}.bg-blue-500{--tw-bg-opacity: 1;background-color:rgb(59 130 246 / var(--tw-bg-opacity, 1))}.bg-blue-500\/10{background-color:#3b82f61a}.bg-blue-600{--tw-bg-opacity: 1;background-color:rgb(37 99 235 / var(--tw-bg-opacity, 1))}.bg-emerald-500{--tw-bg-opacity: 1;background-color:rgb(16 185 129 / var(--tw-bg-opacity, 1))}.bg-green-100{--tw-bg-opacity: 1;background-color:rgb(220 252 231 / var(--tw-bg-opacity, 1))}.bg-green-400{--tw-bg-opacity: 1;background-color:rgb(74 222 128 / var(--tw-bg-opacity, 1))}.bg-green-500{--tw-bg-opacity: 1;background-color:rgb(34 197 94 / var(--tw-bg-opacity, 1))}.bg-green-500\/10{background-color:#22c55e1a}.bg-green-500\/20{background-color:#22c55e33}.bg-green-600{--tw-bg-opacity: 1;background-color:rgb(22 163 74 / var(--tw-bg-opacity, 1))}.bg-orange-100{--tw-bg-opacity: 1;background-color:rgb(255 237 213 / var(--tw-bg-opacity, 1))}.bg-purple-100{--tw-bg-opacity: 1;background-color:rgb(243 232 255 / var(--tw-bg-opacity, 1))}.bg-red-100{--tw-bg-opacity: 1;background-color:rgb(254 226 226 / var(--tw-bg-opacity, 1))}.bg-red-500{--tw-bg-opacity: 1;background-color:rgb(239 68 68 / var(--tw-bg-opacity, 1))}.bg-red-500\/10{background-color:#ef44441a}.bg-red-500\/5{background-color:#ef44440d}.bg-red-600{--tw-bg-opacity: 1;background-color:rgb(220 38 38 / var(--tw-bg-opacity, 1))}.bg-yellow-500{--tw-bg-opacity: 1;background-color:rgb(234 179 8 / var(--tw-bg-opacity, 1))}.p-1{padding:.25rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-2{padding-bottom:.5rem}.pl-10{padding-left:2.5rem}.pr-3{padding-right:.75rem}.pr-4{padding-right:1rem}.pt-2{padding-top:.5rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:JetBrains Mono,ui-monospace,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.tracking-wide{letter-spacing:.025em}.tracking-wider{letter-spacing:.05em}.text-\[var\(--accent\)\]{color:var(--accent)}.text-\[var\(--error\)\]{color:var(--error)}.text-\[var\(--text-primary\)\]{color:var(--text-primary)}.text-\[var\(--text-secondary\)\]{color:var(--text-secondary)}.text-\[var\(--text-tertiary\)\]{color:var(--text-tertiary)}.text-black{--tw-text-opacity: 1;color:rgb(0 0 0 / var(--tw-text-opacity, 1))}.text-blue-400{--tw-text-opacity: 1;color:rgb(96 165 250 / var(--tw-text-opacity, 1))}.text-blue-800{--tw-text-opacity: 1;color:rgb(30 64 175 / var(--tw-text-opacity, 1))}.text-emerald-400{--tw-text-opacity: 1;color:rgb(52 211 153 / var(--tw-text-opacity, 1))}.text-green-400{--tw-text-opacity: 1;color:rgb(74 222 128 / var(--tw-text-opacity, 1))}.text-green-500{--tw-text-opacity: 1;color:rgb(34 197 94 / var(--tw-text-opacity, 1))}.text-green-800{--tw-text-opacity: 1;color:rgb(22 101 52 / var(--tw-text-opacity, 1))}.text-orange-800{--tw-text-opacity: 1;color:rgb(154 52 18 / var(--tw-text-opacity, 1))}.text-purple-400{--tw-text-opacity: 1;color:rgb(192 132 252 / var(--tw-text-opacity, 1))}.text-purple-800{--tw-text-opacity: 1;color:rgb(107 33 168 / var(--tw-text-opacity, 1))}.text-red-300{--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity, 1))}.text-red-400{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.text-red-500{--tw-text-opacity: 1;color:rgb(239 68 68 / var(--tw-text-opacity, 1))}.text-red-800{--tw-text-opacity: 1;color:rgb(153 27 27 / var(--tw-text-opacity, 1))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.accent-\[var\(--accent\)\]{accent-color:var(--accent)}.shadow-lg{--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-300{transition-duration:.3s}:root{--bg-primary: #0a0a0a;--bg-secondary: #141414;--bg-tertiary: #1a1a1a;--text-primary: #f5f5f5;--text-secondary: #a3a3a3;--accent: #22c55e;--accent-dim: #166534;--border: #262626;--error: #ef4444}[data-theme=light]{--bg-primary: #ffffff;--bg-secondary: #f7f8f9;--bg-tertiary: #eef0f2;--text-primary: #1a1a1a;--text-secondary: #4a4a4a;--accent: #16a34a;--accent-dim: #dcfce7;--border: #d1d5db;--error: #dc2626}*{box-sizing:border-box}body{margin:0;background-color:var(--bg-primary);color:var(--text-primary);font-family:JetBrains Mono,ui-monospace,monospace;font-size:14px;line-height:1.6}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:var(--bg-secondary)}::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px}::-webkit-scrollbar-thumb:hover{background:#404040}[data-theme=light] ::-webkit-scrollbar-thumb:hover{background:silver}.last\:border-0:last-child{border-width:0px}.hover\:border-\[var\(--accent-dim\)\]:hover{border-color:var(--accent-dim)}.hover\:bg-\[\#16a34a\]:hover{--tw-bg-opacity: 1;background-color:rgb(22 163 74 / var(--tw-bg-opacity, 1))}.hover\:bg-\[var\(--bg-primary\)\]:hover{background-color:var(--bg-primary)}.hover\:bg-\[var\(--bg-tertiary\)\]:hover{background-color:var(--bg-tertiary)}.hover\:bg-\[var\(--border\)\]:hover{background-color:var(--border)}.hover\:bg-red-600:hover{--tw-bg-opacity: 1;background-color:rgb(220 38 38 / var(--tw-bg-opacity, 1))}.hover\:text-\[var\(--accent\)\]:hover{color:var(--accent)}.hover\:text-\[var\(--text-primary\)\]:hover{color:var(--text-primary)}.hover\:opacity-80:hover{opacity:.8}.focus\:border-\[var\(--accent\)\]:focus{border-color:var(--accent)}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-\[var\(--accent\)\]:focus{--tw-ring-color: var(--accent)}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}@media (min-width: 768px){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width: 1024px){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width: 1280px){.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (prefers-color-scheme: dark){.dark\:bg-blue-900{--tw-bg-opacity: 1;background-color:rgb(30 58 138 / var(--tw-bg-opacity, 1))}.dark\:bg-green-900{--tw-bg-opacity: 1;background-color:rgb(20 83 45 / var(--tw-bg-opacity, 1))}.dark\:bg-orange-900{--tw-bg-opacity: 1;background-color:rgb(124 45 18 / var(--tw-bg-opacity, 1))}.dark\:bg-purple-900{--tw-bg-opacity: 1;background-color:rgb(88 28 135 / var(--tw-bg-opacity, 1))}.dark\:bg-red-900{--tw-bg-opacity: 1;background-color:rgb(127 29 29 / var(--tw-bg-opacity, 1))}.dark\:text-blue-200{--tw-text-opacity: 1;color:rgb(191 219 254 / var(--tw-text-opacity, 1))}.dark\:text-green-200{--tw-text-opacity: 1;color:rgb(187 247 208 / var(--tw-text-opacity, 1))}.dark\:text-orange-200{--tw-text-opacity: 1;color:rgb(254 215 170 / var(--tw-text-opacity, 1))}.dark\:text-purple-200{--tw-text-opacity: 1;color:rgb(233 213 255 / var(--tw-text-opacity, 1))}.dark\:text-red-200{--tw-text-opacity: 1;color:rgb(254 202 202 / var(--tw-text-opacity, 1))}}
|
src/flow/ui/ui/assets/index-CsLpRsjU.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
src/flow/ui/ui/flow.svg
CHANGED
|
|
|
|
src/flow/ui/ui/index.html
CHANGED
|
@@ -8,8 +8,8 @@
|
|
| 8 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 9 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 10 |
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
| 11 |
-
<script type="module" crossorigin src="/assets/index-
|
| 12 |
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
| 13 |
</head>
|
| 14 |
<body>
|
| 15 |
<div id="root"></div>
|
|
|
|
| 8 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 9 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 10 |
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
| 11 |
+
<script type="module" crossorigin src="/assets/index-CsLpRsjU.js"></script>
|
| 12 |
+
<link rel="stylesheet" crossorigin href="/assets/index-CcGRa0_M.css">
|
| 13 |
</head>
|
| 14 |
<body>
|
| 15 |
<div id="root"></div>
|