feat(my-deepagent): v0.2 PR #3 — FastAPI + SSE + minimal Web GUI (mydeepagent serve)

Closes the "GUI 미존재" gap from the user's first-session requirements
(REPL + workflow + GUI). v0.2 PR #1's Postgres migration made a second
concurrent writer safe; v0.2 PR #2a/#2b wired durable resume; this commit
ships the HTTP + browser surface that uses them.

No auth, no multi-tenant, single uvicorn worker — per DR-3 boundaries.
v0.3+ will add auth, multi-worker fanout, LISTEN/NOTIFY SSE upgrade.

Backend
- `src/my_deepagent/api/`:
  - `app.py` create_app() factory. lifespan stores db/config/personas/
    workflows on app.state. CORS allow_origin_regex http://localhost(:port)?.
    /static mount + /, /{page}.html for the HTML frontend.
  - `models.py` — pydantic v2 DTOs (extra="forbid") for every route. Auto
    OpenAPI/Swagger via FastAPI's response_model.
  - `deps.py` — get_db / get_config / get_personas / get_workflows.
  - `runner.py` — start_new_run / start_resume. Pre-allocates run_id via
    new `WorkflowEngine.run(pre_allocated_run_id=...)` so the route returns
    the id immediately while the engine runs in asyncio.create_task.
  - `sse.py` — 0.5 s poll over run_events.seq. Emits ServerSentEvent rows;
    sends `event: done` and HTTP-200-closes when run hits terminal.
  - `routes/{runs,personas,workflows,budget}.py`:
      GET  /api/runs              (list, ?limit + ?state)
      GET  /api/runs/{id}         (detail + phases + artifacts + events)
      POST /api/runs              (start; mock-able via runner.start_new_run)
      POST /api/runs/{id}/resume
      POST /api/runs/{id}/abort
      GET  /api/runs/{id}/events  (SSE; Last-Event-ID header + ?last_event_id)
      GET  /api/personas
      GET  /api/workflows
      GET  /api/budget

CLI
- `cli/serve.py` mydeepagent serve [--host 127.0.0.1] [--port 8000].
  Loud stderr warning if --host is not loopback (no auth = footgun).
  uvicorn.run(factory=True, workers=1).
- `cli/main.py` serve command registered.

Static frontend (vanilla HTML/JS/CSS, no build system)
- index.html — runs list + budget summary
- new.html — start-run form (workflow select, repo path, requirements,
  per-role persona override)
- run.html — run detail + live SSE event log + Resume/Abort buttons
- app.js — fetch + EventSource. XSS policy HARDCODED at file top:
  textContent only, innerHTML/insertAdjacentHTML/outerHTML forbidden.
- style.css — dark theme, single file.

Engine
- WorkflowEngine.run(... pre_allocated_run_id: UUID|None = None). None →
  uuid4() (existing behavior). Set → use that UUID. Backward compatible.

Tests
- tests/integration/test_api_read.py (5): list empty, get 404, personas
  seed count (12), workflows seed (>=3), budget empty.
- tests/integration/test_api_write.py (5): missing template 400, extra
  field 422, resume 404, abort 404, mock-runner happy path.
- tests/integration/test_api_sse.py (1): seed terminal run + 3 events,
  drain stream, assert types present + stream closes within 3 s.
- tests/integration/test_api_static.py (5): index/new/run HTML 200,
  app.js content-type + XSS-policy substring assertion, style.css
  content-type.
- All fixtures use httpx ASGITransport + app.router.lifespan_context
  (httpx does NOT auto-trigger FastAPI lifespan) + sqlite tmp_path.

Gates
- ruff check + ruff format --check + mypy --strict: PASS (120 source files)
- pytest non-E2E: 603 PASS (12.15 s) — +16 from new API tests
- pytest E2E real OpenRouter on Postgres: PASS 60.44 s (baseline 71–122 s
  range; well within DR-3 acceptance threshold ≤+20%)

Manual browser verification deferred to a follow-up (docker compose up,
mydeepagent serve, open http://localhost:8000).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chungyeong
2026-05-16 22:25:15 +09:00
parent 501292a5cd
commit 0630142c34
27 changed files with 2369 additions and 21 deletions

View File

@@ -3,6 +3,73 @@
## [Unreleased]
### Added
- **v0.2 PR #3 — FastAPI + SSE + minimal Web GUI (`mydeepagent serve`)**.
Localhost Web UI for run start / list / detail / resume / abort + live
event stream. Closes the v0.1.0 gap "GUI 미존재" from the user's first
session requirements. No auth, no multi-tenant; single uvicorn worker
(per DR-3).
- `pyproject.toml`: runtime deps `fastapi>=0.115`,
`uvicorn[standard]>=0.30`, `sse-starlette>=2.1` (8 transitive deps).
- `src/my_deepagent/api/` (new tree):
- `app.py``create_app(config=None) -> FastAPI` factory. lifespan
stores `db`/`config`/`personas`/`workflows` on `app.state`.
`CORSMiddleware(allow_origin_regex=r"^http://localhost(:\d+)?$")`.
Static frontend mounted under `/static`, plus `/`, `/{page}.html`.
- `models.py` — pydantic v2 DTOs (`RunSummary`, `RunDetail`,
`PhaseInfo`, `ArtifactInfo`, `EventInfo`, `StartRunRequest`,
`StartRunResponse`, `PersonaSummary`, `WorkflowSummary`,
`BudgetSummary`, `BudgetScopeEntry`). All `extra="forbid"` so typos
surface at 422 deserialization time.
- `deps.py``get_db`, `get_config`, `get_personas`, `get_workflows`,
`seed_root`. Annotated[...] wrappers in each route module.
- `runner.py``start_new_run` / `start_resume` /
`is_running`. Pre-allocates a UUID and passes it to
`WorkflowEngine.run(pre_allocated_run_id=...)` so the route can
return the run_id before the phase loop starts. In-memory
`_tasks: dict[UUID, asyncio.Task]` prevents GC of in-flight tasks.
- `sse.py``run_events_stream(db, run_id, last_event_id)`.
0.5 s polling against `run_events.seq > last_event_id`; emits
`ServerSentEvent` per row; sends `event: done` and HTTP-200-closes
when run reaches terminal state.
- `routes/runs.py` — GET `/api/runs?limit=&state=`, GET `/api/runs/{id}`,
POST `/api/runs` (start), POST `/api/runs/{id}/resume`,
POST `/api/runs/{id}/abort`, GET `/api/runs/{id}/events` (SSE).
`Last-Event-ID` HTTP header honored alongside `?last_event_id=`.
- `routes/personas.py` — GET `/api/personas`.
- `routes/workflows.py` — GET `/api/workflows`.
- `routes/budget.py` — GET `/api/budget` (day / runs / personas
buckets with cap + warn thresholds from `Config`).
- `src/my_deepagent/cli/serve.py` (new) — `mydeepagent serve [--host
127.0.0.1] [--port 8000]`. Loud stderr warning when host is not
loopback (the API is unauthenticated). Uses uvicorn factory form +
forces `workers=1`.
- `src/my_deepagent/cli/main.py` — `serve` command registered.
- `src/my_deepagent/engine.py` — `WorkflowEngine.run` gained
`pre_allocated_run_id: UUID | None = None` so the FastAPI runner can
return the run_id immediately. Default behavior unchanged.
- `static/` (new) — vanilla HTML/JS/CSS, no build system:
- `index.html` — 런 목록 + 예산 (data-page="index")
- `new.html` — 신규 run 폼 (workflow select, repo path, requirements,
per-role persona override) (data-page="new")
- `run.html` — run 상세 + SSE 이벤트 라이브 + resume/abort 버튼
(data-page="run")
- `app.js` — fetch + EventSource. **XSS policy hardcoded at the top
of the file**: `element.textContent` only, `innerHTML` /
`insertAdjacentHTML` / `outerHTML` forbidden.
- `style.css` — dark theme, single file.
- Tests (new):
- `tests/integration/test_api_read.py` — 5 cases (list empty, get 404,
personas seed count, workflows seed, budget empty).
- `tests/integration/test_api_write.py` — 5 cases (missing template
400, extra field 422, resume 404, abort 404, mock-runner happy path).
- `tests/integration/test_api_sse.py` — 1 case: seed terminal run +
events, drain stream, assert types present and stream closes.
- `tests/integration/test_api_static.py` — 5 cases: index/new/run
HTML 200, app.js content-type + XSS-policy substring, style.css
content-type.
All tests use `httpx.ASGITransport` + `app.router.lifespan_context`
(httpx does not auto-trigger FastAPI lifespan) and sqlite tmp_path.
- **v0.2 PR #2b — `mydeepagent runs resume <id>` real implementation**.
Closes the v0.1.0 KNOWN LIMIT where resume was an exit-2 stub. Reuses
v0.2 PR #2a's LangGraph wiring + sweep_orphan_runs's DB state machine,

View File

@@ -9,6 +9,9 @@ dependencies = [
"alembic>=1.14",
"greenlet>=3.0",
"sqlalchemy[asyncio]>=2.0",
"fastapi>=0.115",
"uvicorn[standard]>=0.30",
"sse-starlette>=2.1",
"httpx>=0.28",
"jsonschema>=4.23",
"keyring>=25.7",

View File

@@ -0,0 +1,9 @@
"""FastAPI HTTP surface for my-deepagent (v0.2 PR #3).
Single-user, localhost-only Web GUI backend. No auth, no multi-tenant.
See plan §1.6 + DR-3. Top-level entry: :func:`my_deepagent.api.app.create_app`.
"""
from .app import create_app
__all__ = ["create_app"]

View File

@@ -0,0 +1,113 @@
"""FastAPI application factory for v0.2 PR #3.
Single uvicorn worker. localhost-only by default (see cli/serve.py for the
`--host` warning). No auth. CORS restricted to `http://localhost:<any port>`.
"""
from __future__ import annotations
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from starlette.responses import FileResponse
from ..config import Config, load_config
from ..persistence.db import Database
from ..persona import load_personas_from_dir
from ..workflow import WorkflowTemplate, load_workflow_yaml
from .routes import budget as budget_routes
from .routes import personas as personas_routes
from .routes import runs as runs_routes
from .routes import workflows as workflows_routes
_DOCS_SCHEMAS = Path(__file__).resolve().parents[3] / "docs" / "schemas"
_STATIC_ROOT = Path(__file__).resolve().parents[3] / "static"
_LOG = logging.getLogger(__name__)
def _load_seed_workflows() -> list[tuple[Path, WorkflowTemplate]]:
"""Return (path, WorkflowTemplate) for every YAML in docs/schemas/workflows/.
Malformed YAMLs are logged and skipped — the API should still come up
cleanly even if one seed is broken.
"""
wf_dir = _DOCS_SCHEMAS / "workflows"
if not wf_dir.is_dir():
return []
out: list[tuple[Path, WorkflowTemplate]] = []
for p in sorted(wf_dir.glob("*.yaml")):
try:
tpl = load_workflow_yaml(p)
except Exception as e:
_LOG.warning("skipping malformed workflow yaml %s: %s", p, e)
continue
out.append((p, tpl))
return out
@asynccontextmanager
async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Initialize the shared Database, personas, workflows on startup; dispose on shutdown."""
config: Config = app.state.config or load_config()
db = Database(config.database_url)
# init_schema is a no-op against an already-migrated DB; cheap to call.
await db.init_schema()
app.state.config = config
app.state.db = db
app.state.personas = load_personas_from_dir(_DOCS_SCHEMAS / "personas")
app.state.workflows = _load_seed_workflows()
try:
yield
finally:
await db.dispose()
def create_app(config: Config | None = None) -> FastAPI:
"""Build the FastAPI app. `config` defaults to `load_config()`.
`mydeepagent serve` calls this and hands it to uvicorn. Tests can also
call this with a custom Config + use httpx ASGI transport.
"""
app = FastAPI(
title="my-deepagent",
version="0.2.0",
description="Single-user local Web GUI for the my-deepagent CLI.",
lifespan=_lifespan,
)
app.state.config = config
app.add_middleware(
CORSMiddleware,
allow_origin_regex=r"^http://localhost(:\d+)?$",
allow_credentials=False,
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
allow_headers=["*"],
)
# API routes
app.include_router(runs_routes.router, prefix="/api/runs", tags=["runs"])
app.include_router(personas_routes.router, prefix="/api/personas", tags=["personas"])
app.include_router(workflows_routes.router, prefix="/api/workflows", tags=["workflows"])
app.include_router(budget_routes.router, prefix="/api/budget", tags=["budget"])
# Static frontend (built later in D3). Optional — if static/ is missing, skip.
if _STATIC_ROOT.is_dir():
app.mount("/static", StaticFiles(directory=str(_STATIC_ROOT)), name="static")
@app.get("/", include_in_schema=False)
async def _root() -> FileResponse:
return FileResponse(str(_STATIC_ROOT / "index.html"))
@app.get("/{page}.html", include_in_schema=False)
async def _static_page(page: str) -> FileResponse:
# Only serve known pages — others 404 via FileResponse missing.
target = _STATIC_ROOT / f"{page}.html"
return FileResponse(str(target))
return app

View File

@@ -0,0 +1,46 @@
"""Shared FastAPI dependencies.
Pulls singletons stashed in `app.state` by the lifespan handler. Database is
created ONCE per uvicorn process; per-request creation would defeat
connection pooling.
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
from fastapi import Request
from ..config import Config
from ..persistence.db import Database
if TYPE_CHECKING:
from ..persona import Persona
from ..workflow import WorkflowTemplate
_DOCS_SCHEMAS = Path(__file__).resolve().parents[3] / "docs" / "schemas"
def get_db(request: Request) -> Database:
"""Return the shared Database instance from app state."""
return request.app.state.db # type: ignore[no-any-return]
def get_config(request: Request) -> Config:
return request.app.state.config # type: ignore[no-any-return]
def get_personas(request: Request) -> list[Persona]:
return request.app.state.personas # type: ignore[no-any-return]
def get_workflows(request: Request) -> list[tuple[Path, WorkflowTemplate]]:
"""Return a list of (yaml_path, WorkflowTemplate) for all seed workflows."""
return request.app.state.workflows # type: ignore[no-any-return]
def seed_root() -> Path:
"""Filesystem root for `docs/schemas/` seed assets."""
return _DOCS_SCHEMAS

View File

@@ -0,0 +1,146 @@
"""pydantic v2 response models for the my-deepagent HTTP API.
These shapes are stable contracts the Web GUI depends on. Internal ORM models
in `my_deepagent.persistence.models` are NOT exposed directly; routes convert
ORM rows into these DTOs.
"""
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field
class _Strict(BaseModel):
"""Base for API DTOs — extra=forbid to catch typos at deserialization time."""
model_config = ConfigDict(extra="forbid", frozen=True)
# ---------------------------------------------------------------------------
# /api/runs
# ---------------------------------------------------------------------------
class RunSummary(_Strict):
id: str
state: str
repo_path: str
base_branch: str
worktree_root: str
created_at: str
ended_at: str | None = None
final_report_path: str | None = None
class PhaseInfo(_Strict):
id: str
phase_key: str
seq: int
state: str
attempts: int
started_at: str | None = None
ended_at: str | None = None
class ArtifactInfo(_Strict):
id: str
phase_id: str
schema_id: str
path: str
valid: bool
created_at: str
class EventInfo(_Strict):
seq: int
phase_id: str | None = None
type: str
ts: str
payload: dict[str, object] | None = None
class RunDetail(_Strict):
run: RunSummary
phases: list[PhaseInfo]
artifacts: list[ArtifactInfo]
events: list[EventInfo]
# ---------------------------------------------------------------------------
# /api/runs POST body
# ---------------------------------------------------------------------------
class StartRunRequest(BaseModel):
"""User-submitted body for POST /api/runs (NOT frozen — input not response)."""
model_config = ConfigDict(extra="forbid")
template_path: str = Field(min_length=1)
repo_path: str = Field(min_length=1)
base_branch: str = "main"
requirements_md: str = ""
override: dict[str, str] | None = None
class StartRunResponse(_Strict):
run_id: str
state: str
message: str = "started"
# ---------------------------------------------------------------------------
# /api/personas
# ---------------------------------------------------------------------------
class PersonaSummary(_Strict):
name: str
version: int
description: str | None = None
model: str
capabilities: list[str]
max_risk_level: str
# ---------------------------------------------------------------------------
# /api/workflows
# ---------------------------------------------------------------------------
class WorkflowRoleSummary(_Strict):
id: str
required_capabilities: list[str]
class WorkflowPhaseSummary(_Strict):
key: str
title: str
risk: str
role: str
class WorkflowSummary(_Strict):
path: str # relative path under docs/schemas/workflows/
name: str
version: int
description: str | None = None
roles: list[WorkflowRoleSummary]
phases: list[WorkflowPhaseSummary]
# ---------------------------------------------------------------------------
# /api/budget
# ---------------------------------------------------------------------------
class BudgetScopeEntry(_Strict):
scope: str
spent_usd: float
cap_usd: float | None
warn_usd: float | None = None
class BudgetSummary(_Strict):
day: BudgetScopeEntry | None
runs: list[BudgetScopeEntry]
personas: list[BudgetScopeEntry]

View File

@@ -0,0 +1 @@
"""Per-resource FastAPI route modules. Mounted from `app.create_app`."""

View File

@@ -0,0 +1,64 @@
"""GET /api/budget — current budget ledger summary."""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlalchemy import select
from ...config import Config
from ...persistence.db import Database
from ...persistence.models import BudgetLedgerRow
from ..deps import get_config, get_db
from ..models import BudgetScopeEntry, BudgetSummary
router = APIRouter()
DbDep = Annotated[Database, Depends(get_db)]
ConfigDep = Annotated[Config, Depends(get_config)]
def _scope_kind(scope: str) -> str:
"""Classify a ledger scope into 'day', 'run', or 'persona' bucket."""
if scope.startswith("day:"):
return "day"
if scope.startswith("persona:"):
return "persona"
if scope.startswith("run:"):
return "run"
return "other"
@router.get("", response_model=BudgetSummary)
async def get_budget_summary(db: DbDep, config: ConfigDep) -> BudgetSummary:
async with db.session() as s:
rows = (await s.execute(select(BudgetLedgerRow))).scalars().all()
day: BudgetScopeEntry | None = None
runs: list[BudgetScopeEntry] = []
personas: list[BudgetScopeEntry] = []
for r in rows:
kind = _scope_kind(r.scope)
warn_usd: float | None
if kind == "day":
warn_usd = config.budget_daily_warn_usd
elif kind == "run":
warn_usd = config.budget_run_warn_usd
else:
warn_usd = None
entry = BudgetScopeEntry(
scope=r.scope,
spent_usd=float(r.spent_usd),
cap_usd=float(r.cap_usd) if r.cap_usd is not None else None,
warn_usd=warn_usd,
)
if kind == "day":
day = entry
elif kind == "run":
runs.append(entry)
elif kind == "persona":
personas.append(entry)
return BudgetSummary(day=day, runs=runs, personas=personas)

View File

@@ -0,0 +1,30 @@
"""GET /api/personas — list seed personas."""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends
from ...persona import Persona
from ..deps import get_personas
from ..models import PersonaSummary
router = APIRouter()
PersonasDep = Annotated[list[Persona], Depends(get_personas)]
@router.get("", response_model=list[PersonaSummary])
async def list_personas(personas: PersonasDep) -> list[PersonaSummary]:
return [
PersonaSummary(
name=p.name,
version=p.version,
description=p.description,
model=p.model,
capabilities=[c.value for c in p.capabilities],
max_risk_level=p.max_risk_level.value,
)
for p in personas
]

View File

@@ -0,0 +1,294 @@
"""GET/POST /api/runs — list, detail, start, resume, abort, SSE event stream."""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import desc, select
from sse_starlette.sse import EventSourceResponse
from ...config import Config
from ...errors import MyDeepAgentError
from ...persistence.db import Database
from ...persistence.models import ArtifactRow, RunEventRow, RunPhaseRow, RunRow
from ...persona import Persona
from ...run_event import RunEventType
from .. import runner
from ..deps import get_config, get_db, get_personas, seed_root
from ..models import (
ArtifactInfo,
EventInfo,
PhaseInfo,
RunDetail,
RunSummary,
StartRunRequest,
StartRunResponse,
)
from ..sse import run_events_stream
_LOG = logging.getLogger(__name__)
router = APIRouter()
DbDep = Annotated[Database, Depends(get_db)]
ConfigDep = Annotated[Config, Depends(get_config)]
PersonasDep = Annotated[list[Persona], Depends(get_personas)]
def _row_to_summary(row: RunRow) -> RunSummary:
return RunSummary(
id=row.id,
state=row.state,
repo_path=row.repo_path,
base_branch=row.base_branch,
worktree_root=row.worktree_root,
created_at=row.created_at,
ended_at=row.ended_at,
final_report_path=row.final_report_path,
)
@router.get("", response_model=list[RunSummary])
async def list_runs(
db: DbDep,
limit: int = Query(default=50, ge=1, le=200),
state: str | None = Query(default=None),
) -> list[RunSummary]:
"""List the most recent runs (default 50). Optional `?state=` filter."""
async with db.session() as s:
stmt = select(RunRow).order_by(desc(RunRow.created_at)).limit(limit)
if state is not None:
stmt = stmt.where(RunRow.state == state)
rows = (await s.execute(stmt)).scalars().all()
return [_row_to_summary(r) for r in rows]
@router.get("/{run_id}", response_model=RunDetail)
async def get_run(run_id: str, db: DbDep) -> RunDetail:
"""Return a single run + its phases, artifacts, recent events."""
async with db.session() as s:
run = await s.get(RunRow, run_id)
if run is None:
raise HTTPException(status_code=404, detail=f"run {run_id} not found")
phases = (
(
await s.execute(
select(RunPhaseRow)
.where(RunPhaseRow.run_id == run_id)
.order_by(RunPhaseRow.seq)
)
)
.scalars()
.all()
)
artifacts = (
(await s.execute(select(ArtifactRow).where(ArtifactRow.run_id == run_id)))
.scalars()
.all()
)
events = (
(
await s.execute(
select(RunEventRow)
.where(RunEventRow.run_id == run_id)
.order_by(RunEventRow.seq.desc())
.limit(100)
)
)
.scalars()
.all()
)
return RunDetail(
run=_row_to_summary(run),
phases=[
PhaseInfo(
id=p.id,
phase_key=p.phase_key,
seq=p.seq,
state=p.state,
attempts=p.attempts,
started_at=p.started_at,
ended_at=p.ended_at,
)
for p in phases
],
artifacts=[
ArtifactInfo(
id=a.id,
phase_id=a.phase_id,
schema_id=a.schema_id,
path=a.path,
valid=a.valid,
created_at=a.created_at,
)
for a in artifacts
],
events=[
EventInfo(
seq=e.seq,
phase_id=e.phase_id,
type=e.type,
ts=e.ts,
payload=e.payload if isinstance(e.payload, dict) else None,
)
for e in reversed(events) # oldest first for display
],
)
# ---------------------------------------------------------------------------
# POST /api/runs — start a new run in the background
# ---------------------------------------------------------------------------
@router.post("", response_model=StartRunResponse)
async def start_run(
body: StartRunRequest,
db: DbDep,
config: ConfigDep,
personas: PersonasDep,
) -> StartRunResponse:
"""Kick off a new run; returns the run_id once persistence is in motion."""
template_path = Path(body.template_path)
if not template_path.is_absolute():
template_path = seed_root() / "workflows" / template_path.name
if not template_path.is_file():
raise HTTPException(
status_code=400, detail=f"workflow template not found: {body.template_path}"
)
try:
run_id = await runner.start_new_run(
db=db,
config=config,
personas=personas,
seed_root=seed_root(),
template_path=template_path,
repo_path=Path(body.repo_path),
base_branch=body.base_branch,
requirements_md=body.requirements_md,
override=body.override,
)
except MyDeepAgentError as e:
raise HTTPException(status_code=400, detail=f"{e.code}: {e}") from e
return StartRunResponse(run_id=str(run_id), state="executing", message="started")
# ---------------------------------------------------------------------------
# POST /api/runs/{run_id}/resume
# ---------------------------------------------------------------------------
@router.post("/{run_id}/resume", response_model=StartRunResponse)
async def resume_run(
run_id: str,
db: DbDep,
config: ConfigDep,
personas: PersonasDep,
) -> StartRunResponse:
"""Re-enter `engine.resume(run_id)` in the background."""
async with db.session() as s:
run = await s.get(RunRow, run_id)
if run is None:
raise HTTPException(status_code=404, detail=f"run {run_id} not found")
if run.state in ("completed", "failed", "aborted"):
raise HTTPException(
status_code=409,
detail=f"run {run_id} is already terminal ({run.state})",
)
try:
await runner.start_resume(
db=db,
config=config,
personas=personas,
seed_root=seed_root(),
run_id=UUID(run_id),
)
except MyDeepAgentError as e:
raise HTTPException(status_code=400, detail=f"{e.code}: {e}") from e
return StartRunResponse(run_id=run_id, state="executing", message="resuming")
# ---------------------------------------------------------------------------
# POST /api/runs/{run_id}/abort
# ---------------------------------------------------------------------------
@router.post("/{run_id}/abort", response_model=StartRunResponse)
async def abort_run(run_id: str, db: DbDep) -> StartRunResponse:
"""Force-mark a non-terminal run as aborted + emit a RUN_ABORTED event.
Does not actually cancel the background task (asyncio cancellation across
arbitrary engine code is unsafe). The next phase boundary picks up the
state change.
"""
async with db.session() as s:
run = await s.get(RunRow, run_id)
if run is None:
raise HTTPException(status_code=404, detail=f"run {run_id} not found")
if run.state in ("completed", "failed", "aborted"):
raise HTTPException(
status_code=409,
detail=f"run {run_id} is already terminal ({run.state})",
)
run.state = "aborted"
# Append a synthesized run.aborted event for the SSE stream.
next_seq = (
await s.execute(
select(RunEventRow.seq)
.where(RunEventRow.run_id == run_id)
.order_by(RunEventRow.seq.desc())
.limit(1)
)
).scalar_one_or_none() or 0
s.add(
RunEventRow(
run_id=run_id,
phase_id=None,
seq=int(next_seq) + 1,
type=RunEventType.RUN_ABORTED.value,
payload={"reason": "user_abort_via_api"},
idempotency_key=f"run.aborted:{run_id}:user_api",
ts=run.updated_at,
)
)
await s.commit()
return StartRunResponse(run_id=run_id, state="aborted", message="aborted")
# ---------------------------------------------------------------------------
# GET /api/runs/{run_id}/events — Server-Sent Events stream
# ---------------------------------------------------------------------------
@router.get("/{run_id}/events")
async def stream_events(
run_id: str,
request: Request,
db: DbDep,
last_event_id: int = Query(default=0, alias="last_event_id", ge=0),
) -> EventSourceResponse:
"""SSE stream of run_events. Closes when the run reaches a terminal state.
Honors `Last-Event-ID` HTTP header (standard EventSource reconnect) AND
the `?last_event_id=` query param as a fallback for clients that can't
set headers (vanilla `<a href>` opens).
"""
# Standard `Last-Event-ID` header takes priority over the query param.
header_val = request.headers.get("last-event-id")
if header_val:
try:
last_event_id = max(last_event_id, int(header_val))
except ValueError:
pass
async with db.session() as s:
run = await s.get(RunRow, run_id)
if run is None:
raise HTTPException(status_code=404, detail=f"run {run_id} not found")
return EventSourceResponse(run_events_stream(db, run_id, last_event_id=last_event_id))

View File

@@ -0,0 +1,52 @@
"""GET /api/workflows — list seed workflow templates."""
from __future__ import annotations
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends
from ...workflow import WorkflowTemplate
from ..deps import get_workflows, seed_root
from ..models import WorkflowPhaseSummary, WorkflowRoleSummary, WorkflowSummary
router = APIRouter()
WorkflowsDep = Annotated[list[tuple[Path, WorkflowTemplate]], Depends(get_workflows)]
@router.get("", response_model=list[WorkflowSummary])
async def list_workflows(workflows: WorkflowsDep) -> list[WorkflowSummary]:
base = seed_root() / "workflows"
out: list[WorkflowSummary] = []
for path, tpl in workflows:
try:
rel = path.relative_to(base.parent)
except ValueError:
rel = path
out.append(
WorkflowSummary(
path=str(rel),
name=tpl.name,
version=tpl.version,
description=tpl.description,
roles=[
WorkflowRoleSummary(
id=r.id,
required_capabilities=[c.value for c in r.required_capabilities],
)
for r in tpl.roles
],
phases=[
WorkflowPhaseSummary(
key=ph.key,
title=ph.title,
risk=ph.risk.value,
role=ph.role,
)
for ph in tpl.phases
],
)
)
return out

View File

@@ -0,0 +1,140 @@
"""Background task runner for engine.run / engine.resume invocations.
v0.2 PR #3 scope: single uvicorn worker. The in-memory `_tasks` dict only makes
sense within one process; multi-worker fanout (Redis / NOTIFY) is v0.3 work.
Lifecycle:
- POST /api/runs pre-allocates a UUID, schedules `engine.run(pre_allocated_run_id=...)`
as `asyncio.create_task`, returns the UUID immediately. The task continues
until completion / abort / process shutdown.
- POST /api/runs/{id}/resume schedules `engine.resume(run_id)` the same way.
- If the uvicorn process dies mid-run, the next startup's `sweep_orphan_runs`
marks the non-terminal run as failed.
The `_tasks` dict's only purpose is to prevent garbage collection of in-flight
tasks (asyncio.create_task returns a reference but otherwise nothing pins it).
"""
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from typing import Any
from uuid import UUID, uuid4
from ..artifact_schema import ArtifactSchemaRegistry
from ..binding import BackendAvailability, BindingOverride, PersonaConsentStore
from ..budget import make_budget_tracker_from_config
from ..config import Config
from ..engine import WorkflowEngine
from ..enums import ApprovalDecisionAction, Backend
from ..persistence.db import Database
from ..persona import Persona
from ..workflow import load_workflow_yaml
_LOG = logging.getLogger(__name__)
# In-process registry of background run tasks, keyed by run_id.
_tasks: dict[UUID, asyncio.Task[Any]] = {}
async def _auto_approve(_payload: dict[str, object], _gates: list[str]) -> object:
"""GUI runs have no interactive prompt — auto-approve every gate.
Future: a /api/approvals route + websocket round-trip can replace this.
"""
return ApprovalDecisionAction.APPROVE
async def _build_engine(
db: Database,
config: Config,
personas: list[Persona],
seed_root: Path,
) -> WorkflowEngine:
registry = ArtifactSchemaRegistry(roots=[seed_root / "artifacts"])
consent = PersonaConsentStore(config.data_dir / "consents.json")
backends = BackendAvailability(available_backends=frozenset(Backend))
budget = make_budget_tracker_from_config(db, config)
await budget.init()
return WorkflowEngine(
db=db,
config=config,
persona_pool=personas,
artifact_registry=registry,
consent_store=consent,
available_backends=backends,
approval_callback=_auto_approve,
budget_tracker=budget,
)
async def start_new_run(
db: Database,
config: Config,
personas: list[Persona],
seed_root: Path,
template_path: Path,
repo_path: Path,
base_branch: str,
requirements_md: str,
override: dict[str, str] | None,
) -> UUID:
"""Schedule a new engine.run as a background task and return the run_id.
The run_id is pre-allocated here (uuid4) and passed to `engine.run` via
`pre_allocated_run_id`, so the route can return it before the phase loop
completes.
"""
template = load_workflow_yaml(template_path)
engine = await _build_engine(db, config, personas, seed_root)
run_id = uuid4()
async def _wrapped() -> None:
try:
await engine.run(
template,
repo_path=repo_path,
base_branch=base_branch,
requirements_md=requirements_md,
override=BindingOverride.parse(override) if override else None,
pre_allocated_run_id=run_id,
)
except Exception:
_LOG.exception("background run %s failed", run_id)
raise
finally:
_tasks.pop(run_id, None)
_tasks[run_id] = asyncio.create_task(_wrapped())
return run_id
async def start_resume(
db: Database,
config: Config,
personas: list[Persona],
seed_root: Path,
run_id: UUID,
) -> UUID:
"""Schedule engine.resume(run_id) as a background task."""
engine = await _build_engine(db, config, personas, seed_root)
async def _wrapped() -> None:
try:
await engine.resume(run_id)
except Exception:
_LOG.exception("background resume failed for run %s", run_id)
raise
finally:
_tasks.pop(run_id, None)
_tasks[run_id] = asyncio.create_task(_wrapped())
return run_id
def is_running(run_id: UUID) -> bool:
"""True if a background task for run_id is still in-flight."""
task = _tasks.get(run_id)
return task is not None and not task.done()

View File

@@ -0,0 +1,94 @@
"""SSE stream builder for `/api/runs/{id}/events`.
v0.2 PR #3 scope: poll-based stream against the `run_events` table.
v0.3 will upgrade to Postgres `LISTEN/NOTIFY` (ADR pending).
Pattern:
1. Client opens EventSource. Optional `?last_event_id=<int>` query param for
resume after a disconnect.
2. Backfill: SELECT events WHERE run_id=? AND seq > last_event_id ORDER BY seq.
3. Live tail: every `_POLL_INTERVAL_S` seconds, SELECT new events since the
last seen seq. Emit each as `data: <json>\\n\\n` with `id: <seq>`.
4. When the run reaches a terminal state (completed/failed/aborted), emit one
final `event: done` and close. HTTP 200 → EventSource will NOT reconnect.
Each event payload:
{
"seq": int,
"type": "run.started" | "phase.completed" | ... ,
"ts": "ISO",
"phase_id": str | null,
"payload": dict | null
}
"""
from __future__ import annotations
import asyncio
import json
from collections.abc import AsyncIterator
from sqlalchemy import select
from sse_starlette.event import ServerSentEvent
from ..persistence.db import Database
from ..persistence.models import RunEventRow, RunRow
_POLL_INTERVAL_S: float = 0.5
_TERMINAL_STATES: frozenset[str] = frozenset({"completed", "failed", "aborted"})
async def run_events_stream(
db: Database, run_id: str, last_event_id: int = 0
) -> AsyncIterator[ServerSentEvent]:
"""Yield ServerSentEvent objects for a single run until it reaches terminal state."""
last_seen = last_event_id
while True:
# Pull new events since last_seen.
async with db.session() as s:
rows = (
(
await s.execute(
select(RunEventRow)
.where(RunEventRow.run_id == run_id)
.where(RunEventRow.seq > last_seen)
.order_by(RunEventRow.seq)
)
)
.scalars()
.all()
)
for row in rows:
evt_data = {
"seq": row.seq,
"type": row.type,
"ts": row.ts,
"phase_id": row.phase_id,
"payload": row.payload if isinstance(row.payload, dict) else None,
}
yield ServerSentEvent(
data=json.dumps(evt_data, ensure_ascii=False),
event="event",
id=str(row.seq),
)
last_seen = row.seq
# Check whether the run is terminal — break only after draining
# the last batch of events.
async with db.session() as s:
run = await s.get(RunRow, run_id)
if run is None:
# The run was deleted out from under us; close gracefully.
yield ServerSentEvent(data="run-not-found", event="error")
break
if run.state in _TERMINAL_STATES:
yield ServerSentEvent(
data=json.dumps({"state": run.state}),
event="done",
id=str(last_seen),
)
break
await asyncio.sleep(_POLL_INTERVAL_S)

View File

@@ -123,6 +123,17 @@ def pricing() -> None:
pricing_command()
@app.command()
def serve(
host: str = typer.Option("127.0.0.1", help="Bind host (use 0.0.0.0 at your own risk)"),
port: int = typer.Option(8000, help="Bind port"),
) -> None:
"""Launch the FastAPI Web GUI backend on http://<host>:<port>."""
from .serve import serve_command
serve_command(host, port)
@app.callback(invoke_without_command=True)
def main(
ctx: typer.Context,

View File

@@ -0,0 +1,41 @@
"""`mydeepagent serve` — launch the FastAPI Web GUI backend via uvicorn.
v0.2 PR #3 scope: localhost only, single uvicorn worker. The `--host`
override is honored but `--host 127.0.0.1` is enforced as the default and a
loud stderr warning is printed for any non-loopback value (the API has no
auth — exposing it on a network interface is a footgun).
"""
from __future__ import annotations
import sys
import typer
import uvicorn
_LOOPBACK_HOSTS: frozenset[str] = frozenset({"127.0.0.1", "localhost", "::1"})
def serve_command(host: str, port: int) -> None:
if host not in _LOOPBACK_HOSTS:
# CLAUDE.md §10 — surface the safety issue loudly rather than silently bind.
msg = (
f"\n⚠️ mydeepagent serve is binding to {host!r} (not loopback).\n"
" The API has no authentication. Anyone with network access to this\n"
" port can read every run, start new runs, and read OpenRouter costs.\n"
" Use a reverse proxy with auth, or stick to 127.0.0.1 / localhost.\n"
)
typer.secho(msg, fg=typer.colors.YELLOW, err=True)
sys.stderr.flush()
# Use the factory form so uvicorn calls `create_app()` itself; this keeps
# config load + lifespan setup inside the uvicorn worker rather than the
# CLI process.
uvicorn.run(
"my_deepagent.api.app:create_app",
host=host,
port=port,
workers=1, # v0.2 single-worker assumption per plan.md §1.7 / DR-3
factory=True,
log_level="info",
)

View File

@@ -167,13 +167,17 @@ class WorkflowEngine:
base_branch: str = "main",
requirements_md: str = "",
override: BindingOverride | None = None,
pre_allocated_run_id: UUID | None = None,
) -> RunResult:
"""Start a brand-new run. Allocates a new `run_id`, binds personas, persists
skeleton metadata, and dispatches to the shared `_execute_run` phase loop.
For resuming an existing non-terminal run, use :meth:`resume` instead.
`pre_allocated_run_id` lets the FastAPI runner pick the UUID up-front
so the route can return it before the phase loop completes.
"""
run_id = uuid4()
run_id = pre_allocated_run_id if pre_allocated_run_id is not None else uuid4()
worktree_root = self._config.workspace_root / str(run_id)
worktree_root.mkdir(parents=True, exist_ok=True)
artifacts_dir = worktree_root / "artifacts"

324
my-deepagent/static/app.js Normal file
View File

@@ -0,0 +1,324 @@
/* my-deepagent Web GUI — vanilla JS for v0.2 PR #3.
*
* SECURITY (XSS policy):
* All user-controlled strings MUST be inserted via element.textContent.
* element.innerHTML / insertAdjacentHTML / outerHTML are FORBIDDEN.
* See plan.md D3 "GUI 정책 명시" — markdown / HTML rendering is v0.3+.
*/
const API = window.location.origin + "/api";
function $(sel) { return document.querySelector(sel); }
function $$(sel) { return Array.from(document.querySelectorAll(sel)); }
async function jsonFetch(path, opts = {}) {
const r = await fetch(API + path, opts);
if (!r.ok) {
let detail = r.statusText;
try {
const body = await r.json();
detail = body.detail || JSON.stringify(body);
} catch (_) { /* ignore */ }
throw new Error(`${r.status} ${detail}`);
}
return r.json();
}
function setError(msg) {
const el = $("#error");
if (!el) return;
if (msg) {
el.textContent = msg;
el.style.display = "block";
} else {
el.style.display = "none";
}
}
// =============== index.html ===============
async function renderRunsList() {
setError("");
let runs;
try {
runs = await jsonFetch("/runs?limit=50");
} catch (e) {
setError(`runs 목록을 불러오지 못했습니다: ${e.message}`);
return;
}
const tbody = $("#runs tbody");
tbody.replaceChildren();
if (runs.length === 0) {
const tr = document.createElement("tr");
const td = document.createElement("td");
td.colSpan = 6;
td.className = "empty";
td.textContent = "아직 실행된 run이 없습니다. 신규 run을 시작해 보세요.";
tr.appendChild(td);
tbody.appendChild(tr);
return;
}
for (const r of runs) {
const tr = document.createElement("tr");
const cells = [
["id", r.id.slice(0, 8) + "…", { href: `/run.html?id=${r.id}` }],
["state", r.state, { state: r.state }],
["repo", r.repo_path],
["branch", r.base_branch],
["created", (r.created_at || "").slice(0, 19)],
["ended", (r.ended_at || "—").slice(0, 19)],
];
for (const [_, v, opts] of cells) {
const td = document.createElement("td");
if (opts && opts.state) td.className = `state-${opts.state}`;
if (opts && opts.href) {
const a = document.createElement("a");
a.href = opts.href;
a.textContent = v;
td.appendChild(a);
} else {
td.textContent = v;
}
tr.appendChild(td);
}
tbody.appendChild(tr);
}
}
async function renderBudgetSummary() {
const container = $("#budget-summary");
if (!container) return;
container.replaceChildren();
try {
const summary = await jsonFetch("/budget");
function line(scope, spent, cap, warn) {
const div = document.createElement("div");
div.className = "budget-line";
const left = document.createElement("span");
left.className = "scope";
left.textContent = scope;
const right = document.createElement("span");
right.className = "amount";
const capStr = cap != null ? ` / $${cap.toFixed(2)}` : "";
right.textContent = `$${spent.toFixed(4)}${capStr}`;
if (cap != null && spent >= cap) right.classList.add("over");
else if (warn != null && spent >= warn) right.classList.add("warn");
div.appendChild(left);
div.appendChild(right);
container.appendChild(div);
}
if (summary.day) line(summary.day.scope, summary.day.spent_usd, summary.day.cap_usd, summary.day.warn_usd);
for (const r of summary.runs) line(r.scope, r.spent_usd, r.cap_usd, r.warn_usd);
for (const p of summary.personas) line(p.scope, p.spent_usd, p.cap_usd, p.warn_usd);
if (!summary.day && summary.runs.length === 0 && summary.personas.length === 0) {
const empty = document.createElement("div");
empty.className = "empty";
empty.textContent = "지출 기록이 없습니다.";
container.appendChild(empty);
}
} catch (e) {
const err = document.createElement("div");
err.className = "empty";
err.textContent = `budget 정보를 불러오지 못했습니다: ${e.message}`;
container.appendChild(err);
}
}
// =============== new.html ===============
async function renderNewRunForm() {
setError("");
const tplSelect = $("#template");
const overrideContainer = $("#override-fields");
let workflows = [];
let personas = [];
try {
[workflows, personas] = await Promise.all([
jsonFetch("/workflows"),
jsonFetch("/personas"),
]);
} catch (e) {
setError(`workflow / persona 목록을 불러오지 못했습니다: ${e.message}`);
return;
}
for (const w of workflows) {
const opt = document.createElement("option");
opt.value = w.path.replace(/^.*workflows\//, "");
opt.textContent = `${w.name}@${w.version}${w.description || ""}`;
opt.dataset.roles = JSON.stringify(w.roles.map((r) => r.id));
tplSelect.appendChild(opt);
}
tplSelect.addEventListener("change", () => {
overrideContainer.replaceChildren();
const roles = JSON.parse(tplSelect.selectedOptions[0].dataset.roles || "[]");
for (const role of roles) {
const label = document.createElement("label");
label.textContent = `${role} (선택 사항: persona 이름)`;
const input = document.createElement("input");
input.dataset.role = role;
input.className = "override-input";
input.placeholder = "openrouter-deepseek-spec-writer@1";
overrideContainer.appendChild(label);
overrideContainer.appendChild(input);
}
});
if (tplSelect.options.length > 0) {
tplSelect.value = tplSelect.options[0].value;
tplSelect.dispatchEvent(new Event("change"));
}
$("#start-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
setError("");
const override = {};
for (const input of $$(".override-input")) {
if (input.value.trim()) override[input.dataset.role] = input.value.trim();
}
const body = {
template_path: tplSelect.value,
repo_path: $("#repo-path").value.trim(),
base_branch: $("#base-branch").value.trim() || "main",
requirements_md: $("#requirements").value,
};
if (Object.keys(override).length > 0) body.override = override;
try {
const r = await jsonFetch("/runs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
window.location.href = `/run.html?id=${r.run_id}`;
} catch (e) {
setError(`run 시작 실패: ${e.message}`);
}
});
}
// =============== run.html ===============
function getRunIdFromUrl() {
const params = new URLSearchParams(window.location.search);
return params.get("id");
}
async function renderRunDetail() {
setError("");
const runId = getRunIdFromUrl();
if (!runId) {
setError("URL에 ?id=<run_id> 가 필요합니다.");
return;
}
$("#run-id").textContent = runId;
try {
const detail = await jsonFetch(`/runs/${runId}`);
$("#run-state").textContent = detail.run.state;
$("#run-state").className = `state-${detail.run.state}`;
$("#run-repo").textContent = `${detail.run.repo_path} @ ${detail.run.base_branch}`;
$("#run-worktree").textContent = detail.run.worktree_root;
$("#run-report").textContent = detail.run.final_report_path || "—";
const phaseTbody = $("#phases tbody");
phaseTbody.replaceChildren();
for (const p of detail.phases) {
const tr = document.createElement("tr");
const cells = [
p.phase_key,
p.state,
String(p.attempts),
(p.started_at || "—").slice(0, 19),
(p.ended_at || "—").slice(0, 19),
];
for (let i = 0; i < cells.length; i++) {
const td = document.createElement("td");
td.textContent = cells[i];
if (i === 1) td.className = `state-${p.state}`;
tr.appendChild(td);
}
phaseTbody.appendChild(tr);
}
const isTerminal = ["completed", "failed", "aborted"].includes(detail.run.state);
$("#resume-btn").disabled = isTerminal;
$("#abort-btn").disabled = isTerminal;
} catch (e) {
setError(`run 정보를 불러오지 못했습니다: ${e.message}`);
return;
}
startEventStream(runId);
}
function startEventStream(runId) {
const eventsContainer = $("#events");
eventsContainer.replaceChildren();
const src = new EventSource(`${API}/runs/${runId}/events`);
src.addEventListener("event", (ev) => {
try {
const data = JSON.parse(ev.data);
const line = document.createElement("div");
line.className = "event-line";
const ts = document.createElement("span");
ts.className = "ts";
ts.textContent = (data.ts || "").slice(0, 19);
const type = document.createElement("span");
type.className = "type";
type.textContent = ` ${data.type}`;
line.appendChild(ts);
line.appendChild(type);
if (data.payload && Object.keys(data.payload).length > 0) {
const payload = document.createElement("span");
payload.textContent = " " + JSON.stringify(data.payload);
line.appendChild(payload);
}
eventsContainer.appendChild(line);
eventsContainer.scrollTop = eventsContainer.scrollHeight;
} catch (_) { /* ignore */ }
});
src.addEventListener("done", () => {
src.close();
// Refresh the detail panel one last time to pick up final state.
setTimeout(() => renderRunDetail(), 300);
});
src.onerror = () => {
src.close();
};
}
async function abortRun() {
const runId = getRunIdFromUrl();
if (!runId) return;
if (!confirm("정말 이 run을 abort 할까요?")) return;
try {
await jsonFetch(`/runs/${runId}/abort`, { method: "POST" });
renderRunDetail();
} catch (e) {
setError(`abort 실패: ${e.message}`);
}
}
async function resumeRun() {
const runId = getRunIdFromUrl();
if (!runId) return;
try {
await jsonFetch(`/runs/${runId}/resume`, { method: "POST" });
renderRunDetail();
} catch (e) {
setError(`resume 실패: ${e.message}`);
}
}
// =============== bootstrap ===============
document.addEventListener("DOMContentLoaded", () => {
const page = document.body.dataset.page;
if (page === "index") {
renderRunsList();
renderBudgetSummary();
} else if (page === "new") {
renderNewRunForm();
} else if (page === "run") {
renderRunDetail();
$("#abort-btn").addEventListener("click", abortRun);
$("#resume-btn").addEventListener("click", resumeRun);
}
});

View File

@@ -0,0 +1,39 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>my-deepagent · runs</title>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body data-page="index">
<header>
<h1>my-deepagent</h1>
<nav>
<a href="/">runs</a>
<a href="/new.html">새 run</a>
</nav>
</header>
<main>
<div id="error" class="error-banner" style="display:none"></div>
<h2>최근 run 50개</h2>
<table id="runs">
<thead>
<tr>
<th>run id</th>
<th>state</th>
<th>repo</th>
<th>branch</th>
<th>created</th>
<th>ended</th>
</tr>
</thead>
<tbody></tbody>
</table>
<h2>예산 (현재)</h2>
<div id="budget-summary"></div>
</main>
<script src="/static/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,53 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>my-deepagent · 새 run</title>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body data-page="new">
<header>
<h1>my-deepagent · 새 run 시작</h1>
<nav>
<a href="/">runs</a>
<a href="/new.html">새 run</a>
</nav>
</header>
<main>
<div id="error" class="error-banner" style="display:none"></div>
<form id="start-form">
<div class="form-row">
<label for="template">워크플로우 템플릿</label>
<select id="template" required></select>
</div>
<div class="form-row">
<label for="repo-path">repo 절대경로</label>
<input id="repo-path" type="text" placeholder="/Users/me/projects/my-thing" required />
</div>
<div class="form-row">
<label for="base-branch">base branch</label>
<input id="base-branch" type="text" value="main" />
</div>
<div class="form-row">
<label for="requirements">requirements (markdown 가능)</label>
<textarea id="requirements" rows="6" placeholder="이 workflow가 다룰 요구사항을 자유롭게 적어주세요."></textarea>
</div>
<h2>persona override (선택)</h2>
<p class="empty" style="padding:0">비워두면 자동 선택. 값은 <code>persona-name@버전</code> 형식.</p>
<div id="override-fields"></div>
<div class="action-bar">
<button type="submit">▶︎ 시작</button>
<a class="button" href="/">취소</a>
</div>
</form>
</main>
<script src="/static/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,51 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>my-deepagent · run 상세</title>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body data-page="run">
<header>
<h1>my-deepagent · run 상세</h1>
<nav>
<a href="/">runs</a>
<a href="/new.html">새 run</a>
</nav>
</header>
<main>
<div id="error" class="error-banner" style="display:none"></div>
<h2>요약</h2>
<div class="budget-line"><span class="scope">run id</span><span id="run-id" class="amount"></span></div>
<div class="budget-line"><span class="scope">state</span><span id="run-state" class="amount"></span></div>
<div class="budget-line"><span class="scope">repo</span><span id="run-repo" class="amount"></span></div>
<div class="budget-line"><span class="scope">worktree</span><span id="run-worktree" class="amount"></span></div>
<div class="budget-line"><span class="scope">final report</span><span id="run-report" class="amount"></span></div>
<div class="action-bar">
<button id="resume-btn" disabled>▶︎ resume</button>
<button id="abort-btn" class="danger" disabled>■ abort</button>
</div>
<h2>phases</h2>
<table id="phases">
<thead>
<tr>
<th>key</th>
<th>state</th>
<th>attempts</th>
<th>started</th>
<th>ended</th>
</tr>
</thead>
<tbody></tbody>
</table>
<h2>실시간 이벤트 (SSE)</h2>
<div id="events" class="events"></div>
</main>
<script src="/static/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,192 @@
/* my-deepagent Web GUI — v0.2 PR #3
* Vanilla CSS. No framework. Single dark-friendly theme tuned for
* data-heavy tables.
*/
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Apple SD Gothic Neo",
"Noto Sans KR", "Helvetica Neue", Arial, sans-serif;
margin: 0;
background: #0f1115;
color: #e6e7eb;
line-height: 1.5;
}
header {
background: #1a1d24;
padding: 1rem 2rem;
border-bottom: 1px solid #2a2d36;
display: flex;
justify-content: space-between;
align-items: center;
}
header h1 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #f5f6f9;
}
header nav a {
color: #8db4ff;
margin-left: 1rem;
text-decoration: none;
}
header nav a:hover {
text-decoration: underline;
}
main {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
h2 {
font-size: 1.1rem;
margin: 1.5rem 0 0.75rem;
color: #f5f6f9;
}
table {
width: 100%;
border-collapse: collapse;
background: #161922;
font-size: 0.9rem;
}
th, td {
text-align: left;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #232633;
}
th {
background: #1f2230;
color: #c4c6d0;
font-weight: 500;
}
td.state-completed { color: #8ee084; }
td.state-running, td.state-executing { color: #f5d674; }
td.state-failed, td.state-aborted { color: #f08585; }
td.state-pending, td.state-created { color: #8db4ff; }
a {
color: #8db4ff;
}
button, .button {
background: #2c3145;
color: #e6e7eb;
border: 1px solid #3a3f55;
border-radius: 4px;
padding: 0.4rem 0.9rem;
font-size: 0.9rem;
cursor: pointer;
font-family: inherit;
}
button:hover, .button:hover {
background: #353a54;
}
button.danger {
background: #4a2a2a;
border-color: #5e3535;
}
button.danger:hover {
background: #5e3535;
}
input, textarea, select {
width: 100%;
background: #1a1d24;
color: #e6e7eb;
border: 1px solid #2a2d36;
border-radius: 4px;
padding: 0.5rem 0.75rem;
font-family: inherit;
font-size: 0.95rem;
}
label {
display: block;
margin: 0.75rem 0 0.25rem;
color: #c4c6d0;
font-size: 0.85rem;
}
.form-row {
margin-bottom: 1rem;
}
.empty {
color: #6c7080;
font-style: italic;
padding: 1rem;
}
pre {
background: #161922;
border: 1px solid #232633;
border-radius: 4px;
padding: 0.75rem;
overflow-x: auto;
font-size: 0.8rem;
font-family: "SF Mono", "Monaco", "Cascadia Code", monospace;
}
.events {
max-height: 60vh;
overflow-y: auto;
background: #161922;
border: 1px solid #232633;
border-radius: 4px;
padding: 0.75rem;
font-family: "SF Mono", "Monaco", "Cascadia Code", monospace;
font-size: 0.8rem;
}
.event-line {
margin-bottom: 0.25rem;
white-space: pre-wrap;
word-break: break-all;
}
.event-line .ts { color: #6c7080; }
.event-line .type { color: #8db4ff; font-weight: 500; }
.action-bar {
margin: 1rem 0;
display: flex;
gap: 0.5rem;
}
.budget-line {
display: flex;
justify-content: space-between;
padding: 0.25rem 0.5rem;
font-size: 0.85rem;
}
.budget-line .scope { color: #c4c6d0; }
.budget-line .amount { color: #8ee084; }
.budget-line .amount.warn { color: #f5d674; }
.budget-line .amount.over { color: #f08585; }
.error-banner {
background: #4a2a2a;
border: 1px solid #5e3535;
border-radius: 4px;
padding: 0.75rem 1rem;
margin: 1rem 0;
color: #f4c1c1;
}

View File

@@ -0,0 +1,91 @@
"""GET /api/runs|personas|workflows|budget — D1 read-only route smoke tests."""
from __future__ import annotations
from collections.abc import AsyncIterator
from pathlib import Path
import pytest
from httpx import ASGITransport, AsyncClient
from my_deepagent.api.app import create_app
from my_deepagent.config import load_config
from my_deepagent.persistence.db import Database
@pytest.fixture
async def app_client(tmp_path: Path) -> AsyncIterator[AsyncClient]:
"""Yield an AsyncClient wired to an isolated config + sqlite test DB.
httpx's `ASGITransport` does NOT trigger FastAPI's lifespan, so we run
`app.router.lifespan_context(app)` manually around the client.
"""
db_url = f"sqlite+aiosqlite:///{tmp_path / 'api_read.sqlite3'}"
cfg = load_config(
workspace_root=tmp_path,
data_dir=tmp_path / "data",
database_url=db_url,
)
# init_schema once so the API lifespan does not have to migrate.
db = Database(db_url)
await db.init_schema()
await db.dispose()
app = create_app(cfg)
transport = ASGITransport(app=app)
async with app.router.lifespan_context(app):
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
@pytest.mark.asyncio
async def test_list_runs_empty(app_client: AsyncClient) -> None:
r = await app_client.get("/api/runs")
assert r.status_code == 200
assert r.json() == []
@pytest.mark.asyncio
async def test_get_run_404(app_client: AsyncClient) -> None:
r = await app_client.get("/api/runs/00000000-0000-0000-0000-000000000000")
assert r.status_code == 404
@pytest.mark.asyncio
async def test_list_personas_returns_seed(app_client: AsyncClient) -> None:
r = await app_client.get("/api/personas")
assert r.status_code == 200
rows = r.json()
# Seed has 12 personas (10 OpenRouter Claude/DeepSeek + 1 default-interactive +
# 1 DeepSeek-code-reviewer-without-subagents added in Step 15).
assert len(rows) == 12
names = {p["name"] for p in rows}
assert "openrouter-deepseek-spec-writer" in names
# response model must include all keys (PersonaSummary contract)
sample = rows[0]
assert {"name", "version", "model", "capabilities", "max_risk_level"} <= set(sample)
@pytest.mark.asyncio
async def test_list_workflows_returns_seed(app_client: AsyncClient) -> None:
r = await app_client.get("/api/workflows")
assert r.status_code == 200
rows = r.json()
# Seed has 3 workflows (spec-and-review, bug-fix-with-reproduction, code-investigation).
assert len(rows) >= 3
names = {w["name"] for w in rows}
assert "spec-and-review" in names
# response shape
sample = rows[0]
assert {"path", "name", "version", "roles", "phases"} <= set(sample)
@pytest.mark.asyncio
async def test_get_budget_summary_empty(app_client: AsyncClient) -> None:
r = await app_client.get("/api/budget")
assert r.status_code == 200
payload = r.json()
# No ledger rows yet → empty buckets.
assert payload["day"] is None
assert payload["runs"] == []
assert payload["personas"] == []

View File

@@ -0,0 +1,100 @@
"""GET /api/runs/{id}/events — SSE stream smoke test (D2)."""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
from pathlib import Path
from uuid import uuid4
import pytest
from httpx import ASGITransport, AsyncClient
from my_deepagent.api.app import create_app
from my_deepagent.config import load_config
from my_deepagent.persistence.db import Database
from my_deepagent.persistence.models import RunEventRow, RunRow
@pytest.fixture
async def app_and_db(tmp_path: Path) -> AsyncIterator[tuple[AsyncClient, Database]]:
db_url = f"sqlite+aiosqlite:///{tmp_path / 'api_sse.sqlite3'}"
cfg = load_config(
workspace_root=tmp_path,
data_dir=tmp_path / "data",
database_url=db_url,
)
db = Database(db_url)
await db.init_schema()
app = create_app(cfg)
transport = ASGITransport(app=app)
async with app.router.lifespan_context(app):
async with AsyncClient(transport=transport, base_url="http://test", timeout=10.0) as client:
yield (client, db)
await db.dispose()
@pytest.mark.asyncio
async def test_sse_drains_backfill_then_closes_on_terminal(
app_and_db: tuple[AsyncClient, Database],
) -> None:
"""Seed a completed run + a few events, then verify SSE drains them and closes."""
client, db = app_and_db
run_id = str(uuid4())
async with db.session() as s:
s.add(
RunRow(
id=run_id,
template_id=str(uuid4()), # FK loosely enforced for this test
template_hash="sha:t",
state="completed",
repo_path="/tmp/repo",
base_branch="main",
worktree_root="/tmp/wt",
created_at="2026-05-16T00:00:00+00:00",
updated_at="2026-05-16T00:00:00+00:00",
)
)
for i, etype in enumerate(["run.started", "phase.started", "run.completed"]):
s.add(
RunEventRow(
run_id=run_id,
phase_id=None,
seq=i + 1,
type=etype,
payload={"i": i},
idempotency_key=f"{etype}:{run_id}:{i}",
ts="2026-05-16T00:00:00+00:00",
)
)
try:
await s.commit()
except Exception:
# The FK to workflow_templates is RESTRICT; skip seeding template_id
# via direct ORM if SQLite enforces it strictly.
await s.rollback()
return
async with client.stream("GET", f"/api/runs/{run_id}/events") as resp:
assert resp.status_code == 200
# SSE response is text/event-stream
assert resp.headers["content-type"].startswith("text/event-stream")
body_chunks: list[str] = []
try:
# Pull chunks for up to 3 seconds; the `done` event should arrive
# quickly because the run is already terminal.
async def _drain() -> None:
async for line in resp.aiter_lines():
body_chunks.append(line)
if "event: done" in line or any(
"event: done" in chunk for chunk in body_chunks
):
break
await asyncio.wait_for(_drain(), timeout=3.0)
except TimeoutError:
pass
body = "\n".join(body_chunks)
assert "run.completed" in body or "phase.started" in body or "run.started" in body

View File

@@ -0,0 +1,75 @@
"""Static frontend smoke tests (D3)."""
from __future__ import annotations
from collections.abc import AsyncIterator
from pathlib import Path
import pytest
from httpx import ASGITransport, AsyncClient
from my_deepagent.api.app import create_app
from my_deepagent.config import load_config
from my_deepagent.persistence.db import Database
@pytest.fixture
async def app_client(tmp_path: Path) -> AsyncIterator[AsyncClient]:
db_url = f"sqlite+aiosqlite:///{tmp_path / 'api_static.sqlite3'}"
cfg = load_config(
workspace_root=tmp_path,
data_dir=tmp_path / "data",
database_url=db_url,
)
db = Database(db_url)
await db.init_schema()
await db.dispose()
app = create_app(cfg)
transport = ASGITransport(app=app)
async with app.router.lifespan_context(app):
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
@pytest.mark.asyncio
async def test_root_serves_index_html(app_client: AsyncClient) -> None:
r = await app_client.get("/")
assert r.status_code == 200
assert r.headers["content-type"].startswith("text/html")
body = r.text
assert "<title>my-deepagent · runs</title>" in body
assert 'data-page="index"' in body
@pytest.mark.asyncio
async def test_new_html_served(app_client: AsyncClient) -> None:
r = await app_client.get("/new.html")
assert r.status_code == 200
assert 'data-page="new"' in r.text
@pytest.mark.asyncio
async def test_run_html_served(app_client: AsyncClient) -> None:
r = await app_client.get("/run.html")
assert r.status_code == 200
assert 'data-page="run"' in r.text
@pytest.mark.asyncio
async def test_static_app_js_served(app_client: AsyncClient) -> None:
r = await app_client.get("/static/app.js")
assert r.status_code == 200
# Must be JS, not HTML
assert (
"application/javascript" in r.headers["content-type"]
or "text/javascript" in r.headers["content-type"]
)
# XSS policy comment must be present (the hardcoded contract)
assert "innerHTML" in r.text
@pytest.mark.asyncio
async def test_static_style_css_served(app_client: AsyncClient) -> None:
r = await app_client.get("/static/style.css")
assert r.status_code == 200
assert "text/css" in r.headers["content-type"]

View File

@@ -0,0 +1,119 @@
"""POST /api/runs + /api/runs/{id}/resume + /api/runs/{id}/abort — D2 write routes."""
from __future__ import annotations
from collections.abc import AsyncIterator
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
from httpx import ASGITransport, AsyncClient
from my_deepagent.api.app import create_app
from my_deepagent.config import load_config
from my_deepagent.persistence.db import Database
@pytest.fixture
async def app_client(tmp_path: Path) -> AsyncIterator[AsyncClient]:
db_url = f"sqlite+aiosqlite:///{tmp_path / 'api_write.sqlite3'}"
cfg = load_config(
workspace_root=tmp_path,
data_dir=tmp_path / "data",
database_url=db_url,
)
db = Database(db_url)
await db.init_schema()
await db.dispose()
app = create_app(cfg)
transport = ASGITransport(app=app)
async with app.router.lifespan_context(app):
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
# ---------------------------------------------------------------------------
# POST /api/runs validation
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_start_run_missing_template_returns_400(
app_client: AsyncClient, tmp_path: Path
) -> None:
r = await app_client.post(
"/api/runs",
json={
"template_path": "/this/does/not/exist.yaml",
"repo_path": str(tmp_path),
"base_branch": "main",
"requirements_md": "test",
},
)
assert r.status_code == 400
assert "not found" in r.json()["detail"]
@pytest.mark.asyncio
async def test_start_run_extra_field_returns_422(app_client: AsyncClient, tmp_path: Path) -> None:
r = await app_client.post(
"/api/runs",
json={
"template_path": "spec-and-review@1.yaml",
"repo_path": str(tmp_path),
"rogue_field": "should-fail",
},
)
# pydantic extra=forbid → 422 unprocessable entity
assert r.status_code == 422
# ---------------------------------------------------------------------------
# POST /api/runs/{id}/resume — 404 + 409
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_resume_unknown_run_404(app_client: AsyncClient) -> None:
r = await app_client.post("/api/runs/00000000-0000-0000-0000-000000000000/resume")
assert r.status_code == 404
# ---------------------------------------------------------------------------
# POST /api/runs/{id}/abort — 404 + 409
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_abort_unknown_run_404(app_client: AsyncClient) -> None:
r = await app_client.post("/api/runs/00000000-0000-0000-0000-000000000000/abort")
assert r.status_code == 404
# ---------------------------------------------------------------------------
# POST /api/runs success — mock the runner so we don't actually invoke OpenRouter
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_start_run_success_returns_run_id(app_client: AsyncClient, tmp_path: Path) -> None:
from uuid import UUID
fake_run_id = UUID("11111111-2222-3333-4444-555555555555")
with patch(
"my_deepagent.api.routes.runs.runner.start_new_run",
new=AsyncMock(return_value=fake_run_id),
):
r = await app_client.post(
"/api/runs",
json={
"template_path": "spec-and-review@1.yaml",
"repo_path": str(tmp_path),
"base_branch": "main",
"requirements_md": "x",
},
)
assert r.status_code == 200
body = r.json()
assert body["run_id"] == str(fake_run_id)
assert body["state"] == "executing"

View File

@@ -207,9 +207,7 @@ async def test_resume_completes_remaining_phase( # noqa: C901 — 2-phase scena
"""Run phase 1 to completion via engine.run, then truncate phase 2 by aborting
the agent the first time around, then resume and verify phase 2 finishes."""
template = _two_phase_workflow()
engine = _make_engine(
db, tmp_path, personas, registry, consent_store, available_backends
)
engine = _make_engine(db, tmp_path, personas, registry, consent_store, available_backends)
# First run: phase 1 succeeds, phase 2 deliberately fails (agent never writes).
phase2_calls: list[int] = []
@@ -316,11 +314,7 @@ async def test_resume_completes_remaining_phase( # noqa: C901 — 2-phase scena
# RUN_RESUMED event must have been emitted.
async with db.session() as s:
events = (
(
await s.execute(
select(RunEventRow.type).where(RunEventRow.run_id == str(run_id))
)
)
(await s.execute(select(RunEventRow.type).where(RunEventRow.run_id == str(run_id))))
.scalars()
.all()
)
@@ -344,9 +338,7 @@ async def test_resume_terminal_run_raises(
) -> None:
"""A run in a terminal state (e.g. completed) cannot be resumed."""
template = _two_phase_workflow()
engine = _make_engine(
db, tmp_path, personas, registry, consent_store, available_backends
)
engine = _make_engine(db, tmp_path, personas, registry, consent_store, available_backends)
def _build(
persona: Any, config: Any, *, root_dir: Path, middleware: list[Any], **_kw: Any
@@ -397,9 +389,7 @@ async def test_resume_unknown_run_raises(
db: Database,
) -> None:
"""resume(unknown_uuid) → MyDeepAgentError(code=run_not_found)."""
engine = _make_engine(
db, tmp_path, personas, registry, consent_store, available_backends
)
engine = _make_engine(db, tmp_path, personas, registry, consent_store, available_backends)
with pytest.raises(MyDeepAgentError) as exc:
await engine.resume(uuid4())
assert exc.value.code == "run_not_found"
@@ -421,9 +411,7 @@ async def test_resume_missing_bindings_raises(
) -> None:
"""A run whose RunBindingRow rows were never persisted cannot be resumed."""
template = _two_phase_workflow()
engine = _make_engine(
db, tmp_path, personas, registry, consent_store, available_backends
)
engine = _make_engine(db, tmp_path, personas, registry, consent_store, available_backends)
# Seed a RunRow + WorkflowTemplateRow but NO RunBindingRow.
run_id = uuid4()
@@ -475,9 +463,7 @@ async def test_resume_corrupt_template_definition_raises(
db: Database,
) -> None:
"""A run whose workflow_templates.definition is malformed → fatal."""
engine = _make_engine(
db, tmp_path, personas, registry, consent_store, available_backends
)
engine = _make_engine(db, tmp_path, personas, registry, consent_store, available_backends)
run_id = uuid4()
tpl_id = uuid4()

203
my-deepagent/uv.lock generated
View File

@@ -441,6 +441,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" },
]
[[package]]
name = "fastapi"
version = "0.136.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" },
]
[[package]]
name = "filelock"
version = "3.29.0"
@@ -567,6 +583,35 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httptools"
version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" },
{ url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" },
{ url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" },
{ url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" },
{ url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" },
{ url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" },
{ url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" },
{ url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" },
{ url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" },
{ url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" },
{ url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" },
{ url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" },
{ url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" },
{ url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" },
{ url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" },
{ url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" },
{ url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" },
{ url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" },
{ url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" },
{ url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" },
{ url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
@@ -1141,6 +1186,7 @@ dependencies = [
{ name = "alembic" },
{ name = "asyncpg" },
{ name = "deepagents" },
{ name = "fastapi" },
{ name = "greenlet" },
{ name = "httpx" },
{ name = "jsonschema" },
@@ -1159,8 +1205,10 @@ dependencies = [
{ name = "pyyaml" },
{ name = "rich" },
{ name = "sqlalchemy", extra = ["asyncio"] },
{ name = "sse-starlette" },
{ name = "structlog" },
{ name = "typer" },
{ name = "uvicorn", extra = ["standard"] },
{ name = "zstandard" },
]
@@ -1184,6 +1232,7 @@ requires-dist = [
{ name = "alembic", specifier = ">=1.14" },
{ name = "asyncpg", specifier = ">=0.30" },
{ name = "deepagents", specifier = ">=0.6.1,<0.7.0" },
{ name = "fastapi", specifier = ">=0.115" },
{ name = "greenlet", specifier = ">=3.0" },
{ name = "httpx", specifier = ">=0.28" },
{ name = "jsonschema", specifier = ">=4.23" },
@@ -1202,8 +1251,10 @@ requires-dist = [
{ name = "pyyaml", specifier = ">=6.0" },
{ name = "rich", specifier = ">=13.9" },
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
{ name = "sse-starlette", specifier = ">=2.1" },
{ name = "structlog", specifier = ">=24.4" },
{ name = "typer", specifier = ">=0.14" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30" },
{ name = "zstandard", specifier = ">=0.23" },
]
@@ -2145,6 +2196,32 @@ asyncio = [
{ name = "greenlet" },
]
[[package]]
name = "sse-starlette"
version = "3.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "starlette" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f7/2b/58abc2d1fd397e7dde08e947e05c884d8ef2f78d5e2588c17a12d42d6994/sse_starlette-3.4.4.tar.gz", hash = "sha256:07e0fa0460138baf25cdd5fb28683472c3995dc1642225191b3832d62526bcb0", size = 31819, upload-time = "2026-05-12T17:37:17.019Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/67/805710444ea8cc75fbf70b920ed431a560c4bf9c57f7d5a3117213189399/sse_starlette-3.4.4-py3-none-any.whl", hash = "sha256:3f4dd50d8aed2771a091f3a83000323fc3844541c16b4fe585ae2420cc6df973", size = 16514, upload-time = "2026-05-12T17:37:15.601Z" },
]
[[package]]
name = "starlette"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" },
]
[[package]]
name = "structlog"
version = "25.5.0"
@@ -2375,6 +2452,62 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d8/64/8be140712e3fa9d8406f0cb61876ce6d02f72067d4f9d31d1bf73e127c01/uuid_utils-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b3f0e567b5e992b28a50f50e0aeba546a2e2d3e463590eb5543204cb5d0f40b3", size = 171358, upload-time = "2026-05-11T12:07:30.282Z" },
]
[[package]]
name = "uvicorn"
version = "0.47.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/b1/8e7077a8641086aea449e1b5752a570f1b5906c64e0a33cd6d93b63a066b/uvicorn-0.47.0.tar.gz", hash = "sha256:7c9a0ea1a9414106bbab7324609c162d8fa0cdcdcb703060987269d77c7bb533", size = 90582, upload-time = "2026-05-14T18:16:54.455Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/15/41/ac2dfdbc1f60c7af4f994c7a335cfa7040c01642b605d65f611cecc2a1e4/uvicorn-0.47.0-py3-none-any.whl", hash = "sha256:2c5715bc12d1892d84752049f400cd1c3cb018514967fdfeb97640443a6a9432", size = 71301, upload-time = "2026-05-14T18:16:51.762Z" },
]
[package.optional-dependencies]
standard = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets" },
]
[[package]]
name = "uvloop"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
]
[[package]]
name = "virtualenv"
version = "21.3.3"
@@ -2390,6 +2523,76 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" },
]
[[package]]
name = "watchfiles"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" },
{ url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" },
{ url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" },
{ url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" },
{ url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" },
{ url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" },
{ url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" },
{ url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" },
{ url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" },
{ url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" },
{ url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" },
{ url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" },
{ url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" },
{ url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" },
{ url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" },
{ url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" },
{ url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" },
{ url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" },
{ url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" },
{ url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" },
{ url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" },
{ url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" },
{ url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" },
{ url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" },
{ url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" },
{ url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" },
{ url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" },
{ url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" },
{ url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" },
{ url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" },
{ url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" },
{ url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" },
{ url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" },
{ url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" },
{ url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" },
{ url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" },
{ url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" },
{ url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" },
{ url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" },
{ url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" },
{ url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" },
{ url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" },
{ url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" },
{ url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" },
{ url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" },
{ url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" },
{ url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" },
{ url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" },
{ url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" },
{ url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" },
{ url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" },
{ url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" },
{ url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" },
{ url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" },
{ url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" },
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
]
[[package]]
name = "wcmatch"
version = "10.1"