From 53ebba8d729d8e2fcac99d4b6411f50a6edd6c5a Mon Sep 17 00:00:00 2001 From: Alex Barclay Date: Fri, 24 Jul 2026 21:45:12 -0600 Subject: [PATCH 01/10] =?UTF-8?q?docs(plan):=20Plan=20D=20=E2=80=94=20web?= =?UTF-8?q?=20Socket.IO=20bridge=20+=20LangGraph=20retirement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...-orchestration-engine-plan-d-web-bridge.md | 694 ++++++++++++++++++ 1 file changed, 694 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-24-mcp-orchestration-engine-plan-d-web-bridge.md diff --git a/docs/superpowers/plans/2026-07-24-mcp-orchestration-engine-plan-d-web-bridge.md b/docs/superpowers/plans/2026-07-24-mcp-orchestration-engine-plan-d-web-bridge.md new file mode 100644 index 0000000..e1b1359 --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-mcp-orchestration-engine-plan-d-web-bridge.md @@ -0,0 +1,694 @@ +# MCP Orchestration Engine — Plan D: Web Bridge + LangGraph Retirement + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Retire the legacy LangGraph orchestrator and repoint `backend/main.py` (FastAPI + Socket.IO) at the parallel engine, so the existing React frontend renders live engine runs — then delete the old orchestrator, its tests, and the `langgraph` deps. + +**Architecture:** `main.py` becomes a thin **Socket.IO ↔ engine bridge**: on `start_project` it boots an engine run (state server + real worker subprocesses, human-approval mode) via a new `run.start_run()` handle, then a background poller diffs engine snapshots and emits the frontend's existing Socket.IO events (`agent_status`, `phase_complete`, `approval_required`, `budget_update`). `approve`/`reject` call `submit_approval`. No LangGraph anywhere. Full design: [`docs/superpowers/specs/2026-07-23-parallel-mcp-orchestration-engine-design.md`](../specs/2026-07-23-parallel-mcp-orchestration-engine-design.md) §11. + +**Tech Stack:** Python 3.11+, UV, `mcp==1.28.1`, aiosqlite, FastAPI + python-socketio, pytest + pytest-asyncio. Windows/win32. + +## Global Constraints + +- **Python** `>=3.11`; deps via **UV**. Builds on Plans A–C (`backend/engine/`). +- **Windows/win32:** engine runs spawn real worker subprocesses; close aiosqlite before tmp teardown. +- **CI must stay green:** `ruff check backend/ tests/` (0), `black --check backend/ tests/`, `pytest tests/ --cov-fail-under=70`. Each task's final step lints its touched files. +- **Frontend Socket.IO contract (reconned — reproduce EXACTLY):** + - Client→server: `start_project {idea}`, `approve {project_id, comment?}`, `reject {project_id, comment?}`, `modify {project_id, comment}`, `retry {project_id}`, `load_project {project_id}`. + - Server→client: `project_created {project_id}`; `agent_status {agent, status, details?}` where `status ∈ {"pending","running","complete","error","downgraded"}`; `approval_required {agent, phase:int, content:str, kind:"prd"|"plan", alternatives?, escalation?}`; `phase_complete {phase:int, summary, status?:"success"|"failed", reason?}`; `budget_update {spent, limit, threshold}`; `project_state `. + - `ProjectStateSnapshot`: `{project_id, idea, messages:[], agents:Record, approval_pending:ApprovalRequest|null, budget:{spent,limit,threshold?}, phase:int, prd:str|null, status:"running"|"paused"|"complete"|"failed", adr?, tasks?, design_spec?}`. +- **Engine→frontend phase-number map:** `clarify→3, design→4, code→6, test→7, deploy→8, iterate→10`. Gate kinds: clarify gate → `"prd"`, design gate → `"plan"`. +- **Agent status map:** task `blocked`/`ready` → `"pending"`; `claimed`/`running` (owner set, not done) → `"running"`; `done` → `"complete"`; `failed` → `"error"`. A task whose `model` differs from its agent's base model (`config/agents.yaml`) → emit `"downgraded"` on completion instead of `"complete"`. +- Mock mode only (`MOCK_AGENTS=true`) for tests. No "OpenBarclay"; commits carry no attribution footer. + +--- + +## File Structure + +| File | Change | +|---|---| +| `backend/engine/run.py` | extract `RunHandle` + `start_run()` / `stop_run()`; `run_pipeline` reuses them | +| `backend/engine/store.py` | `snapshot` gains a `budget` field (`{spent, limit}`) | +| `backend/engine/webbridge.py` | NEW: pure snapshot→frontend mappers (`phase_number`, `to_project_state`, `diff_to_events`) | +| `backend/main.py` | REWRITE: Socket.IO↔engine bridge; delete `Orchestrator` usage | +| `backend/graph.py`, `backend/orchestrator.py` | DELETE (Task 4) | +| `pyproject.toml` | drop `langgraph`, `langgraph-checkpoint-sqlite`, mypy `langgraph.*` (Task 4) | +| `tests/engine/test_run_handle.py`, `test_webbridge.py`, `tests/integration/test_web_bridge.py` | NEW tests | +| ~10 langgraph-coupled test files | DELETE (Task 4) | + +--- + +## Task 1: Extract a run handle (`start_run` / `stop_run`) + +**Files:** +- Modify: `backend/engine/run.py` +- Test: `tests/engine/test_run_handle.py` + +**Interfaces:** +- Produces: + - `RunHandle` dataclass: `run_id: str`, `url: str`, `procs: list`, `server_task`. + - `async start_run(idea, workers=4, budget_limit=200.0, db_path=None, host="127.0.0.1", port=None) -> RunHandle` — boots the state server, waits until it accepts a `create_run`, spawns N worker subprocesses, returns the handle. Does NOT drive gates. + - `async stop_run(handle: RunHandle)` — terminate/kill workers + cancel/await the server task (the teardown currently inside `run_pipeline`'s finally). + - `run_pipeline` refactored to `start_run` → `_drive_gates` → `stop_run` (behavior unchanged; its e2e test must still pass). + +- [ ] **Step 1: Write the failing test** + +`tests/engine/test_run_handle.py`: +```python +import os +import sqlite3 + +import pytest + +from backend.engine.client import EngineClient +from backend.engine.run import start_run, stop_run + + +@pytest.fixture(autouse=True) +def mock_mode(monkeypatch): + monkeypatch.setenv("MOCK_AGENTS", "true") + + +async def test_start_run_boots_and_seeds_then_stop_cleans_up(tmp_path): + db = str(tmp_path / "run.db") + handle = await start_run("todo app", workers=2, budget_limit=200.0, db_path=db) + try: + assert handle.run_id and handle.url.endswith("/mcp") + assert len(handle.procs) == 2 + async with EngineClient(handle.url) as c: + snap = await c.get_run(handle.run_id) + assert snap["status"] == "running" # not auto-driven; gate not yet approved + finally: + await stop_run(handle) + # workers terminated + for p in handle.procs: + assert p.returncode is not None +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/engine/test_run_handle.py -v` +Expected: FAIL — `ImportError: cannot import name 'start_run'`. + +- [ ] **Step 3: Refactor `run.py`** + +Read the current `run.py`. Extract the server-boot + worker-spawn (currently the top of `run_pipeline`'s `try`) into `start_run`, and the teardown (`finally`) into `stop_run`; keep `_drive_gates` and rewrite `run_pipeline` to compose them. Add at the top: +```python +from dataclasses import dataclass, field + + +@dataclass +class RunHandle: + run_id: str + url: str + procs: list = field(default_factory=list) + server_task: object = None +``` +```python +async def start_run(idea, workers=4, budget_limit=200.0, db_path=None, + host="127.0.0.1", port=None) -> RunHandle: + db_path = db_path or "data/engine.db" + port = port or free_port() + url = f"http://{host}:{port}/mcp" + server_task = asyncio.create_task(serve(db_path, host, port)) + run_id = None + for _ in range(200): + try: + async with EngineClient(url) as c: + run_id = await c.create_run(idea, budget_limit) + break + except Exception: # noqa: BLE001 + await asyncio.sleep(0.05) + if run_id is None: + server_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await server_task + raise RuntimeError("state server failed to start") + procs = [] + for i in range(workers): + procs.append( + await asyncio.create_subprocess_exec( + sys.executable, "-m", "backend.engine.worker", + "--server-url", url, "--run-id", run_id, "--worker-id", f"w{i}", + ) + ) + return RunHandle(run_id=run_id, url=url, procs=procs, server_task=server_task) + + +async def stop_run(handle: RunHandle) -> None: + for p in handle.procs: + if p.returncode is None: + p.terminate() + for p in handle.procs: + try: + await asyncio.wait_for(p.wait(), timeout=5.0) + except asyncio.TimeoutError: + p.kill() + if handle.server_task is not None: + handle.server_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await handle.server_task +``` +Rewrite `run_pipeline` to use them (preserving its return `{run_id, snapshot, worker_pids}` and its teardown-on-any-failure guarantee): +```python +async def run_pipeline(idea, workers=4, budget_limit=200.0, auto_approve=True, + db_path=None, host="127.0.0.1", port=None, poll=0.1, timeout=60.0) -> dict: + handle = await start_run(idea, workers, budget_limit, db_path, host, port) + worker_pids = [p.pid for p in handle.procs] + final = None + try: + final = await _drive_gates(handle.url, handle.run_id, auto_approve, timeout, poll) + finally: + await stop_run(handle) + return {"run_id": handle.run_id, "snapshot": final, "worker_pids": worker_pids} +``` +Ensure `contextlib`, `sys`, `free_port`, `serve`, `EngineClient`, `asyncio` are imported. + +- [ ] **Step 4: Run tests** + +Run: `uv run pytest tests/engine/test_run_handle.py tests/engine/test_run_e2e.py -v` (the e2e test proves `run_pipeline` still works; ~15-40s — foreground, wait). Expect both pass. + +- [ ] **Step 5: Lint + commit** + +```bash +uv run ruff check backend/engine/run.py tests/engine/test_run_handle.py +uv run black --check backend/engine/run.py tests/engine/test_run_handle.py +git add backend/engine/run.py tests/engine/test_run_handle.py +git commit -m "refactor(engine): extract start_run/stop_run RunHandle for external drivers" +``` + +--- + +## Task 2: Snapshot budget field + pure web-bridge mappers + +**Files:** +- Modify: `backend/engine/store.py` +- Create: `backend/engine/webbridge.py` +- Test: `tests/engine/test_webbridge.py` + +**Interfaces:** +- `Store.snapshot` result gains `"budget": {"spent": float, "limit": float}` (computed from `SUM(spend.cost)` and `runs.budget_limit`). +- `webbridge.phase_number(name: str) -> int`; `webbridge.PHASE_GATE_KIND = {"clarify": "prd", "design": "plan"}`. +- `webbridge.to_project_state(snapshot: dict, idea: str, state: dict) -> dict` — the frontend `ProjectStateSnapshot`. +- `webbridge.diff_to_events(prev: dict | None, new: dict, state: dict, base_models: dict) -> list[tuple[str, dict]]` — pure; returns ordered `(event_name, payload)` for agent-status changes, newly-complete phases, newly-pending gates, and a budget delta. +- `state` is the engine `state` values dict `{key: value}` (from `EngineClient.get_state` — used to fill `prd`/`adr` into approval/project_state). + +- [ ] **Step 1: Write the failing test** + +`tests/engine/test_webbridge.py`: +```python +from backend.engine import webbridge as wb + +BASE = {"qa_test": "gpt-4o", "clarifying_pm": "claude-3-5-sonnet-20241022"} + + +def _snap(status, phases, tasks, spent=0.0, limit=200.0): + return {"run_id": "r1", "status": status, "phases": phases, "tasks": tasks, + "budget": {"spent": spent, "limit": limit}} + + +def test_phase_number_and_kind(): + assert wb.phase_number("clarify") == 3 and wb.phase_number("code") == 6 + assert wb.PHASE_GATE_KIND["design"] == "plan" + + +def test_agent_status_transitions_emit_events(): + prev = _snap("running", + [{"name": "clarify", "status": "open", "gate": "none"}], + [{"agent_id": "clarifying_pm", "phase": "clarify", "status": "ready", + "owner": None, "model": "claude-3-5-sonnet-20241022"}]) + new = _snap("running", + [{"name": "clarify", "status": "open", "gate": "none"}], + [{"agent_id": "clarifying_pm", "phase": "clarify", "status": "running", + "owner": "w0", "model": "claude-3-5-sonnet-20241022"}]) + events = wb.diff_to_events(prev, new, {}, BASE) + assert ("agent_status", {"agent": "clarifying_pm", "status": "running"}) in [ + (e, {k: v for k, v in p.items() if k in ("agent", "status")}) for e, p in events + ] + + +def test_pending_gate_emits_approval_required_with_prd(): + prev = _snap("running", [{"name": "clarify", "status": "complete", "gate": "none"}], []) + new = _snap("running", [{"name": "clarify", "status": "complete", "gate": "pending"}], []) + events = wb.diff_to_events(prev, new, {"prd": "# PRD"}, BASE) + appr = [p for e, p in events if e == "approval_required"] + assert appr and appr[0]["kind"] == "prd" and appr[0]["phase"] == 3 and appr[0]["content"] == "# PRD" + + +def test_downgraded_status_on_completion(): + prev = _snap("running", [{"name": "test", "status": "open", "gate": "none"}], + [{"agent_id": "qa_test", "phase": "test", "status": "running", + "owner": "w0", "model": "gpt-4o-mini"}]) + new = _snap("running", [{"name": "test", "status": "open", "gate": "none"}], + [{"agent_id": "qa_test", "phase": "test", "status": "done", + "owner": "w0", "model": "gpt-4o-mini"}]) # base gpt-4o, ran on mini + events = wb.diff_to_events(prev, new, {}, BASE) + st = [p["status"] for e, p in events if e == "agent_status" and p["agent"] == "qa_test"] + assert st == ["downgraded"] + + +def test_to_project_state_shape(): + snap = _snap("running", + [{"name": "clarify", "status": "complete", "gate": "pending"}], + [{"agent_id": "clarifying_pm", "phase": "clarify", "status": "done", + "owner": "w0", "model": "claude-3-5-sonnet-20241022"}], spent=1.0) + ps = wb.to_project_state(snap, "todo", {"prd": "# PRD"}) + assert ps["idea"] == "todo" and ps["prd"] == "# PRD" + assert ps["agents"]["clarifying_pm"]["status"] == "complete" + assert ps["approval_pending"]["kind"] == "prd" + assert ps["budget"]["spent"] == 1.0 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/engine/test_webbridge.py -v` +Expected: FAIL — `ModuleNotFoundError: backend.engine.webbridge`. + +- [ ] **Step 3: Add `budget` to `Store.snapshot`** + +In `Store.snapshot`, after computing `tasks`, add a spend rollup and include it: +```python + cur = await self._db.execute( + "SELECT COALESCE(SUM(cost),0) AS s FROM spend WHERE run_id=?", (run_id,) + ) + spent = (await cur.fetchone())["s"] + cur = await self._db.execute( + "SELECT budget_limit FROM runs WHERE run_id=?", (run_id,) + ) + row = await cur.fetchone() + limit = row["budget_limit"] if row else 0.0 +``` +and add `"budget": {"spent": spent, "limit": limit},` to the returned dict. + +- [ ] **Step 4: Implement `backend/engine/webbridge.py`** + +```python +"""Pure mappers: engine snapshot -> the React frontend's Socket.IO contract.""" +from __future__ import annotations + +from typing import Any + +_PHASE_NUMBER = {"clarify": 3, "design": 4, "code": 6, "test": 7, "deploy": 8, "iterate": 10} +PHASE_GATE_KIND = {"clarify": "prd", "design": "plan"} +_WRITES_KEY = {"clarify": "prd", "design": "adr"} # content shown on the approval card + + +def phase_number(name: str) -> int: + return _PHASE_NUMBER.get(name, 0) + + +def _agent_status(task: dict, base_models: dict) -> str: + status = task["status"] + if status == "failed": + return "error" + if status == "done": + base = base_models.get(task["agent_id"]) + if task.get("model") and base and task["model"] != base: + return "downgraded" + return "complete" + if status in ("claimed", "running") or task.get("owner"): + return "running" + return "pending" + + +def _agents_map(snapshot: dict, base_models: dict) -> dict: + return { + t["agent_id"]: { + "id": t["agent_id"], + "name": t["agent_id"], + "status": _agent_status(t, base_models), + } + for t in snapshot["tasks"] + } + + +def to_project_state(snapshot: dict, idea: str, state: dict) -> dict: + agents = _agents_map(snapshot, {}) + pending = None + for p in snapshot["phases"]: + if p["gate"] == "pending": + kind = PHASE_GATE_KIND.get(p["name"], "prd") + content = state.get(_WRITES_KEY.get(p["name"], "prd")) or "" + pending = {"agent": p["name"], "phase": phase_number(p["name"]), + "content": content if isinstance(content, str) else str(content), + "kind": kind} + open_phase = next((p for p in snapshot["phases"] if p["status"] == "open"), None) + fe_status = {"done": "complete", "failed": "failed"}.get(snapshot["status"], "running") + if pending is not None: + fe_status = "paused" + return { + "project_id": snapshot["run_id"], + "idea": idea, + "messages": [], + "agents": agents, + "approval_pending": pending, + "budget": snapshot.get("budget", {"spent": 0.0, "limit": 0.0}), + "phase": phase_number(open_phase["name"]) if open_phase else 3, + "prd": state.get("prd"), + "status": fe_status, + "adr": state.get("adr"), + } + + +def diff_to_events(prev: dict | None, new: dict, state: dict, base_models: dict) -> list[tuple[str, dict]]: + events: list[tuple[str, dict]] = [] + prev_tasks = {t["agent_id"]: t for t in (prev["tasks"] if prev else [])} + for t in new["tasks"]: + old = prev_tasks.get(t["agent_id"]) + new_s = _agent_status(t, base_models) + old_s = _agent_status(old, base_models) if old else "pending" + if new_s != old_s: + events.append(("agent_status", {"agent": t["agent_id"], "status": new_s})) + prev_phase = {p["name"]: p for p in (prev["phases"] if prev else [])} + for p in new["phases"]: + op = prev_phase.get(p["name"]) + if p["status"] == "complete" and (op is None or op["status"] != "complete"): + events.append(("phase_complete", {"phase": phase_number(p["name"]), + "summary": f"{p['name']} complete", + "status": "success"})) + if p["gate"] == "pending" and (op is None or op["gate"] != "pending"): + kind = PHASE_GATE_KIND.get(p["name"], "prd") + content = state.get(_WRITES_KEY.get(p["name"], "prd")) or "" + events.append(("approval_required", {"agent": p["name"], "phase": phase_number(p["name"]), + "content": content if isinstance(content, str) else str(content), + "kind": kind})) + nb = new.get("budget", {}) + ob = prev.get("budget", {}) if prev else {} + if nb and nb.get("spent") != ob.get("spent"): + limit = nb.get("limit", 0.0) or 1.0 + events.append(("budget_update", {"spent": nb.get("spent", 0.0), "limit": nb.get("limit", 0.0), + "threshold": round(nb.get("spent", 0.0) / limit, 4)})) + return events +``` +Note: `to_project_state` passes `{}` base_models (status granularity is enough for hydration); `diff_to_events` receives the real `base_models` so it can detect `downgraded`. If a test needs `agents` downgraded-aware in `to_project_state`, thread `base_models` through — the provided tests don't require it. + +- [ ] **Step 5: Run tests + commit** + +Run: `uv run pytest tests/engine/test_webbridge.py tests/engine/test_server_gate.py -q` (webbridge unit tests + snapshot budget field didn't break the gate test). +Lint the two files, then: +```bash +git add backend/engine/store.py backend/engine/webbridge.py tests/engine/test_webbridge.py +git commit -m "feat(engine): snapshot budget field + pure web-bridge event mappers" +``` + +--- + +## Task 3: Rewrite `main.py` as the Socket.IO ↔ engine bridge + +**Files:** +- Rewrite: `backend/main.py` +- Test: `tests/integration/test_web_bridge.py` + +**Interfaces:** +- Consumes: `run.start_run/stop_run` (Task 1), `webbridge` (Task 2), `EngineClient`. +- Keeps the FastAPI app + `/health` + the Socket.IO event names; drops `Orchestrator`. + +- [ ] **Step 1: Write the failing integration test** + +`tests/integration/test_web_bridge.py`: +```python +import os + +import pytest +import socketio +import uvicorn + + +@pytest.fixture(autouse=True) +def mock_mode(monkeypatch): + monkeypatch.setenv("MOCK_AGENTS", "true") + + +async def _serve_app(port): + from backend.main import asgi_app + + server = uvicorn.Server(uvicorn.Config(asgi_app, host="127.0.0.1", port=port, log_level="error")) + server.install_signal_handlers = lambda: None + return server + + +async def test_start_project_drives_engine_and_reaches_prd_gate(tmp_path, monkeypatch): + import asyncio + from tests.engine.server_harness import free_port + + monkeypatch.setenv("APPFORGE_WEB_DB", str(tmp_path / "web.db")) + port = free_port() + server = await _serve_app(port) + task = asyncio.create_task(server.serve()) + while not server.started: + await asyncio.sleep(0.02) + + events: list[tuple[str, dict]] = [] + client = socketio.AsyncClient() + + @client.on("project_created") + async def _created(d): + events.append(("project_created", d)) + + @client.on("agent_status") + async def _status(d): + events.append(("agent_status", d)) + + @client.on("approval_required") + async def _appr(d): + events.append(("approval_required", d)) + + try: + await client.connect(f"http://127.0.0.1:{port}", socketio_path="/socket.io") + await client.emit("start_project", {"idea": "todo app"}) + # wait until the PRD gate is reached (~10s: clarify Q&A loop) + for _ in range(400): + if any(e == "approval_required" and p.get("kind") == "prd" for e, p in events): + break + await asyncio.sleep(0.05) + assert any(e == "project_created" for e, p in events) + assert any(e == "agent_status" and p["agent"] == "clarifying_pm" for e, p in events) + appr = [p for e, p in events if e == "approval_required" and p.get("kind") == "prd"] + assert appr and appr[0]["content"] # PRD content present + finally: + await client.disconnect() + server.should_exit = True + await task +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/integration/test_web_bridge.py -v` +Expected: FAIL — the current `main.py` still imports/uses `Orchestrator`, so no engine-backed `approval_required` arrives (times out / assertion fails). + +- [ ] **Step 3: Rewrite `backend/main.py`** + +```python +"""FastAPI + Socket.IO bridge over the parallel MCP orchestration engine. + +Run: uv run -- python -m backend.main (serves on :8000) +The React frontend's existing Socket.IO events are driven by live engine runs. +""" +from __future__ import annotations + +import asyncio +import contextlib +import os +from typing import Any + +import socketio +from fastapi import FastAPI + +from backend.engine import webbridge +from backend.engine.client import EngineClient +from backend.engine.run import RunHandle, start_run, stop_run +from backend.engine.state_server import base_models_from_config + +app = FastAPI(title="AppForge engine backend", version="1.0.0") +sio = socketio.AsyncServer( + async_mode="asgi", + cors_allowed_origins=["http://localhost:5173", "http://127.0.0.1:5173"], +) +asgi_app = socketio.ASGIApp(sio, other_asgi_app=app, socketio_path="/socket.io") + +_BASE_MODELS = base_models_from_config() +_runs: dict[str, dict[str, Any]] = {} # project_id -> {handle, idea, poller, prev} + + +@app.get("/health") +async def health() -> dict[str, str]: + return {"status": "ok"} + + +async def _poll_and_emit(project_id: str, room: str) -> None: + ctx = _runs[project_id] + handle: RunHandle = ctx["handle"] + prev = None + while True: + try: + async with EngineClient(handle.url) as c: + snap = await c.get_run(handle.run_id) + keys = ["prd", "adr", "tasks", "design_spec"] + state = {k: v["value"] for k, v in (await c.get_state(handle.run_id, keys)).items()} + except Exception: # noqa: BLE001 - server may be tearing down + return + for event, payload in webbridge.diff_to_events(prev, snap, state, _BASE_MODELS): + await sio.emit(event, payload, room=room) + prev = snap + ctx["prev"], ctx["state"] = snap, state + if snap["status"] in ("done", "failed"): + await sio.emit("phase_complete", {"phase": 10, "summary": f"run {snap['status']}", + "status": "success" if snap["status"] == "done" else "failed"}, + room=room) + return + await asyncio.sleep(0.4) + + +@sio.event +async def connect(sid, environ, auth=None): # noqa: ARG001 + pass + + +@sio.event +async def start_project(sid, data): + idea = (data or {}).get("idea", "").strip() + if not idea: + return {"error": "idea required"} + handle = await start_run(idea, workers=4, budget_limit=200.0, + db_path=os.getenv("APPFORGE_WEB_DB", "data/web.db")) + project_id = handle.run_id + room = f"project:{project_id}" + await sio.enter_room(sid, room) + _runs[project_id] = {"handle": handle, "idea": idea, "prev": None, "state": {}} + await sio.emit("project_created", {"project_id": project_id}, to=sid) + _runs[project_id]["poller"] = asyncio.create_task(_poll_and_emit(project_id, room)) + return None + + +async def _resolve_gate(project_id: str, decision: str) -> dict | None: + ctx = _runs.get(project_id) + if not ctx: + return {"error": "project not found"} + snap = ctx.get("prev") or {} + pending = next((p for p in snap.get("phases", []) if p["gate"] == "pending"), None) + if pending is None: + return {"error": "no pending gate"} + async with EngineClient(ctx["handle"].url) as c: + await c.submit_approval(project_id, pending["name"], decision) + return None + + +@sio.event +async def approve(sid, data): # noqa: ARG001 + return await _resolve_gate((data or {}).get("project_id", ""), "approved") + + +@sio.event +async def reject(sid, data): # noqa: ARG001 + return await _resolve_gate((data or {}).get("project_id", ""), "rejected") + + +@sio.event +async def load_project(sid, data): + project_id = (data or {}).get("project_id", "") + ctx = _runs.get(project_id) + if not ctx: + return {"error": "project not found"} + await sio.enter_room(sid, f"project:{project_id}") + ps = webbridge.to_project_state(ctx.get("prev") or {"run_id": project_id, "status": "running", + "phases": [], "tasks": [], "budget": {}}, + ctx["idea"], ctx.get("state", {})) + await sio.emit("project_state", ps, to=sid) + return None + + +@sio.event +async def disconnect(sid): # noqa: ARG001 + pass + + +async def shutdown() -> None: + for ctx in list(_runs.values()): + poller = ctx.get("poller") + if poller: + poller.cancel() + with contextlib.suppress(asyncio.CancelledError): + await poller + await stop_run(ctx["handle"]) + _runs.clear() + + +def main() -> None: + import uvicorn + + uvicorn.run("backend.main:asgi_app", host="127.0.0.1", port=8000) + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 4: Run the integration test** + +Run: `uv run pytest tests/integration/test_web_bridge.py -v` (boots the app + a real engine run; ~15-30s — foreground, wait). Expect PASS: `project_created`, `agent_status` for `clarifying_pm`, and `approval_required {kind: "prd"}` with PRD content. + +- [ ] **Step 5: Lint + commit** + +```bash +uv run ruff check backend/main.py tests/integration/test_web_bridge.py +uv run black --check backend/main.py tests/integration/test_web_bridge.py +git add backend/main.py tests/integration/test_web_bridge.py +git commit -m "feat(web): main.py drives the engine over Socket.IO (retires Orchestrator usage)" +``` + +--- + +## Task 4: Delete LangGraph orchestrator, its tests, and deps + +**Files:** +- Delete: `backend/graph.py`, `backend/orchestrator.py` +- Delete: `tests/unit/test_graph.py`, `tests/integration/test_approval_flow.py`, `test_load_snapshot.py`, `test_mock_fallback.py`, `test_orchestrator_flow.py`, `test_persistence.py`, `test_planning_sprint.py`, `test_rejection_cycle.py`, `tests/e2e/test_phase3_demo.py`, `tests/e2e/test_phase4_planning.py` +- Modify: `pyproject.toml` + +**Interfaces:** none produced. This removes the legacy engine entirely; the web bridge (Task 3) already replaced `main.py`'s only use of it. + +- [ ] **Step 1: Confirm nothing else imports the orchestrator** + +Run: `git grep -lE "backend\.graph|backend\.orchestrator|from langgraph|import langgraph" -- backend/ tests/` +Expected (after Task 3): only `backend/graph.py`, `backend/orchestrator.py`, and the 10 test files above. If anything else appears (e.g. a stray import in `main.py`), STOP and report. + +- [ ] **Step 2: Delete the files** + +```bash +git rm backend/graph.py backend/orchestrator.py \ + tests/unit/test_graph.py tests/integration/test_approval_flow.py \ + tests/integration/test_load_snapshot.py tests/integration/test_mock_fallback.py \ + tests/integration/test_orchestrator_flow.py tests/integration/test_persistence.py \ + tests/integration/test_planning_sprint.py tests/integration/test_rejection_cycle.py \ + tests/e2e/test_phase3_demo.py tests/e2e/test_phase4_planning.py +``` + +- [ ] **Step 3: Drop the langgraph deps** + +In `pyproject.toml` remove the three lines: `"langgraph",` (keywords), `"langgraph>=0.2.0",` and `"langgraph-checkpoint-sqlite>=2.0.0",` (dependencies), and the `"langgraph.*",` mypy override block entry. Run `uv lock` then `uv sync` to update the lockfile. + +- [ ] **Step 4: Full suite + coverage** + +Run: `uv run pytest tests/ --cov=backend --cov-report=term --cov-fail-under=70 -q` (foreground, ~3-4 min — spawns processes). Expect all pass, coverage ≥70%. Removing `orchestrator.py`/`graph.py` (and their tests) removes both covered-and-uncovered lines; if coverage *drops* below 70%, report the number (it should rise, since the deleted code had partial coverage). +Also run `uv run ruff check backend/ tests/` and `uv run black --check backend/ tests/` — expect clean (no dangling imports of the deleted modules). + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "chore(engine): retire LangGraph orchestrator + coupled tests + deps" +``` + +--- + +## Self-Review (against the spec) + +**Spec coverage (Plan D = spec §11 LangGraph retirement + main.py repoint via events bridge):** +- §11 "main.py repoints at the engine via the events bridge" → Tasks 1–3 (RunHandle, pure mappers, Socket.IO bridge driving live engine runs into the frontend's exact event contract). §11 "retire graph.py + orchestrator core; drop langgraph*; migrate ~25 coupled tests" → Task 4 (delete the legacy engine + its ~10 test files + deps; the engine's own suite is the replacement coverage). +- **Frontend reconciliation:** the emitted event names/payloads are reproduced verbatim from `frontend/src/types/index.ts`; agent-node ids are the real engine `agent_id`s (already the frontend's node ids). **Live browser rendering is a manual final check** (run `python -m backend.main` + `cd frontend && npm run dev`) — noted in the PR, not automated here (no headless-browser harness in this repo's Python suite). + +**Placeholder scan:** no TBD/TODO; complete code per step. + +**Type consistency:** `RunHandle` fields (Task 1) consumed by `main.py` (Task 3); `webbridge` signatures (`phase_number`, `to_project_state(snapshot, idea, state)`, `diff_to_events(prev, new, state, base_models)`) identical across Task 2 def, its tests, and Task 3's `main.py`; `snapshot["budget"]` added in Task 2 and read by `webbridge` + `main.py`; `submit_approval(run_id, phase, decision)` matches the Plan B tool. + +**Risk:** the web-bridge integration test (Task 3) drives a real engine run through the Clarify Q&A loop (~10s) — slow but bounded; if it times out, suspect the poller or `start_run`, not the delay. Deleting 10 test files (Task 4) is the "≥70% coverage" risk — mitigated because the deleted production modules go with them; verify the exact coverage number in Task 4 Step 4. From 20c860c97acd00619bca80c06317ad64f7400be0 Mon Sep 17 00:00:00 2001 From: Alex Barclay Date: Fri, 24 Jul 2026 21:49:02 -0600 Subject: [PATCH 02/10] refactor(engine): extract start_run/stop_run RunHandle for external drivers --- backend/engine/run.py | 129 ++++++++++++++++++++------------ tests/engine/test_run_handle.py | 25 +++++++ 2 files changed, 106 insertions(+), 48 deletions(-) create mode 100644 tests/engine/test_run_handle.py diff --git a/backend/engine/run.py b/backend/engine/run.py index 79199c4..df58cc2 100644 --- a/backend/engine/run.py +++ b/backend/engine/run.py @@ -6,11 +6,20 @@ import asyncio import contextlib import sys +from dataclasses import dataclass, field from backend.engine.client import EngineClient from backend.engine.state_server import free_port, serve +@dataclass +class RunHandle: + run_id: str + url: str + procs: list = field(default_factory=list) + server_task: object = None + + async def _drive_gates(url, run_id, auto_approve, timeout, poll): loop = asyncio.get_running_loop() deadline = loop.time() + timeout @@ -28,70 +37,94 @@ async def _drive_gates(url, run_id, auto_approve, timeout, poll): await asyncio.sleep(poll) -async def run_pipeline( +async def start_run( idea, workers=4, budget_limit=200.0, - auto_approve=True, db_path=None, host="127.0.0.1", port=None, - poll=0.1, - timeout=60.0, -) -> dict: +) -> RunHandle: + """Boot the state server, wait for it to accept a create_run, spawn worker + subprocesses, and return the handle. Does NOT drive gates.""" db_path = db_path or "data/engine.db" port = port or free_port() url = f"http://{host}:{port}/mcp" server_task = asyncio.create_task(serve(db_path, host, port)) - procs = [] - worker_pids = [] run_id = None - final = None - try: - # wait for the server to accept connections, then create the run - for _ in range(200): - try: - async with EngineClient(url) as c: - run_id = await c.create_run(idea, budget_limit) - break - except Exception: # noqa: BLE001 - server may not be up yet - await asyncio.sleep(0.05) - if run_id is None: - raise RuntimeError("state server failed to start") - - # spawn worker subprocesses (inside the try so a mid-spawn failure still tears down) - for i in range(workers): - procs.append( - await asyncio.create_subprocess_exec( - sys.executable, - "-m", - "backend.engine.worker", - "--server-url", - url, - "--run-id", - run_id, - "--worker-id", - f"w{i}", - ) - ) - worker_pids = [p.pid for p in procs] - - final = await _drive_gates(url, run_id, auto_approve, timeout, poll) - finally: - for p in procs: - if p.returncode is None: - p.terminate() - for p in procs: - try: - await asyncio.wait_for(p.wait(), timeout=5.0) - except TimeoutError: - p.kill() + # wait for the server to accept connections, then create the run + for _ in range(200): + try: + async with EngineClient(url) as c: + run_id = await c.create_run(idea, budget_limit) + break + except Exception: # noqa: BLE001 - server may not be up yet + await asyncio.sleep(0.05) + if run_id is None: server_task.cancel() with contextlib.suppress(asyncio.CancelledError): await server_task + raise RuntimeError("state server failed to start") + + # spawn worker subprocesses + procs = [] + for i in range(workers): + procs.append( + await asyncio.create_subprocess_exec( + sys.executable, + "-m", + "backend.engine.worker", + "--server-url", + url, + "--run-id", + run_id, + "--worker-id", + f"w{i}", + ) + ) + + return RunHandle(run_id=run_id, url=url, procs=procs, server_task=server_task) + + +async def stop_run(handle: RunHandle) -> None: + """Terminate worker subprocesses and cancel/await the server task.""" + for p in handle.procs: + if p.returncode is None: + p.terminate() + for p in handle.procs: + try: + await asyncio.wait_for(p.wait(), timeout=5.0) + except TimeoutError: + p.kill() + if handle.server_task is not None: + handle.server_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await handle.server_task + + +async def run_pipeline( + idea, + workers=4, + budget_limit=200.0, + auto_approve=True, + db_path=None, + host="127.0.0.1", + port=None, + poll=0.1, + timeout=60.0, +) -> dict: + handle = await start_run(idea, workers, budget_limit, db_path, host, port) + worker_pids = [p.pid for p in handle.procs] + final = None + try: + final = await _drive_gates( + handle.url, handle.run_id, auto_approve, timeout, poll + ) + finally: + await stop_run(handle) - return {"run_id": run_id, "snapshot": final, "worker_pids": worker_pids} + return {"run_id": handle.run_id, "snapshot": final, "worker_pids": worker_pids} def main() -> None: diff --git a/tests/engine/test_run_handle.py b/tests/engine/test_run_handle.py new file mode 100644 index 0000000..09d300c --- /dev/null +++ b/tests/engine/test_run_handle.py @@ -0,0 +1,25 @@ +import pytest + +from backend.engine.client import EngineClient +from backend.engine.run import start_run, stop_run + + +@pytest.fixture(autouse=True) +def mock_mode(monkeypatch): + monkeypatch.setenv("MOCK_AGENTS", "true") + + +async def test_start_run_boots_and_seeds_then_stop_cleans_up(tmp_path): + db = str(tmp_path / "run.db") + handle = await start_run("todo app", workers=2, budget_limit=200.0, db_path=db) + try: + assert handle.run_id and handle.url.endswith("/mcp") + assert len(handle.procs) == 2 + async with EngineClient(handle.url) as c: + snap = await c.get_run(handle.run_id) + assert snap["status"] == "running" # not auto-driven; gate not yet approved + finally: + await stop_run(handle) + # workers terminated + for p in handle.procs: + assert p.returncode is not None From af752026a1e3d314b5c9b12e46650a754eb3698b Mon Sep 17 00:00:00 2001 From: Alex Barclay Date: Fri, 24 Jul 2026 21:55:50 -0600 Subject: [PATCH 03/10] =?UTF-8?q?fix(engine):=20atomic=20start=5Frun=20?= =?UTF-8?q?=E2=80=94=20tear=20down=20on=20mid-spawn=20failure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/engine/run.py | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/backend/engine/run.py b/backend/engine/run.py index df58cc2..2edf693 100644 --- a/backend/engine/run.py +++ b/backend/engine/run.py @@ -68,23 +68,26 @@ async def start_run( raise RuntimeError("state server failed to start") # spawn worker subprocesses - procs = [] - for i in range(workers): - procs.append( - await asyncio.create_subprocess_exec( - sys.executable, - "-m", - "backend.engine.worker", - "--server-url", - url, - "--run-id", - run_id, - "--worker-id", - f"w{i}", + handle = RunHandle(run_id=run_id, url=url, procs=[], server_task=server_task) + try: + for i in range(workers): + handle.procs.append( + await asyncio.create_subprocess_exec( + sys.executable, + "-m", + "backend.engine.worker", + "--server-url", + url, + "--run-id", + run_id, + "--worker-id", + f"w{i}", + ) ) - ) - - return RunHandle(run_id=run_id, url=url, procs=procs, server_task=server_task) + except BaseException: + await stop_run(handle) # terminate spawned procs + cancel/await server task + raise + return handle async def stop_run(handle: RunHandle) -> None: From 968647d94a9da3b8cce4d7402208ff32f3e2f5e0 Mon Sep 17 00:00:00 2001 From: Alex Barclay Date: Fri, 24 Jul 2026 22:01:21 -0600 Subject: [PATCH 04/10] docs(plans): refer to the embargoed name indirectly --- ...6-07-23-mcp-orchestration-engine-plan-a-coordination-core.md | 2 +- ...2026-07-24-mcp-orchestration-engine-plan-b-server-workers.md | 2 +- .../2026-07-24-mcp-orchestration-engine-plan-c-evidence.md | 2 +- .../2026-07-24-mcp-orchestration-engine-plan-d-web-bridge.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-07-23-mcp-orchestration-engine-plan-a-coordination-core.md b/docs/superpowers/plans/2026-07-23-mcp-orchestration-engine-plan-a-coordination-core.md index 8755bac..e9a6081 100644 --- a/docs/superpowers/plans/2026-07-23-mcp-orchestration-engine-plan-a-coordination-core.md +++ b/docs/superpowers/plans/2026-07-23-mcp-orchestration-engine-plan-a-coordination-core.md @@ -16,7 +16,7 @@ - **Single writer + `_db_lock`:** every DB-mutating `Store` method runs under `async with self._db_lock`. Collision-freedom = serialized DB ops + versioned CAS guards. - **Exactly-once *effect*:** completion/heartbeat are guarded by `owner==worker AND version==:v`; the reaper bumps `version` on reclaim. - **Config source-of-truth:** `config/phases.yaml` (new) for phases; `config/budget.yaml` for budget incl. the new `downgrade_paths`. `config/agents.yaml` provides each agent's base model. -- **Embargo:** the string "OpenBarclay" must not appear anywhere. +- **Embargo:** the LLC name must not appear anywhere. Frame all work as independent/personal. - **Commits:** no `Co-Authored-By` line, no "Generated with" attribution. Conventional-commit style. - **Critical agents never downgrade:** `clarifying_pm`, `solution_architect` (skip-list). - **Six phases / agent membership (verbatim, used across tasks):** diff --git a/docs/superpowers/plans/2026-07-24-mcp-orchestration-engine-plan-b-server-workers.md b/docs/superpowers/plans/2026-07-24-mcp-orchestration-engine-plan-b-server-workers.md index 32b591d..9da551f 100644 --- a/docs/superpowers/plans/2026-07-24-mcp-orchestration-engine-plan-b-server-workers.md +++ b/docs/superpowers/plans/2026-07-24-mcp-orchestration-engine-plan-b-server-workers.md @@ -19,7 +19,7 @@ - Server object: `FastMCP("appforge-state", stateless_http=True)`; ASGI app via `mcp.streamable_http_app()` under uvicorn; client connects to `http://host:port/mcp`. - **`appforge_mcp_server.py`** entry alias at repo root (matches the resume artifact name), delegating to `backend.engine.state_server`. - **Mock mode:** tests + the documented run use `MOCK_AGENTS=true` (default). No real Anthropic calls. -- **Embargo:** the string "OpenBarclay" must not appear anywhere. +- **Embargo:** the LLC name must not appear anywhere. Frame all work as independent/personal. - **Commits:** conventional style, no `Co-Authored-By`/attribution footer. - Six phases + agents are fixed by `config/phases.yaml` (Plan A). The terminal phase is `iterate`. diff --git a/docs/superpowers/plans/2026-07-24-mcp-orchestration-engine-plan-c-evidence.md b/docs/superpowers/plans/2026-07-24-mcp-orchestration-engine-plan-c-evidence.md index 99b89ee..e4d0f0d 100644 --- a/docs/superpowers/plans/2026-07-24-mcp-orchestration-engine-plan-c-evidence.md +++ b/docs/superpowers/plans/2026-07-24-mcp-orchestration-engine-plan-c-evidence.md @@ -15,7 +15,7 @@ - **CI must stay green:** `uv run ruff check backend/ tests/` (0 errors), `uv run black --check backend/ tests/` (clean), `uv run pytest tests/ --cov-fail-under=70`. **Every task's final step runs `ruff check` + `black --check` on the files it touched** and fixes any finding before commit (the plan code is written Black-compatible, but verify). - **Budget facts (from `config/phases.yaml` sim_costs):** Clarify 0.30 + Design 1.30 + Code 2.70 = **4.30** committed by the time Test opens. With `--budget-limit 5.0`, `spend_ratio` at Test-open = 0.86 ≥ 0.85 → `qa_test` (gpt-4o→gpt-4o-mini) and `security` (claude-3-5-sonnet-20241022→claude-3-5-haiku-20241022) are downgraded; critical agents (`clarifying_pm`, `solution_architect`) never are. - **Authoritative downgrade paths** live in `config/budget.yaml` (`downgrade_paths`), loaded via `backend.engine.phases.load_downgrade_paths`. -- **Mock mode only** (`MOCK_AGENTS=true`). No "OpenBarclay". Commits: no attribution footer. +- **Mock mode only** (`MOCK_AGENTS=true`). No LLC name. Commits: no attribution footer. --- diff --git a/docs/superpowers/plans/2026-07-24-mcp-orchestration-engine-plan-d-web-bridge.md b/docs/superpowers/plans/2026-07-24-mcp-orchestration-engine-plan-d-web-bridge.md index e1b1359..0810b96 100644 --- a/docs/superpowers/plans/2026-07-24-mcp-orchestration-engine-plan-d-web-bridge.md +++ b/docs/superpowers/plans/2026-07-24-mcp-orchestration-engine-plan-d-web-bridge.md @@ -19,7 +19,7 @@ - `ProjectStateSnapshot`: `{project_id, idea, messages:[], agents:Record, approval_pending:ApprovalRequest|null, budget:{spent,limit,threshold?}, phase:int, prd:str|null, status:"running"|"paused"|"complete"|"failed", adr?, tasks?, design_spec?}`. - **Engine→frontend phase-number map:** `clarify→3, design→4, code→6, test→7, deploy→8, iterate→10`. Gate kinds: clarify gate → `"prd"`, design gate → `"plan"`. - **Agent status map:** task `blocked`/`ready` → `"pending"`; `claimed`/`running` (owner set, not done) → `"running"`; `done` → `"complete"`; `failed` → `"error"`. A task whose `model` differs from its agent's base model (`config/agents.yaml`) → emit `"downgraded"` on completion instead of `"complete"`. -- Mock mode only (`MOCK_AGENTS=true`) for tests. No "OpenBarclay"; commits carry no attribution footer. +- Mock mode only (`MOCK_AGENTS=true`) for tests. No LLC name; commits carry no attribution footer. --- From a07009122848f49719f1ea94a15ddd67043d0a85 Mon Sep 17 00:00:00 2001 From: Alex Barclay Date: Fri, 24 Jul 2026 22:02:43 -0600 Subject: [PATCH 05/10] feat(engine): snapshot budget field + pure web-bridge event mappers --- backend/engine/store.py | 10 +++ backend/engine/webbridge.py | 134 +++++++++++++++++++++++++++++++++ tests/engine/test_webbridge.py | 126 +++++++++++++++++++++++++++++++ 3 files changed, 270 insertions(+) create mode 100644 backend/engine/webbridge.py create mode 100644 tests/engine/test_webbridge.py diff --git a/backend/engine/store.py b/backend/engine/store.py index f5dc5b4..2be8e85 100644 --- a/backend/engine/store.py +++ b/backend/engine/store.py @@ -399,6 +399,15 @@ async def snapshot(self, run_id: str) -> dict: status = "done" else: status = "running" + cur = await self._db.execute( + "SELECT COALESCE(SUM(cost),0) AS s FROM spend WHERE run_id=?", (run_id,) + ) + spent = (await cur.fetchone())["s"] + cur = await self._db.execute( + "SELECT budget_limit FROM runs WHERE run_id=?", (run_id,) + ) + row = await cur.fetchone() + limit = row["budget_limit"] if row else 0.0 return { "run_id": run_id, "status": status, @@ -416,4 +425,5 @@ async def snapshot(self, run_id: str) -> dict: } for t in tasks ], + "budget": {"spent": spent, "limit": limit}, } diff --git a/backend/engine/webbridge.py b/backend/engine/webbridge.py new file mode 100644 index 0000000..547d59f --- /dev/null +++ b/backend/engine/webbridge.py @@ -0,0 +1,134 @@ +"""Pure mappers: engine snapshot -> the React frontend's Socket.IO contract.""" + +from __future__ import annotations + +_PHASE_NUMBER = { + "clarify": 3, + "design": 4, + "code": 6, + "test": 7, + "deploy": 8, + "iterate": 10, +} +PHASE_GATE_KIND = {"clarify": "prd", "design": "plan"} +_WRITES_KEY = {"clarify": "prd", "design": "adr"} # content shown on the approval card + + +def phase_number(name: str) -> int: + return _PHASE_NUMBER.get(name, 0) + + +def _agent_status(task: dict, base_models: dict) -> str: + status = task["status"] + if status == "failed": + return "error" + if status == "done": + base = base_models.get(task["agent_id"]) + if task.get("model") and base and task["model"] != base: + return "downgraded" + return "complete" + if status in ("claimed", "running") or task.get("owner"): + return "running" + return "pending" + + +def _agents_map(snapshot: dict, base_models: dict) -> dict: + return { + t["agent_id"]: { + "id": t["agent_id"], + "name": t["agent_id"], + "status": _agent_status(t, base_models), + } + for t in snapshot["tasks"] + } + + +def to_project_state(snapshot: dict, idea: str, state: dict) -> dict: + agents = _agents_map(snapshot, {}) + pending = None + for p in snapshot["phases"]: + if p["gate"] == "pending": + kind = PHASE_GATE_KIND.get(p["name"], "prd") + content = state.get(_WRITES_KEY.get(p["name"], "prd")) or "" + pending = { + "agent": p["name"], + "phase": phase_number(p["name"]), + "content": content if isinstance(content, str) else str(content), + "kind": kind, + } + open_phase = next((p for p in snapshot["phases"] if p["status"] == "open"), None) + fe_status = {"done": "complete", "failed": "failed"}.get( + snapshot["status"], "running" + ) + if pending is not None: + fe_status = "paused" + return { + "project_id": snapshot["run_id"], + "idea": idea, + "messages": [], + "agents": agents, + "approval_pending": pending, + "budget": snapshot.get("budget", {"spent": 0.0, "limit": 0.0}), + "phase": phase_number(open_phase["name"]) if open_phase else 3, + "prd": state.get("prd"), + "status": fe_status, + "adr": state.get("adr"), + } + + +def diff_to_events( + prev: dict | None, new: dict, state: dict, base_models: dict +) -> list[tuple[str, dict]]: + events: list[tuple[str, dict]] = [] + prev_tasks = {t["agent_id"]: t for t in (prev["tasks"] if prev else [])} + for t in new["tasks"]: + old = prev_tasks.get(t["agent_id"]) + new_s = _agent_status(t, base_models) + old_s = _agent_status(old, base_models) if old else "pending" + if new_s != old_s: + events.append(("agent_status", {"agent": t["agent_id"], "status": new_s})) + prev_phase = {p["name"]: p for p in (prev["phases"] if prev else [])} + for p in new["phases"]: + op = prev_phase.get(p["name"]) + if p["status"] == "complete" and (op is None or op["status"] != "complete"): + events.append( + ( + "phase_complete", + { + "phase": phase_number(p["name"]), + "summary": f"{p['name']} complete", + "status": "success", + }, + ) + ) + if p["gate"] == "pending" and (op is None or op["gate"] != "pending"): + kind = PHASE_GATE_KIND.get(p["name"], "prd") + content = state.get(_WRITES_KEY.get(p["name"], "prd")) or "" + events.append( + ( + "approval_required", + { + "agent": p["name"], + "phase": phase_number(p["name"]), + "content": ( + content if isinstance(content, str) else str(content) + ), + "kind": kind, + }, + ) + ) + nb = new.get("budget", {}) + ob = prev.get("budget", {}) if prev else {} + if nb and nb.get("spent") != ob.get("spent"): + limit = nb.get("limit", 0.0) or 1.0 + events.append( + ( + "budget_update", + { + "spent": nb.get("spent", 0.0), + "limit": nb.get("limit", 0.0), + "threshold": round(nb.get("spent", 0.0) / limit, 4), + }, + ) + ) + return events diff --git a/tests/engine/test_webbridge.py b/tests/engine/test_webbridge.py new file mode 100644 index 0000000..72cedc3 --- /dev/null +++ b/tests/engine/test_webbridge.py @@ -0,0 +1,126 @@ +from backend.engine import webbridge as wb + +BASE = {"qa_test": "gpt-4o", "clarifying_pm": "claude-3-5-sonnet-20241022"} + + +def _snap(status, phases, tasks, spent=0.0, limit=200.0): + return { + "run_id": "r1", + "status": status, + "phases": phases, + "tasks": tasks, + "budget": {"spent": spent, "limit": limit}, + } + + +def test_phase_number_and_kind(): + assert wb.phase_number("clarify") == 3 and wb.phase_number("code") == 6 + assert wb.PHASE_GATE_KIND["design"] == "plan" + + +def test_agent_status_transitions_emit_events(): + prev = _snap( + "running", + [{"name": "clarify", "status": "open", "gate": "none"}], + [ + { + "agent_id": "clarifying_pm", + "phase": "clarify", + "status": "ready", + "owner": None, + "model": "claude-3-5-sonnet-20241022", + } + ], + ) + new = _snap( + "running", + [{"name": "clarify", "status": "open", "gate": "none"}], + [ + { + "agent_id": "clarifying_pm", + "phase": "clarify", + "status": "running", + "owner": "w0", + "model": "claude-3-5-sonnet-20241022", + } + ], + ) + events = wb.diff_to_events(prev, new, {}, BASE) + assert ("agent_status", {"agent": "clarifying_pm", "status": "running"}) in [ + (e, {k: v for k, v in p.items() if k in ("agent", "status")}) for e, p in events + ] + + +def test_pending_gate_emits_approval_required_with_prd(): + prev = _snap( + "running", [{"name": "clarify", "status": "complete", "gate": "none"}], [] + ) + new = _snap( + "running", [{"name": "clarify", "status": "complete", "gate": "pending"}], [] + ) + events = wb.diff_to_events(prev, new, {"prd": "# PRD"}, BASE) + appr = [p for e, p in events if e == "approval_required"] + assert ( + appr + and appr[0]["kind"] == "prd" + and appr[0]["phase"] == 3 + and appr[0]["content"] == "# PRD" + ) + + +def test_downgraded_status_on_completion(): + prev = _snap( + "running", + [{"name": "test", "status": "open", "gate": "none"}], + [ + { + "agent_id": "qa_test", + "phase": "test", + "status": "running", + "owner": "w0", + "model": "gpt-4o-mini", + } + ], + ) + new = _snap( + "running", + [{"name": "test", "status": "open", "gate": "none"}], + [ + { + "agent_id": "qa_test", + "phase": "test", + "status": "done", + "owner": "w0", + "model": "gpt-4o-mini", + } + ], + ) # base gpt-4o, ran on mini + events = wb.diff_to_events(prev, new, {}, BASE) + st = [ + p["status"] + for e, p in events + if e == "agent_status" and p["agent"] == "qa_test" + ] + assert st == ["downgraded"] + + +def test_to_project_state_shape(): + snap = _snap( + "running", + [{"name": "clarify", "status": "complete", "gate": "pending"}], + [ + { + "agent_id": "clarifying_pm", + "phase": "clarify", + "status": "done", + "owner": "w0", + "model": "claude-3-5-sonnet-20241022", + } + ], + spent=1.0, + ) + ps = wb.to_project_state(snap, "todo", {"prd": "# PRD"}) + assert ps["idea"] == "todo" and ps["prd"] == "# PRD" + assert ps["agents"]["clarifying_pm"]["status"] == "complete" + assert ps["approval_pending"]["kind"] == "prd" + assert ps["budget"]["spent"] == 1.0 From 8a58276ed89eedb3cfce17f72985a73ed3dfb358 Mon Sep 17 00:00:00 2001 From: Alex Barclay Date: Fri, 24 Jul 2026 22:10:33 -0600 Subject: [PATCH 06/10] fix(engine): discrete budget threshold bucket in webbridge (matches frontend) --- backend/engine/webbridge.py | 24 +++++++++++++++++++++--- tests/engine/test_webbridge.py | 14 ++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/backend/engine/webbridge.py b/backend/engine/webbridge.py index 547d59f..8c579c9 100644 --- a/backend/engine/webbridge.py +++ b/backend/engine/webbridge.py @@ -18,6 +18,16 @@ def phase_number(name: str) -> int: return _PHASE_NUMBER.get(name, 0) +def _threshold_bucket(spent: float, limit: float) -> int: + if limit <= 0: + return 0 + ratio = spent / limit + for bucket in (100, 95, 85, 75, 50): + if ratio >= bucket / 100: + return bucket + return 0 + + def _agent_status(task: dict, base_models: dict) -> str: status = task["status"] if status == "failed": @@ -56,19 +66,26 @@ def to_project_state(snapshot: dict, idea: str, state: dict) -> dict: "content": content if isinstance(content, str) else str(content), "kind": kind, } + break open_phase = next((p for p in snapshot["phases"] if p["status"] == "open"), None) fe_status = {"done": "complete", "failed": "failed"}.get( snapshot["status"], "running" ) if pending is not None: fe_status = "paused" + b = snapshot.get("budget", {"spent": 0.0, "limit": 0.0}) + budget = { + "spent": b.get("spent", 0.0), + "limit": b.get("limit", 0.0), + "threshold": _threshold_bucket(b.get("spent", 0.0), b.get("limit", 0.0)), + } return { "project_id": snapshot["run_id"], "idea": idea, "messages": [], "agents": agents, "approval_pending": pending, - "budget": snapshot.get("budget", {"spent": 0.0, "limit": 0.0}), + "budget": budget, "phase": phase_number(open_phase["name"]) if open_phase else 3, "prd": state.get("prd"), "status": fe_status, @@ -120,14 +137,15 @@ def diff_to_events( nb = new.get("budget", {}) ob = prev.get("budget", {}) if prev else {} if nb and nb.get("spent") != ob.get("spent"): - limit = nb.get("limit", 0.0) or 1.0 events.append( ( "budget_update", { "spent": nb.get("spent", 0.0), "limit": nb.get("limit", 0.0), - "threshold": round(nb.get("spent", 0.0) / limit, 4), + "threshold": _threshold_bucket( + nb.get("spent", 0.0), nb.get("limit", 0.0) + ), }, ) ) diff --git a/tests/engine/test_webbridge.py b/tests/engine/test_webbridge.py index 72cedc3..cdbbe8d 100644 --- a/tests/engine/test_webbridge.py +++ b/tests/engine/test_webbridge.py @@ -124,3 +124,17 @@ def test_to_project_state_shape(): assert ps["agents"]["clarifying_pm"]["status"] == "complete" assert ps["approval_pending"]["kind"] == "prd" assert ps["budget"]["spent"] == 1.0 + + +def test_budget_update_uses_discrete_threshold_bucket(): + prev = _snap("running", [], [], spent=0.0, limit=200.0) + new = _snap("running", [], [], spent=190.0, limit=200.0) # 0.95 + events = wb.diff_to_events(prev, new, {}, {}) + bu = [p for e, p in events if e == "budget_update"] + assert bu and bu[0]["threshold"] == 95 and bu[0]["spent"] == 190.0 + + +def test_to_project_state_includes_threshold_bucket(): + snap = _snap("running", [], [], spent=170.0, limit=200.0) # 0.85 + ps = wb.to_project_state(snap, "todo", {}) + assert ps["budget"]["threshold"] == 85 From 614aa3d277749c62c4386c52c40dbbbc7ba03847 Mon Sep 17 00:00:00 2001 From: Alex Barclay Date: Fri, 24 Jul 2026 22:23:29 -0600 Subject: [PATCH 07/10] feat(web): main.py drives the engine over Socket.IO (retires Orchestrator usage) --- backend/main.py | 175 +++++++++++++++------------ tests/integration/test_web_bridge.py | 69 +++++++++++ 2 files changed, 168 insertions(+), 76 deletions(-) create mode 100644 tests/integration/test_web_bridge.py diff --git a/backend/main.py b/backend/main.py index 4981c56..8f741cc 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,41 +1,33 @@ -"""FastAPI + Socket.IO application entry point. +"""FastAPI + Socket.IO bridge over the parallel MCP orchestration engine. -HTTP endpoints and Socket.IO event handlers are both mounted on one ASGI app -served by uvicorn on :8000. For local development, run: - - uv run -- python -m backend.main +Run: uv run -- python -m backend.main (serves on :8000) +The React frontend's existing Socket.IO events are driven by live engine runs. """ from __future__ import annotations -import uuid +import asyncio +import contextlib +import os from typing import Any import socketio from fastapi import FastAPI -from backend.config import Config -from backend.orchestrator import Orchestrator - -config = Config.load() - -app = FastAPI(title="DevTeam.AI backend", version="0.3.0") +from backend.engine import webbridge +from backend.engine.client import EngineClient +from backend.engine.run import RunHandle, start_run, stop_run +from backend.engine.state_server import base_models_from_config +app = FastAPI(title="AppForge engine backend", version="1.0.0") sio = socketio.AsyncServer( async_mode="asgi", cors_allowed_origins=["http://localhost:5173", "http://127.0.0.1:5173"], - logger=config.debug, - engineio_logger=config.debug, ) - -# Combined ASGI app: FastAPI handles HTTP, Socket.IO handles /socket.io/* asgi_app = socketio.ASGIApp(sio, other_asgi_app=app, socketio_path="/socket.io") -orchestrator = Orchestrator(config=config) - - -async def _emit(event: str, data: dict[str, Any], room: str) -> None: - await sio.emit(event, data, room=room) +_BASE_MODELS = base_models_from_config() +_runs: dict[str, dict[str, Any]] = {} # project_id -> {handle, idea, poller, prev} @app.get("/health") @@ -43,99 +35,130 @@ async def health() -> dict[str, str]: return {"status": "ok"} -@sio.event -async def connect(sid: str, environ: dict, auth: dict | None = None) -> None: - # No-op for now; per-project rooms are joined later via start_project / load_project - pass +async def _poll_and_emit(project_id: str, room: str) -> None: + ctx = _runs[project_id] + handle: RunHandle = ctx["handle"] + prev = None + while True: + try: + async with EngineClient(handle.url) as c: + snap = await c.get_run(handle.run_id) + keys = ["prd", "adr", "tasks", "design_spec"] + state = { + k: v["value"] + for k, v in (await c.get_state(handle.run_id, keys)).items() + } + except Exception: # noqa: BLE001 - server may be tearing down + return + for event, payload in webbridge.diff_to_events(prev, snap, state, _BASE_MODELS): + await sio.emit(event, payload, room=room) + prev = snap + ctx["prev"], ctx["state"] = snap, state + if snap["status"] in ("done", "failed"): + await sio.emit( + "phase_complete", + { + "phase": 10, + "summary": f"run {snap['status']}", + "status": "success" if snap["status"] == "done" else "failed", + }, + room=room, + ) + return + await asyncio.sleep(0.4) @sio.event -async def disconnect(sid: str) -> None: +async def connect(sid, environ, auth=None): # noqa: ARG001 pass @sio.event -async def start_project(sid: str, data: dict[str, Any]) -> dict | None: +async def start_project(sid, data): idea = (data or {}).get("idea", "").strip() if not idea: return {"error": "idea required"} - - project_id = str(uuid.uuid4()) + handle = await start_run( + idea, + workers=4, + budget_limit=200.0, + db_path=os.getenv("APPFORGE_WEB_DB", "data/web.db"), + ) + project_id = handle.run_id room = f"project:{project_id}" await sio.enter_room(sid, room) + _runs[project_id] = {"handle": handle, "idea": idea, "prev": None, "state": {}} await sio.emit("project_created", {"project_id": project_id}, to=sid) - await orchestrator.run(project_id, idea, _emit) + _runs[project_id]["poller"] = asyncio.create_task(_poll_and_emit(project_id, room)) return None -@sio.event -async def user_message(sid: str, data: dict[str, Any]) -> dict | None: - project_id = (data or {}).get("project_id", "") - text = (data or {}).get("text", "") - if not project_id or not text: - return {"error": "project_id and text required"} - - await orchestrator.user_message(project_id, text) +async def _resolve_gate(project_id: str, decision: str) -> dict | None: + ctx = _runs.get(project_id) + if not ctx: + return {"error": "project not found"} + snap = ctx.get("prev") or {} + pending = next((p for p in snap.get("phases", []) if p["gate"] == "pending"), None) + if pending is None: + return {"error": "no pending gate"} + async with EngineClient(ctx["handle"].url) as c: + await c.submit_approval(project_id, pending["name"], decision) return None @sio.event -async def approve(sid: str, data: dict[str, Any]) -> dict | None: - project_id = (data or {}).get("project_id", "") - if not project_id: - return {"error": "project_id required"} - await orchestrator.approve(project_id, (data or {}).get("comment")) - return None +async def approve(sid, data): # noqa: ARG001 + return await _resolve_gate((data or {}).get("project_id", ""), "approved") @sio.event -async def reject(sid: str, data: dict[str, Any]) -> dict | None: - project_id = (data or {}).get("project_id", "") - if not project_id: - return {"error": "project_id required"} - await orchestrator.reject(project_id, (data or {}).get("comment")) - return None +async def reject(sid, data): # noqa: ARG001 + return await _resolve_gate((data or {}).get("project_id", ""), "rejected") @sio.event -async def modify(sid: str, data: dict[str, Any]) -> dict | None: +async def load_project(sid, data): project_id = (data or {}).get("project_id", "") - comment = (data or {}).get("comment", "") - if not project_id or not comment: - return {"error": "project_id and comment required"} - await orchestrator.modify(project_id, comment) + ctx = _runs.get(project_id) + if not ctx: + return {"error": "project not found"} + await sio.enter_room(sid, f"project:{project_id}") + ps = webbridge.to_project_state( + ctx.get("prev") + or { + "run_id": project_id, + "status": "running", + "phases": [], + "tasks": [], + "budget": {}, + }, + ctx["idea"], + ctx.get("state", {}), + ) + await sio.emit("project_state", ps, to=sid) return None @sio.event -async def retry(sid: str, data: dict[str, Any]) -> dict | None: - project_id = (data or {}).get("project_id", "") - if not project_id: - return {"error": "project_id required"} - await orchestrator.retry(project_id, _emit) - return None +async def disconnect(sid): # noqa: ARG001 + pass -@sio.event -async def load_project(sid: str, data: dict[str, Any]) -> dict | None: - project_id = (data or {}).get("project_id", "") - if not project_id: - return {"error": "project_id required"} - room = f"project:{project_id}" - await sio.enter_room(sid, room) - snap = await orchestrator.load_snapshot(project_id) - if snap is None: - return {"error": "project not found"} - await sio.emit("project_state", snap, to=sid) - return None +async def shutdown() -> None: + for ctx in list(_runs.values()): + poller = ctx.get("poller") + if poller: + poller.cancel() + with contextlib.suppress(asyncio.CancelledError): + await poller + await stop_run(ctx["handle"]) + _runs.clear() def main() -> None: import uvicorn - uvicorn.run( - "backend.main:asgi_app", host="127.0.0.1", port=8000, reload=config.debug - ) + uvicorn.run("backend.main:asgi_app", host="127.0.0.1", port=8000) if __name__ == "__main__": diff --git a/tests/integration/test_web_bridge.py b/tests/integration/test_web_bridge.py new file mode 100644 index 0000000..7819305 --- /dev/null +++ b/tests/integration/test_web_bridge.py @@ -0,0 +1,69 @@ +import pytest +import socketio +import uvicorn + + +@pytest.fixture(autouse=True) +def mock_mode(monkeypatch): + monkeypatch.setenv("MOCK_AGENTS", "true") + + +async def _serve_app(port): + from backend.main import asgi_app + + server = uvicorn.Server( + uvicorn.Config(asgi_app, host="127.0.0.1", port=port, log_level="error") + ) + server.install_signal_handlers = lambda: None + return server + + +async def test_start_project_drives_engine_and_reaches_prd_gate(tmp_path, monkeypatch): + import asyncio + + from tests.engine.server_harness import free_port + + monkeypatch.setenv("APPFORGE_WEB_DB", str(tmp_path / "web.db")) + port = free_port() + server = await _serve_app(port) + task = asyncio.create_task(server.serve()) + while not server.started: + await asyncio.sleep(0.02) + + events: list[tuple[str, dict]] = [] + client = socketio.AsyncClient() + + @client.on("project_created") + async def _created(d): + events.append(("project_created", d)) + + @client.on("agent_status") + async def _status(d): + events.append(("agent_status", d)) + + @client.on("approval_required") + async def _appr(d): + events.append(("approval_required", d)) + + try: + await client.connect(f"http://127.0.0.1:{port}", socketio_path="/socket.io") + await client.emit("start_project", {"idea": "todo app"}) + # wait until the PRD gate is reached (~10s: clarify Q&A loop) + for _ in range(400): + if any( + e == "approval_required" and p.get("kind") == "prd" for e, p in events + ): + break + await asyncio.sleep(0.05) + assert any(e == "project_created" for e, p in events) + assert any( + e == "agent_status" and p["agent"] == "clarifying_pm" for e, p in events + ) + appr = [ + p for e, p in events if e == "approval_required" and p.get("kind") == "prd" + ] + assert appr and appr[0]["content"] # PRD content present + finally: + await client.disconnect() + server.should_exit = True + await task From 500510c8cd0765529837d808e764b84af87cdd8a Mon Sep 17 00:00:00 2001 From: Alex Barclay Date: Fri, 24 Jul 2026 22:37:18 -0600 Subject: [PATCH 08/10] fix(web): stop engine runs on completion + lifespan shutdown + poller logging --- backend/main.py | 18 ++++++++++++++++-- tests/integration/test_web_bridge.py | 4 ++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/backend/main.py b/backend/main.py index 8f741cc..839edc5 100644 --- a/backend/main.py +++ b/backend/main.py @@ -9,9 +9,11 @@ import asyncio import contextlib import os +from contextlib import asynccontextmanager from typing import Any import socketio +import structlog from fastapi import FastAPI from backend.engine import webbridge @@ -19,7 +21,16 @@ from backend.engine.run import RunHandle, start_run, stop_run from backend.engine.state_server import base_models_from_config -app = FastAPI(title="AppForge engine backend", version="1.0.0") +logger = structlog.get_logger(__name__) + + +@asynccontextmanager +async def _lifespan(_app): + yield + await shutdown() + + +app = FastAPI(title="AppForge engine backend", version="1.0.0", lifespan=_lifespan) sio = socketio.AsyncServer( async_mode="asgi", cors_allowed_origins=["http://localhost:5173", "http://127.0.0.1:5173"], @@ -48,7 +59,8 @@ async def _poll_and_emit(project_id: str, room: str) -> None: k: v["value"] for k, v in (await c.get_state(handle.run_id, keys)).items() } - except Exception: # noqa: BLE001 - server may be tearing down + except Exception as e: # noqa: BLE001 - server may be tearing down + logger.warning("web.poller_error", project_id=project_id, error=str(e)) return for event, payload in webbridge.diff_to_events(prev, snap, state, _BASE_MODELS): await sio.emit(event, payload, room=room) @@ -64,6 +76,8 @@ async def _poll_and_emit(project_id: str, room: str) -> None: }, room=room, ) + await stop_run(handle) + _runs.pop(project_id, None) return await asyncio.sleep(0.4) diff --git a/tests/integration/test_web_bridge.py b/tests/integration/test_web_bridge.py index 7819305..4f19042 100644 --- a/tests/integration/test_web_bridge.py +++ b/tests/integration/test_web_bridge.py @@ -65,5 +65,9 @@ async def _appr(d): assert appr and appr[0]["content"] # PRD content present finally: await client.disconnect() + + from backend.main import shutdown + + await shutdown() server.should_exit = True await task From 155155ec27fecbce3829191cfcd93027e3e157db Mon Sep 17 00:00:00 2001 From: Alex Barclay Date: Fri, 24 Jul 2026 22:54:40 -0600 Subject: [PATCH 09/10] chore(engine): retire LangGraph orchestrator + coupled tests + deps --- backend/graph.py | 161 ---- backend/orchestrator.py | 774 -------------------- pyproject.toml | 4 - tests/e2e/test_phase3_demo.py | 98 --- tests/e2e/test_phase4_planning.py | 163 ----- tests/integration/test_approval_flow.py | 120 --- tests/integration/test_load_snapshot.py | 187 ----- tests/integration/test_mock_fallback.py | 80 -- tests/integration/test_orchestrator_flow.py | 38 - tests/integration/test_persistence.py | 39 - tests/integration/test_planning_sprint.py | 109 --- tests/integration/test_rejection_cycle.py | 55 -- tests/integration/test_slice3_smoke.py | 112 --- tests/integration/test_socketio_events.py | 57 -- tests/unit/test_graph.py | 73 -- uv.lock | 237 ------ 16 files changed, 2307 deletions(-) delete mode 100644 backend/graph.py delete mode 100644 backend/orchestrator.py delete mode 100644 tests/e2e/test_phase3_demo.py delete mode 100644 tests/e2e/test_phase4_planning.py delete mode 100644 tests/integration/test_approval_flow.py delete mode 100644 tests/integration/test_load_snapshot.py delete mode 100644 tests/integration/test_mock_fallback.py delete mode 100644 tests/integration/test_orchestrator_flow.py delete mode 100644 tests/integration/test_persistence.py delete mode 100644 tests/integration/test_planning_sprint.py delete mode 100644 tests/integration/test_rejection_cycle.py delete mode 100644 tests/integration/test_slice3_smoke.py delete mode 100644 tests/unit/test_graph.py diff --git a/backend/graph.py b/backend/graph.py deleted file mode 100644 index b99a2af..0000000 --- a/backend/graph.py +++ /dev/null @@ -1,161 +0,0 @@ -"""LangGraph definition for the Phase 3 clarification workflow. - -The graph has three real nodes plus all 15 agent ids registered in state-only -form so the frontend can render them. Execution flow for this sub-project: - - START -> clarifying_pm -> product_owner_approval -> delivery_summarizer -> END - -Rejection from product_owner_approval routes back to clarifying_pm for revision. -""" - -from __future__ import annotations - -from collections.abc import Awaitable, Callable -from typing import Any, Literal - -from langgraph.graph import END, START, StateGraph -from pydantic import BaseModel, Field - - -class Question(BaseModel): - text: str - index: int - - -class Answer(BaseModel): - question_index: int - text: str - - -class Task(BaseModel): - id: str - title: str - description: str - owner_agent: str - depends_on: list[str] = Field(default_factory=list) - - -class ProjectState(BaseModel): - idea: str = "" - questions: list[Question] = Field(default_factory=list) - answers: list[Answer] = Field(default_factory=list) - prd: str | None = None - approval_status: Literal["pending", "approved", "rejected", "modified"] | None = ( - None - ) - approval_count: int = 0 - pending_input: str | None = None - rejection_comments: list[str] = Field(default_factory=list) - current_phase: int = 3 - cost_so_far: float = 0.0 - adr: str | None = None - tasks: list[Task] = Field(default_factory=list) - design_spec: dict[str, Any] | None = None - planning_approval_status: ( - Literal["pending", "approved", "rejected", "modified"] | None - ) = None - planning_approval_count: int = 0 - planning_rejection_comments: list[str] = Field(default_factory=list) - - -# Node function signatures. Actual implementations are provided by the -# orchestrator at build time (so they can close over emit and agent instances). -NodeFn = Callable[[ProjectState], Awaitable[dict[str, Any]]] - - -def build_graph( - checkpointer: Any | None, - clarifying_pm_node: NodeFn | None = None, - approval_node: NodeFn | None = None, - summarizer_node: NodeFn | None = None, - solution_architect_node: NodeFn | None = None, - tech_lead_node: NodeFn | None = None, - uiux_designer_node: NodeFn | None = None, - planning_fan_in_node: NodeFn | None = None, - planning_approval_node: NodeFn | None = None, - enable_phase4: bool = False, -) -> Any: - """Compile the LangGraph. Nodes default to no-ops for static testing. - - Phase 3 flow: clarifying_pm -> product_owner_approval -> delivery_summarizer. - With enable_phase4, an approved PRD fans out to three planning agents that - run concurrently, then a fan-in node emits the planning approval card, then - a planning approval gate, then the summarizer. - - The approval gate pauses via the dynamic interrupt() helper called inside - approval_node (see orchestrator), NOT via a static interrupt_before. The - two are mutually exclusive: interrupt_before returns from ainvoke *before* - the node body runs, which would skip the interrupt() call and the driver's - Command(resume=...) re-entry entirely. A checkpointer is still required for - interrupt()/resume to work, so we pass it through when provided. - """ - - async def _noop(state: ProjectState) -> dict[str, Any]: - return {} - - clarifying_pm_node = clarifying_pm_node or _noop - approval_node = approval_node or _noop - summarizer_node = summarizer_node or _noop - solution_architect_node = solution_architect_node or _noop - tech_lead_node = tech_lead_node or _noop - uiux_designer_node = uiux_designer_node or _noop - planning_fan_in_node = planning_fan_in_node or _noop - planning_approval_node = planning_approval_node or _noop - - builder: StateGraph = StateGraph(ProjectState) - builder.add_node("clarifying_pm", clarifying_pm_node) - builder.add_node("product_owner_approval", approval_node) - builder.add_node("delivery_summarizer", summarizer_node) - builder.add_node("solution_architect", solution_architect_node) - builder.add_node("tech_lead", tech_lead_node) - builder.add_node("uiux_designer", uiux_designer_node) - builder.add_node("planning_fan_in", planning_fan_in_node) - builder.add_node("planning_approval", planning_approval_node) - - builder.add_edge(START, "clarifying_pm") - builder.add_edge("clarifying_pm", "product_owner_approval") - - planning_nodes = ["solution_architect", "tech_lead", "uiux_designer"] - - def _route_after_prd(state: ProjectState): - if state.approval_status == "approved": - return planning_nodes if enable_phase4 else "delivery_summarizer" - return "clarifying_pm" - - builder.add_conditional_edges( - "product_owner_approval", - _route_after_prd, - { - "delivery_summarizer": "delivery_summarizer", - "clarifying_pm": "clarifying_pm", - "solution_architect": "solution_architect", - "tech_lead": "tech_lead", - "uiux_designer": "uiux_designer", - }, - ) - - for n in planning_nodes: - builder.add_edge(n, "planning_fan_in") - builder.add_edge("planning_fan_in", "planning_approval") - - def _route_after_planning(state: ProjectState): - if state.planning_approval_status == "approved": - return "delivery_summarizer" - return planning_nodes - - builder.add_conditional_edges( - "planning_approval", - _route_after_planning, - { - "delivery_summarizer": "delivery_summarizer", - "solution_architect": "solution_architect", - "tech_lead": "tech_lead", - "uiux_designer": "uiux_designer", - }, - ) - - builder.add_edge("delivery_summarizer", END) - - if checkpointer is not None: - return builder.compile(checkpointer=checkpointer) - return builder.compile() diff --git a/backend/orchestrator.py b/backend/orchestrator.py deleted file mode 100644 index c65a9d1..0000000 --- a/backend/orchestrator.py +++ /dev/null @@ -1,774 +0,0 @@ -"""Orchestrator: compiles the graph, runs it per project, and bridges to Socket.IO via emit callback. - -The orchestrator owns one asyncio task per project and a registry of pending -interrupt resumes. It does NOT import Socket.IO -- the emit callable is -injected by main.py. -""" - -from __future__ import annotations - -import asyncio -import contextlib -import logging -from collections.abc import Awaitable, Callable -from pathlib import Path -from typing import Any - -from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver -from langgraph.types import Command, interrupt - -from backend.agents.budget_guard import BudgetGuard -from backend.agents.registry import AgentRegistry -from backend.config import Config -from backend.graph import Answer, ProjectState, Question, build_graph - -logger = logging.getLogger(__name__) - -EmitFn = Callable[[str, dict, str], Awaitable[None]] - -# Per the Phase 3 plan (Task 4.4), the clarifying_pm agent does not yet report -# real token costs (its result has cost=0.0). Use a small placeholder so the -# budget bookkeeping still exercises the can_spend / record_spend path end to -# end. When the real LLM cost is plumbed through, drop this constant. -_CLARIFYING_COST_ESTIMATE = 0.05 - -_PLANNING_COST_ESTIMATE = ( - 0.05 # per planning agent; placeholder until real cost is threaded -) - - -def _result_field(result: Any, field: str, default: Any = None) -> Any: - """Read a field from an agent result that may be a dict or a dataclass/Pydantic model.""" - if isinstance(result, dict): - return result.get(field, default) - return getattr(result, field, default) - - -def _state_attr(state_like: Any, field: str, default: Any = None) -> Any: - """Read a field from a ProjectState-like object OR a plain dict (load()'s dump).""" - if isinstance(state_like, dict): - return state_like.get(field, default) - return getattr(state_like, field, default) - - -def _task_attr(task: Any, field: str, default: Any = None) -> Any: - """Read a task field whether the task is a dict (load() dump) or a Task model.""" - if isinstance(task, dict): - return task.get(field, default) - return getattr(task, field, default) - - -def _render_plan(state_like: Any) -> str: - """Render ADR + tasks + design into the combined-plan markdown. - - Tolerates both a ProjectState-like object (the live graph state) and a - plain dict (what load() returns from model_dump). Tasks may likewise be - Task models or plain dicts, so attribute access goes through _task_attr. - """ - adr = _state_attr(state_like, "adr") or "" - tasks = _state_attr(state_like, "tasks") or [] - design_spec = _state_attr(state_like, "design_spec") - - lines = [ - "# Implementation Plan", - "", - "## Architecture Decision Record", - adr, - ] - lines += ["", "## Tasks"] - for t in tasks: - depends_on = _task_attr(t, "depends_on") or [] - dep = f" (depends on {', '.join(depends_on)})" if depends_on else "" - title = _task_attr(t, "title", "") - owner = _task_attr(t, "owner_agent", "") - description = _task_attr(t, "description", "") - lines.append(f"- **{title}** — _{owner}_{dep}: {description}") - lines += ["", "## Design", "```json", str(design_spec), "```"] - return "\n".join(lines) - - -class Orchestrator: - """Owns the per-project asyncio tasks and drives the LangGraph workflow.""" - - def __init__( - self, - mock_mode: bool | None = None, - config: Config | None = None, - registry: AgentRegistry | None = None, - budget_guard: BudgetGuard | None = None, - ) -> None: - self.config = config or Config.load() - self.mock_mode = self.config.mock_agents if mock_mode is None else mock_mode - self.registry = registry or AgentRegistry() - # BudgetGuard is a long-lived per-orchestrator collaborator. We use its - # defaults (loads config/budget.yaml when present, otherwise uses the - # built-in $200 limit). Callers may inject a configured instance for - # testing. - self.budget_guard = budget_guard or BudgetGuard() - self._tasks: dict[str, asyncio.Task[None]] = {} - # Per-project queue of resume decisions. A queue (not a single future) - # is used so a decision that arrives in the window between one ainvoke - # returning at an interrupt and the driver re-arming via _await_resume - # is buffered rather than dropped. - self._resume_queues: dict[str, asyncio.Queue[dict]] = {} - # Last emit callback per project, captured at run() time so retry() can - # re-drive the graph without the caller re-supplying it. - self._last_emit: dict[str, EmitFn] = {} - - # Slice 5 / Task 5.1: SQLite-backed checkpointing. - # - # AsyncSqliteSaver.from_conn_string returns an async context manager. - # We hold the CM on the instance and lazily enter it the first time a - # caller (run() or load()) actually needs a saver. This avoids forcing - # __init__ to be async while still giving us a single saver shared by - # every project on this orchestrator instance. - Path(self.config.sqlite_path).parent.mkdir(parents=True, exist_ok=True) - self._saver_cm = AsyncSqliteSaver.from_conn_string(self.config.sqlite_path) - self._saver: AsyncSqliteSaver | None = None - # aiosqlite connections are bound to the event loop they were opened - # on. The orchestrator is a module-level singleton in backend.main, so - # in tests (which create a fresh loop per test) we need to detect a - # loop swap and re-open the saver. Track the loop the saver was on. - self._saver_loop: asyncio.AbstractEventLoop | None = None - - def _room(self, project_id: str) -> str: - return f"project:{project_id}" - - async def _ensure_saver(self) -> AsyncSqliteSaver: - """Lazily enter the AsyncSqliteSaver context manager. - - First call enters the CM, captures the live saver, and enables WAL - journaling so concurrent reads (load() while run() is writing) don't - block. Subsequent calls reuse the same saver — unless we have moved - to a new event loop (tests create one per test), in which case the - prior connection is dead and we open a fresh saver on the new loop. - """ - current_loop = asyncio.get_running_loop() - if self._saver is not None and self._saver_loop is not current_loop: - self._saver = None - self._saver_cm = AsyncSqliteSaver.from_conn_string(self.config.sqlite_path) - if self._saver is None: - self._saver = await self._saver_cm.__aenter__() - self._saver_loop = current_loop - # WAL improves read/write concurrency on SQLite, which matters - # because load() may be invoked while run() is mid-execution. - async with self._saver.conn.cursor() as cur: - await cur.execute("PRAGMA journal_mode=WAL;") - return self._saver - - async def run(self, project_id: str, idea: str, emit: EmitFn) -> None: - """Kick off a new workflow for the given project id. - - Stored as an asyncio Task keyed by project_id. Callers can stop the task - via stop(). Resumption after an interrupt is driven by resume(). - """ - room = self._room(project_id) - self._last_emit[project_id] = emit - # Arm the resume queue before any node can interrupt so user_message / - # approve decisions are never dropped for lack of a destination. - self._resume_queues[project_id] = asyncio.Queue() - - # The injected emit may be either an async coroutine function (the - # production Socket.IO bridge in main.py) or a plain sync callable - # (used by the socket-level integration tests). Normalize so the node - # closures below can always `await emit(...)`. - _raw_emit = emit - - async def emit(event: str, data: dict, room_: str) -> None: - result = _raw_emit(event, data, room_) - if asyncio.iscoroutine(result): - await result - - await emit("agent_status", {"agent": "orchestrator", "status": "running"}, room) - - clarifying_agent = self.registry.get_agent("clarifying_pm") - - async def clarifying_node(state: ProjectState) -> dict[str, Any]: - # Budget gate: refuse to call the agent if even the cheap placeholder - # estimate would breach the hard limit (or the 95% require-ack tier). - can_proceed, reason = self.budget_guard.can_spend(_CLARIFYING_COST_ESTIMATE) - if not can_proceed: - await emit( - "agent_status", - { - "agent": "clarifying_pm", - "status": "error", - "details": "budget_exceeded", - "reason": reason, - }, - room, - ) - raise RuntimeError("Budget hard stop before clarifying_pm") - - await emit( - "agent_status", - {"agent": "clarifying_pm", "status": "running"}, - room, - ) - result = await clarifying_agent.execute( - { - "idea": state.idea, - "questions": [q.model_dump() for q in state.questions], - "answers": [a.model_dump() for a in state.answers], - "rejection_comments": list(state.rejection_comments), - "mode": "mock" if self.mock_mode else "real", - } - ) - if _result_field(result, "status") != "success": - await emit( - "agent_status", - { - "agent": "clarifying_pm", - "status": "error", - "details": _result_field(result, "error"), - }, - room, - ) - raise RuntimeError( - _result_field(result, "error", "clarifying_pm failed") - ) - - # Record actual spend and notify the UI if a threshold tier changed. - # BudgetGuard exposes the highest crossed threshold as a float on - # state.current_threshold (0.0, 0.5, 0.75, 0.85, 0.95, or 1.0); we - # treat changes to that value as the "threshold_pct changed" signal - # described in the plan. - actual_cost = float(_result_field(result, "cost", 0.0) or 0.0) - prev_threshold = self.budget_guard.state.current_threshold - self.budget_guard.record_spend( - agent_id="clarifying_pm", - cost=actual_cost, - phase=3, - ) - new_threshold = self.budget_guard.state.current_threshold - if new_threshold != prev_threshold: - await emit( - "budget_update", - { - "spent": self.budget_guard.state.total_spent, - "limit": self.budget_guard.state.hard_limit, - "threshold": new_threshold, - }, - room, - ) - - artifact = _result_field(result, "artifact") or {} - update: dict[str, Any] = {} - if isinstance(artifact, dict): - question_text = artifact.get("question") - prd_text = artifact.get("prd") - else: - question_text = None - prd_text = None - if question_text: - await emit( - "agent_message", - {"agent": "clarifying_pm", "text": question_text}, - room, - ) - update["questions"] = state.questions + [ - Question(text=question_text, index=len(state.questions)) - ] - if prd_text: - update["prd"] = prd_text - await emit( - "approval_required", - { - "phase": 3, - "agent": "clarifying_pm", - "content": prd_text, - # After three rejections the gate surfaces an escalation - # flag (per approval-gate-protocol). approval_count is - # bumped by approval_node on each non-approve decision. - "escalation": state.approval_count >= 3, - }, - room, - ) - await emit( - "agent_status", - {"agent": "clarifying_pm", "status": "complete"}, - room, - ) - return update - - async def approval_node(state: ProjectState) -> dict[str, Any]: - # Pause here via the dynamic interrupt() helper. ainvoke returns to - # the driver with a "__interrupt__" key; the driver awaits the user's - # input and re-invokes with Command(resume=decision), at which point - # interrupt() returns that value. There is intentionally no side - # effect before this line: on resume LangGraph re-runs the node body - # from the top, so anything above interrupt() would run twice. - # (approval_required is emitted from clarifying_node when a PRD - # exists, so it is not duplicated here.) - decision = interrupt({"phase": 3, "has_prd": bool(state.prd)}) - - # Until the PRD is generated, the clarifying loop is still gathering - # answers. The user_message handler dispatches every chat input - # through resume() as {"answer": text}, so when prd is unset we - # treat the value as an answer to the most recent question and - # route back to clarifying_pm via approval_status="rejected". - if not state.prd: - answer_text = decision.get("answer", "").strip() - if answer_text and state.questions: - new_answer = Answer( - question_index=state.questions[-1].index, - text=answer_text, - ) - # NB: do NOT bump approval_count here. It counts PRD - # rejection cycles (used for escalation); answering a - # clarifying question is not a rejection. We reuse - # approval_status="rejected" only as the routing signal back - # to clarifying_pm. - return { - "answers": state.answers + [new_answer], - "approval_status": "rejected", - } - return {"approval_status": "rejected"} - - # PRD exists -- this is a real approval gate. In Slice 5 the - # frontend will send {"decision": "approved" | "rejected"}; for the - # mock smoke any non-empty input approves the PRD. - decision_value = decision.get("decision") or ( - "approved" if decision.get("answer", "").strip() else "rejected" - ) - approved = decision_value == "approved" - update: dict[str, Any] = { - "approval_status": decision_value, - "approval_count": state.approval_count + (0 if approved else 1), - } - # Thread reject/modify feedback back to clarifying_pm for the next - # revision so the agent can incorporate it. - comment = (decision.get("comment") or "").strip() - if not approved and comment: - update["rejection_comments"] = state.rejection_comments + [comment] - if approved and self.config.enable_phase4: - await emit( - "phase_complete", - {"phase": 3, "summary": "PRD approved", "status": "success"}, - room, - ) - can, reason = self.budget_guard.can_spend(3 * _PLANNING_COST_ESTIMATE) - if not can: - await emit( - "agent_status", - { - "agent": "orchestrator", - "status": "error", - "details": "budget_exceeded", - "reason": reason, - }, - room, - ) - raise RuntimeError("Budget hard stop before planning fan-out") - return update - - async def summarizer_node(state: ProjectState) -> dict[str, Any]: - await emit( - "agent_status", - {"agent": "delivery_summarizer", "status": "running"}, - room, - ) - phase = 4 if self.config.enable_phase4 else 3 - summary = ( - "Planning approved" if self.config.enable_phase4 else "PRD approved" - ) - await emit( - "phase_complete", - {"phase": phase, "summary": summary, "status": "success"}, - room, - ) - await emit( - "agent_status", - {"agent": "delivery_summarizer", "status": "complete"}, - room, - ) - return {"current_phase": phase + 1} - - def _make_planning_node( - agent_id: str, artifact_key: str, state_field: str, kind: str - ): - agent = self.registry.get_agent(agent_id) - - async def _node(state: ProjectState) -> dict[str, Any]: - await emit( - "agent_status", {"agent": agent_id, "status": "running"}, room - ) - result = await agent.execute( - { - "idea": state.idea, - "prd": state.prd or "", - "rejection_comments": list(state.planning_rejection_comments), - "mode": "mock" if self.mock_mode else "real", - } - ) - if _result_field(result, "status") != "success": - await emit( - "agent_status", - { - "agent": agent_id, - "status": "error", - "details": _result_field(result, "error"), - }, - room, - ) - raise RuntimeError( - _result_field(result, "error", f"{agent_id} failed") - ) - self.budget_guard.record_spend( - agent_id=agent_id, - cost=float(_result_field(result, "cost", 0.0) or 0.0), - phase=4, - ) - artifact = _result_field(result, "artifact") or {} - value = artifact.get(artifact_key) - await emit("planning_artifact", {"kind": kind, "content": value}, room) - await emit( - "agent_status", {"agent": agent_id, "status": "complete"}, room - ) - return {state_field: value} - - return _node - - solution_architect_node = _make_planning_node( - "solution_architect", "adr", "adr", "adr" - ) - tech_lead_node = _make_planning_node("tech_lead", "tasks", "tasks", "tasks") - uiux_designer_node = _make_planning_node( - "uiux_designer", "design_spec", "design_spec", "design" - ) - - async def planning_fan_in_node(state: ProjectState) -> dict[str, Any]: - await emit( - "approval_required", - { - "phase": 4, - "agent": "tech_lead", - "kind": "plan", - "content": _render_plan(state), - "escalation": state.planning_approval_count >= 3, - }, - room, - ) - return {} - - async def planning_approval_node(state: ProjectState) -> dict[str, Any]: - decision = interrupt({"phase": 4, "gate": "plan"}) - decision_value = decision.get("decision") or ( - "approved" if decision.get("answer", "").strip() else "rejected" - ) - approved = decision_value == "approved" - update: dict[str, Any] = { - "planning_approval_status": decision_value, - "planning_approval_count": state.planning_approval_count - + (0 if approved else 1), - } - comment = (decision.get("comment") or "").strip() - if not approved and comment: - update["planning_rejection_comments"] = ( - state.planning_rejection_comments + [comment] - ) - return update - - saver = await self._ensure_saver() - graph = build_graph( - checkpointer=saver, - clarifying_pm_node=clarifying_node, - approval_node=approval_node, - summarizer_node=summarizer_node, - solution_architect_node=solution_architect_node, - tech_lead_node=tech_lead_node, - uiux_designer_node=uiux_designer_node, - planning_fan_in_node=planning_fan_in_node, - planning_approval_node=planning_approval_node, - enable_phase4=self.config.enable_phase4, - ) - - async def _driver() -> None: - try: - config_dict = {"configurable": {"thread_id": project_id}} - inputs: Any = ProjectState(idea=idea) - # Drive the graph across interrupts. Each ainvoke runs until the - # approval gate calls interrupt(), which surfaces as a - # "__interrupt__" key in the returned state. We then block on the - # user's next decision (an answer during clarification, or an - # approve/reject once the PRD exists) and resume with it. - while True: - result = await graph.ainvoke(inputs, config=config_dict) - if isinstance(result, dict) and "__interrupt__" in result: - decision = await self._await_resume(project_id) - inputs = Command(resume=decision) - continue - break - logger.info("orchestrator.run completed for %s", project_id) - except asyncio.CancelledError: - logger.info("orchestrator.run cancelled for %s", project_id) - raise - except Exception as exc: - logger.exception("orchestrator.run failed for %s: %s", project_id, exc) - await emit( - "phase_complete", - { - "phase": 4 if self.config.enable_phase4 else 3, - "summary": str(exc), - "status": "failed", - "reason": "exception", - }, - room, - ) - - task = asyncio.create_task(_driver(), name=f"orchestrator:{project_id}") - self._tasks[project_id] = task - - async def resume(self, project_id: str, decision: dict) -> None: - """Deliver the user's decision to the waiting driver loop. - - Enqueues on the per-project queue so a decision that arrives before the - driver has re-armed is buffered rather than dropped (see _resume_queues). - """ - queue = self._resume_queues.get(project_id) - if queue is None: - queue = self._resume_queues[project_id] = asyncio.Queue() - await queue.put(decision) - - # --- Public decision API ------------------------------------------------ - # - # These map user actions onto resume payloads. During clarification a chat - # message is the answer to the latest question; once a PRD exists the same - # interrupt becomes the approval gate, where approve/reject/modify apply. - - async def user_message(self, project_id: str, text: str) -> None: - """Treat a chat message as the answer to the latest clarifying question.""" - await self.resume(project_id, {"answer": text}) - - async def approve(self, project_id: str, comment: str | None = None) -> None: - await self.resume(project_id, {"decision": "approved", "comment": comment}) - - async def reject(self, project_id: str, comment: str | None = None) -> None: - await self.resume(project_id, {"decision": "rejected", "comment": comment}) - - async def modify(self, project_id: str, comment: str) -> None: - await self.resume(project_id, {"decision": "modified", "comment": comment}) - - async def retry(self, project_id: str, emit: EmitFn | None = None) -> None: - """Re-run the graph from the last checkpoint after a failed/ended task. - - No-op if a driver task is still running. Otherwise re-invoke run() with - the persisted idea so ainvoke resumes from the latest checkpoint. - """ - task = self._tasks.get(project_id) - if task and not task.done(): - return - emit_fn = emit or self._last_emit.get(project_id) - if emit_fn is None: - return - snap = await self.load(project_id) - if snap is None: - return - await self.run(project_id, snap.get("idea", ""), emit_fn) - - async def stop(self, project_id: str) -> None: - task = self._tasks.pop(project_id, None) - if task and not task.done(): - task.cancel() - with contextlib.suppress(asyncio.CancelledError, Exception): - await task - self._resume_queues.pop(project_id, None) - - async def load(self, project_id: str) -> dict | None: - """Hydrate the latest persisted ProjectState for a project. - - Returns a plain dict (the ProjectState model_dump) so the Socket.IO - layer can serialize it directly. Returns None when the thread has no - checkpoint yet -- e.g. the project was never started or the saver was - wiped between runs. - """ - saver = await self._ensure_saver() - config_dict = {"configurable": {"thread_id": project_id}} - tup = await saver.aget_tuple(config_dict) - if tup is None: - return None - - # LangGraph 1.0 with a Pydantic StateGraph stores each model field as - # its own channel, so tup.checkpoint["channel_values"] is a dict shaped - # like {"idea": ..., "questions": [...], ...}. Bookkeeping channels - # such as "__start__" or "messages" can also appear; filter to the - # fields ProjectState actually declares before validating to avoid - # Pydantic extras errors and to drop runtime-only signals. - checkpoint = tup.checkpoint or {} - channel_values: dict[str, Any] = checkpoint.get("channel_values") or {} - known_fields = set(ProjectState.model_fields.keys()) - filtered = {k: v for k, v in channel_values.items() if k in known_fields} - - try: - state_obj = ProjectState.model_validate(filtered) - except Exception: - logger.exception( - "orchestrator.load: failed to validate checkpoint for %s; raw=%r", - project_id, - channel_values, - ) - return None - return state_obj.model_dump() - - # Display names mirror the frontend's AGENT_NAMES map so a hydrated node - # keeps its label; the store merges these over its pending defaults. - _AGENT_DISPLAY_NAMES = { - "orchestrator": "Orchestrator", - "clarifying_pm": "Clarifying PM", - "solution_architect": "Solution Architect", - "tech_lead": "Tech Lead", - "uiux_designer": "UI/UX Designer", - "delivery_summarizer": "Delivery Summarizer", - } - - async def load_snapshot(self, project_id: str) -> dict | None: - """Adapt the persisted ProjectState into the frontend ProjectStateSnapshot. - - load() returns the raw model_dump (used by retry/persistence). The React - store's hydrateFromState expects a different shape — project_id, a - reconstructed message transcript, an agents map, a pending-approval - object, budget, and a derived status — so reload can rehydrate the view. - Returns None when the thread has no checkpoint. - """ - state = await self.load(project_id) - if state is None: - return None - - prd = state.get("prd") - phase = state.get("current_phase", 3) - approval_count = state.get("approval_count", 0) - approved = state.get("approval_status") == "approved" - - # Phase 4 planning artifacts / gate state. - adr = state.get("adr") - tasks = state.get("tasks", []) or [] - design_spec = state.get("design_spec") - planning_status = state.get("planning_approval_status") - planning_count = state.get("planning_approval_count", 0) - - # Reconstruct the transcript from interleaved questions/answers. The - # checkpoint stores no real timestamps, so use a monotonic counter for - # ordering only. - questions = state.get("questions", []) or [] - answers = state.get("answers", []) or [] - messages: list[dict[str, Any]] = [] - ts = 0 - for i in range(max(len(questions), len(answers))): - if i < len(questions): - messages.append( - { - "id": f"q{i}", - "role": "agent", - "agent": "clarifying_pm", - "text": questions[i].get("text", ""), - "timestamp": ts, - } - ) - ts += 1 - if i < len(answers): - messages.append( - { - "id": f"a{i}", - "role": "user", - "text": answers[i].get("text", ""), - "timestamp": ts, - } - ) - ts += 1 - - # The planning gate is live once the PRD is approved and the three - # planning agents have all produced artifacts, but the plan is not yet - # approved. planning_fan_in emits the card and returns {} without - # setting planning_approval_status, so the durable state at the gate has - # planning_approval_status == None (it only becomes approved/rejected - # after the user responds). We therefore detect the gate from the - # artifacts being present rather than relying on a "pending" marker; - # an explicit "pending"/"rejected" status also counts. A "rejected" - # status means a re-run is in flight, but with all artifacts repopulated - # the card is shown again, matching the live fan-in behaviour. - # - # Mid-fan-out (PRD approved, only some artifacts present) does NOT - # satisfy `all(...)`, so it correctly stays "running" with no card. - planning_artifacts_ready = bool(adr) and bool(tasks) and bool(design_spec) - planning_gate = ( - approved and planning_artifacts_ready and planning_status != "approved" - ) - - approval_pending: dict[str, Any] | None = None - if planning_gate: - approval_pending = { - "agent": "tech_lead", - "phase": 4, - "kind": "plan", - "content": _render_plan(state), - "escalation": planning_count >= 3, - } - elif prd and not approved: - approval_pending = { - "agent": "clarifying_pm", - "phase": 3, - "content": prd, - "escalation": approval_count >= 3, - } - - # Minimal agents map reflecting what the checkpoint implies; the store - # fills the remaining 15 from its pending defaults. - def _node(agent_id: str, status: str) -> dict[str, Any]: - return { - "id": agent_id, - "name": self._AGENT_DISPLAY_NAMES[agent_id], - "status": status, - } - - agents = { - "orchestrator": _node( - "orchestrator", "complete" if phase >= 4 else "running" - ), - "clarifying_pm": _node( - "clarifying_pm", - "complete" if prd else ("running" if questions else "pending"), - ), - } - # Mark a planning agent complete once its artifact field is populated. - for agent_id, artifact in ( - ("solution_architect", adr), - ("tech_lead", tasks), - ("uiux_designer", design_spec), - ): - if artifact: - agents[agent_id] = _node(agent_id, "complete") - if phase >= 4: - agents["delivery_summarizer"] = _node("delivery_summarizer", "complete") - - if phase >= 4: - status = "complete" - elif planning_gate or (prd and not approved): - status = "paused" - else: - status = "running" - - budget_state = self.budget_guard.state - return { - "project_id": project_id, - "idea": state.get("idea", ""), - "messages": messages, - "agents": agents, - "approval_pending": approval_pending, - "budget": { - "spent": budget_state.total_spent, - "limit": budget_state.hard_limit, - "threshold": budget_state.current_threshold, - }, - "phase": phase, - "prd": prd, - "status": status, - "adr": adr, - "tasks": tasks, - "design_spec": design_spec, - } - - async def _await_resume(self, project_id: str) -> dict: - queue = self._resume_queues.get(project_id) - if queue is None: - queue = self._resume_queues[project_id] = asyncio.Queue() - return await queue.get() diff --git a/pyproject.toml b/pyproject.toml index 3e4faa3..cd8ac67 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,6 @@ keywords = [ "ai", "agents", "langchain", - "langgraph", "crewai", "automation", "software-development", @@ -28,7 +27,6 @@ classifiers = [ ] dependencies = [ - "langgraph>=0.2.0", "langchain>=0.3.0", "langchain-openai>=0.2.0", "langchain-anthropic>=0.3.0", @@ -43,7 +41,6 @@ dependencies = [ "fastapi>=0.115.0", "python-socketio>=5.11.0", "uvicorn[standard]>=0.32.0", - "langgraph-checkpoint-sqlite>=2.0.0", "mcp>=1.16,<2", ] @@ -170,7 +167,6 @@ show_error_codes = true module = [ "crewai.*", "langchain.*", - "langgraph.*", "mem0ai.*", "streamlit.*", ] diff --git a/tests/e2e/test_phase3_demo.py b/tests/e2e/test_phase3_demo.py deleted file mode 100644 index 4e79340..0000000 --- a/tests/e2e/test_phase3_demo.py +++ /dev/null @@ -1,98 +0,0 @@ -"""End-to-end test of the Phase 3 milestone via a Socket.IO client. - -Drives the real ASGI app over a localhost socket through the whole Phase 3 -flow: idea -> three clarifying answers -> PRD approval gate -> approve -> -phase_complete. Mock agents keep it deterministic and offline. -""" - -import asyncio -import importlib -import sys - -import pytest -import socketio -import uvicorn - - -@pytest.mark.asyncio -async def test_phase3_happy_path(tmp_path, monkeypatch): - monkeypatch.setenv("MOCK_AGENTS", "true") - # Pin to the Phase-3-only contract: this test approves the PRD and expects - # the run to COMPLETE at phase 3 (no planning fan-out). Phase 4 is the - # default now, so disable it explicitly. backend.main calls Config.load() - # and constructs the Orchestrator at module scope, so ENABLE_PHASE4 must be - # visible before that module is (re-)imported. Reload the backend module - # chain after setenv so a fresh Config is picked up even when these modules - # were already imported by an earlier test in the same session. - monkeypatch.setenv("ENABLE_PHASE4", "false") - monkeypatch.setenv("SQLITE_PATH", str(tmp_path / "e2e.db")) - - for mod_name in ( - "backend.config", - "backend.orchestrator", - "backend.main", - ): - if mod_name in sys.modules: - importlib.reload(sys.modules[mod_name]) - - from backend.main import asgi_app - - server = uvicorn.Server( - uvicorn.Config(asgi_app, host="127.0.0.1", port=8769, log_level="warning") - ) - task = asyncio.create_task(server.serve()) - while not server.started: - await asyncio.sleep(0.02) - - client = socketio.AsyncClient() - events: list = [] - for ev in ( - "project_created", - "agent_status", - "agent_message", - "approval_required", - "phase_complete", - "project_state", - ): - - def make(name): - def handler(data): - events.append((name, data)) - - return handler - - client.on(ev, make(ev)) - - await client.connect("http://127.0.0.1:8769", socketio_path="/socket.io") - - async def wait_for(name: str, timeout: float = 5.0) -> dict: - for _ in range(int(timeout / 0.05)): - await asyncio.sleep(0.05) - for e in events: - if e[0] == name: - return e[1] - raise AssertionError(f"timed out waiting for {name}; events={events}") - - try: - await client.emit("start_project", {"idea": "build a pomodoro timer"}) - - created = await wait_for("project_created") - project_id = created["project_id"] - - # Feed three answers to trigger the mock's PRD, draining each question. - for _ in range(3): - await wait_for("agent_message") - await client.emit("user_message", {"project_id": project_id, "text": "ok"}) - events[:] = [e for e in events if e[0] != "agent_message"] - - approval = await wait_for("approval_required") - assert "# Mock PRD" in approval["content"] - - await client.emit("approve", {"project_id": project_id}) - phase = await wait_for("phase_complete", timeout=5.0) - assert phase.get("status") == "success" - assert phase.get("phase") == 3 - finally: - await client.disconnect() - server.should_exit = True - await task diff --git a/tests/e2e/test_phase4_planning.py b/tests/e2e/test_phase4_planning.py deleted file mode 100644 index 6a35d7e..0000000 --- a/tests/e2e/test_phase4_planning.py +++ /dev/null @@ -1,163 +0,0 @@ -"""End-to-end test of the Phase 4 planning flow via a Socket.IO client. - -Drives the real ASGI app over a localhost socket through the full Phase 4 -path: idea -> three clarifying answers -> PRD approval gate -> approve -> -planning fan-out (adr / tasks / design artifacts) -> planning approval gate -> -approve -> phase_complete(phase=4). Mock agents keep it deterministic and -offline. - -Import-ordering note: backend.main calls Config.load() and constructs the -Orchestrator at module scope, so ENABLE_PHASE4 must be visible in os.environ -before that module is (first) imported or (re-)imported. When this test runs -after test_phase3_demo.py in the same session the backend modules are already -cached in sys.modules, so we reload backend.config -> backend.orchestrator -> -backend.main after calling monkeypatch.setenv to ensure the fresh Config is -picked up. We also pass a distinct SQLITE_PATH (via tmp_path) and a distinct -port (8770) to avoid collisions with the Phase 3 test. -""" - -from __future__ import annotations - -import asyncio -import importlib -import sys - -import pytest -import socketio -import uvicorn - - -@pytest.mark.asyncio -async def test_phase4_planning_happy_path(tmp_path, monkeypatch): - # Set env vars BEFORE any backend module uses them. - monkeypatch.setenv("MOCK_AGENTS", "true") - monkeypatch.setenv("ENABLE_PHASE4", "true") - monkeypatch.setenv("SQLITE_PATH", str(tmp_path / "e2e4.db")) - - # Reload the backend module chain so Config.load() / Orchestrator() pick up - # the updated env vars even when the modules were already imported by - # test_phase3_demo (or any other test earlier in the session). - for mod_name in ( - "backend.config", - "backend.orchestrator", - "backend.main", - ): - if mod_name in sys.modules: - importlib.reload(sys.modules[mod_name]) - - from backend.main import asgi_app # noqa: PLC0415 — must be inside test - - server = uvicorn.Server( - uvicorn.Config(asgi_app, host="127.0.0.1", port=8770, log_level="warning") - ) - task = asyncio.create_task(server.serve()) - while not server.started: - await asyncio.sleep(0.02) - - client = socketio.AsyncClient() - events: list = [] - - for ev in ( - "project_created", - "agent_status", - "agent_message", - "approval_required", - "planning_artifact", - "phase_complete", - "project_state", - ): - - def make(name): - def handler(data): - events.append((name, data)) - - return handler - - client.on(ev, make(ev)) - - await client.connect("http://127.0.0.1:8770", socketio_path="/socket.io") - - async def wait_for( - name: str, - predicate=None, - timeout: float = 10.0, - ) -> dict: - """Wait until an event with the given name (and optional predicate) arrives.""" - for _ in range(int(timeout / 0.05)): - await asyncio.sleep(0.05) - for e in events: - if e[0] == name and (predicate is None or predicate(e[1])): - return e[1] - raise AssertionError( - f"timed out waiting for {name!r} (predicate={predicate}); events={events}" - ) - - try: - await client.emit("start_project", {"idea": "build a pomodoro timer"}) - - created = await wait_for("project_created") - project_id = created["project_id"] - - # Feed three answers to drain clarifying questions and trigger the PRD. - for _ in range(3): - await wait_for("agent_message") - await client.emit("user_message", {"project_id": project_id, "text": "ok"}) - events[:] = [e for e in events if e[0] != "agent_message"] - - # Phase 3 approval gate: PRD arrives. - prd_approval = await wait_for( - "approval_required", - predicate=lambda d: d.get("phase") == 3, - ) - assert "# Mock PRD" in prd_approval["content"] - - # Approve the PRD — should kick off Phase 4 planning fan-out. - await client.emit("approve", {"project_id": project_id}) - - # Phase 4: wait for all three planning artifacts. - expected_kinds = {"adr", "tasks", "design"} - for _ in range(int(15.0 / 0.05)): - await asyncio.sleep(0.05) - arrived = { - e[1]["kind"] - for e in events - if e[0] == "planning_artifact" and "kind" in e[1] - } - if arrived >= expected_kinds: - break - else: - arrived_kinds = { - e[1]["kind"] - for e in events - if e[0] == "planning_artifact" and "kind" in e[1] - } - raise AssertionError( - f"timed out waiting for all planning_artifact kinds " - f"(got {arrived_kinds}); events={events}" - ) - - # Planning approval gate: plan card arrives. - plan_approval = await wait_for( - "approval_required", - predicate=lambda d: d.get("kind") == "plan", - timeout=10.0, - ) - assert plan_approval.get("phase") == 4 - - # Approve the plan — should complete Phase 4. - # Clear stale approval_required events so wait_for below isn't confused. - events[:] = [e for e in events if e[0] != "approval_required"] - await client.emit("approve", {"project_id": project_id}) - - phase = await wait_for( - "phase_complete", - predicate=lambda d: d.get("phase") == 4, - timeout=10.0, - ) - assert phase.get("status") == "success" - assert phase.get("phase") == 4 - - finally: - await client.disconnect() - server.should_exit = True - await task diff --git a/tests/integration/test_approval_flow.py b/tests/integration/test_approval_flow.py deleted file mode 100644 index 9991632..0000000 --- a/tests/integration/test_approval_flow.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Verify the approval gate interrupts, emits approval_required, and resumes. - -Drives the Orchestrator directly (no Socket.IO) through the full clarifying -loop: three mock answers produce a PRD, which surfaces as approval_required; -approving it advances to the delivery_summarizer and emits a successful -phase_complete. -""" - -import asyncio - -import pytest - -from backend.orchestrator import Orchestrator - - -async def _wait_for(predicate, received, timeout: float = 5.0) -> None: - for _ in range(int(timeout / 0.05)): - await asyncio.sleep(0.05) - if predicate(): - return - raise AssertionError(f"timed out; received={received}") - - -@pytest.mark.asyncio -async def test_approval_required_emitted_and_resume_on_approve(tmp_path, monkeypatch): - monkeypatch.setenv("MOCK_AGENTS", "true") - # Pin to the Phase-3-only contract: approving the PRD must complete the run - # at phase 3 (no planning fan-out). Phase 4 is the default now, so disable it - # explicitly here. Orchestrator() reads Config.load() at construction, so - # this env var must be set before the Orchestrator is created below. - monkeypatch.setenv("ENABLE_PHASE4", "false") - monkeypatch.setenv("SQLITE_PATH", str(tmp_path / "chk.db")) - received: list = [] - - async def emit(event: str, data: dict, room: str) -> None: - received.append((event, data)) - - orch = Orchestrator() - await orch.run("proj-apr", "todo app", emit) - - # Answer three clarifying questions; each answer should unlock the next. - for n in (1, 2, 3): - await _wait_for( - lambda n=n: any( - e[0] == "agent_message" and f"#{n}?" in (e[1].get("text") or "") - for e in received - ), - received, - ) - await orch.user_message("proj-apr", f"answer {n}") - - # The third answer produces the PRD, surfaced as approval_required. - await _wait_for( - lambda: any(e[0] == "approval_required" for e in received), - received, - timeout=6.0, - ) - approval = next(e[1] for e in received if e[0] == "approval_required") - assert "# Mock PRD" in approval["content"] - - # Approving the PRD advances to the summarizer and completes the phase. - await orch.approve("proj-apr") - await _wait_for( - lambda: any( - e[0] == "phase_complete" and e[1].get("status") == "success" - for e in received - ), - received, - timeout=6.0, - ) - - await orch.stop("proj-apr") - - -@pytest.mark.asyncio -async def test_reject_routes_back_to_clarifying(tmp_path, monkeypatch): - """Rejecting a PRD routes back to clarifying_pm (a fresh question is asked).""" - monkeypatch.setenv("MOCK_AGENTS", "true") - # Pin to the Phase-3-only contract (see note in the approve test above). - monkeypatch.setenv("ENABLE_PHASE4", "false") - monkeypatch.setenv("SQLITE_PATH", str(tmp_path / "chk.db")) - received: list = [] - - async def emit(event: str, data: dict, room: str) -> None: - received.append((event, data)) - - orch = Orchestrator() - await orch.run("proj-rej", "todo app", emit) - - for n in (1, 2, 3): - await _wait_for( - lambda n=n: any( - e[0] == "agent_message" and f"#{n}?" in (e[1].get("text") or "") - for e in received - ), - received, - ) - await orch.user_message("proj-rej", f"answer {n}") - - await _wait_for( - lambda: any(e[0] == "approval_required" for e in received), - received, - timeout=6.0, - ) - - # Reject: the graph routes back to clarifying_pm, which (still having >=3 - # answers in the mock) re-emits the PRD rather than crashing. We assert the - # workflow does NOT complete the phase on a rejection. - msgs_before = sum(1 for e in received if e[0] == "agent_status") - await orch.reject("proj-rej", comment="needs more detail") - await _wait_for( - lambda: sum(1 for e in received if e[0] == "agent_status") > msgs_before, - received, - timeout=6.0, - ) - assert not any( - e[0] == "phase_complete" and e[1].get("status") == "success" for e in received - ), "rejection must not complete the phase" - - await orch.stop("proj-rej") diff --git a/tests/integration/test_load_snapshot.py b/tests/integration/test_load_snapshot.py deleted file mode 100644 index 65c3e02..0000000 --- a/tests/integration/test_load_snapshot.py +++ /dev/null @@ -1,187 +0,0 @@ -"""orchestrator.load_snapshot returns the frontend ProjectStateSnapshot shape. - -load() returns the raw ProjectState model_dump (used by retry/persistence); -load_snapshot() adapts it to the shape the React store's hydrateFromState -expects, so a browser reload can rehydrate the project view. -""" - -import asyncio - -import pytest - -from backend.orchestrator import Orchestrator - -SNAPSHOT_KEYS = { - "project_id", - "idea", - "messages", - "agents", - "approval_pending", - "budget", - "phase", - "prd", - "status", - "adr", - "tasks", - "design_spec", -} - - -async def _drive_to_prd(orch: Orchestrator, project_id: str, received: list) -> None: - async def wait_for(predicate, timeout: float = 5.0) -> None: - for _ in range(int(timeout / 0.05)): - await asyncio.sleep(0.05) - if predicate(): - return - raise AssertionError(f"timed out; received={received}") - - for n in (1, 2, 3): - await wait_for( - lambda n=n: any( - e[0] == "agent_message" and f"#{n}?" in (e[1].get("text") or "") - for e in received - ) - ) - await orch.user_message(project_id, f"answer {n}") - await wait_for(lambda: any(e[0] == "approval_required" for e in received)) - - -@pytest.mark.asyncio -async def test_load_snapshot_has_frontend_shape(tmp_path, monkeypatch): - monkeypatch.setenv("MOCK_AGENTS", "true") - monkeypatch.setenv("SQLITE_PATH", str(tmp_path / "snap.db")) - received: list = [] - - async def emit(event, data, room): - received.append((event, data)) - - orch = Orchestrator() - await orch.run("p-snap", "build a todo app", emit) - await _drive_to_prd(orch, "p-snap", received) - - # approval_required is emitted from INSIDE clarifying_node before that node - # returns/checkpoints, so a single immediate load_snapshot may read a - # checkpoint where prd is not yet durable. Poll until prd is present. - snap = None - for _ in range(60): - await asyncio.sleep(0.05) - candidate = await orch.load_snapshot("p-snap") - if ( - candidate is not None - and candidate.get("prd") - and "# Mock PRD" in candidate["prd"] - ): - snap = candidate - break - await orch.stop("p-snap") - - assert snap is not None - assert set(snap.keys()) == SNAPSHOT_KEYS - assert snap["project_id"] == "p-snap" - assert snap["idea"] == "build a todo app" - assert snap["prd"] and "# Mock PRD" in snap["prd"] - - # PRD awaiting a decision -> a pending approval mirroring the PRD content. - assert snap["approval_pending"] is not None - assert snap["approval_pending"]["content"] == snap["prd"] - assert snap["approval_pending"]["phase"] == 3 - assert snap["status"] == "paused" - assert snap["phase"] == 3 - - # Transcript reconstructed from questions/answers: 3 questions + 3 answers. - roles = [m["role"] for m in snap["messages"]] - assert roles.count("agent") == 3 - assert roles.count("user") == 3 - for m in snap["messages"]: - assert {"id", "role", "text", "timestamp"} <= set(m.keys()) - - # Budget shape the store expects. - assert set(snap["budget"].keys()) == {"spent", "limit", "threshold"} - - # Agents are id/name/status records the store can merge over its defaults. - assert snap["agents"]["clarifying_pm"]["status"] == "complete" - for agent in snap["agents"].values(): - assert {"id", "name", "status"} <= set(agent.keys()) - - -async def _drive_to_plan_card( - orch: Orchestrator, project_id: str, received: list -) -> None: - """Drive idea -> PRD -> approve PRD -> wait for the planning approval card.""" - - async def wait_for(predicate, timeout: float = 8.0) -> None: - for _ in range(int(timeout / 0.05)): - await asyncio.sleep(0.05) - if predicate(): - return - raise AssertionError(f"timed out; received={[e[0] for e in received]}") - - for n in (1, 2, 3): - await wait_for( - lambda n=n: any( - e[0] == "agent_message" and f"#{n}?" in (e[1].get("text") or "") - for e in received - ) - ) - await orch.user_message(project_id, f"answer {n}") - await wait_for(lambda: any(e[0] == "approval_required" for e in received)) - await orch.approve(project_id) # approve the PRD -> fan out to planning - # Wait for the planning approval card (kind == "plan"). - await wait_for( - lambda: any( - e[0] == "approval_required" and e[1].get("kind") == "plan" for e in received - ) - ) - - -@pytest.mark.asyncio -async def test_load_snapshot_hydrates_planning_gate(tmp_path, monkeypatch): - monkeypatch.setenv("MOCK_AGENTS", "true") - monkeypatch.setenv("ENABLE_PHASE4", "true") - monkeypatch.setenv("SQLITE_PATH", str(tmp_path / "snap_plan.db")) - received: list = [] - - async def emit(event, data, room): - received.append((event, data)) - - orch = Orchestrator() - await orch.run("p-plan", "build a todo app", emit) - await _drive_to_plan_card(orch, "p-plan", received) - - # planning_fan_in emits the card before its return checkpoints, so poll - # until the planning artifacts are durable in the snapshot. - snap = None - for _ in range(80): - await asyncio.sleep(0.05) - candidate = await orch.load_snapshot("p-plan") - if ( - candidate is not None - and candidate.get("approval_pending") - and candidate["approval_pending"].get("kind") == "plan" - and candidate.get("adr") - and candidate.get("tasks") - and candidate.get("design_spec") - ): - snap = candidate - break - await orch.stop("p-plan") - - assert snap is not None - assert snap["status"] == "paused" - assert snap["approval_pending"]["kind"] == "plan" - assert snap["approval_pending"]["phase"] == 4 - - assert snap["adr"] - assert isinstance(snap["tasks"], list) and len(snap["tasks"]) > 0 - assert isinstance(snap["design_spec"], dict) and snap["design_spec"] - - assert snap["agents"]["solution_architect"]["status"] == "complete" - assert snap["agents"]["tech_lead"]["status"] == "complete" - assert snap["agents"]["uiux_designer"]["status"] == "complete" - - -@pytest.mark.asyncio -async def test_load_snapshot_unknown_returns_none(tmp_path, monkeypatch): - monkeypatch.setenv("SQLITE_PATH", str(tmp_path / "snap.db")) - orch = Orchestrator() - assert await orch.load_snapshot("does-not-exist") is None diff --git a/tests/integration/test_mock_fallback.py b/tests/integration/test_mock_fallback.py deleted file mode 100644 index 27c7172..0000000 --- a/tests/integration/test_mock_fallback.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Verify that MOCK_AGENTS=true makes the orchestrator pick the mock agent.""" - -from backend.orchestrator import Orchestrator - - -def test_mock_mode_selects_mock_agent(monkeypatch): - monkeypatch.setenv("MOCK_AGENTS", "true") - orch = Orchestrator() - agent = orch.registry.get("clarifying_pm", mock=orch.mock_mode) - # MockClarifyingPMAgent should be a subclass of MockAgent; real agent is not. - from backend.agents.mock_agent import MockAgent - - assert isinstance(agent, MockAgent) - - -def test_real_mode_selects_real_agent(monkeypatch): - monkeypatch.setenv("MOCK_AGENTS", "false") - monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test") - orch = Orchestrator() - agent = orch.registry.get("clarifying_pm", mock=orch.mock_mode) - from backend.agents.clarifying_pm import ClarifyingPMAgent - - assert isinstance(agent, ClarifyingPMAgent) - - -def test_mock_mode_selects_mock_solution_architect(monkeypatch): - monkeypatch.setenv("MOCK_AGENTS", "true") - orch = Orchestrator() - agent = orch.registry.get("solution_architect", mock=orch.mock_mode) - from backend.agents.mock_agent import MockAgent - - assert isinstance(agent, MockAgent) - - -def test_real_mode_selects_real_solution_architect(monkeypatch): - monkeypatch.setenv("MOCK_AGENTS", "false") - monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test") - orch = Orchestrator() - agent = orch.registry.get("solution_architect", mock=orch.mock_mode) - from backend.agents.solution_architect import SolutionArchitectAgent - - assert isinstance(agent, SolutionArchitectAgent) - - -def test_mock_mode_selects_mock_tech_lead(monkeypatch): - monkeypatch.setenv("MOCK_AGENTS", "true") - orch = Orchestrator() - agent = orch.registry.get("tech_lead", mock=orch.mock_mode) - from backend.agents.mock_agent import MockAgent - - assert isinstance(agent, MockAgent) - - -def test_real_mode_selects_real_tech_lead(monkeypatch): - monkeypatch.setenv("MOCK_AGENTS", "false") - monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test") - orch = Orchestrator() - agent = orch.registry.get("tech_lead", mock=orch.mock_mode) - from backend.agents.tech_lead import TechLeadAgent - - assert isinstance(agent, TechLeadAgent) - - -def test_mock_mode_selects_mock_uiux_designer(monkeypatch): - monkeypatch.setenv("MOCK_AGENTS", "true") - orch = Orchestrator() - agent = orch.registry.get("uiux_designer", mock=orch.mock_mode) - from backend.agents.mock_agent import MockAgent - - assert isinstance(agent, MockAgent) - - -def test_real_mode_selects_real_uiux_designer(monkeypatch): - monkeypatch.setenv("MOCK_AGENTS", "false") - monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test") - orch = Orchestrator() - agent = orch.registry.get("uiux_designer", mock=orch.mock_mode) - from backend.agents.uiux_designer import UiuxDesignerAgent - - assert isinstance(agent, UiuxDesignerAgent) diff --git a/tests/integration/test_orchestrator_flow.py b/tests/integration/test_orchestrator_flow.py deleted file mode 100644 index 342363a..0000000 --- a/tests/integration/test_orchestrator_flow.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Integration tests for Orchestrator.run using mock agents and no real LLM.""" - -import asyncio - -import pytest - -from backend.orchestrator import Orchestrator - - -@pytest.mark.asyncio -async def test_orchestrator_emits_project_started_and_agent_status(): - events: list[tuple[str, dict, str]] = [] - - async def emit(event: str, data: dict, room: str) -> None: - events.append((event, data, room)) - - orch = Orchestrator(mock_mode=True) - await orch.run("proj-1", "build a todo app", emit) - - # Wait for the clarifying_pm agent to emit its first status update. - for _ in range(50): - await asyncio.sleep(0.05) - if any( - e[0] == "agent_status" and e[1].get("agent") == "clarifying_pm" - for e in events - ): - break - - clarifying_events = [ - e - for e in events - if e[0] == "agent_status" and e[1].get("agent") == "clarifying_pm" - ] - assert clarifying_events, "expected at least one clarifying_pm agent_status event" - first = clarifying_events[0] - assert first[2] == "project:proj-1" - - await orch.stop("proj-1") diff --git a/tests/integration/test_persistence.py b/tests/integration/test_persistence.py deleted file mode 100644 index f45b849..0000000 --- a/tests/integration/test_persistence.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Round-trip checkpoint persistence across two Orchestrator instances.""" - -import asyncio - -import pytest - -from backend.orchestrator import Orchestrator - - -@pytest.mark.asyncio -async def test_checkpoint_round_trip(tmp_path, monkeypatch): - db_path = tmp_path / "checkpoints.db" - monkeypatch.setenv("SQLITE_PATH", str(db_path)) - monkeypatch.setenv("MOCK_AGENTS", "true") - - received: list = [] - - async def emit(event: str, data: dict, room: str) -> None: - received.append((event, data)) - - # Run 1: wait until the idea is durably checkpointed, then stop. - # We poll orch1.load() rather than watching for an early "running" event, - # because the first checkpoint is written AFTER the first node returns — - # stopping on the "running" emit (which fires before any node executes) - # leaves orch2 reading an empty pre-superstep checkpoint (idea == ""). - orch1 = Orchestrator() - await orch1.run("proj-persist", "build a thing", emit) - for _ in range(60): - await asyncio.sleep(0.05) - snap_check = await orch1.load("proj-persist") - if snap_check is not None and snap_check.get("idea") == "build a thing": - break - await orch1.stop("proj-persist") - - # Run 2: new orchestrator loads the same thread - orch2 = Orchestrator() - snap = await orch2.load("proj-persist") - assert snap is not None - assert snap["idea"] == "build a thing" diff --git a/tests/integration/test_planning_sprint.py b/tests/integration/test_planning_sprint.py deleted file mode 100644 index fde1659..0000000 --- a/tests/integration/test_planning_sprint.py +++ /dev/null @@ -1,109 +0,0 @@ -"""Phase 4 parallel planning, mock mode, socket-level (no frontend).""" - -import asyncio - -import pytest - -from backend.orchestrator import Orchestrator - - -async def _wait(received, predicate, timeout=6.0): - for _ in range(int(timeout / 0.05)): - await asyncio.sleep(0.05) - if predicate(): - return - raise AssertionError(f"timed out; events={[e[0] for e in received]}") - - -async def _drive_to_prd_approved(orch, pid, received): - await orch.run(pid, "build a todo app", lambda e, d, r: received.append((e, d))) - for n in (1, 2, 3): - await _wait( - received, - lambda n=n: any( - e[0] == "agent_message" and f"#{n}?" in (e[1].get("text") or "") - for e in received - ), - ) - await orch.user_message(pid, f"answer {n}") - await _wait(received, lambda: any(e[0] == "approval_required" for e in received)) - await orch.approve(pid) # approve the PRD - - -@pytest.mark.asyncio -async def test_planning_fan_out_and_approve(tmp_path, monkeypatch): - monkeypatch.setenv("MOCK_AGENTS", "true") - monkeypatch.setenv("ENABLE_PHASE4", "true") - monkeypatch.setenv("SQLITE_PATH", str(tmp_path / "p4.db")) - received: list = [] - - orch = Orchestrator() - await _drive_to_prd_approved(orch, "p4", received) - - await _wait( - received, - lambda: {a[1].get("kind") for a in received if a[0] == "planning_artifact"} - >= {"adr", "tasks", "design"}, - ) - - await _wait( - received, - lambda: any( - e[0] == "approval_required" and e[1].get("kind") == "plan" for e in received - ), - ) - - await orch.approve("p4") - await _wait( - received, - lambda: any( - e[0] == "phase_complete" - and e[1].get("phase") == 4 - and e[1].get("status") == "success" - for e in received - ), - ) - await orch.stop("p4") - - -async def _wait_card(received): - for _ in range(120): - await asyncio.sleep(0.05) - cards = [ - e[1] - for e in received - if e[0] == "approval_required" and e[1].get("kind") == "plan" - ] - if cards: - return cards[-1] - raise AssertionError("no planning card") - - -@pytest.mark.asyncio -async def test_planning_reject_reruns_all_three_and_escalates(tmp_path, monkeypatch): - monkeypatch.setenv("MOCK_AGENTS", "true") - monkeypatch.setenv("ENABLE_PHASE4", "true") - monkeypatch.setenv("SQLITE_PATH", str(tmp_path / "p4r.db")) - received: list = [] - - orch = Orchestrator() - await _drive_to_prd_approved(orch, "p4r", received) - await _wait( - received, - lambda: any( - e[0] == "approval_required" and e[1].get("kind") == "plan" for e in received - ), - ) - - for comment, escalate in [("more", False), ("still", False), ("nope", True)]: - received[:] = [e for e in received if e[0] != "planning_artifact"] - await orch.reject("p4r", comment) - await _wait( - received, - lambda: {a[1].get("kind") for a in received if a[0] == "planning_artifact"} - >= {"adr", "tasks", "design"}, - ) - card = await _wait_card(received) - assert bool(card.get("escalation")) is escalate - - await orch.stop("p4r") diff --git a/tests/integration/test_rejection_cycle.py b/tests/integration/test_rejection_cycle.py deleted file mode 100644 index 3467eb9..0000000 --- a/tests/integration/test_rejection_cycle.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Reject the PRD repeatedly; verify feedback threads back and the third -rejection surfaces an escalation flag on the approval gate.""" - -import asyncio - -import pytest - -from backend.orchestrator import Orchestrator - - -@pytest.mark.asyncio -async def test_three_rejections_escalate(tmp_path, monkeypatch): - monkeypatch.setenv("MOCK_AGENTS", "true") - monkeypatch.setenv("SQLITE_PATH", str(tmp_path / "chk.db")) - received: list = [] - - async def emit(event, data, room): - received.append((event, data)) - - orch = Orchestrator() - await orch.run("proj-reject", "idea", emit) - - async def wait_for(event_name, predicate=lambda d: True, timeout: float = 4.0): - for _ in range(int(timeout / 0.05)): - await asyncio.sleep(0.05) - if any(e[0] == event_name and predicate(e[1]) for e in received): - return True - return False - - # Drive through clarifying -> approval (mock asks 3 questions then PRD). - for n in (1, 2, 3): - assert await wait_for( - "agent_message", lambda d, n=n: f"#{n}?" in (d.get("text") or "") - ) - await orch.user_message("proj-reject", f"answer {n}") - assert await wait_for("approval_required") - - # First and second rejections route back, re-emit a revised PRD, and do - # NOT yet escalate. - for comment in ("not enough detail", "still not enough"): - received.clear() - await orch.reject("proj-reject", comment) - assert await wait_for("approval_required") - assert not any( - e[0] == "approval_required" and e[1].get("escalation") for e in received - ), f"should not escalate before the third rejection ({comment})" - - # Third rejection -> escalation flag. - received.clear() - await orch.reject("proj-reject", "nope") - assert await wait_for( - "approval_required", lambda d: bool(d.get("escalation")) - ), f"third rejection should emit escalation=true; received={received}" - - await orch.stop("proj-reject") diff --git a/tests/integration/test_slice3_smoke.py b/tests/integration/test_slice3_smoke.py deleted file mode 100644 index 30749a5..0000000 --- a/tests/integration/test_slice3_smoke.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Headless E2E smoke for Slice 3. - -Boots the real ASGI app on a localhost port and drives it with a Socket.IO -client: emits start_project, waits for project_created, and confirms the -mock Clarifying PM emits a clarifying question through the room. -""" - -import asyncio - -import pytest -import socketio -import uvicorn - - -@pytest.fixture -async def server_and_client(): - from backend.main import asgi_app - - config = uvicorn.Config(asgi_app, host="127.0.0.1", port=8767, log_level="warning") - server = uvicorn.Server(config) - task = asyncio.create_task(server.serve()) - while not server.started: - await asyncio.sleep(0.02) - client = socketio.AsyncClient() - await client.connect("http://127.0.0.1:8767", socketio_path="/socket.io") - try: - yield server, client - finally: - await client.disconnect() - server.should_exit = True - await task - - -@pytest.mark.asyncio -async def test_idea_yields_project_and_mock_question(server_and_client): - _, client = server_and_client - project_created: list[dict] = [] - statuses: list[dict] = [] - messages: list[dict] = [] - - client.on("project_created", lambda data: project_created.append(data)) - client.on("agent_status", lambda data: statuses.append(data)) - client.on("agent_message", lambda data: messages.append(data)) - - await client.emit("start_project", {"idea": "build me a todo app"}) - - for _ in range(80): - await asyncio.sleep(0.05) - if messages: - break - - assert project_created, "expected project_created" - assert any( - s.get("agent") == "clarifying_pm" and s.get("status") == "running" - for s in statuses - ) - assert messages, "expected at least one mock clarifying question" - assert "Clarifying" in (messages[0].get("text") or "") - - -@pytest.mark.asyncio -async def test_answer_loop_advances_through_questions_to_prd(server_and_client): - _, client = server_and_client - project_created: list[dict] = [] - messages: list[dict] = [] - approval_required: list[dict] = [] - - client.on("project_created", lambda data: project_created.append(data)) - client.on("agent_message", lambda data: messages.append(data)) - client.on("approval_required", lambda data: approval_required.append(data)) - - await client.emit("start_project", {"idea": "build me a todo app"}) - - async def wait_for(predicate, timeout: float = 5.0) -> None: - steps = int(timeout / 0.05) - for _ in range(steps): - await asyncio.sleep(0.05) - if predicate(): - return - raise AssertionError( - f"timed out; messages={messages} approvals={approval_required}" - ) - - await wait_for(lambda: len(project_created) > 0) - project_id = project_created[0]["project_id"] - - # Answer three mock questions, expecting each answer to unlock the next. - for expected in (1, 2, 3): - await wait_for( - lambda e=expected: any(f"#{e}?" in (m.get("text") or "") for m in messages) - ) - await client.emit( - "user_message", - {"project_id": project_id, "text": f"answer {expected}"}, - ) - - # After the third answer the mock returns a PRD via approval_required. - await wait_for(lambda: len(approval_required) > 0, timeout=6.0) - assert "# Mock PRD" in approval_required[0]["content"] - - distinct = sorted( - { - m.get("text", "").split("?")[0].split("#")[-1].strip() - for m in messages - if "Clarifying question" in (m.get("text") or "") - } - ) - assert distinct == [ - "1", - "2", - "3", - ], f"expected three distinct mock questions, got {distinct}: {messages}" diff --git a/tests/integration/test_socketio_events.py b/tests/integration/test_socketio_events.py index 09f9e11..8471a77 100644 --- a/tests/integration/test_socketio_events.py +++ b/tests/integration/test_socketio_events.py @@ -54,60 +54,3 @@ async def test_load_project_unknown_returns_error(server_and_client): server, client = server_and_client ack = await client.call("load_project", {"project_id": "does-not-exist"}, timeout=2) assert ack == {"error": "project not found"} - - -@pytest.mark.asyncio -async def test_load_project_hydrates_persisted_state(server_and_client): - server, client = server_and_client - created: list[dict] = [] - state: list[dict] = [] - messages: list[dict] = [] - client.on("project_created", lambda data: created.append(data)) - client.on("project_state", lambda data: state.append(data)) - client.on("agent_message", lambda data: messages.append(data)) - - await client.emit("start_project", {"idea": "build a todo app"}) - # Wait until the first clarifying question lands so a checkpoint exists. - for _ in range(80): - await asyncio.sleep(0.05) - if messages: - break - assert created, "expected project_created" - project_id = created[0]["project_id"] - - # Poll load_project until the persisted checkpoint has the idea field durable. - # The first agent_message is emitted from inside clarifying_node BEFORE LangGraph - # writes the post-node checkpoint; under full-suite load the load_project handler - # can race the checkpoint write and read an earlier checkpoint where idea=="". - # Polling until the snapshot is non-empty and idea is populated is deterministic. - snap = None - for _ in range(60): - state.clear() - await client.emit("load_project", {"project_id": project_id}) - for _ in range(20): - await asyncio.sleep(0.05) - if state: - break - if state and state[0].get("idea"): - snap = state[0] - break - await asyncio.sleep(0.05) - - assert snap is not None, "expected project_state hydration emit" - # Hydration payload must match the frontend ProjectStateSnapshot shape. - assert set(snap.keys()) == { - "project_id", - "idea", - "messages", - "agents", - "approval_pending", - "budget", - "phase", - "prd", - "status", - "adr", - "tasks", - "design_spec", - } - assert snap["project_id"] == project_id - assert snap["idea"] == "build a todo app" diff --git a/tests/unit/test_graph.py b/tests/unit/test_graph.py deleted file mode 100644 index 3bc1ce2..0000000 --- a/tests/unit/test_graph.py +++ /dev/null @@ -1,73 +0,0 @@ -""" -DevTeam.AI - Graph Tests -Phase 3: Tests for LangGraph Phase 3 clarification workflow - -Tests cover: -- ProjectState shape -- Graph construction (3-node Phase 3 workflow) -""" - -import pytest - -from backend.graph import ProjectState, build_graph - - -@pytest.mark.asyncio -async def test_project_state_has_expected_fields(): - state = ProjectState(idea="todo app") - assert state.idea == "todo app" - assert state.questions == [] - assert state.answers == [] - assert state.prd is None - assert state.approval_status is None - assert state.approval_count == 0 - assert state.current_phase == 3 - - -@pytest.mark.asyncio -async def test_build_graph_compiles_with_three_nodes(): - compiled = build_graph(checkpointer=None) - node_names = set(compiled.get_graph().nodes.keys()) - for required in ("clarifying_pm", "product_owner_approval", "delivery_summarizer"): - assert required in node_names, f"missing node: {required}" - - -def test_projectstate_has_planning_fields(): - from backend.graph import ProjectState, Task - - s = ProjectState() - assert s.adr is None - assert s.tasks == [] - assert s.design_spec is None - assert s.planning_approval_status is None - assert s.planning_approval_count == 0 - assert s.planning_rejection_comments == [] - - t = Task(id="t1", title="Build login", description="...", owner_agent="backend") - assert t.depends_on == [] - - -def test_build_graph_phase4_has_planning_nodes(): - from backend.graph import build_graph - - graph = build_graph(checkpointer=None, enable_phase4=True) - node_names = set(graph.get_graph().nodes.keys()) - for n in [ - "solution_architect", - "tech_lead", - "uiux_designer", - "planning_fan_in", - "planning_approval", - ]: - assert n in node_names - - -def test_build_graph_flag_off_compiles_phase3_only(): - from backend.graph import build_graph - - graph = build_graph(checkpointer=None, enable_phase4=False) - assert graph is not None - - -# Markers for test categorization -pytestmark = [pytest.mark.unit] diff --git a/uv.lock b/uv.lock index 19776d1..842a24e 100644 --- a/uv.lock +++ b/uv.lock @@ -624,8 +624,6 @@ dependencies = [ { name = "langchain-anthropic" }, { name = "langchain-community" }, { name = "langchain-openai" }, - { name = "langgraph" }, - { name = "langgraph-checkpoint-sqlite" }, { name = "mcp" }, { name = "pydantic", version = "2.11.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, @@ -670,8 +668,6 @@ requires-dist = [ { name = "langchain-anthropic", specifier = ">=0.3.0" }, { name = "langchain-community", specifier = ">=0.3.0" }, { name = "langchain-openai", specifier = ">=0.2.0" }, - { name = "langgraph", specifier = ">=0.2.0" }, - { name = "langgraph-checkpoint-sqlite", specifier = ">=2.0.0" }, { name = "mcp", specifier = ">=1.16,<2" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.11.0" }, { name = "pydantic", specifier = ">=2.9.0" }, @@ -1208,77 +1204,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/0d/41a51b40d24ff0384ec4f7ab8dd3dcea8353c05c973836b5e289f1465d4f/langchain_text_splitters-0.3.11-py3-none-any.whl", hash = "sha256:cf079131166a487f1372c8ab5d0bfaa6c0a4291733d9c43a34a16ac9bcd6a393", size = 33845, upload-time = "2025-08-31T23:02:57.195Z" }, ] -[[package]] -name = "langgraph" -version = "1.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "langchain-core" }, - { name = "langgraph-checkpoint" }, - { name = "langgraph-prebuilt" }, - { name = "langgraph-sdk" }, - { name = "pydantic", version = "2.11.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, - { name = "xxhash" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/20/7c/a0f4211f751b8b37aae2d88c6243ceb14027ca9ebf00ac8f3b210657af6a/langgraph-1.0.1.tar.gz", hash = "sha256:4985b32ceabb046a802621660836355dfcf2402c5876675dc353db684aa8f563", size = 480245, upload-time = "2025-10-20T18:51:59.839Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/3c/acc0956a0da96b25a2c5c1a85168eacf1253639a04ed391d7a7bcaae5d6c/langgraph-1.0.1-py3-none-any.whl", hash = "sha256:892f04f64f4889abc80140265cc6bd57823dd8e327a5eef4968875f2cd9013bd", size = 155415, upload-time = "2025-10-20T18:51:58.321Z" }, -] - -[[package]] -name = "langgraph-checkpoint" -version = "3.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "langchain-core" }, - { name = "ormsgpack" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0f/07/2b1c042fa87d40cf2db5ca27dc4e8dd86f9a0436a10aa4361a8982718ae7/langgraph_checkpoint-3.0.1.tar.gz", hash = "sha256:59222f875f85186a22c494aedc65c4e985a3df27e696e5016ba0b98a5ed2cee0", size = 137785, upload-time = "2025-11-04T21:55:47.774Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/48/e3/616e3a7ff737d98c1bbb5700dd62278914e2a9ded09a79a1fa93cf24ce12/langgraph_checkpoint-3.0.1-py3-none-any.whl", hash = "sha256:9b04a8d0edc0474ce4eaf30c5d731cee38f11ddff50a6177eead95b5c4e4220b", size = 46249, upload-time = "2025-11-04T21:55:46.472Z" }, -] - -[[package]] -name = "langgraph-checkpoint-sqlite" -version = "3.0.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiosqlite" }, - { name = "langgraph-checkpoint" }, - { name = "sqlite-vec" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/04/61/40b7f8f29d6de92406e668c35265f409f57064907e31eae84ab3f2a3e3e1/langgraph_checkpoint_sqlite-3.0.3.tar.gz", hash = "sha256:438c234d37dabda979218954c9c6eb1db73bee6492c2f1d3a00552fe23fa34ed", size = 123876, upload-time = "2026-01-19T00:38:44.473Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/d8/84ef22ee1cc485c4910df450108fd5e246497379522b3c6cfba896f71bf6/langgraph_checkpoint_sqlite-3.0.3-py3-none-any.whl", hash = "sha256:02eb683a79aa6fcda7cd4de43861062a5d160dbbb990ef8a9fd76c979998a952", size = 33593, upload-time = "2026-01-19T00:38:43.288Z" }, -] - -[[package]] -name = "langgraph-prebuilt" -version = "1.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "langchain-core" }, - { name = "langgraph-checkpoint" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b2/b6/2bcb992acf67713a3557e51c1955854672ec6c1abe6ba51173a87eb8d825/langgraph_prebuilt-1.0.1.tar.gz", hash = "sha256:ecbfb9024d9d7ed9652dde24eef894650aaab96bf79228e862c503e2a060b469", size = 119918, upload-time = "2025-10-20T18:49:55.991Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/68/47/9ffd10882403020ea866e381de7f8e504a78f606a914af7f8244456c7783/langgraph_prebuilt-1.0.1-py3-none-any.whl", hash = "sha256:8c02e023538f7ef6ad5ed76219ba1ab4f6de0e31b749e4d278f57a8a95eec9f7", size = 28458, upload-time = "2025-10-20T18:49:54.723Z" }, -] - -[[package]] -name = "langgraph-sdk" -version = "0.2.15" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "httpx" }, - { name = "orjson" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/71/46/a0bc5914e4a418ad5e8558b19bccd6f0baf56d0c674d6d65a0acf4f22590/langgraph_sdk-0.2.15.tar.gz", hash = "sha256:8faaafe2c1193b89f782dd66c591060cd67862aa6aaf283749b7846f331d5334", size = 130343, upload-time = "2025-12-09T19:26:40.097Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/c9/bf2bff18f85bb7973fa5280838580049574bd7649c36e3dd346c49304997/langgraph_sdk-0.2.15-py3-none-any.whl", hash = "sha256:746566a5d89aa47160eccc17d71682a78771c754126f6c235a68353d61ed7462", size = 66483, upload-time = "2025-12-09T19:26:39.198Z" }, -] - [[package]] name = "langsmith" version = "0.6.2" @@ -1827,53 +1752,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/dd/f4fff4a6fe601b4f8f3ba3aa6da8ac33d17d124491a3b804c662a70e1636/orjson-3.11.5-cp314-cp314-win_arm64.whl", hash = "sha256:38b22f476c351f9a1c43e5b07d8b5a02eb24a6ab8e75f700f7d479d4568346a5", size = 126713, upload-time = "2025-12-06T15:55:19.738Z" }, ] -[[package]] -name = "ormsgpack" -version = "1.12.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/96/34c40d621996c2f377a18decbd3c59f031dde73c3ba47d1e1e8f29a05aaa/ormsgpack-1.12.1.tar.gz", hash = "sha256:a3877fde1e4f27a39f92681a0aab6385af3a41d0c25375d33590ae20410ea2ac", size = 39476, upload-time = "2025-12-14T07:57:43.248Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/57/e2/f5b89365c8dc8025c27d31316038f1c103758ddbf87dc0fa8e3f78f66907/ormsgpack-1.12.1-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:4038f59ae0e19dac5e5d9aae4ec17ff84a79e046342ee73ccdecf3547ecf0d34", size = 376180, upload-time = "2025-12-14T07:56:56.521Z" }, - { url = "https://files.pythonhosted.org/packages/ca/87/3f694e06f5e32c6d65066f53b4a025282a5072b6b336c17560b00e04606d/ormsgpack-1.12.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16c63b0c5a3eec467e4bb33a14dabba076b7d934dff62898297b5c0b5f7c3cb3", size = 202338, upload-time = "2025-12-14T07:56:57.585Z" }, - { url = "https://files.pythonhosted.org/packages/e5/f5/6d95d7b7c11f97a92522082fc7e5d1ab34537929f1e13f4c369f392f19d0/ormsgpack-1.12.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:74fd6a8e037eb310dda865298e8d122540af00fe5658ec18b97a1d34f4012e4d", size = 210720, upload-time = "2025-12-14T07:56:58.968Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9d/9a49a2686f8b7165dcb2342b8554951263c30c0f0825f1fcc2d56e736a6b/ormsgpack-1.12.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58ad60308e233dd824a1859eabb5fe092e123e885eafa4ad5789322329c80fb5", size = 211264, upload-time = "2025-12-14T07:57:00.099Z" }, - { url = "https://files.pythonhosted.org/packages/02/31/2fdc36eaeca2182900b96fc7b19755f293283fe681750e3d295733d62f0e/ormsgpack-1.12.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:35127464c941c1219acbe1a220e48d55e7933373d12257202f4042f7044b4c90", size = 386081, upload-time = "2025-12-14T07:57:01.177Z" }, - { url = "https://files.pythonhosted.org/packages/f0/65/0a765432f08ae26b4013c6a9aed97be17a9ef85f1600948a474b518e27dd/ormsgpack-1.12.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c48d1c50794692d1e6e3f8c3bb65f5c3acfaae9347e506484a65d60b3d91fb50", size = 479572, upload-time = "2025-12-14T07:57:02.738Z" }, - { url = "https://files.pythonhosted.org/packages/4e/4f/f2f15ebef786ad71cea420bf8692448fbddf04d1bf3feaa68bd5ee3172e6/ormsgpack-1.12.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b512b2ad6feaaefdc26e05431ed2843e42483041e354e167c53401afaa83d919", size = 387862, upload-time = "2025-12-14T07:57:03.842Z" }, - { url = "https://files.pythonhosted.org/packages/15/eb/86fbef1d605fa91ecef077f93f9d0e34fc39b23475dfe3ffb92f6c8db28d/ormsgpack-1.12.1-cp311-cp311-win_amd64.whl", hash = "sha256:93f30db95e101a9616323bfc50807ad00e7f6197cea2216d2d24af42afc77d88", size = 115900, upload-time = "2025-12-14T07:57:05.137Z" }, - { url = "https://files.pythonhosted.org/packages/5b/67/7ba1a46e6a6e263fc42a4fafc24afc1ab21a66116553cad670426f0bd9ef/ormsgpack-1.12.1-cp311-cp311-win_arm64.whl", hash = "sha256:d75b5fa14f6abffce2c392ee03b4731199d8a964c81ee8645c4c79af0e80fd50", size = 109868, upload-time = "2025-12-14T07:57:06.834Z" }, - { url = "https://files.pythonhosted.org/packages/17/fe/ab9167ca037406b5703add24049cf3e18021a3b16133ea20615b1f160ea4/ormsgpack-1.12.1-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:4d7fb0e1b6fbc701d75269f7405a4f79230a6ce0063fb1092e4f6577e312f86d", size = 376725, upload-time = "2025-12-14T07:57:07.894Z" }, - { url = "https://files.pythonhosted.org/packages/c7/ea/2820e65f506894c459b840d1091ae6e327fde3d5a3f3b002a11a1b9bdf7d/ormsgpack-1.12.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43a9353e2db5b024c91a47d864ef15eaa62d81824cfc7740fed4cef7db738694", size = 202466, upload-time = "2025-12-14T07:57:09.049Z" }, - { url = "https://files.pythonhosted.org/packages/45/8b/def01c13339c5bbec2ee1469ef53e7fadd66c8d775df974ee4def1572515/ormsgpack-1.12.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc8fe866b7706fc25af0adf1f600bc06ece5b15ca44e34641327198b821e5c3c", size = 210748, upload-time = "2025-12-14T07:57:10.074Z" }, - { url = "https://files.pythonhosted.org/packages/5d/d2/bf350c92f7f067dd9484499705f2d8366d8d9008a670e3d1d0add1908f85/ormsgpack-1.12.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:813755b5f598a78242042e05dfd1ada4e769e94b98c9ab82554550f97ff4d641", size = 211510, upload-time = "2025-12-14T07:57:11.165Z" }, - { url = "https://files.pythonhosted.org/packages/74/92/9d689bcb95304a6da26c4d59439c350940c25d1b35f146d402ccc6344c51/ormsgpack-1.12.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8eea2a13536fae45d78f93f2cc846c9765c7160c85f19cfefecc20873c137cdd", size = 386237, upload-time = "2025-12-14T07:57:12.306Z" }, - { url = "https://files.pythonhosted.org/packages/17/fe/bd3107547f8b6129265dd957f40b9cd547d2445db2292aacb13335a7ea89/ormsgpack-1.12.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7a02ebda1a863cbc604740e76faca8eee1add322db2dcbe6cf32669fffdff65c", size = 479589, upload-time = "2025-12-14T07:57:13.475Z" }, - { url = "https://files.pythonhosted.org/packages/c1/7c/e8e5cc9edb967d44f6f85e9ebdad440b59af3fae00b137a4327dc5aed9bb/ormsgpack-1.12.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3c0bd63897c439931cdf29348e5e6e8c330d529830e848d10767615c0f3d1b82", size = 388077, upload-time = "2025-12-14T07:57:14.551Z" }, - { url = "https://files.pythonhosted.org/packages/35/6b/5031797e43b58506f28a8760b26dc23f2620fb4f2200c4c1b3045603e67e/ormsgpack-1.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:362f2e812f8d7035dc25a009171e09d7cc97cb30d3c9e75a16aeae00ca3c1dcf", size = 116190, upload-time = "2025-12-14T07:57:15.575Z" }, - { url = "https://files.pythonhosted.org/packages/1e/fd/9f43ea6425e383a6b2dbfafebb06fd60e8d68c700ef715adfbcdb499f75d/ormsgpack-1.12.1-cp312-cp312-win_arm64.whl", hash = "sha256:6190281e381db2ed0045052208f47a995ccf61eed48f1215ae3cce3fbccd59c5", size = 109990, upload-time = "2025-12-14T07:57:16.419Z" }, - { url = "https://files.pythonhosted.org/packages/11/42/f110dfe7cf23a52a82e23eb23d9a6a76ae495447d474686dfa758f3d71d6/ormsgpack-1.12.1-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:9663d6b3ecc917c063d61a99169ce196a80f3852e541ae404206836749459279", size = 376746, upload-time = "2025-12-14T07:57:17.699Z" }, - { url = "https://files.pythonhosted.org/packages/11/76/b386e508a8ae207daec240201a81adb26467bf99b163560724e86bd9ff33/ormsgpack-1.12.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32e85cfbaf01a94a92520e7fe7851cfcfe21a5698299c28ab86194895f9b9233", size = 202489, upload-time = "2025-12-14T07:57:18.807Z" }, - { url = "https://files.pythonhosted.org/packages/ea/0e/5db7a63f387149024572daa3d9512fe8fb14bf4efa0722d6d491bed280e7/ormsgpack-1.12.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dabfd2c24b59c7c69870a5ecee480dfae914a42a0c2e7c9d971cf531e2ba471a", size = 210757, upload-time = "2025-12-14T07:57:19.893Z" }, - { url = "https://files.pythonhosted.org/packages/64/79/3a9899e57cb57430bd766fc1b4c9ad410cb2ba6070bc8cf6301e7d385768/ormsgpack-1.12.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51bbf2b64afeded34ccd8e25402e4bca038757913931fa0d693078d75563f6f9", size = 211518, upload-time = "2025-12-14T07:57:20.972Z" }, - { url = "https://files.pythonhosted.org/packages/d7/cd/4f41710ae9fe50d7fcbe476793b3c487746d0e1cc194cc0fee42ff6d989b/ormsgpack-1.12.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9959a71dde1bd0ced84af17facc06a8afada495a34e9cb1bad8e9b20d4c59cef", size = 386251, upload-time = "2025-12-14T07:57:22.099Z" }, - { url = "https://files.pythonhosted.org/packages/bf/54/ba0c97d6231b1f01daafaa520c8cce1e1b7fceaae6fdc1c763925874a7de/ormsgpack-1.12.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:e9be0e3b62d758f21f5b20e0e06b3a240ec546c4a327bf771f5825462aa74714", size = 479607, upload-time = "2025-12-14T07:57:23.525Z" }, - { url = "https://files.pythonhosted.org/packages/18/75/19a9a97a462776d525baf41cfb7072734528775f0a3d5fbfab3aa7756b9b/ormsgpack-1.12.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a29d49ab7fdd77ea787818e60cb4ef491708105b9c4c9b0f919201625eb036b5", size = 388062, upload-time = "2025-12-14T07:57:24.616Z" }, - { url = "https://files.pythonhosted.org/packages/a8/6a/ec26e3f44e9632ecd2f43638b7b37b500eaea5d79cab984ad0b94be14f82/ormsgpack-1.12.1-cp313-cp313-win_amd64.whl", hash = "sha256:c418390b47a1d367e803f6c187f77e4d67c7ae07ba962e3a4a019001f4b0291a", size = 116195, upload-time = "2025-12-14T07:57:25.626Z" }, - { url = "https://files.pythonhosted.org/packages/7d/64/bfa5f4a34d0f15c6aba1b73e73f7441a66d635bd03249d334a4796b7a924/ormsgpack-1.12.1-cp313-cp313-win_arm64.whl", hash = "sha256:cfa22c91cffc10a7fbd43729baff2de7d9c28cef2509085a704168ae31f02568", size = 109986, upload-time = "2025-12-14T07:57:26.569Z" }, - { url = "https://files.pythonhosted.org/packages/87/0e/78e5697164e3223b9b216c13e99f1acbc1ee9833490d68842b13da8ba883/ormsgpack-1.12.1-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:b93c91efb1a70751a1902a5b43b27bd8fd38e0ca0365cf2cde2716423c15c3a6", size = 376758, upload-time = "2025-12-14T07:57:27.641Z" }, - { url = "https://files.pythonhosted.org/packages/2c/0e/3a3cbb64703263d7bbaed7effa3ce78cb9add360a60aa7c544d7df28b641/ormsgpack-1.12.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf0ea0389167b5fa8d2933dd3f33e887ec4ba68f89c25214d7eec4afd746d22", size = 202487, upload-time = "2025-12-14T07:57:29.051Z" }, - { url = "https://files.pythonhosted.org/packages/d7/2c/807ebe2b77995599bbb1dec8c3f450d5d7dddee14ce3e1e71dc60e2e2a74/ormsgpack-1.12.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4c29af837f35af3375070689e781161e7cf019eb2f7cd641734ae45cd001c0d", size = 210853, upload-time = "2025-12-14T07:57:30.508Z" }, - { url = "https://files.pythonhosted.org/packages/25/57/2cdfc354e3ad8e847628f511f4d238799d90e9e090941e50b9d5ba955ae2/ormsgpack-1.12.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:336fc65aa0fe65896a3dabaae31e332a0a98b4a00ad7b0afde21a7505fd23ff3", size = 211545, upload-time = "2025-12-14T07:57:31.585Z" }, - { url = "https://files.pythonhosted.org/packages/76/1d/c6fda560e4a8ff865b3aec8a86f7c95ab53f4532193a6ae4ab9db35f85aa/ormsgpack-1.12.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:940f60aabfefe71dd6b82cb33f4ff10b2e7f5fcfa5f103cdb0a23b6aae4c713c", size = 386333, upload-time = "2025-12-14T07:57:32.957Z" }, - { url = "https://files.pythonhosted.org/packages/fc/3e/715081b36fceb8b497c68b87d384e1cc6d9c9c130ce3b435634d3d785b86/ormsgpack-1.12.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:596ad9e1b6d4c95595c54aaf49b1392609ca68f562ce06f4f74a5bc4053bcda4", size = 479701, upload-time = "2025-12-14T07:57:34.686Z" }, - { url = "https://files.pythonhosted.org/packages/6d/cf/01ad04def42b3970fc1a302c07f4b46339edf62ef9650247097260471f40/ormsgpack-1.12.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:575210e8fcbc7b0375026ba040a5eef223e9f66a4453d9623fc23282ae09c3c8", size = 388148, upload-time = "2025-12-14T07:57:35.771Z" }, - { url = "https://files.pythonhosted.org/packages/15/91/1fff2fc2b5943c740028f339154e7103c8f2edf1a881d9fbba2ce11c3b1d/ormsgpack-1.12.1-cp314-cp314-win_amd64.whl", hash = "sha256:647daa3718572280893456be44c60aea6690b7f2edc54c55648ee66e8f06550f", size = 116201, upload-time = "2025-12-14T07:57:36.763Z" }, - { url = "https://files.pythonhosted.org/packages/ed/66/142b542aed3f96002c7d1c33507ca6e1e0d0a42b9253ab27ef7ed5793bd9/ormsgpack-1.12.1-cp314-cp314-win_arm64.whl", hash = "sha256:a8b3ab762a6deaf1b6490ab46dda0c51528cf8037e0246c40875c6fe9e37b699", size = 110029, upload-time = "2025-12-14T07:57:37.703Z" }, - { url = "https://files.pythonhosted.org/packages/38/b3/ef4494438c90359e1547eaed3c5ec46e2c431d59a3de2af4e70ebd594c49/ormsgpack-1.12.1-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:12087214e436c1f6c28491949571abea759a63111908c4f7266586d78144d7a8", size = 376777, upload-time = "2025-12-14T07:57:38.795Z" }, - { url = "https://files.pythonhosted.org/packages/05/a0/1149a7163f8b0dfbc64bf9099b6f16d102ad3b03bcc11afee198d751da2d/ormsgpack-1.12.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e6d54c14cf86ef13f10ccade94d1e7de146aa9b17d371e18b16e95f329393b7", size = 202490, upload-time = "2025-12-14T07:57:40.168Z" }, - { url = "https://files.pythonhosted.org/packages/68/82/f2ec5e758d6a7106645cca9bb7137d98bce5d363789fa94075be6572057c/ormsgpack-1.12.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f3584d07882b7ea2a1a589f795a3af97fe4c2932b739408e6d1d9d286cad862", size = 211733, upload-time = "2025-12-14T07:57:42.253Z" }, -] - [[package]] name = "packaging" version = "25.0" @@ -2760,18 +2638,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bf/e1/3ccb13c643399d22289c6a9786c1a91e3dcbb68bce4beb44926ac2c557bf/sqlalchemy-2.0.45-py3-none-any.whl", hash = "sha256:5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0", size = 1936672, upload-time = "2025-12-09T21:54:52.608Z" }, ] -[[package]] -name = "sqlite-vec" -version = "0.1.9" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/68/85/9fad0045d8e7c8df3e0fa5a56c630e8e15ad6e5ca2e6106fceb666aa6638/sqlite_vec-0.1.9-py3-none-macosx_10_6_x86_64.whl", hash = "sha256:1b62a7f0a060d9475575d4e599bbf94a13d85af896bc1ce86ee80d1b5b48e5fb", size = 131171, upload-time = "2026-03-31T08:02:31.717Z" }, - { url = "https://files.pythonhosted.org/packages/a4/3d/3677e0cd2f92e5ebc43cd29fbf565b75582bff1ccfa0b8327c7508e1084f/sqlite_vec-0.1.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d52e30513bae4cc9778ddbf6145610434081be4c3afe57cd877893bad9f6b6c", size = 165434, upload-time = "2026-03-31T08:02:32.712Z" }, - { url = "https://files.pythonhosted.org/packages/00/d4/f2b936d3bdc38eadcbd2a87875815db36430fab0363182ba5d12cd8e0b51/sqlite_vec-0.1.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e921e592f24a5f9a18f590b6ddd530eb637e2d474e3b1972f9bbeb773aa3cb9", size = 160076, upload-time = "2026-03-31T08:02:33.796Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ad/6afd073b0f817b3e03f9e37ad626ae341805891f23c74b5292818f49ac63/sqlite_vec-0.1.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux1_x86_64.whl", hash = "sha256:1515727990b49e79bcaf75fdee2ffc7d461f8b66905013231251f1c8938e7786", size = 163388, upload-time = "2026-03-31T08:02:34.888Z" }, - { url = "https://files.pythonhosted.org/packages/42/89/81b2907cda14e566b9bf215e2ad82fc9b349edf07d2010756ffdb902f328/sqlite_vec-0.1.9-py3-none-win_amd64.whl", hash = "sha256:4a28dc12fa4b53d7b1dced22da2488fade444e96b5d16fd2d698cd670675cf32", size = 292804, upload-time = "2026-03-31T08:02:36.035Z" }, -] - [[package]] name = "sse-starlette" version = "3.4.6" @@ -3166,109 +3032,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, ] -[[package]] -name = "xxhash" -version = "3.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/02/84/30869e01909fb37a6cc7e18688ee8bf1e42d57e7e0777636bd47524c43c7/xxhash-3.6.0.tar.gz", hash = "sha256:f0162a78b13a0d7617b2845b90c763339d1f1d82bb04a4b07f4ab535cc5e05d6", size = 85160, upload-time = "2025-10-02T14:37:08.097Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/17/d4/cc2f0400e9154df4b9964249da78ebd72f318e35ccc425e9f403c392f22a/xxhash-3.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b47bbd8cf2d72797f3c2772eaaac0ded3d3af26481a26d7d7d41dc2d3c46b04a", size = 32844, upload-time = "2025-10-02T14:34:14.037Z" }, - { url = "https://files.pythonhosted.org/packages/5e/ec/1cc11cd13e26ea8bc3cb4af4eaadd8d46d5014aebb67be3f71fb0b68802a/xxhash-3.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2b6821e94346f96db75abaa6e255706fb06ebd530899ed76d32cd99f20dc52fa", size = 30809, upload-time = "2025-10-02T14:34:15.484Z" }, - { url = "https://files.pythonhosted.org/packages/04/5f/19fe357ea348d98ca22f456f75a30ac0916b51c753e1f8b2e0e6fb884cce/xxhash-3.6.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d0a9751f71a1a65ce3584e9cae4467651c7e70c9d31017fa57574583a4540248", size = 194665, upload-time = "2025-10-02T14:34:16.541Z" }, - { url = "https://files.pythonhosted.org/packages/90/3b/d1f1a8f5442a5fd8beedae110c5af7604dc37349a8e16519c13c19a9a2de/xxhash-3.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b29ee68625ab37b04c0b40c3fafdf24d2f75ccd778333cfb698f65f6c463f62", size = 213550, upload-time = "2025-10-02T14:34:17.878Z" }, - { url = "https://files.pythonhosted.org/packages/c4/ef/3a9b05eb527457d5db13a135a2ae1a26c80fecd624d20f3e8dcc4cb170f3/xxhash-3.6.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6812c25fe0d6c36a46ccb002f40f27ac903bf18af9f6dd8f9669cb4d176ab18f", size = 212384, upload-time = "2025-10-02T14:34:19.182Z" }, - { url = "https://files.pythonhosted.org/packages/0f/18/ccc194ee698c6c623acbf0f8c2969811a8a4b6185af5e824cd27b9e4fd3e/xxhash-3.6.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4ccbff013972390b51a18ef1255ef5ac125c92dc9143b2d1909f59abc765540e", size = 445749, upload-time = "2025-10-02T14:34:20.659Z" }, - { url = "https://files.pythonhosted.org/packages/a5/86/cf2c0321dc3940a7aa73076f4fd677a0fb3e405cb297ead7d864fd90847e/xxhash-3.6.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:297b7fbf86c82c550e12e8fb71968b3f033d27b874276ba3624ea868c11165a8", size = 193880, upload-time = "2025-10-02T14:34:22.431Z" }, - { url = "https://files.pythonhosted.org/packages/82/fb/96213c8560e6f948a1ecc9a7613f8032b19ee45f747f4fca4eb31bb6d6ed/xxhash-3.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dea26ae1eb293db089798d3973a5fc928a18fdd97cc8801226fae705b02b14b0", size = 210912, upload-time = "2025-10-02T14:34:23.937Z" }, - { url = "https://files.pythonhosted.org/packages/40/aa/4395e669b0606a096d6788f40dbdf2b819d6773aa290c19e6e83cbfc312f/xxhash-3.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7a0b169aafb98f4284f73635a8e93f0735f9cbde17bd5ec332480484241aaa77", size = 198654, upload-time = "2025-10-02T14:34:25.644Z" }, - { url = "https://files.pythonhosted.org/packages/67/74/b044fcd6b3d89e9b1b665924d85d3f400636c23590226feb1eb09e1176ce/xxhash-3.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:08d45aef063a4531b785cd72de4887766d01dc8f362a515693df349fdb825e0c", size = 210867, upload-time = "2025-10-02T14:34:27.203Z" }, - { url = "https://files.pythonhosted.org/packages/bc/fd/3ce73bf753b08cb19daee1eb14aa0d7fe331f8da9c02dd95316ddfe5275e/xxhash-3.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:929142361a48ee07f09121fe9e96a84950e8d4df3bb298ca5d88061969f34d7b", size = 414012, upload-time = "2025-10-02T14:34:28.409Z" }, - { url = "https://files.pythonhosted.org/packages/ba/b3/5a4241309217c5c876f156b10778f3ab3af7ba7e3259e6d5f5c7d0129eb2/xxhash-3.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:51312c768403d8540487dbbfb557454cfc55589bbde6424456951f7fcd4facb3", size = 191409, upload-time = "2025-10-02T14:34:29.696Z" }, - { url = "https://files.pythonhosted.org/packages/c0/01/99bfbc15fb9abb9a72b088c1d95219fc4782b7d01fc835bd5744d66dd0b8/xxhash-3.6.0-cp311-cp311-win32.whl", hash = "sha256:d1927a69feddc24c987b337ce81ac15c4720955b667fe9b588e02254b80446fd", size = 30574, upload-time = "2025-10-02T14:34:31.028Z" }, - { url = "https://files.pythonhosted.org/packages/65/79/9d24d7f53819fe301b231044ea362ce64e86c74f6e8c8e51320de248b3e5/xxhash-3.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:26734cdc2d4ffe449b41d186bbeac416f704a482ed835d375a5c0cb02bc63fef", size = 31481, upload-time = "2025-10-02T14:34:32.062Z" }, - { url = "https://files.pythonhosted.org/packages/30/4e/15cd0e3e8772071344eab2961ce83f6e485111fed8beb491a3f1ce100270/xxhash-3.6.0-cp311-cp311-win_arm64.whl", hash = "sha256:d72f67ef8bf36e05f5b6c65e8524f265bd61071471cd4cf1d36743ebeeeb06b7", size = 27861, upload-time = "2025-10-02T14:34:33.555Z" }, - { url = "https://files.pythonhosted.org/packages/9a/07/d9412f3d7d462347e4511181dea65e47e0d0e16e26fbee2ea86a2aefb657/xxhash-3.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01362c4331775398e7bb34e3ab403bc9ee9f7c497bc7dee6272114055277dd3c", size = 32744, upload-time = "2025-10-02T14:34:34.622Z" }, - { url = "https://files.pythonhosted.org/packages/79/35/0429ee11d035fc33abe32dca1b2b69e8c18d236547b9a9b72c1929189b9a/xxhash-3.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b7b2df81a23f8cb99656378e72501b2cb41b1827c0f5a86f87d6b06b69f9f204", size = 30816, upload-time = "2025-10-02T14:34:36.043Z" }, - { url = "https://files.pythonhosted.org/packages/b7/f2/57eb99aa0f7d98624c0932c5b9a170e1806406cdbcdb510546634a1359e0/xxhash-3.6.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dc94790144e66b14f67b10ac8ed75b39ca47536bf8800eb7c24b50271ea0c490", size = 194035, upload-time = "2025-10-02T14:34:37.354Z" }, - { url = "https://files.pythonhosted.org/packages/4c/ed/6224ba353690d73af7a3f1c7cdb1fc1b002e38f783cb991ae338e1eb3d79/xxhash-3.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93f107c673bccf0d592cdba077dedaf52fe7f42dcd7676eba1f6d6f0c3efffd2", size = 212914, upload-time = "2025-10-02T14:34:38.6Z" }, - { url = "https://files.pythonhosted.org/packages/38/86/fb6b6130d8dd6b8942cc17ab4d90e223653a89aa32ad2776f8af7064ed13/xxhash-3.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aa5ee3444c25b69813663c9f8067dcfaa2e126dc55e8dddf40f4d1c25d7effa", size = 212163, upload-time = "2025-10-02T14:34:39.872Z" }, - { url = "https://files.pythonhosted.org/packages/ee/dc/e84875682b0593e884ad73b2d40767b5790d417bde603cceb6878901d647/xxhash-3.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7f99123f0e1194fa59cc69ad46dbae2e07becec5df50a0509a808f90a0f03f0", size = 445411, upload-time = "2025-10-02T14:34:41.569Z" }, - { url = "https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49e03e6fe2cac4a1bc64952dd250cf0dbc5ef4ebb7b8d96bce82e2de163c82a2", size = 193883, upload-time = "2025-10-02T14:34:43.249Z" }, - { url = "https://files.pythonhosted.org/packages/53/5a/ddbb83eee8e28b778eacfc5a85c969673e4023cdeedcfcef61f36731610b/xxhash-3.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd17fede52a17a4f9a7bc4472a5867cb0b160deeb431795c0e4abe158bc784e9", size = 210392, upload-time = "2025-10-02T14:34:45.042Z" }, - { url = "https://files.pythonhosted.org/packages/1e/c2/ff69efd07c8c074ccdf0a4f36fcdd3d27363665bcdf4ba399abebe643465/xxhash-3.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6fb5f5476bef678f69db04f2bd1efbed3030d2aba305b0fc1773645f187d6a4e", size = 197898, upload-time = "2025-10-02T14:34:46.302Z" }, - { url = "https://files.pythonhosted.org/packages/58/ca/faa05ac19b3b622c7c9317ac3e23954187516298a091eb02c976d0d3dd45/xxhash-3.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:843b52f6d88071f87eba1631b684fcb4b2068cd2180a0224122fe4ef011a9374", size = 210655, upload-time = "2025-10-02T14:34:47.571Z" }, - { url = "https://files.pythonhosted.org/packages/d4/7a/06aa7482345480cc0cb597f5c875b11a82c3953f534394f620b0be2f700c/xxhash-3.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7d14a6cfaf03b1b6f5f9790f76880601ccc7896aff7ab9cd8978a939c1eb7e0d", size = 414001, upload-time = "2025-10-02T14:34:49.273Z" }, - { url = "https://files.pythonhosted.org/packages/23/07/63ffb386cd47029aa2916b3d2f454e6cc5b9f5c5ada3790377d5430084e7/xxhash-3.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:418daf3db71e1413cfe211c2f9a528456936645c17f46b5204705581a45390ae", size = 191431, upload-time = "2025-10-02T14:34:50.798Z" }, - { url = "https://files.pythonhosted.org/packages/0f/93/14fde614cadb4ddf5e7cebf8918b7e8fac5ae7861c1875964f17e678205c/xxhash-3.6.0-cp312-cp312-win32.whl", hash = "sha256:50fc255f39428a27299c20e280d6193d8b63b8ef8028995323bf834a026b4fbb", size = 30617, upload-time = "2025-10-02T14:34:51.954Z" }, - { url = "https://files.pythonhosted.org/packages/13/5d/0d125536cbe7565a83d06e43783389ecae0c0f2ed037b48ede185de477c0/xxhash-3.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:c0f2ab8c715630565ab8991b536ecded9416d615538be8ecddce43ccf26cbc7c", size = 31534, upload-time = "2025-10-02T14:34:53.276Z" }, - { url = "https://files.pythonhosted.org/packages/54/85/6ec269b0952ec7e36ba019125982cf11d91256a778c7c3f98a4c5043d283/xxhash-3.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:eae5c13f3bc455a3bbb68bdc513912dc7356de7e2280363ea235f71f54064829", size = 27876, upload-time = "2025-10-02T14:34:54.371Z" }, - { url = "https://files.pythonhosted.org/packages/33/76/35d05267ac82f53ae9b0e554da7c5e281ee61f3cad44c743f0fcd354f211/xxhash-3.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:599e64ba7f67472481ceb6ee80fa3bd828fd61ba59fb11475572cc5ee52b89ec", size = 32738, upload-time = "2025-10-02T14:34:55.839Z" }, - { url = "https://files.pythonhosted.org/packages/31/a8/3fbce1cd96534a95e35d5120637bf29b0d7f5d8fa2f6374e31b4156dd419/xxhash-3.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d8b8aaa30fca4f16f0c84a5c8d7ddee0e25250ec2796c973775373257dde8f1", size = 30821, upload-time = "2025-10-02T14:34:57.219Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ea/d387530ca7ecfa183cb358027f1833297c6ac6098223fd14f9782cd0015c/xxhash-3.6.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d597acf8506d6e7101a4a44a5e428977a51c0fadbbfd3c39650cca9253f6e5a6", size = 194127, upload-time = "2025-10-02T14:34:59.21Z" }, - { url = "https://files.pythonhosted.org/packages/ba/0c/71435dcb99874b09a43b8d7c54071e600a7481e42b3e3ce1eb5226a5711a/xxhash-3.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:858dc935963a33bc33490128edc1c12b0c14d9c7ebaa4e387a7869ecc4f3e263", size = 212975, upload-time = "2025-10-02T14:35:00.816Z" }, - { url = "https://files.pythonhosted.org/packages/84/7a/c2b3d071e4bb4a90b7057228a99b10d51744878f4a8a6dd643c8bd897620/xxhash-3.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba284920194615cb8edf73bf52236ce2e1664ccd4a38fdb543506413529cc546", size = 212241, upload-time = "2025-10-02T14:35:02.207Z" }, - { url = "https://files.pythonhosted.org/packages/81/5f/640b6eac0128e215f177df99eadcd0f1b7c42c274ab6a394a05059694c5a/xxhash-3.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b54219177f6c6674d5378bd862c6aedf64725f70dd29c472eaae154df1a2e89", size = 445471, upload-time = "2025-10-02T14:35:03.61Z" }, - { url = "https://files.pythonhosted.org/packages/5e/1e/3c3d3ef071b051cc3abbe3721ffb8365033a172613c04af2da89d5548a87/xxhash-3.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42c36dd7dbad2f5238950c377fcbf6811b1cdb1c444fab447960030cea60504d", size = 193936, upload-time = "2025-10-02T14:35:05.013Z" }, - { url = "https://files.pythonhosted.org/packages/2c/bd/4a5f68381939219abfe1c22a9e3a5854a4f6f6f3c4983a87d255f21f2e5d/xxhash-3.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f22927652cba98c44639ffdc7aaf35828dccf679b10b31c4ad72a5b530a18eb7", size = 210440, upload-time = "2025-10-02T14:35:06.239Z" }, - { url = "https://files.pythonhosted.org/packages/eb/37/b80fe3d5cfb9faff01a02121a0f4d565eb7237e9e5fc66e73017e74dcd36/xxhash-3.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b45fad44d9c5c119e9c6fbf2e1c656a46dc68e280275007bbfd3d572b21426db", size = 197990, upload-time = "2025-10-02T14:35:07.735Z" }, - { url = "https://files.pythonhosted.org/packages/d7/fd/2c0a00c97b9e18f72e1f240ad4e8f8a90fd9d408289ba9c7c495ed7dc05c/xxhash-3.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6f2580ffab1a8b68ef2b901cde7e55fa8da5e4be0977c68f78fc80f3c143de42", size = 210689, upload-time = "2025-10-02T14:35:09.438Z" }, - { url = "https://files.pythonhosted.org/packages/93/86/5dd8076a926b9a95db3206aba20d89a7fc14dd5aac16e5c4de4b56033140/xxhash-3.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40c391dd3cd041ebc3ffe6f2c862f402e306eb571422e0aa918d8070ba31da11", size = 414068, upload-time = "2025-10-02T14:35:11.162Z" }, - { url = "https://files.pythonhosted.org/packages/af/3c/0bb129170ee8f3650f08e993baee550a09593462a5cddd8e44d0011102b1/xxhash-3.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f205badabde7aafd1a31e8ca2a3e5a763107a71c397c4481d6a804eb5063d8bd", size = 191495, upload-time = "2025-10-02T14:35:12.971Z" }, - { url = "https://files.pythonhosted.org/packages/e9/3a/6797e0114c21d1725e2577508e24006fd7ff1d8c0c502d3b52e45c1771d8/xxhash-3.6.0-cp313-cp313-win32.whl", hash = "sha256:2577b276e060b73b73a53042ea5bd5203d3e6347ce0d09f98500f418a9fcf799", size = 30620, upload-time = "2025-10-02T14:35:14.129Z" }, - { url = "https://files.pythonhosted.org/packages/86/15/9bc32671e9a38b413a76d24722a2bf8784a132c043063a8f5152d390b0f9/xxhash-3.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:757320d45d2fbcce8f30c42a6b2f47862967aea7bf458b9625b4bbe7ee390392", size = 31542, upload-time = "2025-10-02T14:35:15.21Z" }, - { url = "https://files.pythonhosted.org/packages/39/c5/cc01e4f6188656e56112d6a8e0dfe298a16934b8c47a247236549a3f7695/xxhash-3.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:457b8f85dec5825eed7b69c11ae86834a018b8e3df5e77783c999663da2f96d6", size = 27880, upload-time = "2025-10-02T14:35:16.315Z" }, - { url = "https://files.pythonhosted.org/packages/f3/30/25e5321c8732759e930c555176d37e24ab84365482d257c3b16362235212/xxhash-3.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a42e633d75cdad6d625434e3468126c73f13f7584545a9cf34e883aa1710e702", size = 32956, upload-time = "2025-10-02T14:35:17.413Z" }, - { url = "https://files.pythonhosted.org/packages/9f/3c/0573299560d7d9f8ab1838f1efc021a280b5ae5ae2e849034ef3dee18810/xxhash-3.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:568a6d743219e717b07b4e03b0a828ce593833e498c3b64752e0f5df6bfe84db", size = 31072, upload-time = "2025-10-02T14:35:18.844Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1c/52d83a06e417cd9d4137722693424885cc9878249beb3a7c829e74bf7ce9/xxhash-3.6.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bec91b562d8012dae276af8025a55811b875baace6af510412a5e58e3121bc54", size = 196409, upload-time = "2025-10-02T14:35:20.31Z" }, - { url = "https://files.pythonhosted.org/packages/e3/8e/c6d158d12a79bbd0b878f8355432075fc82759e356ab5a111463422a239b/xxhash-3.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78e7f2f4c521c30ad5e786fdd6bae89d47a32672a80195467b5de0480aa97b1f", size = 215736, upload-time = "2025-10-02T14:35:21.616Z" }, - { url = "https://files.pythonhosted.org/packages/bc/68/c4c80614716345d55071a396cf03d06e34b5f4917a467faf43083c995155/xxhash-3.6.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ed0df1b11a79856df5ffcab572cbd6b9627034c1c748c5566fa79df9048a7c5", size = 214833, upload-time = "2025-10-02T14:35:23.32Z" }, - { url = "https://files.pythonhosted.org/packages/7e/e9/ae27c8ffec8b953efa84c7c4a6c6802c263d587b9fc0d6e7cea64e08c3af/xxhash-3.6.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0e4edbfc7d420925b0dd5e792478ed393d6e75ff8fc219a6546fb446b6a417b1", size = 448348, upload-time = "2025-10-02T14:35:25.111Z" }, - { url = "https://files.pythonhosted.org/packages/d7/6b/33e21afb1b5b3f46b74b6bd1913639066af218d704cc0941404ca717fc57/xxhash-3.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fba27a198363a7ef87f8c0f6b171ec36b674fe9053742c58dd7e3201c1ab30ee", size = 196070, upload-time = "2025-10-02T14:35:26.586Z" }, - { url = "https://files.pythonhosted.org/packages/96/b6/fcabd337bc5fa624e7203aa0fa7d0c49eed22f72e93229431752bddc83d9/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:794fe9145fe60191c6532fa95063765529770edcdd67b3d537793e8004cabbfd", size = 212907, upload-time = "2025-10-02T14:35:28.087Z" }, - { url = "https://files.pythonhosted.org/packages/4b/d3/9ee6160e644d660fcf176c5825e61411c7f62648728f69c79ba237250143/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:6105ef7e62b5ac73a837778efc331a591d8442f8ef5c7e102376506cb4ae2729", size = 200839, upload-time = "2025-10-02T14:35:29.857Z" }, - { url = "https://files.pythonhosted.org/packages/0d/98/e8de5baa5109394baf5118f5e72ab21a86387c4f89b0e77ef3e2f6b0327b/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f01375c0e55395b814a679b3eea205db7919ac2af213f4a6682e01220e5fe292", size = 213304, upload-time = "2025-10-02T14:35:31.222Z" }, - { url = "https://files.pythonhosted.org/packages/7b/1d/71056535dec5c3177eeb53e38e3d367dd1d16e024e63b1cee208d572a033/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d706dca2d24d834a4661619dcacf51a75c16d65985718d6a7d73c1eeeb903ddf", size = 416930, upload-time = "2025-10-02T14:35:32.517Z" }, - { url = "https://files.pythonhosted.org/packages/dc/6c/5cbde9de2cd967c322e651c65c543700b19e7ae3e0aae8ece3469bf9683d/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f059d9faeacd49c0215d66f4056e1326c80503f51a1532ca336a385edadd033", size = 193787, upload-time = "2025-10-02T14:35:33.827Z" }, - { url = "https://files.pythonhosted.org/packages/19/fa/0172e350361d61febcea941b0cc541d6e6c8d65d153e85f850a7b256ff8a/xxhash-3.6.0-cp313-cp313t-win32.whl", hash = "sha256:1244460adc3a9be84731d72b8e80625788e5815b68da3da8b83f78115a40a7ec", size = 30916, upload-time = "2025-10-02T14:35:35.107Z" }, - { url = "https://files.pythonhosted.org/packages/ad/e6/e8cf858a2b19d6d45820f072eff1bea413910592ff17157cabc5f1227a16/xxhash-3.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b1e420ef35c503869c4064f4a2f2b08ad6431ab7b229a05cce39d74268bca6b8", size = 31799, upload-time = "2025-10-02T14:35:36.165Z" }, - { url = "https://files.pythonhosted.org/packages/56/15/064b197e855bfb7b343210e82490ae672f8bc7cdf3ddb02e92f64304ee8a/xxhash-3.6.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ec44b73a4220623235f67a996c862049f375df3b1052d9899f40a6382c32d746", size = 28044, upload-time = "2025-10-02T14:35:37.195Z" }, - { url = "https://files.pythonhosted.org/packages/7e/5e/0138bc4484ea9b897864d59fce9be9086030825bc778b76cb5a33a906d37/xxhash-3.6.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a40a3d35b204b7cc7643cbcf8c9976d818cb47befcfac8bbefec8038ac363f3e", size = 32754, upload-time = "2025-10-02T14:35:38.245Z" }, - { url = "https://files.pythonhosted.org/packages/18/d7/5dac2eb2ec75fd771957a13e5dda560efb2176d5203f39502a5fc571f899/xxhash-3.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a54844be970d3fc22630b32d515e79a90d0a3ddb2644d8d7402e3c4c8da61405", size = 30846, upload-time = "2025-10-02T14:35:39.6Z" }, - { url = "https://files.pythonhosted.org/packages/fe/71/8bc5be2bb00deb5682e92e8da955ebe5fa982da13a69da5a40a4c8db12fb/xxhash-3.6.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:016e9190af8f0a4e3741343777710e3d5717427f175adfdc3e72508f59e2a7f3", size = 194343, upload-time = "2025-10-02T14:35:40.69Z" }, - { url = "https://files.pythonhosted.org/packages/e7/3b/52badfb2aecec2c377ddf1ae75f55db3ba2d321c5e164f14461c90837ef3/xxhash-3.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f6f72232f849eb9d0141e2ebe2677ece15adfd0fa599bc058aad83c714bb2c6", size = 213074, upload-time = "2025-10-02T14:35:42.29Z" }, - { url = "https://files.pythonhosted.org/packages/a2/2b/ae46b4e9b92e537fa30d03dbc19cdae57ed407e9c26d163895e968e3de85/xxhash-3.6.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63275a8aba7865e44b1813d2177e0f5ea7eadad3dd063a21f7cf9afdc7054063", size = 212388, upload-time = "2025-10-02T14:35:43.929Z" }, - { url = "https://files.pythonhosted.org/packages/f5/80/49f88d3afc724b4ac7fbd664c8452d6db51b49915be48c6982659e0e7942/xxhash-3.6.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cd01fa2aa00d8b017c97eb46b9a794fbdca53fc14f845f5a328c71254b0abb7", size = 445614, upload-time = "2025-10-02T14:35:45.216Z" }, - { url = "https://files.pythonhosted.org/packages/ed/ba/603ce3961e339413543d8cd44f21f2c80e2a7c5cfe692a7b1f2cccf58f3c/xxhash-3.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0226aa89035b62b6a86d3c68df4d7c1f47a342b8683da2b60cedcddb46c4d95b", size = 194024, upload-time = "2025-10-02T14:35:46.959Z" }, - { url = "https://files.pythonhosted.org/packages/78/d1/8e225ff7113bf81545cfdcd79eef124a7b7064a0bba53605ff39590b95c2/xxhash-3.6.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6e193e9f56e4ca4923c61238cdaced324f0feac782544eb4c6d55ad5cc99ddd", size = 210541, upload-time = "2025-10-02T14:35:48.301Z" }, - { url = "https://files.pythonhosted.org/packages/6f/58/0f89d149f0bad89def1a8dd38feb50ccdeb643d9797ec84707091d4cb494/xxhash-3.6.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9176dcaddf4ca963d4deb93866d739a343c01c969231dbe21680e13a5d1a5bf0", size = 198305, upload-time = "2025-10-02T14:35:49.584Z" }, - { url = "https://files.pythonhosted.org/packages/11/38/5eab81580703c4df93feb5f32ff8fa7fe1e2c51c1f183ee4e48d4bb9d3d7/xxhash-3.6.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c1ce4009c97a752e682b897aa99aef84191077a9433eb237774689f14f8ec152", size = 210848, upload-time = "2025-10-02T14:35:50.877Z" }, - { url = "https://files.pythonhosted.org/packages/5e/6b/953dc4b05c3ce678abca756416e4c130d2382f877a9c30a20d08ee6a77c0/xxhash-3.6.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8cb2f4f679b01513b7adbb9b1b2f0f9cdc31b70007eaf9d59d0878809f385b11", size = 414142, upload-time = "2025-10-02T14:35:52.15Z" }, - { url = "https://files.pythonhosted.org/packages/08/a9/238ec0d4e81a10eb5026d4a6972677cbc898ba6c8b9dbaec12ae001b1b35/xxhash-3.6.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:653a91d7c2ab54a92c19ccf43508b6a555440b9be1bc8be553376778be7f20b5", size = 191547, upload-time = "2025-10-02T14:35:53.547Z" }, - { url = "https://files.pythonhosted.org/packages/f1/ee/3cf8589e06c2164ac77c3bf0aa127012801128f1feebf2a079272da5737c/xxhash-3.6.0-cp314-cp314-win32.whl", hash = "sha256:a756fe893389483ee8c394d06b5ab765d96e68fbbfe6fde7aa17e11f5720559f", size = 31214, upload-time = "2025-10-02T14:35:54.746Z" }, - { url = "https://files.pythonhosted.org/packages/02/5d/a19552fbc6ad4cb54ff953c3908bbc095f4a921bc569433d791f755186f1/xxhash-3.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:39be8e4e142550ef69629c9cd71b88c90e9a5db703fecbcf265546d9536ca4ad", size = 32290, upload-time = "2025-10-02T14:35:55.791Z" }, - { url = "https://files.pythonhosted.org/packages/b1/11/dafa0643bc30442c887b55baf8e73353a344ee89c1901b5a5c54a6c17d39/xxhash-3.6.0-cp314-cp314-win_arm64.whl", hash = "sha256:25915e6000338999236f1eb68a02a32c3275ac338628a7eaa5a269c401995679", size = 28795, upload-time = "2025-10-02T14:35:57.162Z" }, - { url = "https://files.pythonhosted.org/packages/2c/db/0e99732ed7f64182aef4a6fb145e1a295558deec2a746265dcdec12d191e/xxhash-3.6.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c5294f596a9017ca5a3e3f8884c00b91ab2ad2933cf288f4923c3fd4346cf3d4", size = 32955, upload-time = "2025-10-02T14:35:58.267Z" }, - { url = "https://files.pythonhosted.org/packages/55/f4/2a7c3c68e564a099becfa44bb3d398810cc0ff6749b0d3cb8ccb93f23c14/xxhash-3.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1cf9dcc4ab9cff01dfbba78544297a3a01dafd60f3bde4e2bfd016cf7e4ddc67", size = 31072, upload-time = "2025-10-02T14:35:59.382Z" }, - { url = "https://files.pythonhosted.org/packages/c6/d9/72a29cddc7250e8a5819dad5d466facb5dc4c802ce120645630149127e73/xxhash-3.6.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01262da8798422d0685f7cef03b2bd3f4f46511b02830861df548d7def4402ad", size = 196579, upload-time = "2025-10-02T14:36:00.838Z" }, - { url = "https://files.pythonhosted.org/packages/63/93/b21590e1e381040e2ca305a884d89e1c345b347404f7780f07f2cdd47ef4/xxhash-3.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51a73fb7cb3a3ead9f7a8b583ffd9b8038e277cdb8cb87cf890e88b3456afa0b", size = 215854, upload-time = "2025-10-02T14:36:02.207Z" }, - { url = "https://files.pythonhosted.org/packages/ce/b8/edab8a7d4fa14e924b29be877d54155dcbd8b80be85ea00d2be3413a9ed4/xxhash-3.6.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9c6df83594f7df8f7f708ce5ebeacfc69f72c9fbaaababf6cf4758eaada0c9b", size = 214965, upload-time = "2025-10-02T14:36:03.507Z" }, - { url = "https://files.pythonhosted.org/packages/27/67/dfa980ac7f0d509d54ea0d5a486d2bb4b80c3f1bb22b66e6a05d3efaf6c0/xxhash-3.6.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:627f0af069b0ea56f312fd5189001c24578868643203bca1abbc2c52d3a6f3ca", size = 448484, upload-time = "2025-10-02T14:36:04.828Z" }, - { url = "https://files.pythonhosted.org/packages/8c/63/8ffc2cc97e811c0ca5d00ab36604b3ea6f4254f20b7bc658ca825ce6c954/xxhash-3.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa912c62f842dfd013c5f21a642c9c10cd9f4c4e943e0af83618b4a404d9091a", size = 196162, upload-time = "2025-10-02T14:36:06.182Z" }, - { url = "https://files.pythonhosted.org/packages/4b/77/07f0e7a3edd11a6097e990f6e5b815b6592459cb16dae990d967693e6ea9/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b465afd7909db30168ab62afe40b2fcf79eedc0b89a6c0ab3123515dc0df8b99", size = 213007, upload-time = "2025-10-02T14:36:07.733Z" }, - { url = "https://files.pythonhosted.org/packages/ae/d8/bc5fa0d152837117eb0bef6f83f956c509332ce133c91c63ce07ee7c4873/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a881851cf38b0a70e7c4d3ce81fc7afd86fbc2a024f4cfb2a97cf49ce04b75d3", size = 200956, upload-time = "2025-10-02T14:36:09.106Z" }, - { url = "https://files.pythonhosted.org/packages/26/a5/d749334130de9411783873e9b98ecc46688dad5db64ca6e04b02acc8b473/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9b3222c686a919a0f3253cfc12bb118b8b103506612253b5baeaac10d8027cf6", size = 213401, upload-time = "2025-10-02T14:36:10.585Z" }, - { url = "https://files.pythonhosted.org/packages/89/72/abed959c956a4bfc72b58c0384bb7940663c678127538634d896b1195c10/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:c5aa639bc113e9286137cec8fadc20e9cd732b2cc385c0b7fa673b84fc1f2a93", size = 417083, upload-time = "2025-10-02T14:36:12.276Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b3/62fd2b586283b7d7d665fb98e266decadf31f058f1cf6c478741f68af0cb/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c1343d49ac102799905e115aee590183c3921d475356cb24b4de29a4bc56518", size = 193913, upload-time = "2025-10-02T14:36:14.025Z" }, - { url = "https://files.pythonhosted.org/packages/9a/9a/c19c42c5b3f5a4aad748a6d5b4f23df3bed7ee5445accc65a0fb3ff03953/xxhash-3.6.0-cp314-cp314t-win32.whl", hash = "sha256:5851f033c3030dd95c086b4a36a2683c2ff4a799b23af60977188b057e467119", size = 31586, upload-time = "2025-10-02T14:36:15.603Z" }, - { url = "https://files.pythonhosted.org/packages/03/d6/4cc450345be9924fd5dc8c590ceda1db5b43a0a889587b0ae81a95511360/xxhash-3.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0444e7967dac37569052d2409b00a8860c2135cff05502df4da80267d384849f", size = 32526, upload-time = "2025-10-02T14:36:16.708Z" }, - { url = "https://files.pythonhosted.org/packages/0f/c9/7243eb3f9eaabd1a88a5a5acadf06df2d83b100c62684b7425c6a11bcaa8/xxhash-3.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95", size = 28898, upload-time = "2025-10-02T14:36:17.843Z" }, - { url = "https://files.pythonhosted.org/packages/93/1e/8aec23647a34a249f62e2398c42955acd9b4c6ed5cf08cbea94dc46f78d2/xxhash-3.6.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0f7b7e2ec26c1666ad5fc9dbfa426a6a3367ceaf79db5dd76264659d509d73b0", size = 30662, upload-time = "2025-10-02T14:37:01.743Z" }, - { url = "https://files.pythonhosted.org/packages/b8/0b/b14510b38ba91caf43006209db846a696ceea6a847a0c9ba0a5b1adc53d6/xxhash-3.6.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5dc1e14d14fa0f5789ec29a7062004b5933964bb9b02aae6622b8f530dc40296", size = 41056, upload-time = "2025-10-02T14:37:02.879Z" }, - { url = "https://files.pythonhosted.org/packages/50/55/15a7b8a56590e66ccd374bbfa3f9ffc45b810886c8c3b614e3f90bd2367c/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:881b47fc47e051b37d94d13e7455131054b56749b91b508b0907eb07900d1c13", size = 36251, upload-time = "2025-10-02T14:37:04.44Z" }, - { url = "https://files.pythonhosted.org/packages/62/b2/5ac99a041a29e58e95f907876b04f7067a0242cb85b5f39e726153981503/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6dc31591899f5e5666f04cc2e529e69b4072827085c1ef15294d91a004bc1bd", size = 32481, upload-time = "2025-10-02T14:37:05.869Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d9/8d95e906764a386a3d3b596f3c68bb63687dfca806373509f51ce8eea81f/xxhash-3.6.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:15e0dac10eb9309508bfc41f7f9deaa7755c69e35af835db9cb10751adebc35d", size = 31565, upload-time = "2025-10-02T14:37:06.966Z" }, -] - [[package]] name = "yarl" version = "1.22.0" From cde5ab773e194ca415b120fc9a0a23e97d0c4f36 Mon Sep 17 00:00:00 2001 From: Alex Barclay Date: Fri, 24 Jul 2026 23:09:45 -0600 Subject: [PATCH 10/10] fix: align frontend qa_test node id to engine + drop dead SQLITE_PATH config --- backend/config.py | 3 --- frontend/src/components/GraphCanvas.tsx | 2 +- frontend/src/stores/projectStore.ts | 2 +- tests/conftest.py | 29 +------------------------ tests/unit/test_config.py | 2 -- 5 files changed, 3 insertions(+), 35 deletions(-) diff --git a/backend/config.py b/backend/config.py index b7d4566..10b1c4b 100644 --- a/backend/config.py +++ b/backend/config.py @@ -54,7 +54,6 @@ class Config: log_level: str budget_limit: float anthropic_model: str - sqlite_path: str max_clarifying_questions: int enable_phase4: bool engine_lease_ttl: float = 120.0 @@ -68,7 +67,6 @@ class Config: @classmethod def load(cls) -> Config: - default_sqlite = str(REPO_ROOT / "data" / "checkpoints.db") return cls( anthropic_api_key=os.getenv("ANTHROPIC_API_KEY"), mock_agents=_env_bool("MOCK_AGENTS", True), @@ -76,7 +74,6 @@ def load(cls) -> Config: log_level=os.getenv("LOG_LEVEL", "INFO"), budget_limit=_env_float("BUDGET_LIMIT", 200.0), anthropic_model=os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-6"), - sqlite_path=os.getenv("SQLITE_PATH", default_sqlite), max_clarifying_questions=_env_int("MAX_CLARIFYING_QUESTIONS", 6), enable_phase4=_env_bool("ENABLE_PHASE4", True), engine_lease_ttl=_env_float("ENGINE_LEASE_TTL", 120.0), diff --git a/frontend/src/components/GraphCanvas.tsx b/frontend/src/components/GraphCanvas.tsx index 8499c8b..c8f60d7 100644 --- a/frontend/src/components/GraphCanvas.tsx +++ b/frontend/src/components/GraphCanvas.tsx @@ -25,7 +25,7 @@ const LAYOUT: Record = { ai_ml: { x: 550, y: 360 }, devops: { x: 100, y: 500 }, security: { x: 250, y: 500 }, - qa: { x: 400, y: 500 }, + qa_test: { x: 400, y: 500 }, technical_writer: { x: 550, y: 500 }, delivery_summarizer: { x: 400, y: 620 }, }; diff --git a/frontend/src/stores/projectStore.ts b/frontend/src/stores/projectStore.ts index 57001b5..8b6230a 100644 --- a/frontend/src/stores/projectStore.ts +++ b/frontend/src/stores/projectStore.ts @@ -23,7 +23,7 @@ const AGENT_NAMES: Record = { ai_ml: "AI/ML", devops: "DevOps", security: "Security", - qa: "QA", + qa_test: "QA", technical_writer: "Technical Writer", delivery_summarizer: "Delivery Summarizer", }; diff --git a/tests/conftest.py b/tests/conftest.py index e9ac3a6..05cab72 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,28 +1 @@ -"""Test-environment isolation shared across the suite. - -Integration tests that boot the FastAPI app use a module-level ``Orchestrator`` -whose SQLite checkpoint path is bound from the environment when ``backend.main`` -is first imported. By default that path is the committed ``./data/checkpoints.db``, -so the suite would write checkpoints into the repo and could inherit stale state -across runs — a real source of local flakiness (e.g. a load reading a prior -run's thread). - -Point ``SQLITE_PATH`` at a throwaway temp directory *before* any test module -imports ``backend.main`` (conftest is imported by pytest ahead of test -collection), so tests never touch ``./data`` and always start from a clean -database. ``setdefault`` is used so an explicitly-provided ``SQLITE_PATH`` (CI, -a developer's shell, or a per-test ``monkeypatch.setenv``) still wins. -""" - -import atexit -import os -import shutil -import tempfile - -_TEST_CKPT_DIR = tempfile.mkdtemp(prefix="appforge-test-ckpt-") -os.environ.setdefault("SQLITE_PATH", os.path.join(_TEST_CKPT_DIR, "checkpoints.db")) - - -@atexit.register -def _cleanup_test_ckpt_dir() -> None: - shutil.rmtree(_TEST_CKPT_DIR, ignore_errors=True) +"""Test-environment isolation shared across the suite.""" diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 8b2700e..226f28b 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -10,7 +10,6 @@ def test_config_defaults_when_env_missing(monkeypatch, tmp_path): "DEBUG", "BUDGET_LIMIT", "ANTHROPIC_MODEL", - "SQLITE_PATH", "MAX_CLARIFYING_QUESTIONS", "LOG_LEVEL", ): @@ -20,7 +19,6 @@ def test_config_defaults_when_env_missing(monkeypatch, tmp_path): assert cfg.debug is False assert cfg.budget_limit == 200.0 assert cfg.anthropic_model == "claude-sonnet-4-6" - assert cfg.sqlite_path.endswith("checkpoints.db") assert cfg.max_clarifying_questions == 6 assert cfg.log_level == "INFO"