From ab170f44a258086204ab93dfa29824e0d07946e6 Mon Sep 17 00:00:00 2001 From: Alex Barclay Date: Thu, 23 Jul 2026 17:00:13 -0600 Subject: [PATCH 01/15] docs(spec): parallel MCP orchestration engine design (3 critique rounds) Full-replacement engine that makes three unbacked resume claims true and testable: an MCP state server (FastMCP/streamable-HTTP, SQLite/WAL), a six-phase dependency graph (Clarify->Design->Code->Test->Deploy->Iterate), and independent OS worker processes coordinating shared state without collision (serialized DB ops + versioned CAS). Preserves 16 agents, approval gates, and budget auto-downgrade. Survived three independent adversarial critique rounds (log appended). --- ...arallel-mcp-orchestration-engine-design.md | 480 ++++++++++++++++++ 1 file changed, 480 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-23-parallel-mcp-orchestration-engine-design.md diff --git a/docs/superpowers/specs/2026-07-23-parallel-mcp-orchestration-engine-design.md b/docs/superpowers/specs/2026-07-23-parallel-mcp-orchestration-engine-design.md new file mode 100644 index 0000000..117fdbe --- /dev/null +++ b/docs/superpowers/specs/2026-07-23-parallel-mcp-orchestration-engine-design.md @@ -0,0 +1,480 @@ +# Design: Parallel, MCP-Coordinated Orchestration Engine + +**Date:** 2026-07-23 +**Status:** Approved — 3 rounds of adversarial critique applied +**Author:** session (with Alex Barclay) +**Supersedes at runtime:** `backend/graph.py` + the LangGraph orchestration core in `backend/orchestrator.py` + +--- + +## 1. Context & motivation + +A publication-prep verification pass found three headline resume claims **not backed by +the code as written**: + +1. **"MCP-based state server"** — no MCP anywhere; state is a single-process LangGraph + `AsyncSqliteSaver` checkpointer. +2. **"Six-phase dependency graph"** — the project defines 15 roadmap phases; the runtime + graph is an ~8-node fan-out/fan-in graph implementing roughly phases 3–4. +3. **"Independent processes resolving execution order and shared state without + collision"** — a single OS process; no processes, no IPC, no coordination primitives. + +The claims that **do** hold — 16-agent platform, human approval gates, budget +auto-downgrade — are preserved and re-expressed on the new engine. + +**Decision (Alex):** implement the missing architecture as a **full replacement** of the +LangGraph orchestrator, **maximally parallelizable**. + +## 2. Goals & non-goals + +**Goals** +- A genuine **MCP state server** (`mcp` SDK / `FastMCP`, streamable-HTTP, stateless). +- A **six-phase dependency graph** — Clarify → Design → Code → Test → Deploy → Iterate — + named in `config/phases.yaml`, traversed by a real run. +- **Independent OS worker processes** that resolve real-time execution order across workers + by concurrently claiming ready work, while a shared server enforces the dependency + partial-order and collision-freedom — proven by a multi-process test. +- Preserve **16 agents** (13 execute as claimed phase-worker tasks; `product_owner` runs + inline within Clarify; `orchestrator` + `budget_guard` are infrastructure roles — §3), + **approval gates**, and **budget auto-downgrade** (applied in a live run over synthetic + costs). +- **Maximally parallelizable:** the scheduler never serializes what data-dependencies allow + to run concurrently. (The six-phase *todo* pipeline's own max width is ~4, at Code; the + engine's scale-out is proven separately by the stress test in §9.) +- Every claim backed by a runnable test + a preserved documented-run artifact. + +**Non-goals (this spec)** +- Real LLM agents producing a shippable app. Agents run via the registry; the documented + run + tests use **mock** mode (deterministic, free). +- Real token-cost accounting. Costs are **synthetic** (`sim_cost`, §8); real token-cost + threading remains a pre-existing follow-up. The budget claim is "enforcement + downgrade + wired into a live run," not accurate dollars. +- Full React UI rework (events bridge is fast-follow). +- The README / description / topics publication step (post-engine). +- **Preserving the exact existing suite green.** ~25 of 127 tests are hard-coupled to + `langgraph`/`backend.graph`/`backend.orchestrator` and are deliberately retired/rewritten; + one registry budget-accessor test is rewritten/removed (§8). ~100 remain unaffected. New + engine tests are added. Expected, not regression. + +## 3. The six phases + +Canonical definition in **`config/phases.yaml`** (new source of truth, mirroring how +`config/agents.yaml` anchors the 16 agents). Each agent entry declares `reads`, `writes`, +`sim_cost`, and intra-phase `depends_on` edges. + +| # | Phase | Phase-worker agents (claim tasks) | Intra-phase edges | Gate after | +|---|---|---|---|---| +| 0 | Clarify | `clarifying_pm` (drives Q&A loop, consulting `product_owner` inline) | serial | ✅ PRD approval | +| 1 | Design | `solution_architect`, `tech_lead`, `uiux_designer` | none (full fan-out) | ✅ Plan approval | +| 2 | Code | `frontend`, `backend`, `database`, `ai_ml` | `database → backend → frontend`; `ai_ml` independent | — | +| 3 | Test | `qa_test`, `security` | none (fan-out) | — | +| 4 | Deploy | `devops`, `technical_writer` | none (fan-out) | — | +| 5 | Iterate | `delivery_summarizer` | serial | — | + +**Honest 16-count.** **13** agents execute as independently-claimed phase-worker tasks +(the table minus `product_owner`). `product_owner` is a bare pass-through mock with no real +implementation; it runs **inline inside the Clarify task** as the auto-answerer, so it never +claims a task and shares the Clarify worker's PID (recorded as an inline event). The other +two are **infrastructure roles**: `orchestrator` = engine/scheduler/state server; +`budget_guard` = the per-claim enforcement hook (§8). `phases.yaml` records this so the count +reconciles as **13 claimants + 1 inline + 2 infra = 16**, stated plainly, never "16 executing +tasks." + +**Clarify loop contract (grounded in the real agents).** The real `clarifying_pm.execute` +reads `idea`, **`questions`**, and `answers`, force-synthesizing when `len(questions) ≥ +max_questions`; the mock counts only `answers` (PRD after 3). The Clarify adapter runs the +loop passing **both accumulators every turn**: `clarifying_pm.execute({idea, questions, +answers, mode})`; on a question, append it to `questions`, call +`product_owner.execute({question})`, append its (scalar-string) artifact to `answers`, +repeat; terminate on a PRD or the `MAX_CLARIFYING_QUESTIONS` cap. *(Note: `MAX_CLARIFYING_ +QUESTIONS` from `config.py` is threaded into the real `ClarifyingPMAgent` here — today the +registry never passes it, leaving the agent's own default; the adapter closes that gap.)* +Deterministic in mock mode; no human required. Output: `prd` state key. + +**`reads`/`writes` are decorative for the nine bare-mock agents** (`frontend`, `backend`, +`database`, `ai_ml`, `security`, `devops`, `qa_test`, `technical_writer`, +`delivery_summarizer`, `product_owner`): they ignore their task and emit a constant string. +Dependency edges are enforced by the **scheduler**, not consumed by agents; the mock +`tech_lead` emits sub-tasks only for `backend`/`frontend`, so `database`/`ai_ml` Code inputs +degrade to empty. Honest-but-hollow at the data level in mock mode; the DAG shape, ordering, +and collision-freedom are still real and tested. + +## 4. Architecture + +A standalone **state server** is the single source of truth and the **only store writer**. +A pool of **independent worker processes** pulls work over MCP/HTTP. Nothing shares memory. + +``` + ┌──────────────────────────────────────────┐ + appforge run │ AppForge State Server (1 process) │ + ───────────► │ • FastMCP, streamable-HTTP (stateless) │◄──── React UI + (controller) │ • owns phase+task DAG (source of truth) │ (events bridge, + │ • scheduler + reaper (bg asyncio task) │ fast-follow) + │ • single aiosqlite writer (WAL) + _db_lock + └──────▲──────────────▲───────────────▲─────┘ + claim/complete claim/complete claim/complete + │ │ │ (concurrent tool calls; + ┌──────┴──┐ ┌──────┴──┐ ┌────────┴─┐ each DB op serialized, + │ Worker1 │ │ Worker2 │ … │ WorkerN │ CAS-guarded) + │ agent+ │ │ agent+ │ │ agent+ │ (separate OS processes; + │ registry│ │ registry│ │ registry │ claim→execute + └─────────┘ └─────────┘ └──────────┘ +heartbeat→complete) +``` + +**Components (new, `backend/engine/`):** `state_server.py` (FastMCP; only store writer; hosts +the **reaper** as a background asyncio task and the `_db_lock`), `mcp_tools.py`, +`scheduler.py` (pure: readiness/advance/gate/seeding), `store.py` (aiosqlite DAL: atomic +claim, CAS, single-tx `complete_task`, spend rollup), `models.py`, `worker.py` +(claim→execute+heartbeat→complete), `run.py` (controller/CLI), `agent_adapter.py` +(task↔agent bridge + Clarify loop), `phases.py`, `events.py` (Socket.IO bridge, fast-follow). + +**Reused / extended:** all 16 agents + `AgentRegistry`; `BudgetGuard` (+ one pure method, +minus its in-memory total as authority — §8); `config.py` extended (server URL/port, worker +count, **lease TTL, heartbeat interval, reaper interval**, poll/backoff, attempts cap); +`prompts/`; `config/agents.yaml` (−stale budget block, −two dead registry accessors); +`config/budget.yaml` (+`downgrade_paths`, authoritative). `backend/main.py` repoints via +`events.py`. **Timing invariant:** `heartbeat_interval ≪ lease_TTL`, `reaper_interval ≈ +lease_TTL`, `lease_TTL >` worst-case task time (mock Clarify ≈ 7 s). + +## 5. Data model + +Two-level DAG: static **phase DAG** (six phases, linear + gates) + a **task DAG** inside each +phase (`depends_on` edges). + +| Table | Key fields | Purpose | +|---|---|---| +| `runs` | `run_id`, `idea`, `status`, `current_phase`, `budget_limit` | one pipeline run | +| `phases` | `run_id`, `name`, `order`, `status`, `gate`, `seeded` | six rows per run | +| `tasks` | `task_id`, `run_id`, `phase`, **`phase_order`**, `agent_id`, `input`, `depends_on[]`, `status`, `owner`, **`version`**, `attempts`, `lease_expires`, **`created_at`**, **`claimed_at`**, `model`, `sim_cost`, `result` | the unit of work | +| `state` | `run_id`, `key`, `value`, **`version`** | shared blackboard (versioned CAS) | +| `spend` | `run_id`, `task_id`, `agent_id`, `cost`, `model`, `ts` | **durable** budget ledger (sole cost store) | +| `events` | append-only (incl. `worker_pid`) | audit + bridge + documented-run artifact | + +`phase_order` is denormalized onto `tasks` at seed time so dispatch ordering never sorts by +phase *name* (which would order alphabetically). + +**Task lifecycle:** `blocked → ready → claimed → running → done` (or `failed → ready`, capped +`attempts` = 3). + +**Phase-task seeding.** The scheduler seeds one task per phase-worker agent when a phase +becomes `open` (edges from `phases.yaml`), sets `phase_order`, then `phases.seeded=true`. **A +phase cannot complete unless `seeded` and it has ≥1 task**, so Test/Deploy/Iterate agents +always execute. Seeding fires from **both** open paths: `complete_task` (ungated) and +`submit_approval('approved')` (gated). Code seeds all four agent tasks (all four execute); +`tech_lead` output feeds their `input`, not which agents run. + +**Readiness rule (dependency partial-order, enforced centrally):** a task is `ready` iff (1) +its phase is `open` — all prior phases `complete` and gates `approved` — and (2) every +`depends_on` task is `done`. Recomputed on every completion. + +## 6. Concurrency, collision-freedom & what "processes resolve" + +**Division of responsibility (claim-integrity core).** The **dependency partial-order** is +declared in the shared graph and enforced by the server's readiness rule. The **real-time +execution order** (which ready task runs *when*, and *on which process*) is resolved by the +**independent worker processes** racing to claim ready work; their completions unlock the next +ready set — no process is a leader. **Collision-freedom** is guaranteed by *two* mechanisms +together: + +1. **Serialized DB operations.** The server has a single aiosqlite writer; every logical + operation (`claim`, `complete`, `put_state`, `submit_approval`, reaper sweep) runs under + `async with self._db_lock` (asyncio.Lock). FastMCP handlers otherwise run concurrently, so + without this a multi-statement `complete_task` transaction could interleave with a + `claim`/spend read on the shared connection and corrupt isolation (uncommitted spend read; + a claim rolled back with an unrelated failure). The lock makes each transaction atomic and + isolated. *(This corrects an earlier framing that credited collision-freedom to lock-free + CAS alone; with one writer, serialization at the writer is real and necessary.)* +2. **Versioned CAS guards.** `claim`/`complete`/`heartbeat` carry `version`/`owner` guards so + the reaper-vs-zombie lease races resolve to exactly one winner (the lock alone can't defeat + a stale-lease write from a process that resumed after its lease was reclaimed). + +Neither alone suffices; both together give collision-freedom. The DB ops are microseconds; +agent **execution** (the long part) runs in the worker processes, so serializing DB ops does +not reduce real parallelism. + +**Atomic claim (single guarded statement, run under the lock).** + +```sql +UPDATE tasks SET status='claimed', owner=:worker, version=version+1, + claimed_at=:now, lease_expires=:now+:lease +WHERE task_id = (SELECT task_id FROM tasks + WHERE run_id=:run AND status='ready' + ORDER BY phase_order, created_at LIMIT 1) + AND status='ready' +RETURNING *; +``` + +Zero rows ⇒ no ready task ⇒ worker backs off. Claimed tasks leave `ready`, so `LIMIT 1` +dispatch order is not a starvation source. + +**Versioned shared state (CAS).** `put_state(key, value, expected_version)` updates only +`WHERE version=:expected`; stale writers get `conflict` and re-read. The adapter writes each +result to a **disjoint** key `result:{phase}:{agent_id}` (plus explicit `state_writes`), so +no-collision is demonstrable even with bare-string mocks. The conflict path is exercised by a +dedicated two-writer test (§9), not the documented run (which never contends a state key). + +**Leases, heartbeat (guarded), exactly-once effect.** The worker runs a **background heartbeat +coroutine** for the duration of `execute`. `heartbeat` is **owner-guarded** — +`UPDATE tasks SET lease_expires=:now+:ttl WHERE task_id=:t AND owner=:worker AND status IN +('claimed','running')`, returning rows-affected; **0 rows ⇒ the worker lost the lease (reaper +reclaimed it) and aborts its in-flight work** rather than clobbering the new owner. The reaper +reverts genuinely-expired `claimed`/`running` tasks to `ready`, **bumps `version`**, +`attempts++`. `complete_task` is a **single lock-held transaction** (result + `state_writes` + +optional `spawn_tasks` + `spend` INSERT + `status→done` + scheduler advance) guarded by +`owner==worker AND version==:v`; a zombie's late completion fails the guard. Spawned ids are +namespaced per attempt. Result: **at-least-once execution, exactly-once effect.** The marquee +test asserts **exactly-once effect** (one committed `complete_task` per task, no two live +workers holding the same lease, dependency-order never violated) — not exactly-once execution. + +## 7. Contracts: MCP tools, agent adapter, task inputs + +**Agent adapter (no single `execute()` signature exists in the real code).** Always passes a +plain `dict` to `agent.execute(...)` (never a bare `AgentTask`), built from the task's `reads` +resolved from `state`; reads results via dict-or-attribute duck typing (`_result_field`, the +`orchestrator.py` pattern); extracts the `writes` value defensively (`artifact` is a dict for +structured agents, a bare string for the nine mocks): + +``` +val = artifact.get(writes_key, artifact) if isinstance(artifact, dict) else artifact +``` + +Writes `val` to `result:{phase}:{agent_id}` and the declared `writes` key. Clarify runs the §3 +loop. + +**MCP tools** (`FastMCP`, streamable-HTTP, stateless). Every DB-touching tool runs under +`_db_lock`. + +| Tool | Contract | +|---|---| +| `create_run(idea, budget_limit?)` | seed run + six phases; open+seed Clarify; returns `run_id` | +| `claim_next_task(worker_id, run_id)` | atomic CAS claim; returns `{task_id, phase, agent_id, input, model, version}` or `null` | +| `complete_task(task_id, worker_id, version, result, state_writes?, spawn_tasks?)` | single lock-held transaction; write result/state, expand DAG, INSERT spend, advance scheduler | +| `fail_task(task_id, worker_id, version, error)` | retry/backoff; requeues until `attempts`=3, then marks `failed` (fails the run) | +| `heartbeat(task_id, worker_id)` | owner-guarded lease extension; returns rows-affected | +| `get_state(run_id, keys?)` / `put_state(run_id, key, value, expected_version)` | read / CAS write | +| `submit_approval(run_id, phase, decision, comment?)` | resolve a gate; `decision ∈ {approved, rejected, modified}` | +| `get_budget(run_id)` | spend rollup (`SUM(spend)/limit`) | +| `get_run(run_id)` | full snapshot | + +The server runs **one active run per process** (the controller boots a server per run); the +`run_id` params keep the schema multi-run-ready without requiring it now. **Two-process +sharing (DoD):** worker A `put_state`, a separate worker B `get_state` reads it back. + +## 8. Budget: durable spend + deterministic downgrade + +**`spend` table is the sole, durable cost store.** `complete_task` INSERTs a `spend` row +(cost = `sim_cost`) inside its lock-held transaction. `spend_ratio = SUM(spend.cost)/ +run.budget_limit` is computed **from the store** (recomputed on restart) — durable and +transactionally consistent with completion. `BudgetGuard`'s in-memory total is **not** the +authority; it is used only as pure helpers (`get_threshold_action` labels; the new +`downgrade_model_for`), fed the store total. JSONL logging stays *outside* the DB transaction. + +**New `BudgetGuard.downgrade_model_for(current_model) -> str | None`** — pure lookup in the +model→model `downgrade_paths`. The stateful, fixed-subset `get_downgrade_targets` does not fit +a per-claim need and is unused by the engine. + +**One authoritative downgrade representation.** `config/budget.yaml` gains the model→model +**`downgrade_paths`** block (absent today — must be created; `_load_config` extended to read +it). The agent-list `downgrade_rules` under `warning_levels[0.85]` and the duplicate block in +`config/agents.yaml` are **removed**. The two now-dead registry accessors +(`get_downgrade_paths` — untested; `get_budget_config` — covered by the single +`test_get_budget_config`, whose assertions are the *agents.yaml* shape) are **deleted**, and +`test_get_budget_config` is **removed/rewritten** against the engine's budget path (not merely +"repointed" — the asserted shape differs). `gpt-4o-mini` has no successor, so +`technical_writer`/`delivery_summarizer` never downgrade — acceptable, stated. + +**Claim-time downgrade + critical-agent protection.** At `claim_next_task`, if `spend_ratio ≥ +0.85`, the `agent_id` is not in the critical skip-list (`clarifying_pm`, `solution_architect` +— matching the CLAUDE.md guarantee), and `downgrade_model_for(task.model)` exists, the +scheduler swaps `task.model` and logs a `downgrade` event. `≥0.95` stops issuing claims +(require-ack); `1.0` hard-stop. + +**Deterministic downgrade demo (by construction, not tuning).** Test opens only after **all** +Code tasks are `done` — and each Code `spend` row commits in the same lock-held transaction +that advances readiness, so **every** Test claim sees the full, committed Design+Code spend. +Size `--budget-limit` so `SUM(Design+Code sim_cost) ≥ 0.85·limit`; then **every Test task** +(`qa_test`: gpt-4o→gpt-4o-mini; `security`: sonnet→haiku) is deterministically downgraded, +independent of intra-Code claim/finish order. `test_budget_downgrade_live.py` asserts +`spend_ratio < 0.85` at each Code claim is *not* required — it asserts every Test claim is +issued downgraded (the invariant that actually matters), plus a unit test of +`downgrade_model_for`. + +**Approval gates** re-express as graph state: a gated phase completing sets `gate='pending'`, +keeping downstream tasks `blocked` until `submit_approval('approved')`; `rejected` re-opens +that phase's tasks (capped revision loop). The documented run **shows a gate actually +pending/blocking** (workers idle) before approval. + +## 9. Testing strategy & evidence mapping + +pytest + pytest-asyncio. Real-process tests launch `python -m backend.engine.worker` (spawn) +against an ephemeral-port server + tmp SQLite. Mock agents → fast, free, deterministic. + +| Claim | Test / artifact | +|---|---| +| 16-agent platform | registry count `==16`; run log shows **13 claimant tasks** distributed across the worker PIDs + a `product_owner`-inline event + `budget_guard`/scheduler events (§3) | +| MCP-based state server | `test_state_server_mcp.py` + **two-process sharing test** + `test_state_cas_conflict.py` (two workers CAS one key → conflict + re-read) | +| Six-phase dependency graph | `test_scheduler.py` (readiness/advance/gate/seeding) + `test_phase_traversal.py` (order + every phase's agents ran) | +| Human approval gates | `test_gates.py` (blocked-until-approve; reject re-opens) + documented pending→approved gate | +| Independent processes, no collision | **marquee:** `test_concurrency_no_collision.py` — the **stress variant** (one synthetic phase, **M≫N independent ready tasks**, N real worker processes) asserts **exactly-once effect** + no two live workers per lease; plus the pipeline run asserts dependency-order never violated | +| Budget + auto downgrade | `test_budget_downgrade_live.py` (real run; every Test claim downgraded) + `downgrade_model_for` unit test | + +The documented 6-phase pipeline's max width is ~4 (Code), so the **stress variant** — not the +pipeline run — is what actually exercises high claim contention; it is specified explicitly +(M, N chosen so M≫N, e.g. M=50, N=8). + +**Test migration (verified numbers).** Exactly **25** test functions across 10 files import +`langgraph`/`graph`/`orchestrator` → retired/rewritten on the engine. **1** registry test +(`test_get_budget_config`) is removed/rewritten (§8). ~100 agent/config/registry/prompt unit +tests are unaffected. New engine tests are added. + +## 10. The documented run + +`appforge run "Build a todo app" --workers 4 --budget-limit ` (4 ≈ the DAG's +max width; more workers would idle) writes to `docs/runs//`: `events.jsonl` (ordered +claim/complete/gate/downgrade log, with `worker_pid`), `run-summary.md` (phases, per-phase +agents, parallelism width achieved, the gate shown **pending → approved**, the budget +downgrade that fired at Test), the task **DAG (JSON; mermaid optional)**, and **per-task PID +attribution** showing the **13 claimant tasks spread across the worker PIDs** — proving +genuine multi-process execution. + +## 11. Replacement, migration & file layout + +**New package `backend/engine/`** (files §4). **Retire:** `backend/graph.py` + LangGraph core +of `orchestrator.py`; drop `langgraph*`/`langgraph-checkpoint-sqlite`; add `mcp` (pinned). +**Keep/extend:** all 16 agents + `AgentRegistry` (−`get_downgrade_paths`/`get_budget_config`) ++ `BudgetGuard` (+method, −in-memory authority); `config.py` (+lease TTL, heartbeat interval, +reaper interval, poll/backoff, attempts cap); `prompts/`; `config/agents.yaml` (−stale budget +block); `config/budget.yaml` (+`downgrade_paths`). `backend/main.py` repoints via `events.py`. +**New:** `config/phases.yaml`. **Docs cleanup (small):** fix the stale "15 agents" label in +`agents.yaml` + `CLAUDE.md`; retire the stale README status block (full README rewrite is a +separate post-engine step). **Windows test hygiene:** the server aiosqlite connection must be +closed before `tmp_path` teardown, else the WAL `-wal`/`-shm` files can raise +`PermissionError` on win32 cleanup — a conftest fixture handles server shutdown ordering. + +## 12. Build order (feeds the implementation plan) + +0. **Go/no-go spike (gates everything):** `FastMCP` **stateless streamable-HTTP** server with + custom tools; drive **8 concurrent client sessions × 100 tool calls** on a **pinned `mcp` + version**; PASS = zero dropped/failed calls + correct concurrent dispatch. Fail ⇒ **stop + and escalate to Alex** (no fallback to plain FastAPI JSON — that re-breaks the claim). +1. `config/phases.yaml` schema + `phases.py` (the contract steps 2–5 consume — define first). +2. `models.py` + `store.py` (schema, `_db_lock`, atomic-CAS claim, CAS state, single-tx + `complete_task`, spend rollup) — TDD. +3. `scheduler.py` (readiness/advance/gate/seeding; budget-resolution **stub**) — TDD. +4. `state_server.py` + `mcp_tools.py` + the **reaper** background task. +5. `agent_adapter.py` (dict contract, defensive extraction, Clarify loop, registry wiring). +6. `worker.py` (claim→execute + **background heartbeat**→complete; single-worker happy path). +7. Budget: durable spend rollup + `downgrade_model_for` + claim-time swap + skip-list + + gates; delete dead registry accessors + rewrite `test_get_budget_config`. +8. Concurrency tests: marquee stress variant (M≫N processes) + pipeline dependency-order. +9. Documented run + `--budget-limit` sizing (own verification step). +10. **Separate follow-up PR (gated behind a green engine):** retire LangGraph; migrate the 25 + coupled tests. +11. `events.py` Socket.IO bridge (fast-follow). + +## 13. Risks & open questions + +- **MCP multi-client transport (highest risk).** Gated by step 0; pin the version. No + claim-breaking fallback; failure escalates to Alex. +- **MCP is claim-driven, owned honestly.** MCP is an LLM-client tool surface, not a natural + headless coordination bus; a plain HTTP/JSON-RPC queue would be simpler. We adopt MCP because + it is the claim to back — which is exactly why step 0 is a hard gate. +- **Budget is synthetic.** `sim_cost` drives thresholds; real agents report `cost=0.0`. Live + behavior over synthetic costs; real token-cost threading stays a follow-up. +- **Windows (win32).** Workers are `python -m …` subprocesses (spawn — fork/signals avoided); + the server is the only SQLite writer (no cross-process file locking). Only gap: WAL-file + teardown ordering in tests (§11), handled by a fixture. +- **Claim wording.** The literal resume phrase needs the §14 adjusted wording; Alex finalizes + it on the resume. + +## 14. Claim → evidence traceability + +| Resume claim | Made true by | Evidenced by | +|---|---|---| +| 16-agent orchestration platform | 13 agents claim phase tasks + `product_owner` inline + 2 infra | count test + run log (13 claimant tasks across PIDs + inline + budget/scheduler events) | +| MCP-based state server | `state_server.py` (FastMCP, streamable-HTTP) | `test_state_server_mcp.py` + two-process sharing + CAS-conflict tests | +| Six-phase dependency graph | `config/phases.yaml` + `scheduler.py` | `test_phase_traversal.py` | +| Human approval gates | gate-as-readiness in scheduler | `test_gates.py` + documented pending→approved gate | +| Independent processes + shared state, no collision | workers resolve real-time order + server serialized DB ops + CAS guards | `test_concurrency_no_collision.py` (stress variant: exactly-once effect + dependency-order under M≫N processes) | +| Budget enforcement + auto downgrade | durable spend rollup + `downgrade_model_for` + claim-time swap | `test_budget_downgrade_live.py` (real run; every Test claim downgraded) | + +**Recommended resume wording (defensible, and exactly what this design builds):** +> *"Independent worker processes concurrently claim and execute a dependency-ordered task +> graph through a shared MCP state server, resolving real-time execution order across workers +> with collision-free coordination (serialized writes + versioned compare-and-swap)."* + +Each property is attributed to its real owner: processes resolve *temporal* order; the shared +graph declares *dependency* order; serialized writes + CAS give collision-freedom. + +--- + +## Design Critique Log + +### Critique Round 1 + +An independent adversarial subagent found 15 code-grounded flaws. Highlights & resolutions: +Clarify couldn't produce a PRD one-shot → §3 internal Q&A loop; no unified `execute()` → §7 +dict contract + duck typing; BudgetGuard API mismatch → new `downgrade_model_for`; mock cost ≈0 +→ `sim_cost` + small budget; exactly-once not guaranteed → reaper version-bump + single-tx +`complete_task` + per-attempt spawn ids; collision-freedom mischaracterized → §6 reframed; +vacuous empty phases → eager seeding + `seeded` guard; static/dynamic Code contradiction → four +static Code tasks; "16 executing" → split (corrected further in rounds 2–3); bare-string mocks → +adapter writes `result:{phase}:{agent_id}`; `claim_next_task` payload enumerated; "125 green" +false → explicit non-goal; `mcp` unproven → step-0 spike; two budget configs → `budget.yaml` +authoritative; gold-plating deferred. + +### Critique Round 2 + +A second independent subagent found 10 deeper flaws: count was 13-claimants+inline not 14 +(§3/§9/§14); §6 had conceded workers don't "resolve execution order" → split dependency vs +real-time order + recommended resume wording (§6/§14); BudgetGuard spend non-durable / +non-transactional → `spend` table is the sole store, ratio computed in-store (§5/§8); +exactly-once *execution* vs marquee + no heartbeat during long `execute` → background heartbeat ++ exactly-once *effect* (§6); Clarify needed `questions` too → pass both accumulators (§3); +intra-Code downgrade race → phase-boundary demo (§8, further hardened in round 3); +scalar-vs-dict `artifact` crash → `isinstance`-guarded extraction (§7); budget "one source" +incomplete + broke registry accessors → one representation + accessor/test handling (§8); MCP +"serialized server-side" wrong + claim SQL + stateless mode + spike bar (§4/§6/§12); build-order +`phases.yaml` precedes consumers → moved to step 1 (§12); plus critical-agent skip-list and +dual seeding triggers. + +### Critique Round 3 + +A third independent subagent found one serious surviving hole plus consistency defects the +rewrites introduced: + +- **Serious:** shared single aiosqlite connection + concurrent handlers + multi-statement + `complete_task` → transaction interleaving corrupts isolation (a concurrent `claim` inside an + open `complete` tx could be rolled back → two live workers on one task). **Resolved:** §6 adds + an `async with _db_lock` around every logical DB operation (serialized writer), with CAS + guards retained for reaper/lease races; §4 diagram + wording corrected. +- **Schema vs SQL:** the claim SQL referenced undeclared `created_at`/`claimed_at` and + `ORDER BY phase` (name → alphabetical). **Resolved:** §5 adds `created_at`, `claimed_at`, + `phase_order`; §6 orders by `phase_order, created_at`. +- **"13 workers" vs `--workers 8`:** conflated claimant tasks with PIDs; the pipeline's width + (~4) never exercises real contention. **Resolved:** §9/§10 reworded to "13 claimant tasks + across worker PIDs," `--workers 4`, and an explicit **stress variant** (M≫N) as the real + collision proof. +- **Unguarded heartbeat:** a zombie's late heartbeat could extend the new owner's lease. + **Resolved:** §6/§7 make `heartbeat` owner-guarded, returning rows-affected so a lease-loser + aborts. +- **Under-specified contracts:** `claim_next_task` missing `run_id`; reaper unassigned; + `fail_task`/approval caps + enum unstated. **Resolved:** §7 adds `run_id`, one-active-run + note, `attempts`=3, `decision ∈ {approved,rejected,modified}`; §4 names the reaper + + timing invariant. +- **Migration count wrong:** only **1** registry budget test exists (not 2), and repointing it + is a shape rewrite; `get_downgrade_paths` is untested. **Resolved:** §2/§8/§9 correct to 1 + test rewritten/removed and **delete** both dead accessors; the 25 langgraph functions figure + is confirmed exact. +- **Downgrade determinism fragile:** depended on 4 unstated preconditions; "phase gate" is a + misnomer (Code has no gate). **Resolved:** §8 makes it deterministic **by construction** — + Test opens only after all Code spend commits, so every Test claim sees `≥0.85`; assert on + Test-claim downgrade, size `SUM(Design+Code sim_cost) ≥ 0.85·limit`. +- **CAS conflict path never exercised:** disjoint keys mean no contention in the run. + **Resolved:** §9 adds `test_state_cas_conflict.py`; §6/§14 wording softened. +- **Plan realism:** scheduler step listed budget-resolution (a later step); worker preceded the + adapter. **Resolved:** §12 splits budget-resolution into stub (step 3) + real (step 7), moves + the adapter before the worker, and makes LangGraph retirement a separate gated PR. +- **Confirmed sound (round 3):** step-0 spike placement; Windows spawn/single-writer choices + (with a WAL-teardown test-hygiene note added, §11); starvation-free claim; internally + consistent 16-count; Clarify loop reconciles with the real agents. From a62060acf566f11c55206935a187ab02d9571205 Mon Sep 17 00:00:00 2001 From: Alex Barclay Date: Thu, 23 Jul 2026 17:17:30 -0600 Subject: [PATCH 02/15] =?UTF-8?q?docs(plan):=20Plan=20A=20=E2=80=94=20coor?= =?UTF-8?q?dination=20core=20(spike=20gate=20+=20config=20+=20store=20+=20?= =?UTF-8?q?scheduler=20+=20budget)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...tration-engine-plan-a-coordination-core.md | 1427 +++++++++++++++++ 1 file changed, 1427 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-23-mcp-orchestration-engine-plan-a-coordination-core.md 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 new file mode 100644 index 0000000..8755bac --- /dev/null +++ b/docs/superpowers/plans/2026-07-23-mcp-orchestration-engine-plan-a-coordination-core.md @@ -0,0 +1,1427 @@ +# MCP Orchestration Engine — Plan A: Coordination Core Implementation Plan + +> **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:** Build the spike-independent coordination core of the parallel orchestration engine — the phase config, SQLite store (atomic claim, versioned CAS, single-transaction completion, durable spend), and the pure scheduler (readiness, phase advance, gate/seeding, budget model-resolution) — as a fully unit-tested in-process library, plus a hard go/no-go spike on the MCP SDK that gates the whole effort. + +**Architecture:** A single-writer SQLite (`aiosqlite`, WAL) `Store` guarded by an asyncio lock, driven by a pure `scheduler` module (no DB) that computes readiness, phase transitions, task seeding, and budget-resolved models. Plans B (MCP server + worker processes) and C (budget-live wiring, evidence, LangGraph cutover) build on this core. Full design: [`docs/superpowers/specs/2026-07-23-parallel-mcp-orchestration-engine-design.md`](../specs/2026-07-23-parallel-mcp-orchestration-engine-design.md). + +**Tech Stack:** Python 3.11+, UV, `aiosqlite`, PyYAML, Pydantic v2, pytest + pytest-asyncio. (`mcp` SDK is validated in Task 1 but not consumed until Plan B.) + +## Global Constraints + +- **Python** `>=3.11`; manage deps with **UV** (`uv add`, `uv run`) — never pip/requirements.txt. +- **Platform is Windows (win32).** Tests must not rely on `fork` or POSIX signals. Close any `aiosqlite` connection before `tmp_path` teardown (WAL `-wal`/`-shm` files raise `PermissionError` otherwise). +- **New package** lives under `backend/engine/`. Do **not** modify `backend/graph.py` or `backend/orchestrator.py` in Plan A (LangGraph retirement is Plan C, a separate PR). +- **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. +- **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):** + - clarify(0): `clarifying_pm` — gate `prd` + - design(1): `solution_architect`, `tech_lead`, `uiux_designer` — gate `plan` + - code(2): `database` → `backend` → `frontend`; `ai_ml` independent — gate none + - test(3): `qa_test`, `security` — gate none + - deploy(4): `devops`, `technical_writer` — gate none + - iterate(5): `delivery_summarizer` — gate none + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `scripts/spikes/mcp_multiclient_spike.py` | Task 1 throwaway: prove FastMCP multi-client streamable-HTTP | +| `config/phases.yaml` | Source of truth: six phases, per-agent reads/writes/sim_cost/edges | +| `config/budget.yaml` | + `downgrade_paths` (model→model), authoritative | +| `backend/engine/__init__.py` | package marker | +| `backend/engine/phases.py` | load + validate `phases.yaml` → `PhasesConfig` | +| `backend/engine/models.py` | SQL schema DDL + Pydantic row/result types | +| `backend/engine/scheduler.py` | pure functions: seed specs, readiness, advance, resolve_model | +| `backend/engine/store.py` | `Store`: aiosqlite DAL, atomic claim, CAS, single-tx complete, spend | +| `backend/agents/budget_guard.py` | + `downgrade_model_for` method | +| `backend/agents/registry.py` | − dead `get_downgrade_paths`/`get_budget_config` | +| `backend/config.py` | + engine settings (lease TTL, heartbeat/reaper interval, attempts cap) | +| `tests/engine/test_*.py` | unit tests for each module | + +--- + +## Task 1: MCP SDK go/no-go spike (HARD GATE) + +**Files:** +- Create: `scripts/spikes/mcp_multiclient_spike.py` +- Modify: `pyproject.toml` (add pinned `mcp`) + +**Interfaces:** +- Consumes: nothing. +- Produces: a decision (PASS/FAIL) and a pinned `mcp` version other plans rely on. **No engine code depends on the spike script; it is throwaway evidence.** + +This task is a spike, not TDD. Its deliverable is a **decision**. If it fails, STOP and escalate to Alex per the spec — do not proceed to Task 2+ with a broken transport, and do not fall back to a plain FastAPI JSON API (that re-breaks the "MCP-based" claim). + +- [ ] **Step 1: Add and pin the MCP SDK** + +Run: +```bash +uv add "mcp>=1.16,<2" +uv run python -c "import mcp, importlib.metadata as m; print('mcp', m.version('mcp'))" +``` +Expected: prints a concrete version (record it in the commit message; that pin is authoritative for Plan B). + +- [ ] **Step 2: Write the spike server+clients script** + +`scripts/spikes/mcp_multiclient_spike.py`: +```python +"""Throwaway spike: can FastMCP serve many concurrent streamable-HTTP clients? + +PASS bar: 8 concurrent client sessions x 100 tool calls each = 800 calls, +zero dropped/failed, correct results. Run with: uv run python scripts/spikes/mcp_multiclient_spike.py +""" +import asyncio +import contextlib + +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("spike", stateless_http=True) + + +@mcp.tool() +def echo(n: int) -> int: + """Return n unchanged (proves per-call dispatch).""" + return n + + +async def run_client(base_url: str, worker: int, calls: int) -> int: + from mcp import ClientSession + from mcp.client.streamable_http import streamablehttp_client + + ok = 0 + async with streamablehttp_client(base_url) as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + for i in range(calls): + res = await session.call_tool("echo", {"n": worker * 1000 + i}) + assert res.structuredContent["result"] == worker * 1000 + i + ok += 1 + return ok + + +async def main() -> None: + import uvicorn + + app = mcp.streamable_http_app() + config = uvicorn.Config(app, host="127.0.0.1", port=8765, log_level="warning") + server = uvicorn.Server(config) + server_task = asyncio.create_task(server.serve()) + await asyncio.sleep(1.0) # let it bind + + base = "http://127.0.0.1:8765/mcp" + try: + results = await asyncio.gather(*(run_client(base, w, 100) for w in range(8))) + total = sum(results) + print(f"SPIKE RESULT: {total}/800 calls ok") + assert total == 800, "FAIL: dropped/failed calls" + print("SPIKE PASS") + finally: + server.should_exit = True + with contextlib.suppress(Exception): + await server_task + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +- [ ] **Step 3: Run the spike** + +Run: `uv run python scripts/spikes/mcp_multiclient_spike.py` +Expected: `SPIKE RESULT: 800/800 calls ok` then `SPIKE PASS`. + +- [ ] **Step 4: Decision gate** + +- If it prints `SPIKE PASS` → PASS. Continue to Task 2. +- If it errors, hangs, drops calls, or the streamable-HTTP client/server API differs from the script (the SDK surface has churned across versions) → **FAIL. STOP.** Report to Alex with the exact error and the installed `mcp` version. Do not start Task 2's server assumptions until the transport is resolved. (Task 2–8 are the store/scheduler and are technically spike-independent, but the whole engine is pointless if the transport fails, so treat this as a hard gate.) + +- [ ] **Step 5: Commit** + +```bash +git add scripts/spikes/mcp_multiclient_spike.py pyproject.toml uv.lock +git commit -m "spike(engine): validate FastMCP multi-client streamable-HTTP (PASS, mcp==)" +``` + +--- + +## Task 2: Phase configuration (`phases.yaml` + `phases.py`) + +**Files:** +- Create: `config/phases.yaml` +- Create: `backend/engine/__init__.py` (empty) +- Create: `backend/engine/phases.py` +- Test: `tests/engine/__init__.py` (empty), `tests/engine/test_phases.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `PhasesConfig.load(path="config/phases.yaml") -> PhasesConfig` + - `PhasesConfig.phase_names -> list[str]` (in order) + - `PhasesConfig.order_of(name: str) -> int` + - `PhasesConfig.gate_of(name: str) -> str` (gate id or `"none"`) + - `PhasesConfig.agents_of(name: str) -> dict[str, AgentSpec]` where `AgentSpec` has `.reads:list[str]`, `.writes:str`, `.sim_cost:float`, `.depends_on:list[str]` (agent ids within the phase) + - `PhasesConfig.all_agent_ids() -> list[str]` (the 13 phase-worker ids) + +- [ ] **Step 1: Write `config/phases.yaml`** + +```yaml +# Source of truth for the six-phase dependency graph. +# depends_on lists refer to OTHER agent ids WITHIN the same phase. +phases: + - name: clarify + order: 0 + gate: prd + agents: + clarifying_pm: { reads: [idea], writes: prd, sim_cost: 0.30, depends_on: [] } + - name: design + order: 1 + gate: plan + agents: + solution_architect: { reads: [prd], writes: adr, sim_cost: 0.50, depends_on: [] } + tech_lead: { reads: [prd], writes: tasks, sim_cost: 0.40, depends_on: [] } + uiux_designer: { reads: [prd], writes: design_spec, sim_cost: 0.40, depends_on: [] } + - name: code + order: 2 + gate: none + agents: + database: { reads: [adr, tasks], writes: db_schema, sim_cost: 0.60, depends_on: [] } + backend: { reads: [adr, tasks, db_schema], writes: backend_code, sim_cost: 0.80, depends_on: [database] } + frontend: { reads: [design_spec, backend_code], writes: frontend_code, sim_cost: 0.80, depends_on: [backend] } + ai_ml: { reads: [adr], writes: ml_code, sim_cost: 0.50, depends_on: [] } + - name: test + order: 3 + gate: none + agents: + qa_test: { reads: [backend_code, frontend_code], writes: test_report, sim_cost: 0.50, depends_on: [] } + security: { reads: [backend_code], writes: security_report, sim_cost: 0.50, depends_on: [] } + - name: deploy + order: 4 + gate: none + agents: + devops: { reads: [backend_code, frontend_code], writes: deploy_manifest, sim_cost: 0.40, depends_on: [] } + technical_writer: { reads: [prd, adr], writes: docs, sim_cost: 0.30, depends_on: [] } + - name: iterate + order: 5 + gate: none + agents: + delivery_summarizer: { reads: [test_report, deploy_manifest], writes: summary, sim_cost: 0.20, depends_on: [] } + +# Non-phase-worker roles, recorded so the 16-agent count reconciles honestly. +infra_roles: + orchestrator: "engine / scheduler / state server" + budget_guard: "per-claim budget enforcement hook" +inline_roles: + product_owner: "auto-answerer inside the clarify task (not an independent claimant)" +``` + +- [ ] **Step 2: Write the failing test** + +`tests/engine/test_phases.py`: +```python +from backend.engine.phases import PhasesConfig + + +def test_loads_six_phases_in_order(): + cfg = PhasesConfig.load("config/phases.yaml") + assert cfg.phase_names == ["clarify", "design", "code", "test", "deploy", "iterate"] + assert cfg.order_of("code") == 2 + + +def test_gates_only_on_clarify_and_design(): + cfg = PhasesConfig.load("config/phases.yaml") + assert cfg.gate_of("clarify") == "prd" + assert cfg.gate_of("design") == "plan" + assert cfg.gate_of("code") == "none" + + +def test_code_intra_phase_edges(): + cfg = PhasesConfig.load("config/phases.yaml") + agents = cfg.agents_of("code") + assert agents["backend"].depends_on == ["database"] + assert agents["frontend"].depends_on == ["backend"] + assert agents["ai_ml"].depends_on == [] + + +def test_all_agent_ids_are_the_thirteen_workers(): + cfg = PhasesConfig.load("config/phases.yaml") + assert len(cfg.all_agent_ids()) == 13 + assert "product_owner" not in cfg.all_agent_ids() +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `uv run pytest tests/engine/test_phases.py -v` +Expected: FAIL — `ModuleNotFoundError: backend.engine.phases`. + +- [ ] **Step 4: Implement `backend/engine/phases.py`** + +```python +"""Loader/validator for config/phases.yaml — the six-phase source of truth.""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +import yaml + + +@dataclass(frozen=True) +class AgentSpec: + agent_id: str + reads: list[str] + writes: str + sim_cost: float + depends_on: list[str] + + +@dataclass(frozen=True) +class PhaseSpec: + name: str + order: int + gate: str + agents: dict[str, AgentSpec] + + +class PhasesConfig: + def __init__(self, phases: list[PhaseSpec]): + self._phases = sorted(phases, key=lambda p: p.order) + self._by_name = {p.name: p for p in self._phases} + + @classmethod + def load(cls, path: str = "config/phases.yaml") -> "PhasesConfig": + raw = yaml.safe_load(Path(path).read_text(encoding="utf-8")) + phases: list[PhaseSpec] = [] + for p in raw["phases"]: + agents = { + aid: AgentSpec( + agent_id=aid, + reads=list(a.get("reads", [])), + writes=a["writes"], + sim_cost=float(a.get("sim_cost", 0.0)), + depends_on=list(a.get("depends_on", [])), + ) + for aid, a in p["agents"].items() + } + phases.append( + PhaseSpec(name=p["name"], order=int(p["order"]), gate=p.get("gate", "none"), agents=agents) + ) + cfg = cls(phases) + cfg._validate() + return cfg + + def _validate(self) -> None: + orders = [p.order for p in self._phases] + assert orders == list(range(len(self._phases))), f"phase orders must be 0..N: {orders}" + for p in self._phases: + for aid, spec in p.agents.items(): + for dep in spec.depends_on: + assert dep in p.agents, f"{p.name}.{aid} depends on unknown intra-phase agent {dep!r}" + + @property + def phase_names(self) -> list[str]: + return [p.name for p in self._phases] + + def order_of(self, name: str) -> int: + return self._by_name[name].order + + def gate_of(self, name: str) -> str: + return self._by_name[name].gate + + def agents_of(self, name: str) -> dict[str, AgentSpec]: + return dict(self._by_name[name].agents) + + def all_agent_ids(self) -> list[str]: + return [aid for p in self._phases for aid in p.agents] +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `uv run pytest tests/engine/test_phases.py -v` +Expected: 4 passed. + +- [ ] **Step 6: Commit** + +```bash +git add config/phases.yaml backend/engine/__init__.py backend/engine/phases.py tests/engine/ +git commit -m "feat(engine): six-phase config source of truth + loader" +``` + +--- + +## Task 3: Data model (schema DDL + Pydantic types) + +**Files:** +- Create: `backend/engine/models.py` +- Test: `tests/engine/test_models.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `SCHEMA_SQL: str` (all `CREATE TABLE` statements, idempotent via `IF NOT EXISTS`) + - `ClaimResult` (Pydantic): `task_id, run_id, phase, phase_order, agent_id, input:dict, model:str|None, version:int` + - `PHASE_STATUS = {"blocked","open","complete"}`, `TASK_STATUS = {"blocked","ready","claimed","running","done","failed"}`, `GATE = {"none","pending","approved","rejected"}` + +- [ ] **Step 1: Write the failing test** + +`tests/engine/test_models.py`: +```python +import sqlite3 + +from backend.engine.models import SCHEMA_SQL, ClaimResult + + +def test_schema_creates_all_tables(tmp_path): + db = sqlite3.connect(tmp_path / "t.db") + db.executescript(SCHEMA_SQL) + names = {r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type='table'")} + assert {"runs", "phases", "tasks", "state", "spend", "events"} <= names + db.close() + + +def test_tasks_has_ordering_and_lease_columns(tmp_path): + db = sqlite3.connect(tmp_path / "t.db") + db.executescript(SCHEMA_SQL) + cols = {r[1] for r in db.execute("PRAGMA table_info(tasks)")} + assert {"phase_order", "created_at", "claimed_at", "lease_expires", "version", "sim_cost"} <= cols + db.close() + + +def test_claim_result_roundtrips(): + cr = ClaimResult(task_id="t1", run_id="r1", phase="code", phase_order=2, + agent_id="backend", input={"prd": "x"}, model="gpt-4o", version=1) + assert cr.agent_id == "backend" + assert cr.input["prd"] == "x" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/engine/test_models.py -v` +Expected: FAIL — `ModuleNotFoundError: backend.engine.models`. + +- [ ] **Step 3: Implement `backend/engine/models.py`** + +```python +"""SQL schema + typed result models for the engine store.""" +from __future__ import annotations + +from pydantic import BaseModel + +PHASE_STATUS = {"blocked", "open", "complete"} +TASK_STATUS = {"blocked", "ready", "claimed", "running", "done", "failed"} +GATE = {"none", "pending", "approved", "rejected"} + +SCHEMA_SQL = """ +CREATE TABLE IF NOT EXISTS runs ( + run_id TEXT PRIMARY KEY, + idea TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'running', + current_phase INTEGER NOT NULL DEFAULT 0, + budget_limit REAL NOT NULL DEFAULT 200.0, + created_at REAL NOT NULL +); +CREATE TABLE IF NOT EXISTS phases ( + run_id TEXT NOT NULL, + name TEXT NOT NULL, + phase_order INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'blocked', + gate TEXT NOT NULL DEFAULT 'none', + seeded INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (run_id, name) +); +CREATE TABLE IF NOT EXISTS tasks ( + task_id TEXT PRIMARY KEY, + run_id TEXT NOT NULL, + phase TEXT NOT NULL, + phase_order INTEGER NOT NULL, + agent_id TEXT NOT NULL, + input TEXT NOT NULL DEFAULT '{}', + depends_on TEXT NOT NULL DEFAULT '[]', + status TEXT NOT NULL DEFAULT 'blocked', + owner TEXT, + version INTEGER NOT NULL DEFAULT 0, + attempts INTEGER NOT NULL DEFAULT 0, + lease_expires REAL, + created_at REAL NOT NULL, + claimed_at REAL, + model TEXT, + sim_cost REAL NOT NULL DEFAULT 0.0, + result TEXT +); +CREATE INDEX IF NOT EXISTS idx_tasks_ready ON tasks (run_id, status, phase_order, created_at); +CREATE TABLE IF NOT EXISTS state ( + run_id TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (run_id, key) +); +CREATE TABLE IF NOT EXISTS spend ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL, + task_id TEXT, + agent_id TEXT, + cost REAL NOT NULL, + model TEXT, + ts REAL NOT NULL +); +CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL, + type TEXT NOT NULL, + payload TEXT NOT NULL, + worker_pid INTEGER, + ts REAL NOT NULL +); +""" + + +class ClaimResult(BaseModel): + task_id: str + run_id: str + phase: str + phase_order: int + agent_id: str + input: dict + model: str | None + version: int +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/engine/test_models.py -v` +Expected: 3 passed. + +- [ ] **Step 5: Commit** + +```bash +git add backend/engine/models.py tests/engine/test_models.py +git commit -m "feat(engine): SQLite schema + typed ClaimResult" +``` + +--- + +## Task 4: Pure scheduler (seeding, readiness, advance, model-resolution) + +**Files:** +- Create: `backend/engine/scheduler.py` +- Test: `tests/engine/test_scheduler.py` + +**Interfaces:** +- Consumes: `PhasesConfig` (Task 2). +- Produces (all pure, no DB — operate on plain dicts): + - `seed_specs_for_phase(cfg, run_id, phase_name, base_models: dict[str,str]) -> list[TaskSeed]` where `TaskSeed` is a dict `{task_id, agent_id, phase, phase_order, depends_on:list[str] (task_ids), sim_cost, model, input_keys:list[str]}`. Task ids are `f"{run_id}:{phase}:{agent_id}"`; intra-phase `depends_on` agent ids are mapped to those task ids. + - `compute_ready(tasks: list[dict], phases: list[dict]) -> list[str]` (task_ids to flip `blocked→ready`) + - `advance(phases: list[dict], tasks: list[dict], cfg) -> AdvancePlan` where `AdvancePlan` is a dict `{complete_phases:list[str], open_gates:list[str], open_phases:list[str]}` + - `resolve_model(agent_id, base_model, spend_ratio, downgrade_paths: dict[str,str], skip_list: set[str], threshold: float=0.85) -> str | None` + +- [ ] **Step 1: Write the failing test** + +`tests/engine/test_scheduler.py`: +```python +from backend.engine import scheduler as sch +from backend.engine.phases import PhasesConfig + +CFG = PhasesConfig.load("config/phases.yaml") +BASE = {"clarifying_pm": "claude-3-5-sonnet-20241022", "database": "gpt-4o", + "backend": "claude-3-5-sonnet-20241022", "frontend": "claude-3-5-sonnet-20241022", + "ai_ml": "claude-3-5-sonnet-20241022", "qa_test": "gpt-4o", "security": "claude-3-5-sonnet-20241022"} + + +def test_seed_clarify_makes_one_task(): + seeds = sch.seed_specs_for_phase(CFG, "r1", "clarify", BASE) + assert len(seeds) == 1 + assert seeds[0]["task_id"] == "r1:clarify:clarifying_pm" + assert seeds[0]["depends_on"] == [] + + +def test_seed_code_maps_intra_phase_edges_to_task_ids(): + seeds = {s["agent_id"]: s for s in sch.seed_specs_for_phase(CFG, "r1", "code", BASE)} + assert seeds["backend"]["depends_on"] == ["r1:code:database"] + assert seeds["frontend"]["depends_on"] == ["r1:code:backend"] + assert seeds["ai_ml"]["depends_on"] == [] + + +def test_compute_ready_respects_deps_and_open_phase(): + phases = [{"name": "code", "status": "open"}] + tasks = [ + {"task_id": "d", "phase": "code", "status": "blocked", "depends_on": []}, + {"task_id": "b", "phase": "code", "status": "blocked", "depends_on": ["d"]}, + ] + ready = sch.compute_ready(tasks, phases) + assert ready == ["d"] # b blocked until d done + + +def test_compute_ready_skips_closed_phase(): + phases = [{"name": "code", "status": "blocked"}] + tasks = [{"task_id": "d", "phase": "code", "status": "blocked", "depends_on": []}] + assert sch.compute_ready(tasks, phases) == [] + + +def test_advance_completes_phase_and_opens_gate(): + phases = [ + {"name": "clarify", "phase_order": 0, "status": "open", "gate": "prd", "seeded": 1}, + {"name": "design", "phase_order": 1, "status": "blocked", "gate": "plan", "seeded": 0}, + ] + tasks = [{"task_id": "c", "phase": "clarify", "status": "done", "depends_on": []}] + plan = sch.advance(phases, tasks, CFG) + assert "clarify" in plan["complete_phases"] + assert "prd" in [g for g in plan["open_gates"]] # gate goes pending, next phase NOT opened yet + assert plan["open_phases"] == [] + + +def test_advance_ungated_opens_next_phase(): + phases = [ + {"name": "code", "phase_order": 2, "status": "open", "gate": "none", "seeded": 1}, + {"name": "test", "phase_order": 3, "status": "blocked", "gate": "none", "seeded": 0}, + ] + tasks = [{"task_id": "x", "phase": "code", "status": "done", "depends_on": []}] + plan = sch.advance(phases, tasks, CFG) + assert plan["complete_phases"] == ["code"] + assert plan["open_phases"] == ["test"] + + +def test_unseeded_or_empty_phase_never_completes(): + phases = [{"name": "test", "phase_order": 3, "status": "open", "gate": "none", "seeded": 0}] + plan = sch.advance(phases, [], CFG) + assert plan["complete_phases"] == [] + + +def test_resolve_model_downgrades_over_threshold(): + dp = {"gpt-4o": "gpt-4o-mini", "claude-3-5-sonnet-20241022": "claude-3-5-haiku-20241022"} + skip = {"clarifying_pm", "solution_architect"} + assert sch.resolve_model("qa_test", "gpt-4o", 0.90, dp, skip) == "gpt-4o-mini" + assert sch.resolve_model("qa_test", "gpt-4o", 0.50, dp, skip) == "gpt-4o" # under threshold + assert sch.resolve_model("solution_architect", "claude-3-5-sonnet-20241022", 0.99, dp, skip) == "claude-3-5-sonnet-20241022" # protected + assert sch.resolve_model("technical_writer", "gpt-4o-mini", 0.99, dp, skip) == "gpt-4o-mini" # no successor +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/engine/test_scheduler.py -v` +Expected: FAIL — `ModuleNotFoundError: backend.engine.scheduler`. + +- [ ] **Step 3: Implement `backend/engine/scheduler.py`** + +```python +"""Pure scheduling logic. No DB, no I/O — takes plain dicts, returns plans.""" +from __future__ import annotations + +from backend.engine.phases import PhasesConfig + + +def task_id(run_id: str, phase: str, agent_id: str) -> str: + return f"{run_id}:{phase}:{agent_id}" + + +def seed_specs_for_phase( + cfg: PhasesConfig, run_id: str, phase_name: str, base_models: dict[str, str] +) -> list[dict]: + order = cfg.order_of(phase_name) + specs: list[dict] = [] + for aid, spec in cfg.agents_of(phase_name).items(): + specs.append( + { + "task_id": task_id(run_id, phase_name, aid), + "agent_id": aid, + "phase": phase_name, + "phase_order": order, + "depends_on": [task_id(run_id, phase_name, dep) for dep in spec.depends_on], + "sim_cost": spec.sim_cost, + "model": base_models.get(aid), + "input_keys": list(spec.reads), + } + ) + return specs + + +def compute_ready(tasks: list[dict], phases: list[dict]) -> list[str]: + open_phases = {p["name"] for p in phases if p["status"] == "open"} + done = {t["task_id"] for t in tasks if t["status"] == "done"} + ready: list[str] = [] + for t in tasks: + if t["status"] != "blocked" or t["phase"] not in open_phases: + continue + if all(dep in done for dep in t["depends_on"]): + ready.append(t["task_id"]) + return ready + + +def advance(phases: list[dict], tasks: list[dict], cfg: PhasesConfig) -> dict: + by_phase: dict[str, list[dict]] = {} + for t in tasks: + by_phase.setdefault(t["phase"], []).append(t) + + complete_phases: list[str] = [] + open_gates: list[str] = [] + open_phases: list[str] = [] + + ordered = sorted(phases, key=lambda p: p["phase_order"]) + for p in ordered: + if p["status"] != "open": + continue + pts = by_phase.get(p["name"], []) + # A phase completes only if seeded, non-empty, and all its tasks are done. + if not p.get("seeded") or not pts: + continue + if all(t["status"] == "done" for t in pts): + complete_phases.append(p["name"]) + gate = cfg.gate_of(p["name"]) + if gate != "none": + open_gates.append(gate) # next phase waits for submit_approval + else: + nxt = next((q for q in ordered if q["phase_order"] == p["phase_order"] + 1), None) + if nxt is not None: + open_phases.append(nxt["name"]) + return {"complete_phases": complete_phases, "open_gates": open_gates, "open_phases": open_phases} + + +def resolve_model( + agent_id: str, + base_model: str | None, + spend_ratio: float, + downgrade_paths: dict[str, str], + skip_list: set[str], + threshold: float = 0.85, +) -> str | None: + if base_model is None or agent_id in skip_list or spend_ratio < threshold: + return base_model + return downgrade_paths.get(base_model, base_model) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/engine/test_scheduler.py -v` +Expected: 8 passed. + +- [ ] **Step 5: Commit** + +```bash +git add backend/engine/scheduler.py tests/engine/test_scheduler.py +git commit -m "feat(engine): pure scheduler (seeding, readiness, advance, model-resolution)" +``` + +--- + +## Task 5: Store part 1 — init, `create_run`, state CAS + +**Files:** +- Create: `backend/engine/store.py` +- Test: `tests/engine/test_store_state.py` + +**Interfaces:** +- Consumes: `SCHEMA_SQL` (Task 3), `scheduler.seed_specs_for_phase`, `compute_ready` (Task 4), `PhasesConfig` (Task 2). +- Produces: + - `Store(db_path: str, cfg: PhasesConfig, base_models: dict[str,str], lease_s: float=120.0)` + - `async connect()` / `async close()` + - `async create_run(run_id, idea, budget_limit) -> None` — inserts run + 6 phase rows (all `blocked` except clarify `open`), seeds+readies clarify tasks + - `async get_state(run_id, keys=None) -> dict[str, tuple[value, int]]` + - `async put_state(run_id, key, value, expected_version) -> bool` (CAS: True on success, False on version conflict) + - internal `_now() -> float` (wraps `time.time()`; overridable in tests) + +- [ ] **Step 1: Write the failing test** + +`tests/engine/test_store_state.py`: +```python +import pytest + +from backend.engine.phases import PhasesConfig +from backend.engine.store import Store + +CFG = PhasesConfig.load("config/phases.yaml") +BASE = {aid: "gpt-4o" for aid in CFG.all_agent_ids()} + + +@pytest.fixture +async def store(tmp_path): + s = Store(str(tmp_path / "run.db"), CFG, BASE) + await s.connect() + yield s + await s.close() # close before tmp_path teardown (win32 WAL files) + + +async def test_create_run_seeds_clarify_ready(store): + await store.create_run("r1", "Build a todo app", 5.0) + tasks = await store._all_tasks("r1") + assert [t["agent_id"] for t in tasks] == ["clarifying_pm"] + assert tasks[0]["status"] == "ready" # clarify is open + no deps + + +async def test_put_state_cas_success_then_conflict(store): + await store.create_run("r1", "idea", 5.0) + assert await store.put_state("r1", "prd", {"text": "v1"}, expected_version=0) is True + got = await store.get_state("r1", ["prd"]) + assert got["prd"][0] == {"text": "v1"} and got["prd"][1] == 1 + # stale write with old version fails + assert await store.put_state("r1", "prd", {"text": "stale"}, expected_version=0) is False + # correct version succeeds + assert await store.put_state("r1", "prd", {"text": "v2"}, expected_version=1) is True +``` + +Note: `tests/engine/conftest.py` must set asyncio mode. Add: +```python +# tests/engine/conftest.py +import pytest + +pytest_plugins = () +# ensure async tests run under pytest-asyncio auto mode +def pytest_configure(config): + config.addinivalue_line("markers", "asyncio") +``` +And in `pyproject.toml` under `[tool.pytest.ini_options]` add `asyncio_mode = "auto"` (Step 3 covers this). + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/engine/test_store_state.py -v` +Expected: FAIL — `ModuleNotFoundError: backend.engine.store`. + +- [ ] **Step 3: Enable asyncio auto mode** + +In `pyproject.toml`, ensure this block exists (create if missing): +```toml +[tool.pytest.ini_options] +asyncio_mode = "auto" +``` + +- [ ] **Step 4: Implement `backend/engine/store.py` (part 1)** + +```python +"""Single-writer SQLite store for the engine. All mutations under _db_lock.""" +from __future__ import annotations + +import asyncio +import json +import time +from typing import Any + +import aiosqlite + +from backend.engine import scheduler as sch +from backend.engine.models import SCHEMA_SQL +from backend.engine.phases import PhasesConfig + + +class Store: + def __init__(self, db_path: str, cfg: PhasesConfig, base_models: dict[str, str], lease_s: float = 120.0): + self.db_path = db_path + self.cfg = cfg + self.base_models = base_models + self.lease_s = lease_s + self._db: aiosqlite.Connection | None = None + self._db_lock = asyncio.Lock() + + def _now(self) -> float: + return time.time() + + async def connect(self) -> None: + self._db = await aiosqlite.connect(self.db_path) + self._db.row_factory = aiosqlite.Row + await self._db.execute("PRAGMA journal_mode=WAL") + await self._db.executescript(SCHEMA_SQL) + await self._db.commit() + + async def close(self) -> None: + if self._db is not None: + await self._db.close() + self._db = None + + async def create_run(self, run_id: str, idea: str, budget_limit: float) -> None: + async with self._db_lock: + now = self._now() + await self._db.execute( + "INSERT INTO runs (run_id, idea, budget_limit, created_at) VALUES (?,?,?,?)", + (run_id, idea, budget_limit, now), + ) + for name in self.cfg.phase_names: + order = self.cfg.order_of(name) + status = "open" if name == "clarify" else "blocked" + await self._db.execute( + "INSERT INTO phases (run_id, name, phase_order, status, gate, seeded) VALUES (?,?,?,?,?,0)", + (run_id, name, order, status, self.cfg.gate_of(name)), + ) + await self._seed_phase_locked(run_id, "clarify", now) + await self._recompute_ready_locked(run_id) + await self._db.commit() + + async def _seed_phase_locked(self, run_id: str, phase_name: str, now: float) -> None: + specs = sch.seed_specs_for_phase(self.cfg, run_id, phase_name, self.base_models) + for s in specs: + await self._db.execute( + """INSERT OR IGNORE INTO tasks + (task_id, run_id, phase, phase_order, agent_id, input, depends_on, + status, version, attempts, created_at, model, sim_cost) + VALUES (?,?,?,?,?,?,?,'blocked',0,0,?,?,?)""", + (s["task_id"], run_id, s["phase"], s["phase_order"], s["agent_id"], + json.dumps({"input_keys": s["input_keys"]}), json.dumps(s["depends_on"]), + now, s["model"], s["sim_cost"]), + ) + await self._db.execute( + "UPDATE phases SET seeded=1 WHERE run_id=? AND name=?", (run_id, phase_name) + ) + + async def _recompute_ready_locked(self, run_id: str) -> None: + tasks = await self._all_tasks(run_id) + phases = await self._all_phases(run_id) + ready_ids = sch.compute_ready(tasks, phases) + for tid in ready_ids: + await self._db.execute( + "UPDATE tasks SET status='ready' WHERE task_id=? AND status='blocked'", (tid,) + ) + + async def _all_tasks(self, run_id: str) -> list[dict[str, Any]]: + cur = await self._db.execute("SELECT * FROM tasks WHERE run_id=?", (run_id,)) + rows = await cur.fetchall() + out = [] + for r in rows: + d = dict(r) + d["depends_on"] = json.loads(d["depends_on"]) + out.append(d) + return out + + async def _all_phases(self, run_id: str) -> list[dict[str, Any]]: + cur = await self._db.execute("SELECT * FROM phases WHERE run_id=?", (run_id,)) + return [dict(r) for r in await cur.fetchall()] + + async def get_state(self, run_id: str, keys: list[str] | None = None) -> dict[str, tuple[Any, int]]: + if keys: + q = "SELECT key, value, version FROM state WHERE run_id=? AND key IN (%s)" % ",".join("?" * len(keys)) + cur = await self._db.execute(q, (run_id, *keys)) + else: + cur = await self._db.execute("SELECT key, value, version FROM state WHERE run_id=?", (run_id,)) + return {r["key"]: (json.loads(r["value"]), r["version"]) for r in await cur.fetchall()} + + async def put_state(self, run_id: str, key: str, value: Any, expected_version: int) -> bool: + async with self._db_lock: + if expected_version == 0: + cur = await self._db.execute( + "INSERT OR IGNORE INTO state (run_id, key, value, version) VALUES (?,?,?,1)", + (run_id, key, json.dumps(value)), + ) + await self._db.commit() + return cur.rowcount == 1 + cur = await self._db.execute( + "UPDATE state SET value=?, version=version+1 WHERE run_id=? AND key=? AND version=?", + (json.dumps(value), run_id, key, expected_version), + ) + await self._db.commit() + return cur.rowcount == 1 +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `uv run pytest tests/engine/test_store_state.py -v` +Expected: 2 passed. + +- [ ] **Step 6: Commit** + +```bash +git add backend/engine/store.py tests/engine/test_store_state.py tests/engine/conftest.py pyproject.toml +git commit -m "feat(engine): store init, run seeding, versioned state CAS" +``` + +--- + +## Task 6: Store part 2 — atomic claim, guarded heartbeat, reaper + +**Files:** +- Modify: `backend/engine/store.py` +- Test: `tests/engine/test_store_claim.py` + +**Interfaces:** +- Consumes: Task 5 `Store`, `ClaimResult` (Task 3). +- Produces: + - `async claim_next_task(run_id, worker_id) -> ClaimResult | None` — single guarded `UPDATE … RETURNING`; assembles `input` from the task's `input_keys` resolved against `state`; applies budget-resolved `model` (uses `spend_ratio` from Task 7's `spend_total`, wired here as `self._spend_ratio_locked`) + - `async heartbeat(task_id, worker_id) -> bool` — owner-guarded lease extension + - `async reap_expired() -> int` — reverts expired `claimed`/`running` → `ready`, bumps `version`, `attempts++` + +- [ ] **Step 1: Write the failing test** + +`tests/engine/test_store_claim.py`: +```python +import pytest + +from backend.engine.phases import PhasesConfig +from backend.engine.store import Store + +CFG = PhasesConfig.load("config/phases.yaml") +BASE = {aid: "gpt-4o" for aid in CFG.all_agent_ids()} + + +@pytest.fixture +async def store(tmp_path): + s = Store(str(tmp_path / "run.db"), CFG, BASE, lease_s=100.0) + await s.connect() + await s.create_run("r1", "idea", 200.0) + yield s + await s.close() + + +async def test_claim_returns_clarify_task_once(store): + c1 = await store.claim_next_task("r1", "w1") + assert c1 is not None and c1.agent_id == "clarifying_pm" and c1.version == 1 + c2 = await store.claim_next_task("r1", "w2") + assert c2 is None # only one ready task, already claimed + + +async def test_claim_assembles_input_from_state(store): + await store.put_state("r1", "idea", {"text": "todo app"}, expected_version=0) + c = await store.claim_next_task("r1", "w1") + assert "idea" in c.input # clarifying_pm reads [idea] + + +async def test_heartbeat_owner_guarded(store): + c = await store.claim_next_task("r1", "w1") + assert await store.heartbeat(c.task_id, "w1") is True + assert await store.heartbeat(c.task_id, "someone_else") is False + + +async def test_reaper_reverts_and_bumps_version(store): + c = await store.claim_next_task("r1", "w1") + # force the lease into the past + await store._db.execute("UPDATE tasks SET lease_expires=? WHERE task_id=?", (0.0, c.task_id)) + await store._db.commit() + n = await store.reap_expired() + assert n == 1 + tasks = {t["task_id"]: t for t in await store._all_tasks("r1")} + t = tasks[c.task_id] + assert t["status"] == "ready" and t["version"] == 2 and t["attempts"] == 1 + # zombie w1 (holding version 1) can no longer heartbeat + assert await store.heartbeat(c.task_id, "w1") is False +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/engine/test_store_claim.py -v` +Expected: FAIL — `AttributeError: 'Store' object has no attribute 'claim_next_task'`. + +- [ ] **Step 3: Add claim/heartbeat/reaper + spend-ratio helper to `store.py`** + +Append these methods to the `Store` class: +```python + from backend.engine.models import ClaimResult # noqa: E402 (top-of-file import in real code) + + async def _spend_ratio_locked(self, run_id: str) -> float: + 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 + cur = await self._db.execute("SELECT COALESCE(SUM(cost),0) AS s FROM spend WHERE run_id=?", (run_id,)) + spent = (await cur.fetchone())["s"] + return (spent / limit) if limit > 0 else 1.0 + + async def _downgrade_config(self) -> tuple[dict[str, str], set[str]]: + # Overridden/injected in Plan C to read budget.yaml; Plan A uses no downgrades. + return ({}, {"clarifying_pm", "solution_architect"}) + + async def claim_next_task(self, run_id: str, worker_id: str): + from backend.engine.models import ClaimResult + async with self._db_lock: + now = self._now() + cur = await self._db.execute( + """UPDATE tasks + SET status='claimed', owner=?, version=version+1, claimed_at=?, lease_expires=? + WHERE task_id = ( + SELECT task_id FROM tasks + WHERE run_id=? AND status='ready' + ORDER BY phase_order, created_at LIMIT 1) + AND status='ready' + RETURNING task_id, run_id, phase, phase_order, agent_id, input, model, version""", + (worker_id, now, now + self.lease_s, run_id), + ) + row = await cur.fetchone() + if row is None: + await self._db.commit() + return None + ratio = await self._spend_ratio_locked(run_id) + paths, skip = await self._downgrade_config() + model = sch.resolve_model(row["agent_id"], row["model"], ratio, paths, skip) + if model != row["model"]: + await self._db.execute("UPDATE tasks SET model=? WHERE task_id=?", (model, row["task_id"])) + input_keys = json.loads(row["input"]).get("input_keys", []) + state = await self.get_state(run_id, input_keys) if input_keys else {} + resolved_input = {k: v[0] for k, v in state.items()} + await self._db.commit() + return ClaimResult( + task_id=row["task_id"], run_id=row["run_id"], phase=row["phase"], + phase_order=row["phase_order"], agent_id=row["agent_id"], + input=resolved_input, model=model, version=row["version"], + ) + + async def heartbeat(self, task_id: str, worker_id: str) -> bool: + async with self._db_lock: + cur = await self._db.execute( + """UPDATE tasks SET lease_expires=? + WHERE task_id=? AND owner=? AND status IN ('claimed','running')""", + (self._now() + self.lease_s, task_id, worker_id), + ) + await self._db.commit() + return cur.rowcount == 1 + + async def reap_expired(self) -> int: + async with self._db_lock: + cur = await self._db.execute( + """UPDATE tasks + SET status='ready', owner=NULL, version=version+1, attempts=attempts+1, lease_expires=NULL + WHERE status IN ('claimed','running') AND lease_expires IS NOT NULL AND lease_expires < ?""", + (self._now(),), + ) + await self._db.commit() + return cur.rowcount +``` + +Note: `get_state` acquires no lock and is called inside the locked `claim_next_task`; that is safe because `_db_lock` is not reentrant-required here (single call path). If you refactor to call a locked method from within a lock, extract a `_get_state_locked` helper to avoid deadlock. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/engine/test_store_claim.py -v` +Expected: 4 passed. + +- [ ] **Step 5: Commit** + +```bash +git add backend/engine/store.py tests/engine/test_store_claim.py +git commit -m "feat(engine): atomic claim, owner-guarded heartbeat, reaper" +``` + +--- + +## Task 7: Store part 3 — single-transaction `complete_task`, `fail_task`, spend + +**Files:** +- Modify: `backend/engine/store.py` +- Test: `tests/engine/test_store_complete.py` + +**Interfaces:** +- Consumes: Task 6 `Store`, `scheduler.advance`, `compute_ready`. +- Produces: + - `async complete_task(task_id, worker_id, version, result, state_writes: dict|None=None, spawn_tasks: list|None=None) -> bool` — one transaction: guard on `owner==worker AND version==:v`; write result; apply `state_writes`; INSERT `spend` row (cost=`sim_cost`); mark `done`; run `advance` (complete phase, set gate pending OR open+seed next phase); recompute ready. Returns False if the guard fails. + - `async fail_task(task_id, worker_id, version, error) -> None` — increments attempts; requeues to `ready` until attempts≥`max_attempts` (3), then `failed` (+ run `failed`). + - `async spend_total(run_id) -> float` + - `async submit_approval(run_id, phase, decision) -> None` — gate approved → open+seed next phase; rejected → re-open the phase's tasks. + +- [ ] **Step 1: Write the failing test** + +`tests/engine/test_store_complete.py`: +```python +import pytest + +from backend.engine.phases import PhasesConfig +from backend.engine.store import Store + +CFG = PhasesConfig.load("config/phases.yaml") +BASE = {aid: "gpt-4o" for aid in CFG.all_agent_ids()} + + +@pytest.fixture +async def store(tmp_path): + s = Store(str(tmp_path / "run.db"), CFG, BASE, lease_s=100.0) + await s.connect() + await s.create_run("r1", "idea", 200.0) + yield s + await s.close() + + +async def test_complete_guard_rejects_wrong_version(store): + c = await store.claim_next_task("r1", "w1") + assert await store.complete_task(c.task_id, "w1", version=999, result={"ok": True}) is False + assert await store.complete_task(c.task_id, "w1", version=c.version, result={"ok": True}) is True + + +async def test_complete_records_spend_and_writes_state(store): + c = await store.claim_next_task("r1", "w1") + await store.complete_task(c.task_id, "w1", c.version, result={"prd": "PRD text"}, + state_writes={"prd": "PRD text"}) + assert await store.spend_total("r1") == pytest.approx(0.30) # clarifying_pm sim_cost + st = await store.get_state("r1", ["prd"]) + assert st["prd"][0] == "PRD text" + + +async def test_clarify_completion_sets_prd_gate_pending_not_design(store): + c = await store.claim_next_task("r1", "w1") + await store.complete_task(c.task_id, "w1", c.version, result={"prd": "x"}) + phases = {p["name"]: p for p in await store._all_phases("r1")} + assert phases["clarify"]["status"] == "complete" + assert phases["clarify"]["gate"] == "pending" + assert phases["design"]["status"] == "blocked" # gated: not opened yet + assert await store.claim_next_task("r1", "w2") is None # nothing claimable behind the gate + + +async def test_submit_approval_opens_and_seeds_design(store): + c = await store.claim_next_task("r1", "w1") + await store.complete_task(c.task_id, "w1", c.version, result={"prd": "x"}) + await store.submit_approval("r1", "clarify", "approved") + phases = {p["name"]: p for p in await store._all_phases("r1")} + assert phases["design"]["status"] == "open" and phases["design"]["seeded"] == 1 + # design fans out to 3 ready tasks + ready = [t for t in await store._all_tasks("r1") if t["status"] == "ready"] + assert sorted(t["agent_id"] for t in ready) == ["solution_architect", "tech_lead", "uiux_designer"] + + +async def test_fail_task_requeues_until_cap(store): + c = await store.claim_next_task("r1", "w1") + await store.fail_task(c.task_id, "w1", c.version, "boom") + t = next(t for t in await store._all_tasks("r1") if t["task_id"] == c.task_id) + assert t["status"] == "ready" and t["attempts"] == 1 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/engine/test_store_complete.py -v` +Expected: FAIL — `AttributeError: … 'complete_task'`. + +- [ ] **Step 3: Add completion/approval/fail to `store.py`** + +Append to the `Store` class (`max_attempts` constant = 3): +```python + MAX_ATTEMPTS = 3 + + async def spend_total(self, run_id: str) -> float: + cur = await self._db.execute("SELECT COALESCE(SUM(cost),0) AS s FROM spend WHERE run_id=?", (run_id,)) + return (await cur.fetchone())["s"] + + async def _advance_locked(self, run_id: str) -> None: + now = self._now() + plan = sch.advance(await self._all_phases(run_id), await self._all_tasks(run_id), self.cfg) + for name in plan["complete_phases"]: + await self._db.execute("UPDATE phases SET status='complete' WHERE run_id=? AND name=?", (run_id, name)) + for gate in plan["open_gates"]: + await self._db.execute("UPDATE phases SET gate='pending' WHERE run_id=? AND gate=? AND status='complete'", + (run_id, "prd" if gate == "prd" else gate)) + # mark the completed phase's gate pending by gate id + await self._db.execute("UPDATE phases SET gate='pending' WHERE run_id=? AND gate=?", + (run_id, gate)) + for name in plan["open_phases"]: + await self._db.execute("UPDATE phases SET status='open' WHERE run_id=? AND name=?", (run_id, name)) + await self._seed_phase_locked(run_id, name, now) + await self._recompute_ready_locked(run_id) + + async def complete_task(self, task_id, worker_id, version, result, + state_writes=None, spawn_tasks=None) -> bool: + async with self._db_lock: + cur = await self._db.execute( + "UPDATE tasks SET status='done', result=? WHERE task_id=? AND owner=? AND version=? AND status IN ('claimed','running')", + (json.dumps(result), task_id, worker_id, version), + ) + if cur.rowcount != 1: + await self._db.rollback() + return False + trow = await (await self._db.execute( + "SELECT run_id, agent_id, model, sim_cost FROM tasks WHERE task_id=?", (task_id,))).fetchone() + run_id = trow["run_id"] + for k, v in (state_writes or {}).items(): + await self._db.execute( + """INSERT INTO state (run_id, key, value, version) VALUES (?,?,?,1) + ON CONFLICT(run_id, key) DO UPDATE SET value=excluded.value, version=state.version+1""", + (run_id, k, json.dumps(v)), + ) + await self._db.execute( + "INSERT INTO spend (run_id, task_id, agent_id, cost, model, ts) VALUES (?,?,?,?,?,?)", + (run_id, task_id, trow["agent_id"], trow["sim_cost"], trow["model"], self._now()), + ) + await self._advance_locked(run_id) + await self._db.commit() + return True + + async def fail_task(self, task_id, worker_id, version, error) -> None: + async with self._db_lock: + trow = await (await self._db.execute( + "SELECT run_id, attempts FROM tasks WHERE task_id=? AND owner=? AND version=?", + (task_id, worker_id, version))).fetchone() + if trow is None: + await self._db.rollback() + return + attempts = trow["attempts"] + 1 + if attempts >= self.MAX_ATTEMPTS: + await self._db.execute("UPDATE tasks SET status='failed', attempts=? WHERE task_id=?", (attempts, task_id)) + await self._db.execute("UPDATE runs SET status='failed' WHERE run_id=?", (trow["run_id"],)) + else: + await self._db.execute( + "UPDATE tasks SET status='ready', owner=NULL, version=version+1, attempts=? WHERE task_id=?", + (attempts, task_id)) + await self._db.commit() + + async def submit_approval(self, run_id: str, phase: str, decision: str) -> None: + async with self._db_lock: + now = self._now() + if decision == "approved": + await self._db.execute("UPDATE phases SET gate='approved' WHERE run_id=? AND name=?", (run_id, phase)) + order = self.cfg.order_of(phase) + nxt = next((n for n in self.cfg.phase_names if self.cfg.order_of(n) == order + 1), None) + if nxt is not None: + await self._db.execute("UPDATE phases SET status='open' WHERE run_id=? AND name=?", (run_id, nxt)) + await self._seed_phase_locked(run_id, nxt, now) + elif decision == "rejected": + await self._db.execute("UPDATE phases SET gate='rejected', status='open' WHERE run_id=? AND name=?", (run_id, phase)) + await self._db.execute("UPDATE tasks SET status='ready', owner=NULL WHERE run_id=? AND phase=?", (run_id, phase)) + await self._recompute_ready_locked(run_id) + await self._db.commit() +``` + +Note on `_advance_locked` gate handling: the `open_gates` loop sets the *completed* phase's `gate='pending'` (matched by the gate id stored on that phase row). Simplify the duplicate UPDATE in review — the intent is: the phase whose `gate` equals the plan's gate id and whose `status` just became `complete` gets `gate='pending'`. Verify with `test_clarify_completion_sets_prd_gate_pending_not_design`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/engine/test_store_complete.py -v` +Expected: 5 passed. + +- [ ] **Step 5: Run the whole engine suite** + +Run: `uv run pytest tests/engine/ -v` +Expected: all green (phases, models, scheduler, store×3). + +- [ ] **Step 6: Commit** + +```bash +git add backend/engine/store.py tests/engine/test_store_complete.py +git commit -m "feat(engine): single-tx complete_task, approval gates, fail/retry" +``` + +--- + +## Task 8: Budget helpers — `downgrade_model_for`, `budget.yaml` paths, remove dead accessors + +**Files:** +- Modify: `config/budget.yaml` (add `downgrade_paths`) +- Modify: `config/agents.yaml` (remove the stale `budget:`/`downgrade_paths` block, lines ~206-220) +- Modify: `backend/agents/budget_guard.py` (add `downgrade_model_for`) +- Modify: `backend/agents/registry.py` (remove `get_downgrade_paths`, `get_budget_config`) +- Modify: `backend/config.py` (add engine settings) +- Test: `tests/engine/test_budget_downgrade_unit.py`; Modify: `tests/unit/test_agent_registry.py` (remove `test_get_budget_config`) + +**Interfaces:** +- Consumes: Task 4 `resolve_model` (already uses a `downgrade_paths` dict + skip-list). +- Produces: + - `BudgetGuard.downgrade_model_for(current_model: str) -> str | None` + - `Config` gains: `engine_lease_ttl: float`, `engine_heartbeat_interval: float`, `engine_reaper_interval: float`, `engine_worker_count: int`, `engine_max_attempts: int` + - a `load_downgrade_paths(path="config/budget.yaml") -> dict[str,str]` helper (used by Plan C to inject into `Store._downgrade_config`) + +- [ ] **Step 1: Add `downgrade_paths` to `config/budget.yaml`** + +Append: +```yaml +# Model -> cheaper model. Authoritative (moved out of agents.yaml). +downgrade_paths: + claude-3-5-sonnet-20241022: claude-3-5-haiku-20241022 + gpt-4o: gpt-4o-mini + claude-3-5-haiku-20241022: gpt-4o-mini +``` + +- [ ] **Step 2: Remove the stale budget block from `config/agents.yaml`** + +Delete the `# Budget thresholds …` / `budget:` block and the `# Model downgrade paths …` / `downgrade_paths:` block near the end of `config/agents.yaml` (keep `eco_mode`). Leave the 16 agent definitions untouched. Also fix the file's header comment `# All 15 agents with placeholder LLM configuration` → `# All 16 agents …`. + +- [ ] **Step 3: Write the failing test** + +`tests/engine/test_budget_downgrade_unit.py`: +```python +import yaml + +from backend.agents.budget_guard import BudgetGuard + + +def test_downgrade_model_for_uses_budget_yaml_paths(): + bg = BudgetGuard(config_path="config/budget.yaml") + assert bg.downgrade_model_for("gpt-4o") == "gpt-4o-mini" + assert bg.downgrade_model_for("claude-3-5-sonnet-20241022") == "claude-3-5-haiku-20241022" + assert bg.downgrade_model_for("gpt-4o-mini") is None # no successor + + +def test_budget_yaml_has_downgrade_paths(): + cfg = yaml.safe_load(open("config/budget.yaml")) + assert "downgrade_paths" in cfg and cfg["downgrade_paths"]["gpt-4o"] == "gpt-4o-mini" +``` + +- [ ] **Step 4: Run test to verify it fails** + +Run: `uv run pytest tests/engine/test_budget_downgrade_unit.py -v` +Expected: FAIL — `AttributeError: 'BudgetGuard' object has no attribute 'downgrade_model_for'`. + +- [ ] **Step 5: Implement `downgrade_model_for` on `BudgetGuard`** + +In `backend/agents/budget_guard.py`, in `_load_config` after `self.budget_config = yaml.safe_load(f)`, add: +```python + self.downgrade_paths = self.budget_config.get("downgrade_paths", {}) +``` +and initialize `self.downgrade_paths: dict[str, str] = {}` in `__init__` before `_load_config()`. Then add the method: +```python + def downgrade_model_for(self, current_model: str) -> str | None: + """Return the cheaper model for `current_model`, or None if there is no successor.""" + return self.downgrade_paths.get(current_model) +``` + +- [ ] **Step 6: Remove the two dead registry accessors + their test** + +In `backend/agents/registry.py`, delete `get_downgrade_paths` and `get_budget_config` (they read the now-removed `agents.yaml` block). In `tests/unit/test_agent_registry.py`, delete the `test_get_budget_config` test function (it asserts the removed shape). + +- [ ] **Step 7: Add engine settings to `backend/config.py`** + +In the `Config` dataclass add fields and load them in `load()`: +```python + engine_lease_ttl: float = 120.0 + engine_heartbeat_interval: float = 20.0 + engine_reaper_interval: float = 30.0 + engine_worker_count: int = 4 + engine_max_attempts: int = 3 +``` +In `load()`: +```python + engine_lease_ttl=_env_float("ENGINE_LEASE_TTL", 120.0), + engine_heartbeat_interval=_env_float("ENGINE_HEARTBEAT_INTERVAL", 20.0), + engine_reaper_interval=_env_float("ENGINE_REAPER_INTERVAL", 30.0), + engine_worker_count=_env_int("ENGINE_WORKER_COUNT", 4), + engine_max_attempts=_env_int("ENGINE_MAX_ATTEMPTS", 3), +``` + +- [ ] **Step 8: Add `load_downgrade_paths` helper to `phases.py`** + +Append to `backend/engine/phases.py`: +```python +def load_downgrade_paths(path: str = "config/budget.yaml") -> dict[str, str]: + import yaml + return yaml.safe_load(Path(path).read_text(encoding="utf-8")).get("downgrade_paths", {}) +``` + +- [ ] **Step 9: Run the full unit + engine suites** + +Run: `uv run pytest tests/unit tests/engine -q` +Expected: all pass (including the still-green `test_budget_guard.py` — it uses its own tmp fixture, unaffected by the `budget.yaml` addition). + +- [ ] **Step 10: Commit** + +```bash +git add config/budget.yaml config/agents.yaml backend/agents/budget_guard.py backend/agents/registry.py backend/config.py backend/engine/phases.py tests/engine/test_budget_downgrade_unit.py tests/unit/test_agent_registry.py +git commit -m "feat(engine): budget downgrade_paths (authoritative) + downgrade_model_for; drop dead registry accessors" +``` + +--- + +## Self-Review (completed against the spec) + +**Spec coverage (Plan A scope = spec §5, §6 store mechanics, §8 budget helpers, §12 steps 0–3,7):** +- §12 step 0 spike → Task 1. §3/§5 phases + seeding → Tasks 2, 4, 5, 7. §5 schema → Task 3. + §6 atomic claim / CAS / heartbeat / reaper / single-tx complete / exactly-once guards → Tasks 5–7. + §8 durable spend + `downgrade_model_for` + one authoritative `downgrade_paths` + dead-accessor + removal + critical skip-list → Tasks 4, 7, 8. Config knobs (§4/§11) → Task 8. +- **Deferred to Plan B/C (intentionally, noted in each):** MCP server + tools + reaper *task* + (§4/§7), worker + heartbeat loop (§6 worker side), agent adapter + Clarify loop (§3/§7), + claim-time downgrade *demo tuning* (§8/§10), concurrency stress test + documented run (§9/§10), + LangGraph retirement + 25-test migration (§11, separate PR), events bridge (§11). + +**Placeholder scan:** no TBD/TODO; every code step shows complete code; the one prose note in +Task 7 Step 3 flags a review simplification, not a missing implementation. + +**Type consistency:** `ClaimResult` fields identical in Task 3 (def), Task 6 (produce), Task 7 +(consume). `seed_specs_for_phase`/`compute_ready`/`advance`/`resolve_model` signatures identical +across Tasks 4–7. `Store` ctor `(db_path, cfg, base_models, lease_s)` identical in all store +tests. `downgrade_model_for(current_model)->str|None` consistent in Task 8. + +**Open item for Plan C:** `Store._downgrade_config` returns empty paths in Plan A (no live +downgrade yet); Plan C injects `load_downgrade_paths()` so claim-time downgrade activates. This +is deliberate — Plan A proves the *mechanism* (`resolve_model` unit-tested); Plan C wires the +*live* config + documented run. From a6b7d0e481e99584a686e57ba5884f813240c3f6 Mon Sep 17 00:00:00 2001 From: Alex Barclay Date: Fri, 24 Jul 2026 09:43:22 -0600 Subject: [PATCH 03/15] spike(engine): validate FastMCP multi-client streamable-HTTP (PASS, mcp==1.28.1) --- pyproject.toml | 1 + scripts/spikes/mcp_multiclient_spike.py | 65 +++ uv.lock | 586 +++++++++++++++++++++++- 3 files changed, 637 insertions(+), 15 deletions(-) create mode 100644 scripts/spikes/mcp_multiclient_spike.py diff --git a/pyproject.toml b/pyproject.toml index e998f5e..fe43088 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ dependencies = [ "python-socketio>=5.11.0", "uvicorn[standard]>=0.32.0", "langgraph-checkpoint-sqlite>=2.0.0", + "mcp>=1.16,<2", ] [project.optional-dependencies] diff --git a/scripts/spikes/mcp_multiclient_spike.py b/scripts/spikes/mcp_multiclient_spike.py new file mode 100644 index 0000000..3117796 --- /dev/null +++ b/scripts/spikes/mcp_multiclient_spike.py @@ -0,0 +1,65 @@ +"""Throwaway spike: can FastMCP serve many concurrent streamable-HTTP clients? + +PASS bar: 8 concurrent client sessions x 100 tool calls each = 800 calls, +zero dropped/failed, correct results. Run with: uv run python scripts/spikes/mcp_multiclient_spike.py + +This is a spike, not engine code. No engine module imports this script. + +Verified against the installed SDK (mcp==1.28.1): FastMCP(stateless_http=True), +FastMCP.streamable_http_app(), mcp.client.streamable_http.streamablehttp_client, +mcp.ClientSession, and session.call_tool(...).structuredContent all match the +brief's assumed API surface as-is -- no adaptation was required. +""" +import asyncio +import contextlib + +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("spike", stateless_http=True) + + +@mcp.tool() +def echo(n: int) -> int: + """Return n unchanged (proves per-call dispatch).""" + return n + + +async def run_client(base_url: str, worker: int, calls: int) -> int: + from mcp import ClientSession + from mcp.client.streamable_http import streamablehttp_client + + ok = 0 + async with streamablehttp_client(base_url) as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + for i in range(calls): + res = await session.call_tool("echo", {"n": worker * 1000 + i}) + assert res.structuredContent["result"] == worker * 1000 + i + ok += 1 + return ok + + +async def main() -> None: + import uvicorn + + app = mcp.streamable_http_app() + config = uvicorn.Config(app, host="127.0.0.1", port=8765, log_level="warning") + server = uvicorn.Server(config) + server_task = asyncio.create_task(server.serve()) + await asyncio.sleep(1.0) # let it bind + + base = "http://127.0.0.1:8765/mcp" + try: + results = await asyncio.gather(*(run_client(base, w, 100) for w in range(8))) + total = sum(results) + print(f"SPIKE RESULT: {total}/800 calls ok") + assert total == 800, "FAIL: dropped/failed calls" + print("SPIKE PASS") + finally: + server.should_exit = True + with contextlib.suppress(Exception): + await server_task + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/uv.lock b/uv.lock index 6e9c6d9..19776d1 100644 --- a/uv.lock +++ b/uv.lock @@ -172,7 +172,8 @@ dependencies = [ { name = "docstring-parser" }, { name = "httpx" }, { name = "jiter" }, - { name = "pydantic" }, + { 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 = "sniffio" }, { name = "typing-extensions" }, ] @@ -258,6 +259,104 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, ] +[[package]] +name = "cffi" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", size = 183845, upload-time = "2026-07-06T21:32:26.32Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", size = 184186, upload-time = "2026-07-06T21:32:28.025Z" }, + { url = "https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", size = 211892, upload-time = "2026-07-06T21:32:29.565Z" }, + { url = "https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", size = 218793, upload-time = "2026-07-06T21:32:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", size = 205737, upload-time = "2026-07-06T21:32:32.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", size = 204909, upload-time = "2026-07-06T21:32:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565", size = 217883, upload-time = "2026-07-06T21:32:35.173Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", size = 221251, upload-time = "2026-07-06T21:32:36.527Z" }, + { url = "https://files.pythonhosted.org/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", size = 214250, upload-time = "2026-07-06T21:32:37.852Z" }, + { url = "https://files.pythonhosted.org/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", size = 219441, upload-time = "2026-07-06T21:32:39.146Z" }, + { url = "https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", size = 174496, upload-time = "2026-07-06T21:32:40.467Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", size = 185113, upload-time = "2026-07-06T21:32:41.761Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", size = 179927, upload-time = "2026-07-06T21:32:42.961Z" }, + { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, + { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, + { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, + { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, + { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, + { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, + { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, + { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, + { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, + { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, + { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, + { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, + { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" }, + { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, + { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, + { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, + { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" }, + { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.4" @@ -444,6 +543,62 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, +] + [[package]] name = "dataclasses-json" version = "0.6.7" @@ -471,7 +626,9 @@ dependencies = [ { name = "langchain-openai" }, { name = "langgraph" }, { name = "langgraph-checkpoint-sqlite" }, - { name = "pydantic" }, + { 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'" }, { name = "python-dotenv" }, { name = "python-socketio" }, { name = "pyyaml" }, @@ -515,6 +672,7 @@ requires-dist = [ { 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" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, @@ -565,7 +723,8 @@ version = "0.136.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, - { name = "pydantic" }, + { 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 = "starlette" }, { name = "typing-extensions" }, { name = "typing-inspection" }, @@ -920,6 +1079,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "langchain" version = "0.3.27" @@ -928,7 +1114,8 @@ dependencies = [ { name = "langchain-core" }, { name = "langchain-text-splitters" }, { name = "langsmith" }, - { name = "pydantic" }, + { 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 = "pyyaml" }, { name = "requests" }, { name = "sqlalchemy" }, @@ -945,7 +1132,8 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anthropic" }, { name = "langchain-core" }, - { name = "pydantic" }, + { 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'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b8/ac/4791e4451e1972f80cb517e19d003678239921fc0685a4c4b265fe47e216/langchain_anthropic-0.3.22.tar.gz", hash = "sha256:6c440278bd8012bc94ae341f416bfc724fdc5d2d2b69630fe6e82fa6ee9682ac", size = 471312, upload-time = "2025-10-09T18:39:26.983Z" } wheels = [ @@ -983,7 +1171,8 @@ dependencies = [ { name = "jsonpatch" }, { name = "langsmith" }, { name = "packaging" }, - { name = "pydantic" }, + { 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 = "pyyaml" }, { name = "tenacity" }, { name = "typing-extensions" }, @@ -1028,7 +1217,8 @@ dependencies = [ { name = "langgraph-checkpoint" }, { name = "langgraph-prebuilt" }, { name = "langgraph-sdk" }, - { name = "pydantic" }, + { 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" } @@ -1097,7 +1287,8 @@ dependencies = [ { name = "httpx" }, { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, { name = "packaging" }, - { name = "pydantic" }, + { 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 = "requests" }, { name = "requests-toolbelt" }, { name = "uuid-utils" }, @@ -1269,6 +1460,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/2f/5108cb3ee4ba6501748c4908b908e55f42a5b66245b4cfe0c99326e1ef6e/marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73", size = 50964, upload-time = "2025-12-22T06:53:51.801Z" }, ] +[[package]] +name = "mcp" +version = "1.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { 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 = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -1531,7 +1748,8 @@ dependencies = [ { name = "distro" }, { name = "httpx" }, { name = "jiter" }, - { name = "pydantic" }, + { 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 = "sniffio" }, { name = "tqdm" }, { name = "typing-extensions" }, @@ -1791,27 +2009,64 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.11.10" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version < '3.12'", +] dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, + { name = "annotated-types", marker = "python_full_version < '3.14'" }, + { name = "pydantic-core", version = "2.33.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "typing-extensions", marker = "python_full_version < '3.14'" }, + { name = "typing-inspection", marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ae/54/ecab642b3bed45f7d5f59b38443dcb36ef50f85af192e6ece103dbfe9587/pydantic-2.11.10.tar.gz", hash = "sha256:dc280f0982fbda6c38fada4e476dc0a4f3aeaf9c6ad4c28df68a666ec3c61423", size = 788494, upload-time = "2025-10-04T10:40:41.338Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/bd/1f/73c53fcbfb0b5a78f91176df41945ca466e71e9d9d836e5c522abda39ee7/pydantic-2.11.10-py3-none-any.whl", hash = "sha256:802a655709d49bd004c31e865ef37da30b540786a46bfce02333e0e24b5fe29a", size = 444823, upload-time = "2025-10-04T10:40:39.055Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", +] +dependencies = [ + { name = "annotated-types", marker = "python_full_version >= '3.14'" }, + { name = "pydantic-core", version = "2.46.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.14'" }, + { name = "typing-inspection", marker = "python_full_version >= '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + [[package]] name = "pydantic-core" version = "2.33.2" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version < '3.12'", +] dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } wheels = [ @@ -1871,12 +2126,118 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, ] +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", +] +dependencies = [ + { name = "typing-extensions", marker = "python_full_version >= '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + [[package]] name = "pydantic-settings" version = "2.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pydantic" }, + { 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 = "python-dotenv" }, { name = "typing-inspection" }, ] @@ -1894,6 +2255,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pytest" version = "9.0.2" @@ -1958,6 +2333,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/54/0cce26da03a981f949bb8449c9778537f75f5917c172e1d2992ff25cb57d/python_engineio-4.13.1-py3-none-any.whl", hash = "sha256:f32ad10589859c11053ad7d9bb3c9695cdf862113bfb0d20bc4d890198287399", size = 59847, upload-time = "2026-02-06T23:38:04.861Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + [[package]] name = "python-socketio" version = "5.16.1" @@ -1980,6 +2364,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/25/d9db8be44e205a124f6c98bc0324b2bb149b7431c53877fc6d1038dddaf5/pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3", size = 12195, upload-time = "2025-11-05T13:36:33.183Z" }, ] +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -2035,6 +2441,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "regex" version = "2024.9.11" @@ -2128,6 +2548,129 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, ] +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, +] + [[package]] name = "ruff" version = "0.14.11" @@ -2229,6 +2772,19 @@ wheels = [ { 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" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/10/a34c656829ffc1c4b22ef36d70d9ebb6b99c020e2aeb17cee5485099f028/sse_starlette-3.4.6.tar.gz", hash = "sha256:725f8a1bd6d26ae1b2c9610c0ef5065dfdd496f3988d28adcf8c4b49dc25c627", size = 32542, upload-time = "2026-07-20T14:16:32.201Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/36/e10c1d1b7ca881d2625db2ec28508578499187bb1c389952c398474e1834/sse_starlette-3.4.6-py3-none-any.whl", hash = "sha256:56217ab4c9a9f9c5db7b21e08732d3e7c2b807f45231ad23de0551a24c4a41f6", size = 16516, upload-time = "2026-07-20T14:16:30.978Z" }, +] + [[package]] name = "starlette" version = "0.50.0" From 11061957e478eab1ad432c9a9f4597c8acd1fafe Mon Sep 17 00:00:00 2001 From: Alex Barclay Date: Fri, 24 Jul 2026 09:46:00 -0600 Subject: [PATCH 04/15] feat(engine): six-phase config source of truth + loader --- backend/engine/__init__.py | 0 backend/engine/phases.py | 76 +++++++++++++++++++++++++++++++++++++ config/phases.yaml | 47 +++++++++++++++++++++++ tests/engine/__init__.py | 0 tests/engine/test_phases.py | 28 ++++++++++++++ 5 files changed, 151 insertions(+) create mode 100644 backend/engine/__init__.py create mode 100644 backend/engine/phases.py create mode 100644 config/phases.yaml create mode 100644 tests/engine/__init__.py create mode 100644 tests/engine/test_phases.py diff --git a/backend/engine/__init__.py b/backend/engine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/engine/phases.py b/backend/engine/phases.py new file mode 100644 index 0000000..e6b89af --- /dev/null +++ b/backend/engine/phases.py @@ -0,0 +1,76 @@ +"""Loader/validator for config/phases.yaml — the six-phase source of truth.""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +import yaml + + +@dataclass(frozen=True) +class AgentSpec: + agent_id: str + reads: list[str] + writes: str + sim_cost: float + depends_on: list[str] + + +@dataclass(frozen=True) +class PhaseSpec: + name: str + order: int + gate: str + agents: dict[str, AgentSpec] + + +class PhasesConfig: + def __init__(self, phases: list[PhaseSpec]): + self._phases = sorted(phases, key=lambda p: p.order) + self._by_name = {p.name: p for p in self._phases} + + @classmethod + def load(cls, path: str = "config/phases.yaml") -> "PhasesConfig": + raw = yaml.safe_load(Path(path).read_text(encoding="utf-8")) + phases: list[PhaseSpec] = [] + for p in raw["phases"]: + agents = { + aid: AgentSpec( + agent_id=aid, + reads=list(a.get("reads", [])), + writes=a["writes"], + sim_cost=float(a.get("sim_cost", 0.0)), + depends_on=list(a.get("depends_on", [])), + ) + for aid, a in p["agents"].items() + } + phases.append( + PhaseSpec(name=p["name"], order=int(p["order"]), gate=p.get("gate", "none"), agents=agents) + ) + cfg = cls(phases) + cfg._validate() + return cfg + + def _validate(self) -> None: + orders = [p.order for p in self._phases] + assert orders == list(range(len(self._phases))), f"phase orders must be 0..N: {orders}" + for p in self._phases: + for aid, spec in p.agents.items(): + for dep in spec.depends_on: + assert dep in p.agents, f"{p.name}.{aid} depends on unknown intra-phase agent {dep!r}" + + @property + def phase_names(self) -> list[str]: + return [p.name for p in self._phases] + + def order_of(self, name: str) -> int: + return self._by_name[name].order + + def gate_of(self, name: str) -> str: + return self._by_name[name].gate + + def agents_of(self, name: str) -> dict[str, AgentSpec]: + return dict(self._by_name[name].agents) + + def all_agent_ids(self) -> list[str]: + return [aid for p in self._phases for aid in p.agents] diff --git a/config/phases.yaml b/config/phases.yaml new file mode 100644 index 0000000..047ede8 --- /dev/null +++ b/config/phases.yaml @@ -0,0 +1,47 @@ +# Source of truth for the six-phase dependency graph. +# depends_on lists refer to OTHER agent ids WITHIN the same phase. +phases: + - name: clarify + order: 0 + gate: prd + agents: + clarifying_pm: { reads: [idea], writes: prd, sim_cost: 0.30, depends_on: [] } + - name: design + order: 1 + gate: plan + agents: + solution_architect: { reads: [prd], writes: adr, sim_cost: 0.50, depends_on: [] } + tech_lead: { reads: [prd], writes: tasks, sim_cost: 0.40, depends_on: [] } + uiux_designer: { reads: [prd], writes: design_spec, sim_cost: 0.40, depends_on: [] } + - name: code + order: 2 + gate: none + agents: + database: { reads: [adr, tasks], writes: db_schema, sim_cost: 0.60, depends_on: [] } + backend: { reads: [adr, tasks, db_schema], writes: backend_code, sim_cost: 0.80, depends_on: [database] } + frontend: { reads: [design_spec, backend_code], writes: frontend_code, sim_cost: 0.80, depends_on: [backend] } + ai_ml: { reads: [adr], writes: ml_code, sim_cost: 0.50, depends_on: [] } + - name: test + order: 3 + gate: none + agents: + qa_test: { reads: [backend_code, frontend_code], writes: test_report, sim_cost: 0.50, depends_on: [] } + security: { reads: [backend_code], writes: security_report, sim_cost: 0.50, depends_on: [] } + - name: deploy + order: 4 + gate: none + agents: + devops: { reads: [backend_code, frontend_code], writes: deploy_manifest, sim_cost: 0.40, depends_on: [] } + technical_writer: { reads: [prd, adr], writes: docs, sim_cost: 0.30, depends_on: [] } + - name: iterate + order: 5 + gate: none + agents: + delivery_summarizer: { reads: [test_report, deploy_manifest], writes: summary, sim_cost: 0.20, depends_on: [] } + +# Non-phase-worker roles, recorded so the 16-agent count reconciles honestly. +infra_roles: + orchestrator: "engine / scheduler / state server" + budget_guard: "per-claim budget enforcement hook" +inline_roles: + product_owner: "auto-answerer inside the clarify task (not an independent claimant)" diff --git a/tests/engine/__init__.py b/tests/engine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/engine/test_phases.py b/tests/engine/test_phases.py new file mode 100644 index 0000000..1a64fd3 --- /dev/null +++ b/tests/engine/test_phases.py @@ -0,0 +1,28 @@ +from backend.engine.phases import PhasesConfig + + +def test_loads_six_phases_in_order(): + cfg = PhasesConfig.load("config/phases.yaml") + assert cfg.phase_names == ["clarify", "design", "code", "test", "deploy", "iterate"] + assert cfg.order_of("code") == 2 + + +def test_gates_only_on_clarify_and_design(): + cfg = PhasesConfig.load("config/phases.yaml") + assert cfg.gate_of("clarify") == "prd" + assert cfg.gate_of("design") == "plan" + assert cfg.gate_of("code") == "none" + + +def test_code_intra_phase_edges(): + cfg = PhasesConfig.load("config/phases.yaml") + agents = cfg.agents_of("code") + assert agents["backend"].depends_on == ["database"] + assert agents["frontend"].depends_on == ["backend"] + assert agents["ai_ml"].depends_on == [] + + +def test_all_agent_ids_are_the_thirteen_workers(): + cfg = PhasesConfig.load("config/phases.yaml") + assert len(cfg.all_agent_ids()) == 13 + assert "product_owner" not in cfg.all_agent_ids() From 0995570fc46445370dbd55f687622b4dd6a4195a Mon Sep 17 00:00:00 2001 From: Alex Barclay Date: Fri, 24 Jul 2026 09:52:20 -0600 Subject: [PATCH 05/15] feat(engine): SQLite schema + typed ClaimResult --- backend/engine/models.py | 83 +++++++++++++++++++++++++++++++++++++ tests/engine/test_models.py | 26 ++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 backend/engine/models.py create mode 100644 tests/engine/test_models.py diff --git a/backend/engine/models.py b/backend/engine/models.py new file mode 100644 index 0000000..db7bfd3 --- /dev/null +++ b/backend/engine/models.py @@ -0,0 +1,83 @@ +"""SQL schema + typed result models for the engine store.""" +from __future__ import annotations + +from pydantic import BaseModel + +PHASE_STATUS = {"blocked", "open", "complete"} +TASK_STATUS = {"blocked", "ready", "claimed", "running", "done", "failed"} +GATE = {"none", "pending", "approved", "rejected"} + +SCHEMA_SQL = """ +CREATE TABLE IF NOT EXISTS runs ( + run_id TEXT PRIMARY KEY, + idea TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'running', + current_phase INTEGER NOT NULL DEFAULT 0, + budget_limit REAL NOT NULL DEFAULT 200.0, + created_at REAL NOT NULL +); +CREATE TABLE IF NOT EXISTS phases ( + run_id TEXT NOT NULL, + name TEXT NOT NULL, + phase_order INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'blocked', + gate TEXT NOT NULL DEFAULT 'none', + seeded INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (run_id, name) +); +CREATE TABLE IF NOT EXISTS tasks ( + task_id TEXT PRIMARY KEY, + run_id TEXT NOT NULL, + phase TEXT NOT NULL, + phase_order INTEGER NOT NULL, + agent_id TEXT NOT NULL, + input TEXT NOT NULL DEFAULT '{}', + depends_on TEXT NOT NULL DEFAULT '[]', + status TEXT NOT NULL DEFAULT 'blocked', + owner TEXT, + version INTEGER NOT NULL DEFAULT 0, + attempts INTEGER NOT NULL DEFAULT 0, + lease_expires REAL, + created_at REAL NOT NULL, + claimed_at REAL, + model TEXT, + sim_cost REAL NOT NULL DEFAULT 0.0, + result TEXT +); +CREATE INDEX IF NOT EXISTS idx_tasks_ready ON tasks (run_id, status, phase_order, created_at); +CREATE TABLE IF NOT EXISTS state ( + run_id TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (run_id, key) +); +CREATE TABLE IF NOT EXISTS spend ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL, + task_id TEXT, + agent_id TEXT, + cost REAL NOT NULL, + model TEXT, + ts REAL NOT NULL +); +CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL, + type TEXT NOT NULL, + payload TEXT NOT NULL, + worker_pid INTEGER, + ts REAL NOT NULL +); +""" + + +class ClaimResult(BaseModel): + task_id: str + run_id: str + phase: str + phase_order: int + agent_id: str + input: dict + model: str | None + version: int diff --git a/tests/engine/test_models.py b/tests/engine/test_models.py new file mode 100644 index 0000000..b2e2bf3 --- /dev/null +++ b/tests/engine/test_models.py @@ -0,0 +1,26 @@ +import sqlite3 + +from backend.engine.models import SCHEMA_SQL, ClaimResult + + +def test_schema_creates_all_tables(tmp_path): + db = sqlite3.connect(tmp_path / "t.db") + db.executescript(SCHEMA_SQL) + names = {r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type='table'")} + assert {"runs", "phases", "tasks", "state", "spend", "events"} <= names + db.close() + + +def test_tasks_has_ordering_and_lease_columns(tmp_path): + db = sqlite3.connect(tmp_path / "t.db") + db.executescript(SCHEMA_SQL) + cols = {r[1] for r in db.execute("PRAGMA table_info(tasks)")} + assert {"phase_order", "created_at", "claimed_at", "lease_expires", "version", "sim_cost"} <= cols + db.close() + + +def test_claim_result_roundtrips(): + cr = ClaimResult(task_id="t1", run_id="r1", phase="code", phase_order=2, + agent_id="backend", input={"prd": "x"}, model="gpt-4o", version=1) + assert cr.agent_id == "backend" + assert cr.input["prd"] == "x" From 80efd2455ebff0bf52eae3a637c6003e7f25fe9c Mon Sep 17 00:00:00 2001 From: Alex Barclay Date: Fri, 24 Jul 2026 09:56:23 -0600 Subject: [PATCH 06/15] test(engine): assert all 17 tasks columns in schema test --- tests/engine/test_models.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/engine/test_models.py b/tests/engine/test_models.py index b2e2bf3..164f474 100644 --- a/tests/engine/test_models.py +++ b/tests/engine/test_models.py @@ -15,7 +15,10 @@ def test_tasks_has_ordering_and_lease_columns(tmp_path): db = sqlite3.connect(tmp_path / "t.db") db.executescript(SCHEMA_SQL) cols = {r[1] for r in db.execute("PRAGMA table_info(tasks)")} - assert {"phase_order", "created_at", "claimed_at", "lease_expires", "version", "sim_cost"} <= cols + required_cols = {"task_id", "run_id", "phase", "phase_order", "agent_id", "input", + "depends_on", "status", "owner", "version", "attempts", "lease_expires", + "created_at", "claimed_at", "model", "sim_cost", "result"} + assert required_cols <= cols db.close() From 6935df51405987c01f3783760ae5d8d01b50d0d1 Mon Sep 17 00:00:00 2001 From: Alex Barclay Date: Fri, 24 Jul 2026 09:58:33 -0600 Subject: [PATCH 07/15] feat(engine): pure scheduler (seeding, readiness, advance, model-resolution) --- backend/engine/scheduler.py | 83 ++++++++++++++++++++++++++++++++++ tests/engine/test_scheduler.py | 75 ++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 backend/engine/scheduler.py create mode 100644 tests/engine/test_scheduler.py diff --git a/backend/engine/scheduler.py b/backend/engine/scheduler.py new file mode 100644 index 0000000..98f41fb --- /dev/null +++ b/backend/engine/scheduler.py @@ -0,0 +1,83 @@ +"""Pure scheduling logic. No DB, no I/O — takes plain dicts, returns plans.""" +from __future__ import annotations + +from backend.engine.phases import PhasesConfig + + +def task_id(run_id: str, phase: str, agent_id: str) -> str: + return f"{run_id}:{phase}:{agent_id}" + + +def seed_specs_for_phase( + cfg: PhasesConfig, run_id: str, phase_name: str, base_models: dict[str, str] +) -> list[dict]: + order = cfg.order_of(phase_name) + specs: list[dict] = [] + for aid, spec in cfg.agents_of(phase_name).items(): + specs.append( + { + "task_id": task_id(run_id, phase_name, aid), + "agent_id": aid, + "phase": phase_name, + "phase_order": order, + "depends_on": [task_id(run_id, phase_name, dep) for dep in spec.depends_on], + "sim_cost": spec.sim_cost, + "model": base_models.get(aid), + "input_keys": list(spec.reads), + } + ) + return specs + + +def compute_ready(tasks: list[dict], phases: list[dict]) -> list[str]: + open_phases = {p["name"] for p in phases if p["status"] == "open"} + done = {t["task_id"] for t in tasks if t["status"] == "done"} + ready: list[str] = [] + for t in tasks: + if t["status"] != "blocked" or t["phase"] not in open_phases: + continue + if all(dep in done for dep in t["depends_on"]): + ready.append(t["task_id"]) + return ready + + +def advance(phases: list[dict], tasks: list[dict], cfg: PhasesConfig) -> dict: + by_phase: dict[str, list[dict]] = {} + for t in tasks: + by_phase.setdefault(t["phase"], []).append(t) + + complete_phases: list[str] = [] + open_gates: list[str] = [] + open_phases: list[str] = [] + + ordered = sorted(phases, key=lambda p: p["phase_order"]) + for p in ordered: + if p["status"] != "open": + continue + pts = by_phase.get(p["name"], []) + # A phase completes only if seeded, non-empty, and all its tasks are done. + if not p.get("seeded") or not pts: + continue + if all(t["status"] == "done" for t in pts): + complete_phases.append(p["name"]) + gate = cfg.gate_of(p["name"]) + if gate != "none": + open_gates.append(gate) # next phase waits for submit_approval + else: + nxt = next((q for q in ordered if q["phase_order"] == p["phase_order"] + 1), None) + if nxt is not None: + open_phases.append(nxt["name"]) + return {"complete_phases": complete_phases, "open_gates": open_gates, "open_phases": open_phases} + + +def resolve_model( + agent_id: str, + base_model: str | None, + spend_ratio: float, + downgrade_paths: dict[str, str], + skip_list: set[str], + threshold: float = 0.85, +) -> str | None: + if base_model is None or agent_id in skip_list or spend_ratio < threshold: + return base_model + return downgrade_paths.get(base_model, base_model) diff --git a/tests/engine/test_scheduler.py b/tests/engine/test_scheduler.py new file mode 100644 index 0000000..6b41859 --- /dev/null +++ b/tests/engine/test_scheduler.py @@ -0,0 +1,75 @@ +from backend.engine import scheduler as sch +from backend.engine.phases import PhasesConfig + +CFG = PhasesConfig.load("config/phases.yaml") +BASE = {"clarifying_pm": "claude-3-5-sonnet-20241022", "database": "gpt-4o", + "backend": "claude-3-5-sonnet-20241022", "frontend": "claude-3-5-sonnet-20241022", + "ai_ml": "claude-3-5-sonnet-20241022", "qa_test": "gpt-4o", "security": "claude-3-5-sonnet-20241022"} + + +def test_seed_clarify_makes_one_task(): + seeds = sch.seed_specs_for_phase(CFG, "r1", "clarify", BASE) + assert len(seeds) == 1 + assert seeds[0]["task_id"] == "r1:clarify:clarifying_pm" + assert seeds[0]["depends_on"] == [] + + +def test_seed_code_maps_intra_phase_edges_to_task_ids(): + seeds = {s["agent_id"]: s for s in sch.seed_specs_for_phase(CFG, "r1", "code", BASE)} + assert seeds["backend"]["depends_on"] == ["r1:code:database"] + assert seeds["frontend"]["depends_on"] == ["r1:code:backend"] + assert seeds["ai_ml"]["depends_on"] == [] + + +def test_compute_ready_respects_deps_and_open_phase(): + phases = [{"name": "code", "status": "open"}] + tasks = [ + {"task_id": "d", "phase": "code", "status": "blocked", "depends_on": []}, + {"task_id": "b", "phase": "code", "status": "blocked", "depends_on": ["d"]}, + ] + ready = sch.compute_ready(tasks, phases) + assert ready == ["d"] # b blocked until d done + + +def test_compute_ready_skips_closed_phase(): + phases = [{"name": "code", "status": "blocked"}] + tasks = [{"task_id": "d", "phase": "code", "status": "blocked", "depends_on": []}] + assert sch.compute_ready(tasks, phases) == [] + + +def test_advance_completes_phase_and_opens_gate(): + phases = [ + {"name": "clarify", "phase_order": 0, "status": "open", "gate": "prd", "seeded": 1}, + {"name": "design", "phase_order": 1, "status": "blocked", "gate": "plan", "seeded": 0}, + ] + tasks = [{"task_id": "c", "phase": "clarify", "status": "done", "depends_on": []}] + plan = sch.advance(phases, tasks, CFG) + assert "clarify" in plan["complete_phases"] + assert "prd" in [g for g in plan["open_gates"]] # gate goes pending, next phase NOT opened yet + assert plan["open_phases"] == [] + + +def test_advance_ungated_opens_next_phase(): + phases = [ + {"name": "code", "phase_order": 2, "status": "open", "gate": "none", "seeded": 1}, + {"name": "test", "phase_order": 3, "status": "blocked", "gate": "none", "seeded": 0}, + ] + tasks = [{"task_id": "x", "phase": "code", "status": "done", "depends_on": []}] + plan = sch.advance(phases, tasks, CFG) + assert plan["complete_phases"] == ["code"] + assert plan["open_phases"] == ["test"] + + +def test_unseeded_or_empty_phase_never_completes(): + phases = [{"name": "test", "phase_order": 3, "status": "open", "gate": "none", "seeded": 0}] + plan = sch.advance(phases, [], CFG) + assert plan["complete_phases"] == [] + + +def test_resolve_model_downgrades_over_threshold(): + dp = {"gpt-4o": "gpt-4o-mini", "claude-3-5-sonnet-20241022": "claude-3-5-haiku-20241022"} + skip = {"clarifying_pm", "solution_architect"} + assert sch.resolve_model("qa_test", "gpt-4o", 0.90, dp, skip) == "gpt-4o-mini" + assert sch.resolve_model("qa_test", "gpt-4o", 0.50, dp, skip) == "gpt-4o" # under threshold + assert sch.resolve_model("solution_architect", "claude-3-5-sonnet-20241022", 0.99, dp, skip) == "claude-3-5-sonnet-20241022" # protected + assert sch.resolve_model("technical_writer", "gpt-4o-mini", 0.99, dp, skip) == "gpt-4o-mini" # no successor From f3f3d42bb30747dea33d387279243eeb62cc0040 Mon Sep 17 00:00:00 2001 From: Alex Barclay Date: Fri, 24 Jul 2026 10:04:43 -0600 Subject: [PATCH 08/15] test(engine): isolate advance seeded/empty guard + resolve_model edges --- tests/engine/test_scheduler.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/engine/test_scheduler.py b/tests/engine/test_scheduler.py index 6b41859..b8b81aa 100644 --- a/tests/engine/test_scheduler.py +++ b/tests/engine/test_scheduler.py @@ -73,3 +73,22 @@ def test_resolve_model_downgrades_over_threshold(): assert sch.resolve_model("qa_test", "gpt-4o", 0.50, dp, skip) == "gpt-4o" # under threshold assert sch.resolve_model("solution_architect", "claude-3-5-sonnet-20241022", 0.99, dp, skip) == "claude-3-5-sonnet-20241022" # protected assert sch.resolve_model("technical_writer", "gpt-4o-mini", 0.99, dp, skip) == "gpt-4o-mini" # no successor + + +def test_advance_guard_seeded_but_empty_never_completes(): + phases = [{"name": "test", "phase_order": 3, "status": "open", "gate": "none", "seeded": 1}] + assert sch.advance(phases, [], CFG)["complete_phases"] == [] + + +def test_advance_guard_unseeded_with_done_task_never_completes(): + phases = [{"name": "test", "phase_order": 3, "status": "open", "gate": "none", "seeded": 0}] + tasks = [{"task_id": "x", "phase": "test", "status": "done", "depends_on": []}] + assert sch.advance(phases, tasks, CFG)["complete_phases"] == [] + + +def test_resolve_model_edge_cases(): + dp = {"gpt-4o": "gpt-4o-mini"} + skip = {"clarifying_pm", "solution_architect"} + assert sch.resolve_model("backend", None, 0.99, dp, skip) is None # None passthrough + assert sch.resolve_model("qa_test", "gpt-4o", 0.85, dp, skip) == "gpt-4o-mini" # exact threshold downgrades + assert sch.resolve_model("clarifying_pm", "gpt-4o", 0.99, dp, skip) == "gpt-4o" # critical protected From 6a3cd14dd429c1be998732da8e4401b896aa8da8 Mon Sep 17 00:00:00 2001 From: Alex Barclay Date: Fri, 24 Jul 2026 10:07:17 -0600 Subject: [PATCH 09/15] feat(engine): store init, run seeding, versioned state CAS --- backend/engine/store.py | 119 +++++++++++++++++++++++++++++++ tests/engine/test_store_state.py | 33 +++++++++ 2 files changed, 152 insertions(+) create mode 100644 backend/engine/store.py create mode 100644 tests/engine/test_store_state.py diff --git a/backend/engine/store.py b/backend/engine/store.py new file mode 100644 index 0000000..70956e8 --- /dev/null +++ b/backend/engine/store.py @@ -0,0 +1,119 @@ +"""Single-writer SQLite store for the engine. All mutations under _db_lock.""" +from __future__ import annotations + +import asyncio +import json +import time +from typing import Any + +import aiosqlite + +from backend.engine import scheduler as sch +from backend.engine.models import SCHEMA_SQL +from backend.engine.phases import PhasesConfig + + +class Store: + def __init__(self, db_path: str, cfg: PhasesConfig, base_models: dict[str, str], lease_s: float = 120.0): + self.db_path = db_path + self.cfg = cfg + self.base_models = base_models + self.lease_s = lease_s + self._db: aiosqlite.Connection | None = None + self._db_lock = asyncio.Lock() + + def _now(self) -> float: + return time.time() + + async def connect(self) -> None: + self._db = await aiosqlite.connect(self.db_path) + self._db.row_factory = aiosqlite.Row + await self._db.execute("PRAGMA journal_mode=WAL") + await self._db.executescript(SCHEMA_SQL) + await self._db.commit() + + async def close(self) -> None: + if self._db is not None: + await self._db.close() + self._db = None + + async def create_run(self, run_id: str, idea: str, budget_limit: float) -> None: + async with self._db_lock: + now = self._now() + await self._db.execute( + "INSERT INTO runs (run_id, idea, budget_limit, created_at) VALUES (?,?,?,?)", + (run_id, idea, budget_limit, now), + ) + for name in self.cfg.phase_names: + order = self.cfg.order_of(name) + status = "open" if name == "clarify" else "blocked" + await self._db.execute( + "INSERT INTO phases (run_id, name, phase_order, status, gate, seeded) VALUES (?,?,?,?,?,0)", + (run_id, name, order, status, self.cfg.gate_of(name)), + ) + await self._seed_phase_locked(run_id, "clarify", now) + await self._recompute_ready_locked(run_id) + await self._db.commit() + + async def _seed_phase_locked(self, run_id: str, phase_name: str, now: float) -> None: + specs = sch.seed_specs_for_phase(self.cfg, run_id, phase_name, self.base_models) + for s in specs: + await self._db.execute( + """INSERT OR IGNORE INTO tasks + (task_id, run_id, phase, phase_order, agent_id, input, depends_on, + status, version, attempts, created_at, model, sim_cost) + VALUES (?,?,?,?,?,?,?,'blocked',0,0,?,?,?)""", + (s["task_id"], run_id, s["phase"], s["phase_order"], s["agent_id"], + json.dumps({"input_keys": s["input_keys"]}), json.dumps(s["depends_on"]), + now, s["model"], s["sim_cost"]), + ) + await self._db.execute( + "UPDATE phases SET seeded=1 WHERE run_id=? AND name=?", (run_id, phase_name) + ) + + async def _recompute_ready_locked(self, run_id: str) -> None: + tasks = await self._all_tasks(run_id) + phases = await self._all_phases(run_id) + ready_ids = sch.compute_ready(tasks, phases) + for tid in ready_ids: + await self._db.execute( + "UPDATE tasks SET status='ready' WHERE task_id=? AND status='blocked'", (tid,) + ) + + async def _all_tasks(self, run_id: str) -> list[dict[str, Any]]: + cur = await self._db.execute("SELECT * FROM tasks WHERE run_id=?", (run_id,)) + rows = await cur.fetchall() + out = [] + for r in rows: + d = dict(r) + d["depends_on"] = json.loads(d["depends_on"]) + out.append(d) + return out + + async def _all_phases(self, run_id: str) -> list[dict[str, Any]]: + cur = await self._db.execute("SELECT * FROM phases WHERE run_id=?", (run_id,)) + return [dict(r) for r in await cur.fetchall()] + + async def get_state(self, run_id: str, keys: list[str] | None = None) -> dict[str, tuple[Any, int]]: + if keys: + q = "SELECT key, value, version FROM state WHERE run_id=? AND key IN (%s)" % ",".join("?" * len(keys)) + cur = await self._db.execute(q, (run_id, *keys)) + else: + cur = await self._db.execute("SELECT key, value, version FROM state WHERE run_id=?", (run_id,)) + return {r["key"]: (json.loads(r["value"]), r["version"]) for r in await cur.fetchall()} + + async def put_state(self, run_id: str, key: str, value: Any, expected_version: int) -> bool: + async with self._db_lock: + if expected_version == 0: + cur = await self._db.execute( + "INSERT OR IGNORE INTO state (run_id, key, value, version) VALUES (?,?,?,1)", + (run_id, key, json.dumps(value)), + ) + await self._db.commit() + return cur.rowcount == 1 + cur = await self._db.execute( + "UPDATE state SET value=?, version=version+1 WHERE run_id=? AND key=? AND version=?", + (json.dumps(value), run_id, key, expected_version), + ) + await self._db.commit() + return cur.rowcount == 1 diff --git a/tests/engine/test_store_state.py b/tests/engine/test_store_state.py new file mode 100644 index 0000000..3bc06a4 --- /dev/null +++ b/tests/engine/test_store_state.py @@ -0,0 +1,33 @@ +import pytest + +from backend.engine.phases import PhasesConfig +from backend.engine.store import Store + +CFG = PhasesConfig.load("config/phases.yaml") +BASE = {aid: "gpt-4o" for aid in CFG.all_agent_ids()} + + +@pytest.fixture +async def store(tmp_path): + s = Store(str(tmp_path / "run.db"), CFG, BASE) + await s.connect() + yield s + await s.close() # close before tmp_path teardown (win32 WAL files) + + +async def test_create_run_seeds_clarify_ready(store): + await store.create_run("r1", "Build a todo app", 5.0) + tasks = await store._all_tasks("r1") + assert [t["agent_id"] for t in tasks] == ["clarifying_pm"] + assert tasks[0]["status"] == "ready" # clarify is open + no deps + + +async def test_put_state_cas_success_then_conflict(store): + await store.create_run("r1", "idea", 5.0) + assert await store.put_state("r1", "prd", {"text": "v1"}, expected_version=0) is True + got = await store.get_state("r1", ["prd"]) + assert got["prd"][0] == {"text": "v1"} and got["prd"][1] == 1 + # stale write with old version fails + assert await store.put_state("r1", "prd", {"text": "stale"}, expected_version=0) is False + # correct version succeeds + assert await store.put_state("r1", "prd", {"text": "v2"}, expected_version=1) is True From 4273754954c02a0fcbbc6bdaa0a7c7025429ecca Mon Sep 17 00:00:00 2001 From: Alex Barclay Date: Fri, 24 Jul 2026 10:13:23 -0600 Subject: [PATCH 10/15] fix(engine): rollback-safe _txn helper for store mutations --- backend/engine/store.py | 64 ++++++++++++++++++++++++----------------- 1 file changed, 38 insertions(+), 26 deletions(-) diff --git a/backend/engine/store.py b/backend/engine/store.py index 70956e8..a0344c9 100644 --- a/backend/engine/store.py +++ b/backend/engine/store.py @@ -4,6 +4,7 @@ import asyncio import json import time +from contextlib import asynccontextmanager from typing import Any import aiosqlite @@ -37,23 +38,33 @@ async def close(self) -> None: await self._db.close() self._db = None + @asynccontextmanager + async def _txn(self): + """Commit on success, rollback on any exception. Caller must already hold _db_lock.""" + try: + yield + await self._db.commit() + except Exception: + await self._db.rollback() + raise + async def create_run(self, run_id: str, idea: str, budget_limit: float) -> None: async with self._db_lock: - now = self._now() - await self._db.execute( - "INSERT INTO runs (run_id, idea, budget_limit, created_at) VALUES (?,?,?,?)", - (run_id, idea, budget_limit, now), - ) - for name in self.cfg.phase_names: - order = self.cfg.order_of(name) - status = "open" if name == "clarify" else "blocked" + async with self._txn(): + now = self._now() await self._db.execute( - "INSERT INTO phases (run_id, name, phase_order, status, gate, seeded) VALUES (?,?,?,?,?,0)", - (run_id, name, order, status, self.cfg.gate_of(name)), + "INSERT INTO runs (run_id, idea, budget_limit, created_at) VALUES (?,?,?,?)", + (run_id, idea, budget_limit, now), ) - await self._seed_phase_locked(run_id, "clarify", now) - await self._recompute_ready_locked(run_id) - await self._db.commit() + for name in self.cfg.phase_names: + order = self.cfg.order_of(name) + status = "open" if name == "clarify" else "blocked" + await self._db.execute( + "INSERT INTO phases (run_id, name, phase_order, status, gate, seeded) VALUES (?,?,?,?,?,0)", + (run_id, name, order, status, self.cfg.gate_of(name)), + ) + await self._seed_phase_locked(run_id, "clarify", now) + await self._recompute_ready_locked(run_id) async def _seed_phase_locked(self, run_id: str, phase_name: str, now: float) -> None: specs = sch.seed_specs_for_phase(self.cfg, run_id, phase_name, self.base_models) @@ -104,16 +115,17 @@ async def get_state(self, run_id: str, keys: list[str] | None = None) -> dict[st async def put_state(self, run_id: str, key: str, value: Any, expected_version: int) -> bool: async with self._db_lock: - if expected_version == 0: - cur = await self._db.execute( - "INSERT OR IGNORE INTO state (run_id, key, value, version) VALUES (?,?,?,1)", - (run_id, key, json.dumps(value)), - ) - await self._db.commit() - return cur.rowcount == 1 - cur = await self._db.execute( - "UPDATE state SET value=?, version=version+1 WHERE run_id=? AND key=? AND version=?", - (json.dumps(value), run_id, key, expected_version), - ) - await self._db.commit() - return cur.rowcount == 1 + async with self._txn(): + if expected_version == 0: + cur = await self._db.execute( + "INSERT OR IGNORE INTO state (run_id, key, value, version) VALUES (?,?,?,1)", + (run_id, key, json.dumps(value)), + ) + result = cur.rowcount == 1 + else: + cur = await self._db.execute( + "UPDATE state SET value=?, version=version+1 WHERE run_id=? AND key=? AND version=?", + (json.dumps(value), run_id, key, expected_version), + ) + result = cur.rowcount == 1 + return result From 657e97004dad411d475cd60d2a002aeb250f20e8 Mon Sep 17 00:00:00 2001 From: Alex Barclay Date: Fri, 24 Jul 2026 10:16:43 -0600 Subject: [PATCH 11/15] feat(engine): atomic claim, owner-guarded heartbeat, reaper --- backend/engine/store.py | 73 +++++++++++++++++++++++++++++++- tests/engine/test_store_claim.py | 49 +++++++++++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 tests/engine/test_store_claim.py diff --git a/backend/engine/store.py b/backend/engine/store.py index a0344c9..35adfd5 100644 --- a/backend/engine/store.py +++ b/backend/engine/store.py @@ -10,7 +10,7 @@ import aiosqlite from backend.engine import scheduler as sch -from backend.engine.models import SCHEMA_SQL +from backend.engine.models import SCHEMA_SQL, ClaimResult from backend.engine.phases import PhasesConfig @@ -129,3 +129,74 @@ async def put_state(self, run_id: str, key: str, value: Any, expected_version: i ) result = cur.rowcount == 1 return result + + async def _spend_ratio_locked(self, run_id: str) -> float: + 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 + cur = await self._db.execute("SELECT COALESCE(SUM(cost),0) AS s FROM spend WHERE run_id=?", (run_id,)) + spent = (await cur.fetchone())["s"] + return (spent / limit) if limit > 0 else 1.0 + + async def _downgrade_config(self) -> tuple[dict[str, str], set[str]]: + # Overridden/injected in Plan C to read budget.yaml; Plan A uses no downgrades. + return ({}, {"clarifying_pm", "solution_architect"}) + + async def claim_next_task(self, run_id: str, worker_id: str) -> ClaimResult | None: + async with self._db_lock: + async with self._txn(): + now = self._now() + cur = await self._db.execute( + """UPDATE tasks + SET status='claimed', owner=?, version=version+1, claimed_at=?, lease_expires=? + WHERE task_id = ( + SELECT task_id FROM tasks + WHERE run_id=? AND status='ready' + ORDER BY phase_order, created_at LIMIT 1) + AND status='ready' + RETURNING task_id, run_id, phase, phase_order, agent_id, input, model, version""", + (worker_id, now, now + self.lease_s, run_id), + ) + row = await cur.fetchone() + if row is None: + result = None + else: + ratio = await self._spend_ratio_locked(run_id) + paths, skip = await self._downgrade_config() + model = sch.resolve_model(row["agent_id"], row["model"], ratio, paths, skip) + if model != row["model"]: + await self._db.execute( + "UPDATE tasks SET model=? WHERE task_id=?", (model, row["task_id"]) + ) + input_keys = json.loads(row["input"]).get("input_keys", []) + state = await self.get_state(run_id, input_keys) if input_keys else {} + resolved_input = {k: v[0] for k, v in state.items()} + result = ClaimResult( + task_id=row["task_id"], run_id=row["run_id"], phase=row["phase"], + phase_order=row["phase_order"], agent_id=row["agent_id"], + input=resolved_input, model=model, version=row["version"], + ) + return result + + async def heartbeat(self, task_id: str, worker_id: str) -> bool: + async with self._db_lock: + async with self._txn(): + cur = await self._db.execute( + """UPDATE tasks SET lease_expires=? + WHERE task_id=? AND owner=? AND status IN ('claimed','running')""", + (self._now() + self.lease_s, task_id, worker_id), + ) + rowcount = cur.rowcount + return rowcount == 1 + + async def reap_expired(self) -> int: + async with self._db_lock: + async with self._txn(): + cur = await self._db.execute( + """UPDATE tasks + SET status='ready', owner=NULL, version=version+1, attempts=attempts+1, lease_expires=NULL + WHERE status IN ('claimed','running') AND lease_expires IS NOT NULL AND lease_expires < ?""", + (self._now(),), + ) + rowcount = cur.rowcount + return rowcount diff --git a/tests/engine/test_store_claim.py b/tests/engine/test_store_claim.py new file mode 100644 index 0000000..8e5c182 --- /dev/null +++ b/tests/engine/test_store_claim.py @@ -0,0 +1,49 @@ +import pytest + +from backend.engine.phases import PhasesConfig +from backend.engine.store import Store + +CFG = PhasesConfig.load("config/phases.yaml") +BASE = {aid: "gpt-4o" for aid in CFG.all_agent_ids()} + + +@pytest.fixture +async def store(tmp_path): + s = Store(str(tmp_path / "run.db"), CFG, BASE, lease_s=100.0) + await s.connect() + await s.create_run("r1", "idea", 200.0) + yield s + await s.close() + + +async def test_claim_returns_clarify_task_once(store): + c1 = await store.claim_next_task("r1", "w1") + assert c1 is not None and c1.agent_id == "clarifying_pm" and c1.version == 1 + c2 = await store.claim_next_task("r1", "w2") + assert c2 is None # only one ready task, already claimed + + +async def test_claim_assembles_input_from_state(store): + await store.put_state("r1", "idea", {"text": "todo app"}, expected_version=0) + c = await store.claim_next_task("r1", "w1") + assert "idea" in c.input # clarifying_pm reads [idea] + + +async def test_heartbeat_owner_guarded(store): + c = await store.claim_next_task("r1", "w1") + assert await store.heartbeat(c.task_id, "w1") is True + assert await store.heartbeat(c.task_id, "someone_else") is False + + +async def test_reaper_reverts_and_bumps_version(store): + c = await store.claim_next_task("r1", "w1") + # force the lease into the past + await store._db.execute("UPDATE tasks SET lease_expires=? WHERE task_id=?", (0.0, c.task_id)) + await store._db.commit() + n = await store.reap_expired() + assert n == 1 + tasks = {t["task_id"]: t for t in await store._all_tasks("r1")} + t = tasks[c.task_id] + assert t["status"] == "ready" and t["version"] == 2 and t["attempts"] == 1 + # zombie w1 (holding version 1) can no longer heartbeat + assert await store.heartbeat(c.task_id, "w1") is False From e04cf327b50d4a588f9d21614d87f36b7228a35d Mon Sep 17 00:00:00 2001 From: Alex Barclay Date: Fri, 24 Jul 2026 10:24:01 -0600 Subject: [PATCH 12/15] feat(engine): single-tx complete_task, approval gates, fail/retry --- backend/engine/store.py | 72 +++++++++++++++++++++++++++++ tests/engine/test_store_complete.py | 59 +++++++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 tests/engine/test_store_complete.py diff --git a/backend/engine/store.py b/backend/engine/store.py index 35adfd5..58c577a 100644 --- a/backend/engine/store.py +++ b/backend/engine/store.py @@ -15,6 +15,8 @@ class Store: + MAX_ATTEMPTS = 3 + def __init__(self, db_path: str, cfg: PhasesConfig, base_models: dict[str, str], lease_s: float = 120.0): self.db_path = db_path self.cfg = cfg @@ -200,3 +202,73 @@ async def reap_expired(self) -> int: ) rowcount = cur.rowcount return rowcount + + async def spend_total(self, run_id: str) -> float: + cur = await self._db.execute("SELECT COALESCE(SUM(cost),0) AS s FROM spend WHERE run_id=?", (run_id,)) + return (await cur.fetchone())["s"] + + async def _advance_locked(self, run_id: str) -> None: + now = self._now() + plan = sch.advance(await self._all_phases(run_id), await self._all_tasks(run_id), self.cfg) + for name in plan["complete_phases"]: + await self._db.execute("UPDATE phases SET status='complete' WHERE run_id=? AND name=?", (run_id, name)) + if self.cfg.gate_of(name) != "none": + await self._db.execute("UPDATE phases SET gate='pending' WHERE run_id=? AND name=?", (run_id, name)) + for name in plan["open_phases"]: + await self._db.execute("UPDATE phases SET status='open' WHERE run_id=? AND name=?", (run_id, name)) + await self._seed_phase_locked(run_id, name, now) + await self._recompute_ready_locked(run_id) + + async def complete_task(self, task_id, worker_id, version, result, state_writes=None, spawn_tasks=None) -> bool: + async with self._db_lock: + async with self._txn(): + cur = await self._db.execute( + "UPDATE tasks SET status='done', result=? WHERE task_id=? AND owner=? AND version=? AND status IN ('claimed','running')", + (json.dumps(result), task_id, worker_id, version)) + if cur.rowcount != 1: + ok = False + else: + trow = await (await self._db.execute( + "SELECT run_id, agent_id, model, sim_cost FROM tasks WHERE task_id=?", (task_id,))).fetchone() + run_id = trow["run_id"] + for k, v in (state_writes or {}).items(): + await self._db.execute( + "INSERT INTO state (run_id, key, value, version) VALUES (?,?,?,1) " + "ON CONFLICT(run_id, key) DO UPDATE SET value=excluded.value, version=state.version+1", + (run_id, k, json.dumps(v))) + await self._db.execute( + "INSERT INTO spend (run_id, task_id, agent_id, cost, model, ts) VALUES (?,?,?,?,?,?)", + (run_id, task_id, trow["agent_id"], trow["sim_cost"], trow["model"], self._now())) + await self._advance_locked(run_id) + ok = True + return ok + + async def fail_task(self, task_id, worker_id, version, error) -> None: + async with self._db_lock: + async with self._txn(): + trow = await (await self._db.execute( + "SELECT run_id, attempts FROM tasks WHERE task_id=? AND owner=? AND version=?", + (task_id, worker_id, version))).fetchone() + if trow is not None: + attempts = trow["attempts"] + 1 + if attempts >= self.MAX_ATTEMPTS: + await self._db.execute("UPDATE tasks SET status='failed', attempts=? WHERE task_id=?", (attempts, task_id)) + await self._db.execute("UPDATE runs SET status='failed' WHERE run_id=?", (trow["run_id"],)) + else: + await self._db.execute("UPDATE tasks SET status='ready', owner=NULL, version=version+1, attempts=? WHERE task_id=?", (attempts, task_id)) + + async def submit_approval(self, run_id: str, phase: str, decision: str) -> None: + async with self._db_lock: + async with self._txn(): + now = self._now() + if decision == "approved": + await self._db.execute("UPDATE phases SET gate='approved' WHERE run_id=? AND name=?", (run_id, phase)) + order = self.cfg.order_of(phase) + nxt = next((n for n in self.cfg.phase_names if self.cfg.order_of(n) == order + 1), None) + if nxt is not None: + await self._db.execute("UPDATE phases SET status='open' WHERE run_id=? AND name=?", (run_id, nxt)) + await self._seed_phase_locked(run_id, nxt, now) + elif decision == "rejected": + await self._db.execute("UPDATE phases SET gate='rejected', status='open' WHERE run_id=? AND name=?", (run_id, phase)) + await self._db.execute("UPDATE tasks SET status='ready', owner=NULL WHERE run_id=? AND phase=?", (run_id, phase)) + await self._recompute_ready_locked(run_id) diff --git a/tests/engine/test_store_complete.py b/tests/engine/test_store_complete.py new file mode 100644 index 0000000..4bb3b09 --- /dev/null +++ b/tests/engine/test_store_complete.py @@ -0,0 +1,59 @@ +import pytest + +from backend.engine.phases import PhasesConfig +from backend.engine.store import Store + +CFG = PhasesConfig.load("config/phases.yaml") +BASE = {aid: "gpt-4o" for aid in CFG.all_agent_ids()} + + +@pytest.fixture +async def store(tmp_path): + s = Store(str(tmp_path / "run.db"), CFG, BASE, lease_s=100.0) + await s.connect() + await s.create_run("r1", "idea", 200.0) + yield s + await s.close() + + +async def test_complete_guard_rejects_wrong_version(store): + c = await store.claim_next_task("r1", "w1") + assert await store.complete_task(c.task_id, "w1", version=999, result={"ok": True}) is False + assert await store.complete_task(c.task_id, "w1", version=c.version, result={"ok": True}) is True + + +async def test_complete_records_spend_and_writes_state(store): + c = await store.claim_next_task("r1", "w1") + await store.complete_task(c.task_id, "w1", c.version, result={"prd": "PRD text"}, + state_writes={"prd": "PRD text"}) + assert await store.spend_total("r1") == pytest.approx(0.30) # clarifying_pm sim_cost + st = await store.get_state("r1", ["prd"]) + assert st["prd"][0] == "PRD text" + + +async def test_clarify_completion_sets_prd_gate_pending_not_design(store): + c = await store.claim_next_task("r1", "w1") + await store.complete_task(c.task_id, "w1", c.version, result={"prd": "x"}) + phases = {p["name"]: p for p in await store._all_phases("r1")} + assert phases["clarify"]["status"] == "complete" + assert phases["clarify"]["gate"] == "pending" + assert phases["design"]["status"] == "blocked" # gated: not opened yet + assert await store.claim_next_task("r1", "w2") is None # nothing claimable behind the gate + + +async def test_submit_approval_opens_and_seeds_design(store): + c = await store.claim_next_task("r1", "w1") + await store.complete_task(c.task_id, "w1", c.version, result={"prd": "x"}) + await store.submit_approval("r1", "clarify", "approved") + phases = {p["name"]: p for p in await store._all_phases("r1")} + assert phases["design"]["status"] == "open" and phases["design"]["seeded"] == 1 + # design fans out to 3 ready tasks + ready = [t for t in await store._all_tasks("r1") if t["status"] == "ready"] + assert sorted(t["agent_id"] for t in ready) == ["solution_architect", "tech_lead", "uiux_designer"] + + +async def test_fail_task_requeues_until_cap(store): + c = await store.claim_next_task("r1", "w1") + await store.fail_task(c.task_id, "w1", c.version, "boom") + t = next(t for t in await store._all_tasks("r1") if t["task_id"] == c.task_id) + assert t["status"] == "ready" and t["attempts"] == 1 From 47e5cf9a996a88591313176685e63939653971c7 Mon Sep 17 00:00:00 2001 From: Alex Barclay Date: Fri, 24 Jul 2026 10:33:11 -0600 Subject: [PATCH 13/15] fix(engine): fail_task status guard, scheduler-driven reject re-ready, cap test --- backend/engine/store.py | 5 +++-- tests/engine/test_store_complete.py | 12 ++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/backend/engine/store.py b/backend/engine/store.py index 58c577a..fd32747 100644 --- a/backend/engine/store.py +++ b/backend/engine/store.py @@ -210,6 +210,7 @@ async def spend_total(self, run_id: str) -> float: async def _advance_locked(self, run_id: str) -> None: now = self._now() plan = sch.advance(await self._all_phases(run_id), await self._all_tasks(run_id), self.cfg) + # gate-pending is derived below via cfg.gate_of; plan["open_gates"] is intentionally unused here. for name in plan["complete_phases"]: await self._db.execute("UPDATE phases SET status='complete' WHERE run_id=? AND name=?", (run_id, name)) if self.cfg.gate_of(name) != "none": @@ -247,7 +248,7 @@ async def fail_task(self, task_id, worker_id, version, error) -> None: async with self._db_lock: async with self._txn(): trow = await (await self._db.execute( - "SELECT run_id, attempts FROM tasks WHERE task_id=? AND owner=? AND version=?", + "SELECT run_id, attempts FROM tasks WHERE task_id=? AND owner=? AND version=? AND status IN ('claimed','running')", (task_id, worker_id, version))).fetchone() if trow is not None: attempts = trow["attempts"] + 1 @@ -270,5 +271,5 @@ async def submit_approval(self, run_id: str, phase: str, decision: str) -> None: await self._seed_phase_locked(run_id, nxt, now) elif decision == "rejected": await self._db.execute("UPDATE phases SET gate='rejected', status='open' WHERE run_id=? AND name=?", (run_id, phase)) - await self._db.execute("UPDATE tasks SET status='ready', owner=NULL WHERE run_id=? AND phase=?", (run_id, phase)) + await self._db.execute("UPDATE tasks SET status='blocked', owner=NULL WHERE run_id=? AND phase=?", (run_id, phase)) await self._recompute_ready_locked(run_id) diff --git a/tests/engine/test_store_complete.py b/tests/engine/test_store_complete.py index 4bb3b09..fc39711 100644 --- a/tests/engine/test_store_complete.py +++ b/tests/engine/test_store_complete.py @@ -57,3 +57,15 @@ async def test_fail_task_requeues_until_cap(store): await store.fail_task(c.task_id, "w1", c.version, "boom") t = next(t for t in await store._all_tasks("r1") if t["task_id"] == c.task_id) assert t["status"] == "ready" and t["attempts"] == 1 + assert t["owner"] is None + + +async def test_fail_task_reaches_cap_and_fails_run(store): + for _ in range(3): + c = await store.claim_next_task("r1", "w1") + assert c is not None + await store.fail_task(c.task_id, "w1", c.version, "boom") + t = next(t for t in await store._all_tasks("r1") if t["agent_id"] == "clarifying_pm") + assert t["status"] == "failed" and t["attempts"] == 3 + cur = await store._db.execute("SELECT status FROM runs WHERE run_id='r1'") + assert (await cur.fetchone())["status"] == "failed" From 15e2628f34405cb6bf373be679722624cef65157 Mon Sep 17 00:00:00 2001 From: Alex Barclay Date: Fri, 24 Jul 2026 10:38:39 -0600 Subject: [PATCH 14/15] feat(engine): budget downgrade_paths (authoritative) + downgrade_model_for; drop dead registry accessors --- backend/agents/budget_guard.py | 7 +++++++ backend/agents/registry.py | 12 ------------ backend/config.py | 10 ++++++++++ backend/engine/phases.py | 5 +++++ config/agents.yaml | 18 +----------------- config/budget.yaml | 6 ++++++ tests/engine/test_budget_downgrade_unit.py | 15 +++++++++++++++ tests/unit/test_agent_registry.py | 10 ---------- 8 files changed, 44 insertions(+), 39 deletions(-) create mode 100644 tests/engine/test_budget_downgrade_unit.py diff --git a/backend/agents/budget_guard.py b/backend/agents/budget_guard.py index 629f90b..a4db149 100644 --- a/backend/agents/budget_guard.py +++ b/backend/agents/budget_guard.py @@ -113,6 +113,7 @@ def __init__( self.config_path = Path(config_path) self.budget_config: dict[str, Any] = {} + self.downgrade_paths: dict[str, str] = {} self.state = BudgetState() self._load_config() @@ -134,6 +135,8 @@ def _load_config(self) -> None: with open(self.config_path) as f: self.budget_config = yaml.safe_load(f) + self.downgrade_paths = self.budget_config.get("downgrade_paths", {}) + budget = self.budget_config.get("budget", {}) self.state.hard_limit = budget.get("hard_limit", 200.0) logger.debug("budget_guard.config_loaded", limit=self.state.hard_limit) @@ -269,6 +272,10 @@ def can_spend(self, estimated_cost: float) -> tuple[bool, str | None]: return True, None + def downgrade_model_for(self, current_model: str) -> str | None: + """Return the cheaper model for `current_model`, or None if there is no successor.""" + return self.downgrade_paths.get(current_model) + def get_downgrade_targets(self) -> list[tuple[str, str]]: """ Get list of agents to downgrade based on current spend. diff --git a/backend/agents/registry.py b/backend/agents/registry.py index 910869a..5982d5b 100644 --- a/backend/agents/registry.py +++ b/backend/agents/registry.py @@ -451,18 +451,6 @@ def reload(self) -> None: with self._lock: self._instances.clear() - def get_budget_config(self) -> dict[str, Any]: - """Get budget configuration from the YAML file.""" - with open(self.config_path) as f: - raw_config = yaml.safe_load(f) - return raw_config.get("budget", {}) - - def get_downgrade_paths(self) -> dict[str, str]: - """Get model downgrade paths from the YAML file.""" - with open(self.config_path) as f: - raw_config = yaml.safe_load(f) - return raw_config.get("downgrade_paths", {}) - # Global registry instance (singleton pattern) _registry: AgentRegistry | None = None diff --git a/backend/config.py b/backend/config.py index b93d0a3..b7d4566 100644 --- a/backend/config.py +++ b/backend/config.py @@ -57,6 +57,11 @@ class Config: sqlite_path: str max_clarifying_questions: int enable_phase4: bool + engine_lease_ttl: float = 120.0 + engine_heartbeat_interval: float = 20.0 + engine_reaper_interval: float = 30.0 + engine_worker_count: int = 4 + engine_max_attempts: int = 3 agents_yaml: dict[str, Any] = field(default_factory=dict) budget_yaml: dict[str, Any] = field(default_factory=dict) llm_yaml: dict[str, Any] = field(default_factory=dict) @@ -74,6 +79,11 @@ def load(cls) -> Config: 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), + engine_heartbeat_interval=_env_float("ENGINE_HEARTBEAT_INTERVAL", 20.0), + engine_reaper_interval=_env_float("ENGINE_REAPER_INTERVAL", 30.0), + engine_worker_count=_env_int("ENGINE_WORKER_COUNT", 4), + engine_max_attempts=_env_int("ENGINE_MAX_ATTEMPTS", 3), agents_yaml=_load_yaml(CONFIG_DIR / "agents.yaml"), budget_yaml=_load_yaml(CONFIG_DIR / "budget.yaml"), llm_yaml=_load_yaml(CONFIG_DIR / "llm.yaml"), diff --git a/backend/engine/phases.py b/backend/engine/phases.py index e6b89af..1d3052e 100644 --- a/backend/engine/phases.py +++ b/backend/engine/phases.py @@ -74,3 +74,8 @@ def agents_of(self, name: str) -> dict[str, AgentSpec]: def all_agent_ids(self) -> list[str]: return [aid for p in self._phases for aid in p.agents] + + +def load_downgrade_paths(path: str = "config/budget.yaml") -> dict[str, str]: + import yaml + return yaml.safe_load(Path(path).read_text(encoding="utf-8")).get("downgrade_paths", {}) diff --git a/config/agents.yaml b/config/agents.yaml index 338ec88..c9099c2 100644 --- a/config/agents.yaml +++ b/config/agents.yaml @@ -1,5 +1,5 @@ # DevTeam.AI Agent Configuration -# All 15 agents with placeholder LLM configuration +# All 16 agents with placeholder LLM configuration # Version: 1.0 agents: @@ -203,22 +203,6 @@ agents: temperature: 0.6 max_tokens: 4096 -# Budget thresholds (managed by BudgetGuard) -budget: - default_limit: 200.0 # USD - thresholds: - log_only: 0.50 # 50% - notify_user: 0.75 # 75% - auto_downgrade: 0.85 # 85% - require_ack: 0.95 # 95% - hard_stop: 1.00 # 100% - -# Model downgrade paths for cost optimization -downgrade_paths: - claude-3-5-sonnet-20241022: "claude-3-5-haiku-20241022" - gpt-4o: "gpt-4o-mini" - claude-3-5-haiku-20241022: "gpt-4o-mini" - # Eco mode alternatives (open-source) eco_mode: enabled: false diff --git a/config/budget.yaml b/config/budget.yaml index 620e379..d5c2092 100644 --- a/config/budget.yaml +++ b/config/budget.yaml @@ -138,3 +138,9 @@ logging: threshold_crossings: true downgrade_events: true override_events: true + +# Model -> cheaper model. Authoritative (moved out of agents.yaml). +downgrade_paths: + claude-3-5-sonnet-20241022: claude-3-5-haiku-20241022 + gpt-4o: gpt-4o-mini + claude-3-5-haiku-20241022: gpt-4o-mini diff --git a/tests/engine/test_budget_downgrade_unit.py b/tests/engine/test_budget_downgrade_unit.py new file mode 100644 index 0000000..876c7fa --- /dev/null +++ b/tests/engine/test_budget_downgrade_unit.py @@ -0,0 +1,15 @@ +import yaml + +from backend.agents.budget_guard import BudgetGuard + + +def test_downgrade_model_for_uses_budget_yaml_paths(): + bg = BudgetGuard(config_path="config/budget.yaml") + assert bg.downgrade_model_for("gpt-4o") == "gpt-4o-mini" + assert bg.downgrade_model_for("claude-3-5-sonnet-20241022") == "claude-3-5-haiku-20241022" + assert bg.downgrade_model_for("gpt-4o-mini") is None # no successor + + +def test_budget_yaml_has_downgrade_paths(): + cfg = yaml.safe_load(open("config/budget.yaml")) + assert "downgrade_paths" in cfg and cfg["downgrade_paths"]["gpt-4o"] == "gpt-4o-mini" diff --git a/tests/unit/test_agent_registry.py b/tests/unit/test_agent_registry.py index 344f39d..ad57c7b 100644 --- a/tests/unit/test_agent_registry.py +++ b/tests/unit/test_agent_registry.py @@ -257,16 +257,6 @@ def test_auto_reload_detects_changes(self, temp_config_dir): registry.stop_reload_watcher() -class TestBudgetConfig: - """Tests for budget-related config retrieval.""" - - def test_get_budget_config(self, registry): - """Should retrieve budget configuration.""" - budget = registry.get_budget_config() - assert budget["default_limit"] == 100.0 - assert "thresholds" in budget - - class TestGlobalRegistry: """Tests for the global registry singleton.""" From b8f93b84af79824926a879c32b164e69b4ee2aa9 Mon Sep 17 00:00:00 2001 From: Alex Barclay Date: Fri, 24 Jul 2026 10:51:23 -0600 Subject: [PATCH 15/15] refactor(engine): raise on invalid phases config + self-ref guard; minor cleanups --- backend/engine/phases.py | 11 ++++++++--- tests/unit/test_config.py | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/backend/engine/phases.py b/backend/engine/phases.py index 1d3052e..af639cf 100644 --- a/backend/engine/phases.py +++ b/backend/engine/phases.py @@ -53,11 +53,17 @@ def load(cls, path: str = "config/phases.yaml") -> "PhasesConfig": def _validate(self) -> None: orders = [p.order for p in self._phases] - assert orders == list(range(len(self._phases))), f"phase orders must be 0..N: {orders}" + if orders != list(range(len(self._phases))): + raise ValueError(f"phase orders must be 0..N-1, got {orders}") for p in self._phases: for aid, spec in p.agents.items(): for dep in spec.depends_on: - assert dep in p.agents, f"{p.name}.{aid} depends on unknown intra-phase agent {dep!r}" + if dep == aid: + raise ValueError(f"{p.name}.{aid} depends on itself") + if dep not in p.agents: + raise ValueError( + f"{p.name}.{aid} depends on unknown intra-phase agent {dep!r}" + ) @property def phase_names(self) -> list[str]: @@ -77,5 +83,4 @@ def all_agent_ids(self) -> list[str]: def load_downgrade_paths(path: str = "config/budget.yaml") -> dict[str, str]: - import yaml return yaml.safe_load(Path(path).read_text(encoding="utf-8")).get("downgrade_paths", {}) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index b7ae02b..8b2700e 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -43,7 +43,7 @@ def test_config_reads_env(monkeypatch): def test_config_loads_yaml_files(): cfg = Config.load() - # agents.yaml has top-level `agents:` dict containing the 15 agents + # agents.yaml has top-level `agents:` dict containing the 16 agents assert "agents" in cfg.agents_yaml assert "clarifying_pm" in cfg.agents_yaml["agents"] # budget.yaml has top-level `budget:` dict with thresholds under warning_levels