From 0630142c345e3f99f0b054270820d990605cbaa5 Mon Sep 17 00:00:00 2001 From: chungyeong Date: Sat, 16 May 2026 22:25:15 +0900 Subject: [PATCH] =?UTF-8?q?feat(my-deepagent):=20v0.2=20PR=20#3=20?= =?UTF-8?q?=E2=80=94=20FastAPI=20+=20SSE=20+=20minimal=20Web=20GUI=20(myde?= =?UTF-8?q?epagent=20serve)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- my-deepagent/CHANGELOG.md | 67 ++++ my-deepagent/pyproject.toml | 3 + my-deepagent/src/my_deepagent/api/__init__.py | 9 + my-deepagent/src/my_deepagent/api/app.py | 113 ++++++ my-deepagent/src/my_deepagent/api/deps.py | 46 +++ my-deepagent/src/my_deepagent/api/models.py | 146 ++++++++ .../src/my_deepagent/api/routes/__init__.py | 1 + .../src/my_deepagent/api/routes/budget.py | 64 ++++ .../src/my_deepagent/api/routes/personas.py | 30 ++ .../src/my_deepagent/api/routes/runs.py | 294 ++++++++++++++++ .../src/my_deepagent/api/routes/workflows.py | 52 +++ my-deepagent/src/my_deepagent/api/runner.py | 140 ++++++++ my-deepagent/src/my_deepagent/api/sse.py | 94 +++++ my-deepagent/src/my_deepagent/cli/main.py | 11 + my-deepagent/src/my_deepagent/cli/serve.py | 41 +++ my-deepagent/src/my_deepagent/engine.py | 6 +- my-deepagent/static/app.js | 324 ++++++++++++++++++ my-deepagent/static/index.html | 39 +++ my-deepagent/static/new.html | 53 +++ my-deepagent/static/run.html | 51 +++ my-deepagent/static/style.css | 192 +++++++++++ .../tests/integration/test_api_read.py | 91 +++++ .../tests/integration/test_api_sse.py | 100 ++++++ .../tests/integration/test_api_static.py | 75 ++++ .../tests/integration/test_api_write.py | 119 +++++++ my-deepagent/tests/integration/test_resume.py | 26 +- my-deepagent/uv.lock | 203 +++++++++++ 27 files changed, 2369 insertions(+), 21 deletions(-) create mode 100644 my-deepagent/src/my_deepagent/api/__init__.py create mode 100644 my-deepagent/src/my_deepagent/api/app.py create mode 100644 my-deepagent/src/my_deepagent/api/deps.py create mode 100644 my-deepagent/src/my_deepagent/api/models.py create mode 100644 my-deepagent/src/my_deepagent/api/routes/__init__.py create mode 100644 my-deepagent/src/my_deepagent/api/routes/budget.py create mode 100644 my-deepagent/src/my_deepagent/api/routes/personas.py create mode 100644 my-deepagent/src/my_deepagent/api/routes/runs.py create mode 100644 my-deepagent/src/my_deepagent/api/routes/workflows.py create mode 100644 my-deepagent/src/my_deepagent/api/runner.py create mode 100644 my-deepagent/src/my_deepagent/api/sse.py create mode 100644 my-deepagent/src/my_deepagent/cli/serve.py create mode 100644 my-deepagent/static/app.js create mode 100644 my-deepagent/static/index.html create mode 100644 my-deepagent/static/new.html create mode 100644 my-deepagent/static/run.html create mode 100644 my-deepagent/static/style.css create mode 100644 my-deepagent/tests/integration/test_api_read.py create mode 100644 my-deepagent/tests/integration/test_api_sse.py create mode 100644 my-deepagent/tests/integration/test_api_static.py create mode 100644 my-deepagent/tests/integration/test_api_write.py diff --git a/my-deepagent/CHANGELOG.md b/my-deepagent/CHANGELOG.md index c010f9e..cdb34e2 100644 --- a/my-deepagent/CHANGELOG.md +++ b/my-deepagent/CHANGELOG.md @@ -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 ` 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, diff --git a/my-deepagent/pyproject.toml b/my-deepagent/pyproject.toml index 84a110e..3d9157e 100644 --- a/my-deepagent/pyproject.toml +++ b/my-deepagent/pyproject.toml @@ -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", diff --git a/my-deepagent/src/my_deepagent/api/__init__.py b/my-deepagent/src/my_deepagent/api/__init__.py new file mode 100644 index 0000000..e5d87a6 --- /dev/null +++ b/my-deepagent/src/my_deepagent/api/__init__.py @@ -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"] diff --git a/my-deepagent/src/my_deepagent/api/app.py b/my-deepagent/src/my_deepagent/api/app.py new file mode 100644 index 0000000..1117394 --- /dev/null +++ b/my-deepagent/src/my_deepagent/api/app.py @@ -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:`. +""" + +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 diff --git a/my-deepagent/src/my_deepagent/api/deps.py b/my-deepagent/src/my_deepagent/api/deps.py new file mode 100644 index 0000000..af10203 --- /dev/null +++ b/my-deepagent/src/my_deepagent/api/deps.py @@ -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 diff --git a/my-deepagent/src/my_deepagent/api/models.py b/my-deepagent/src/my_deepagent/api/models.py new file mode 100644 index 0000000..2a7c15c --- /dev/null +++ b/my-deepagent/src/my_deepagent/api/models.py @@ -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] diff --git a/my-deepagent/src/my_deepagent/api/routes/__init__.py b/my-deepagent/src/my_deepagent/api/routes/__init__.py new file mode 100644 index 0000000..71bd02d --- /dev/null +++ b/my-deepagent/src/my_deepagent/api/routes/__init__.py @@ -0,0 +1 @@ +"""Per-resource FastAPI route modules. Mounted from `app.create_app`.""" diff --git a/my-deepagent/src/my_deepagent/api/routes/budget.py b/my-deepagent/src/my_deepagent/api/routes/budget.py new file mode 100644 index 0000000..f9e737d --- /dev/null +++ b/my-deepagent/src/my_deepagent/api/routes/budget.py @@ -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) diff --git a/my-deepagent/src/my_deepagent/api/routes/personas.py b/my-deepagent/src/my_deepagent/api/routes/personas.py new file mode 100644 index 0000000..bdce475 --- /dev/null +++ b/my-deepagent/src/my_deepagent/api/routes/personas.py @@ -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 + ] diff --git a/my-deepagent/src/my_deepagent/api/routes/runs.py b/my-deepagent/src/my_deepagent/api/routes/runs.py new file mode 100644 index 0000000..fbae85b --- /dev/null +++ b/my-deepagent/src/my_deepagent/api/routes/runs.py @@ -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 `` 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)) diff --git a/my-deepagent/src/my_deepagent/api/routes/workflows.py b/my-deepagent/src/my_deepagent/api/routes/workflows.py new file mode 100644 index 0000000..19570d0 --- /dev/null +++ b/my-deepagent/src/my_deepagent/api/routes/workflows.py @@ -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 diff --git a/my-deepagent/src/my_deepagent/api/runner.py b/my-deepagent/src/my_deepagent/api/runner.py new file mode 100644 index 0000000..6c2e425 --- /dev/null +++ b/my-deepagent/src/my_deepagent/api/runner.py @@ -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() diff --git a/my-deepagent/src/my_deepagent/api/sse.py b/my-deepagent/src/my_deepagent/api/sse.py new file mode 100644 index 0000000..95d902a --- /dev/null +++ b/my-deepagent/src/my_deepagent/api/sse.py @@ -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=` 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: \\n\\n` with `id: `. +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) diff --git a/my-deepagent/src/my_deepagent/cli/main.py b/my-deepagent/src/my_deepagent/cli/main.py index 0138fe5..5945de4 100644 --- a/my-deepagent/src/my_deepagent/cli/main.py +++ b/my-deepagent/src/my_deepagent/cli/main.py @@ -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://:.""" + from .serve import serve_command + + serve_command(host, port) + + @app.callback(invoke_without_command=True) def main( ctx: typer.Context, diff --git a/my-deepagent/src/my_deepagent/cli/serve.py b/my-deepagent/src/my_deepagent/cli/serve.py new file mode 100644 index 0000000..c9cf995 --- /dev/null +++ b/my-deepagent/src/my_deepagent/cli/serve.py @@ -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", + ) diff --git a/my-deepagent/src/my_deepagent/engine.py b/my-deepagent/src/my_deepagent/engine.py index 9e25452..eb9ee03 100644 --- a/my-deepagent/src/my_deepagent/engine.py +++ b/my-deepagent/src/my_deepagent/engine.py @@ -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" diff --git a/my-deepagent/static/app.js b/my-deepagent/static/app.js new file mode 100644 index 0000000..dba9629 --- /dev/null +++ b/my-deepagent/static/app.js @@ -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= 가 필요합니다."); + 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); + } +}); diff --git a/my-deepagent/static/index.html b/my-deepagent/static/index.html new file mode 100644 index 0000000..5280b8d --- /dev/null +++ b/my-deepagent/static/index.html @@ -0,0 +1,39 @@ + + + + + + my-deepagent · runs + + + +
+

my-deepagent

+
+
+
+ +

최근 run 50개

+ + + + + + + + + + + + +
run idstaterepobranchcreatedended
+ +

예산 (현재)

+
+
+ + + diff --git a/my-deepagent/static/new.html b/my-deepagent/static/new.html new file mode 100644 index 0000000..db20916 --- /dev/null +++ b/my-deepagent/static/new.html @@ -0,0 +1,53 @@ + + + + + + my-deepagent · 새 run + + + +
+

my-deepagent · 새 run 시작

+ +
+
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +

persona override (선택)

+

비워두면 자동 선택. 값은 persona-name@버전 형식.

+
+ +
+ + 취소 +
+
+
+ + + diff --git a/my-deepagent/static/run.html b/my-deepagent/static/run.html new file mode 100644 index 0000000..c4660b4 --- /dev/null +++ b/my-deepagent/static/run.html @@ -0,0 +1,51 @@ + + + + + + my-deepagent · run 상세 + + + +
+

my-deepagent · run 상세

+ +
+
+ + +

요약

+
run id
+
state
+
repo
+
worktree
+
final report
+ +
+ + +
+ +

phases

+ + + + + + + + + + + +
keystateattemptsstartedended
+ +

실시간 이벤트 (SSE)

+
+
+ + + diff --git a/my-deepagent/static/style.css b/my-deepagent/static/style.css new file mode 100644 index 0000000..7a1abe6 --- /dev/null +++ b/my-deepagent/static/style.css @@ -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; +} diff --git a/my-deepagent/tests/integration/test_api_read.py b/my-deepagent/tests/integration/test_api_read.py new file mode 100644 index 0000000..b0b2a62 --- /dev/null +++ b/my-deepagent/tests/integration/test_api_read.py @@ -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"] == [] diff --git a/my-deepagent/tests/integration/test_api_sse.py b/my-deepagent/tests/integration/test_api_sse.py new file mode 100644 index 0000000..99dd254 --- /dev/null +++ b/my-deepagent/tests/integration/test_api_sse.py @@ -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 diff --git a/my-deepagent/tests/integration/test_api_static.py b/my-deepagent/tests/integration/test_api_static.py new file mode 100644 index 0000000..a90eb30 --- /dev/null +++ b/my-deepagent/tests/integration/test_api_static.py @@ -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 "my-deepagent · runs" 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"] diff --git a/my-deepagent/tests/integration/test_api_write.py b/my-deepagent/tests/integration/test_api_write.py new file mode 100644 index 0000000..1313756 --- /dev/null +++ b/my-deepagent/tests/integration/test_api_write.py @@ -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" diff --git a/my-deepagent/tests/integration/test_resume.py b/my-deepagent/tests/integration/test_resume.py index 3a6b763..e6d0fea 100644 --- a/my-deepagent/tests/integration/test_resume.py +++ b/my-deepagent/tests/integration/test_resume.py @@ -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() diff --git a/my-deepagent/uv.lock b/my-deepagent/uv.lock index 06f291d..7b5bc7d 100644 --- a/my-deepagent/uv.lock +++ b/my-deepagent/uv.lock @@ -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"