victordibia commited on
Commit
a23ff80
·
1 Parent(s): e8ad71f

Deploy 2026-01-28 16:51:38

Browse files
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 select, desc
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, AgentUpdate, AgentResponse
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
- result = await session.execute(select(AgentConfig).where(AgentConfig.id == uuid_id))
 
 
 
 
 
 
 
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
- result = await session.execute(select(AgentConfig).where(AgentConfig.id == uuid_id))
 
 
 
 
 
 
 
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
- result = await session.execute(select(AgentConfig).where(AgentConfig.id == uuid_id))
 
 
 
 
 
 
 
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 typing import Any, AsyncGenerator
 
7
  from uuid import UUID
8
 
9
- from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
10
  from fastapi.responses import StreamingResponse
11
  from sqlalchemy.ext.asyncio import AsyncSession
12
- from sqlmodel import select, desc
13
 
14
- from ..database import get_session, async_session
15
- from ..models.job import OptimizationJob, JobStatus
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
- # Validate candidate_ids exist
 
 
57
  for candidate_id in data.candidate_ids:
58
  uuid_id = parse_uuid(candidate_id)
59
- result = await session.execute(select(AgentConfig).where(AgentConfig.id == uuid_id))
 
 
 
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
- result = await session.execute(select(TaskModel).where(TaskModel.id == uuid_id))
 
 
 
 
 
 
 
 
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
- result = await session.execute(select(OptimizationJob).where(OptimizationJob.id == uuid_id))
 
 
 
 
 
 
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
- if job_id in _running_jobs:
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
- result = await session.execute(select(OptimizationJob).where(OptimizationJob.id == uuid_id))
 
 
 
 
 
 
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
- result = await session.execute(select(OptimizationJob).where(OptimizationJob.id == uuid_id))
 
 
 
 
 
 
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
- result = await session.execute(select(OptimizationJob).where(OptimizationJob.id == uuid_id))
 
 
 
 
 
 
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
- result = await session.execute(select(OptimizationJob).where(OptimizationJob.id == uuid_id))
 
 
 
 
 
 
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, timezone, timedelta
303
 
304
  cutoff = datetime.now(timezone.utc) - timedelta(hours=max_age_hours)
305
 
306
  # Find jobs that are running and started before cutoff
307
- result = await session.execute(
308
- select(OptimizationJob).where(
309
- OptimizationJob.status == JobStatus.RUNNING,
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 select, desc
10
 
 
11
  from ..database import get_session
 
12
  from ..models.run import ExperimentRun
13
- from ..schemas import RunResponse, RunDetailResponse, CriterionResultSchema
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
- result = await session.execute(select(ExperimentRun).where(ExperimentRun.id == uuid_id))
 
 
 
 
 
 
 
 
 
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 select, desc
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
- result = await session.execute(select(TaskModel).where(TaskModel.id == uuid_id))
 
 
 
 
 
 
 
 
 
 
 
 
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
- result = await session.execute(select(TaskModel).where(TaskModel.id == uuid_id))
 
 
 
 
 
 
 
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, TaskModel, OptimizationJob, ExperimentRun # noqa: F401
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-BU8a-zoU.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-BHAF8mLj.css">
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>