From 53f42d3958dfe44f9d8949bba3aed22dc7c30bfd Mon Sep 17 00:00:00 2001 From: "Matt (via Claude Code)" Date: Sun, 19 Apr 2026 13:18:39 -0500 Subject: [PATCH] =?UTF-8?q?refactor:=20wave=203b=20=E2=80=94=20split=20api?= =?UTF-8?q?/routes.py=20into=20a=20package?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api/routes.py was 704 LOC, the second-biggest backend file after queries.py (split in wave 3a). Split by lifecycle concern: routes/ __init__.py (22) barrel: single APIRouter(prefix="/api") that includes the sub-routers _helpers.py (126) classify_run_via_taxonomist — shared between evolve handlers, not a route itself evolve.py (214) POST /api/evolve + POST /api/evolve/from-parent — endpoints that START runs runs.py (402) POST /api/runs/{id}/cancel + every GET for reading run state Largest submodule is now 402 LOC, under the 500-LOC ceiling in docs/clean-code.md §2. Also cleaned up one residual broad except in classify_run_via_taxonomist (logger.warning -> logger.exception). Test patches retargeted ----------------------- Tests that used `patch("skillforge.api.routes.get_run", ...)` etc. were patching the import side of the old monolith. They're updated to target the new symbol location — `...routes.evolve.get_run` for tests hitting POST endpoints and `...routes.runs.get_run` for tests hitting GET endpoints. QA -- ruff check skillforge - clean mypy skillforge - 64 files pass pytest tests/ - 403 passed, 2 skipped, 0 failed Co-Authored-By: Claude Opus 4.7 (1M context) --- skillforge/api/routes/__init__.py | 22 ++ skillforge/api/routes/_helpers.py | 126 +++++++ skillforge/api/routes/evolve.py | 214 ++++++++++++ skillforge/api/{routes.py => routes/runs.py} | 330 +------------------ tests/test_api.py | 28 +- tests/test_evolve_taxonomist_integration.py | 8 +- tests/test_seeds.py | 26 +- tests/test_uploads.py | 6 +- 8 files changed, 410 insertions(+), 350 deletions(-) create mode 100644 skillforge/api/routes/__init__.py create mode 100644 skillforge/api/routes/_helpers.py create mode 100644 skillforge/api/routes/evolve.py rename skillforge/api/{routes.py => routes/runs.py} (54%) diff --git a/skillforge/api/routes/__init__.py b/skillforge/api/routes/__init__.py new file mode 100644 index 0000000..c36b676 --- /dev/null +++ b/skillforge/api/routes/__init__.py @@ -0,0 +1,22 @@ +"""API routes package — split by lifecycle concern. + +- ``evolve`` — POST endpoints that start / fork evolution runs +- ``runs`` — POST cancel + all GET endpoints for reading run state + +``router`` here is the single ``APIRouter(prefix="/api")`` that +``skillforge.main`` mounts. Submodules expose their own un-prefixed +routers and we include them below — same public URLs as before the +split. +""" + +from __future__ import annotations + +from fastapi import APIRouter + +from skillforge.api.routes import evolve, runs + +router = APIRouter(prefix="/api") +router.include_router(evolve.router) +router.include_router(runs.router) + +__all__ = ["router"] diff --git a/skillforge/api/routes/_helpers.py b/skillforge/api/routes/_helpers.py new file mode 100644 index 0000000..150735a --- /dev/null +++ b/skillforge/api/routes/_helpers.py @@ -0,0 +1,126 @@ +"""Shared helpers for the routes submodules. + +Kept out of ``__init__.py`` so the barrel stays focused on wiring the +router hierarchy, and out of the route modules so both ``evolve.py`` +and ``runs.py`` can import the same classifier implementation. +""" + +from __future__ import annotations + +import logging + +from skillforge.models import EvolutionRun + +logger = logging.getLogger("skillforge.api") + + +async def classify_run_via_taxonomist( + run: EvolutionRun, requested_mode: str | None +) -> None: + """Best-effort: classify the run, persist family + new nodes, stamp the run. + + Sets ``run.family_id`` and ``run.evolution_mode`` in place. If + ``requested_mode`` is "atomic" or "molecular" the explicit value wins + over whatever the Taxonomist returns. If the Taxonomist call fails for + any reason — missing API key, network error, JSON parse failure — we + log it, leave ``family_id`` as None, default ``evolution_mode`` to + "molecular", and let the run proceed. + """ + from skillforge.config import ANTHROPIC_API_KEY + from skillforge.db import get_taxonomy_tree, list_families + from skillforge.engine.events import emit + + # No API key → skip classification entirely + if not ANTHROPIC_API_KEY: + run.evolution_mode = requested_mode or "molecular" + return + + # Skip the LLM call when the caller explicitly forced a mode AND specified + # no specialization that needs classification (the autoclassify is the + # whole point of running the agent — if mode is forced, just stamp it). + if requested_mode in {"atomic", "molecular"} and not run.specialization: + run.evolution_mode = requested_mode + return + + try: + from skillforge.agents.taxonomist import classify_and_decompose + + taxonomy_tree = await get_taxonomy_tree() + existing_families = await list_families() + result = await classify_and_decompose( + run.specialization, + taxonomy_tree, + existing_families, + ) + except Exception: # noqa: BLE001 — taxonomist best-effort; fall back to molecular + logger.exception( + "run=%s taxonomist classification failed — defaulting to molecular", + run.id[:8], + ) + run.evolution_mode = requested_mode or "molecular" + return + + run.family_id = result.family.id + # Caller's explicit mode wins over the Taxonomist's recommendation + run.evolution_mode = requested_mode or result.evolution_mode + + await emit( + run.id, + "taxonomy_classified", + family_id=result.family.id, + family_slug=result.family.slug, + domain_slug=result.domain.slug, + focus_slug=result.focus.slug, + language_slug=result.language.slug, + evolution_mode=run.evolution_mode, + created_new_nodes=result.created_new_nodes, + ) + + if result.evolution_mode == "atomic": + await emit( + run.id, + "decomposition_complete", + dimension_count=len(result.variant_dimensions), + dimensions=[d.to_dict() for d in result.variant_dimensions], + reuse_recommendations=[ + r.to_dict() for r in result.reuse_recommendations + ], + ) + + # Persist a VariantEvolution row per dimension ONLY if the run will + # actually execute in atomic mode (the final stamped mode, which may + # have been overridden by the caller). The variant_evolutions FK + # requires the parent run to exist, so we save_run first. + if ( + run.evolution_mode == "atomic" + and result.evolution_mode == "atomic" + and result.variant_dimensions + ): + from datetime import UTC as _UTC + from datetime import datetime as _dt + from uuid import uuid4 as _uuid4 + + # Insert the parent run row first so the FK on + # variant_evolutions.parent_run_id is satisfied. save_run is + # idempotent (INSERT OR REPLACE) so the second save_run later in + # the route handler is a no-op refresh. + from skillforge.db import save_run as _save_run + from skillforge.db import save_variant_evolution + from skillforge.models import VariantEvolution + + await _save_run(run) + + for dim in result.variant_dimensions: + await save_variant_evolution( + VariantEvolution( + id=f"vevo_{_uuid4().hex[:12]}", + family_id=result.family.id, + dimension=dim.name, + tier=dim.tier, + parent_run_id=run.id, + population_size=2, + num_generations=1, + status="pending", + created_at=_dt.now(_UTC), + ) + ) diff --git a/skillforge/api/routes/evolve.py b/skillforge/api/routes/evolve.py new file mode 100644 index 0000000..50233bd --- /dev/null +++ b/skillforge/api/routes/evolve.py @@ -0,0 +1,214 @@ +"""POST endpoints that start evolution runs. + +- POST /api/evolve — start a fresh run from a specialization +- POST /api/evolve/from-parent — fork from a registry skill, upload, or + inline generated skill +""" + +from __future__ import annotations + +import asyncio +import logging +import uuid +from datetime import UTC, datetime + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from skillforge.api.routes._helpers import classify_run_via_taxonomist +from skillforge.api.schemas import EvolveRequest, EvolveResponse, Mode +from skillforge.api.uploads import clear_upload, get_upload +from skillforge.config import invite_code_valid +from skillforge.db.database import init_db +from skillforge.db.queries import get_run, save_run +from skillforge.engine.evolution import run_evolution +from skillforge.engine.run_registry import registry +from skillforge.models import EvolutionRun, SkillGenome + +logger = logging.getLogger("skillforge.api.evolve") +router = APIRouter() + + +@router.post("/evolve", response_model=EvolveResponse) +async def start_evolution(req: EvolveRequest) -> EvolveResponse: + """Start a new evolution run and return its ID + WebSocket URL. + + Validates the request, creates an EvolutionRun, persists it, and spawns + a background task that runs the evolution loop. Returns immediately — + the client subscribes to the WebSocket to watch progress. + """ + # Invite gating — returns True if gating is disabled OR the code is valid + if not invite_code_valid(req.invite_code): + raise HTTPException( + status_code=403, + detail="This platform is invite-only. Enter a valid invite code or request one.", + ) + + # Mode-specific validation + if req.mode == Mode.domain and not req.specialization: + raise HTTPException(status_code=400, detail="domain mode requires 'specialization'") + if req.mode == Mode.meta: + raise HTTPException(status_code=501, detail="meta mode is v1.1, not yet supported") + + # Ensure DB is initialized (idempotent) + await init_db() + + run = EvolutionRun( + id=str(uuid.uuid4()), + mode=req.mode.value, + specialization=req.specialization or "", + population_size=req.population_size, + num_generations=req.num_generations, + max_budget_usd=req.max_budget_usd, + status="pending", + created_at=datetime.now(UTC), + ) + + # v2.0 — Taxonomist classification before evolution starts. Best-effort: + # the run still proceeds in molecular mode if the LLM call fails or no + # API key is available, so we never block submission on classification. + await classify_run_via_taxonomist(run, req.evolution_mode) + + await save_run(run) + + # Spawn background task — store reference so it isn't GC'd + task = asyncio.create_task(run_evolution(run)) + registry.set_task(run.id, task) + logger.info("run=%s started: spec=%s pop=%d gens=%d", + run.id[:8], run.specialization[:60], run.population_size, run.num_generations) + + # Cleanup callback removes the task from the registry when it finishes + def _cleanup(t: asyncio.Task) -> None: + registry.clear_task(run.id) + exc = t.exception() if not t.cancelled() else None + if exc: + logger.error("run=%s task failed: %s", run.id[:8], exc) + else: + logger.info("run=%s task completed", run.id[:8]) + + task.add_done_callback(_cleanup) + + return EvolveResponse(run_id=run.id, ws_url=f"/ws/evolve/{run.id}") + + +# --------------------------------------------------------------------------- +# Fork-and-evolve: start a run from an existing Skill (registry seed or upload) +# --------------------------------------------------------------------------- + + +class EvolveFromParentRequest(BaseModel): + parent_source: str = Field(..., description='"registry", "upload", or "generated"') + parent_id: str = Field("", description="skill_id (registry) or upload_id (upload)") + specialization: str | None = None + population_size: int = 5 + num_generations: int = 3 + max_budget_usd: float = 10.0 + invite_code: str | None = None + # For parent_source="generated" — inline skill content + skill_md_content: str | None = None + supporting_files: dict[str, str] | None = None + + +@router.post("/evolve/from-parent", response_model=EvolveResponse) +async def start_evolution_from_parent(req: EvolveFromParentRequest) -> EvolveResponse: + """Start a new evolution run using an existing Skill as the gen-0 parent. + + Supports two parent sources: + - ``registry``: ``parent_id`` is a skill_id inside the seed-library run + (or any other run's skill). Resolved via get_run(seed-library). + - ``upload``: ``parent_id`` is an upload_id from POST /api/uploads/skill. + Resolved via the in-memory upload cache. + + The parent is stashed in the ``RunRegistry`` (see ``engine/run_registry.py``) + keyed by the new run's id. The evolution engine picks it up at gen 0 spawn + time and routes through ``spawner.spawn_from_parent()`` instead of + ``spawn_gen0()``. + """ + if not invite_code_valid(req.invite_code): + raise HTTPException( + status_code=403, + detail="This platform is invite-only. Enter a valid invite code or request one.", + ) + + # Resolve the parent genome + if req.parent_source == "registry": + # Search the seed-library run first, then fall back to any run + parent = None + seed_run = await get_run("seed-library") + if seed_run: + for gen in seed_run.generations: + for sk in gen.skills: + if sk.id == req.parent_id: + parent = sk + break + if parent: + break + if parent is None: + raise HTTPException( + status_code=404, + detail=f"registry skill {req.parent_id!r} not found", + ) + effective_spec = req.specialization or ( + parent.frontmatter.get("description", "")[:200] + if isinstance(parent.frontmatter, dict) + else "" + ) + elif req.parent_source == "upload": + parent = get_upload(req.parent_id) + if parent is None: + raise HTTPException( + status_code=404, detail=f"upload {req.parent_id!r} not found or expired" + ) + effective_spec = req.specialization or "User-uploaded Skill (evolved)" + elif req.parent_source == "generated": + if not req.skill_md_content: + raise HTTPException( + status_code=400, + detail="generated source requires skill_md_content", + ) + parent = SkillGenome( + id=str(uuid.uuid4()), + generation=0, + skill_md_content=req.skill_md_content, + supporting_files=req.supporting_files or {}, + frontmatter={}, + traits=[], + maturity="draft", + ) + effective_spec = req.specialization or "AI-generated skill" + else: + raise HTTPException( + status_code=400, + detail=f"parent_source must be 'registry', 'upload', or 'generated', got {req.parent_source!r}", + ) + + await init_db() + + run = EvolutionRun( + id=str(uuid.uuid4()), + mode="domain", + specialization=effective_spec, + population_size=req.population_size, + num_generations=req.num_generations, + max_budget_usd=req.max_budget_usd, + status="pending", + created_at=datetime.now(UTC), + ) + await save_run(run) + + # Stash the parent so the engine's gen-0 spawn picks it up + registry.stash_parent(run.id, parent) + + # Clear the upload cache so we don't leak memory + if req.parent_source == "upload": + clear_upload(req.parent_id) + + task = asyncio.create_task(run_evolution(run)) + registry.set_task(run.id, task) + + def _cleanup(t: asyncio.Task) -> None: + registry.clear_task(run.id) + + task.add_done_callback(_cleanup) + + return EvolveResponse(run_id=run.id, ws_url=f"/ws/evolve/{run.id}") diff --git a/skillforge/api/routes.py b/skillforge/api/routes/runs.py similarity index 54% rename from skillforge/api/routes.py rename to skillforge/api/routes/runs.py index 386d89a..f35507d 100644 --- a/skillforge/api/routes.py +++ b/skillforge/api/routes/runs.py @@ -1,335 +1,33 @@ -"""REST API routes.""" +"""GET endpoints that read run state + POST /runs/{id}/cancel. + +All handlers here work off an existing run id — they never start new +evolutions. See ``evolve.py`` for the POST endpoints that do. +""" from __future__ import annotations -import asyncio import json import logging -import uuid -from datetime import UTC, datetime from fastapi import APIRouter, HTTPException, Response -from pydantic import BaseModel, Field from skillforge.api.schemas import ( - EvolveRequest, - EvolveResponse, ExportFormat, LineageEdge, LineageNode, - Mode, RunDetail, ) -from skillforge.api.uploads import clear_upload, get_upload -from skillforge.config import invite_code_valid -from skillforge.db.database import init_db -from skillforge.db.queries import get_lineage, get_run, list_runs, save_run -from skillforge.engine.evolution import run_evolution -from skillforge.engine.export import export_agent_sdk_config, export_skill_md, export_skill_zip +from skillforge.db.queries import get_lineage, get_run, list_runs +from skillforge.engine.export import ( + export_agent_sdk_config, + export_skill_md, + export_skill_zip, +) from skillforge.engine.run_registry import registry -from skillforge.models import EvolutionRun, SkillGenome - -logger = logging.getLogger("skillforge.api") - -router = APIRouter(prefix="/api") - - -async def _classify_run_via_taxonomist( - run: EvolutionRun, requested_mode: str | None -) -> None: - """Best-effort: classify the run, persist family + new nodes, stamp the run. - - Sets ``run.family_id`` and ``run.evolution_mode`` in place. If - ``requested_mode`` is "atomic" or "molecular" the explicit value wins - over whatever the Taxonomist returns. If the Taxonomist call fails for - any reason — missing API key, network error, JSON parse failure — we - log it, leave ``family_id`` as None, default ``evolution_mode`` to - "molecular", and let the run proceed. - """ - from skillforge.config import ANTHROPIC_API_KEY - from skillforge.db import get_taxonomy_tree, list_families - from skillforge.engine.events import emit - - # No API key → skip classification entirely - if not ANTHROPIC_API_KEY: - run.evolution_mode = requested_mode or "molecular" - return - - # Skip the LLM call when the caller explicitly forced a mode AND specified - # no specialization that needs classification (the autoclassify is the - # whole point of running the agent — if mode is forced, just stamp it). - if requested_mode in {"atomic", "molecular"} and not run.specialization: - run.evolution_mode = requested_mode - return - - try: - from skillforge.agents.taxonomist import classify_and_decompose - - taxonomy_tree = await get_taxonomy_tree() - existing_families = await list_families() - result = await classify_and_decompose( - run.specialization, - taxonomy_tree, - existing_families, - ) - except Exception as exc: # noqa: BLE001 - logger.warning( - "run=%s taxonomist classification failed: %s — defaulting to molecular", - run.id[:8], - exc, - ) - run.evolution_mode = requested_mode or "molecular" - return - - run.family_id = result.family.id - # Caller's explicit mode wins over the Taxonomist's recommendation - run.evolution_mode = requested_mode or result.evolution_mode - - await emit( - run.id, - "taxonomy_classified", - family_id=result.family.id, - family_slug=result.family.slug, - domain_slug=result.domain.slug, - focus_slug=result.focus.slug, - language_slug=result.language.slug, - evolution_mode=run.evolution_mode, - created_new_nodes=result.created_new_nodes, - ) - - if result.evolution_mode == "atomic": - await emit( - run.id, - "decomposition_complete", - dimension_count=len(result.variant_dimensions), - dimensions=[d.to_dict() for d in result.variant_dimensions], - reuse_recommendations=[ - r.to_dict() for r in result.reuse_recommendations - ], - ) - - # Persist a VariantEvolution row per dimension ONLY if the run will - # actually execute in atomic mode (the final stamped mode, which may - # have been overridden by the caller). The variant_evolutions FK - # requires the parent run to exist, so we save_run first. - if ( - run.evolution_mode == "atomic" - and result.evolution_mode == "atomic" - and result.variant_dimensions - ): - from datetime import UTC as _UTC - from datetime import datetime as _dt - from uuid import uuid4 as _uuid4 - - # Insert the parent run row first so the FK on - # variant_evolutions.parent_run_id is satisfied. save_run is - # idempotent (INSERT OR REPLACE) so the second save_run later in - # the route handler is a no-op refresh. - from skillforge.db import save_run as _save_run - from skillforge.db import save_variant_evolution - from skillforge.models import VariantEvolution - - await _save_run(run) - - for dim in result.variant_dimensions: - await save_variant_evolution( - VariantEvolution( - id=f"vevo_{_uuid4().hex[:12]}", - family_id=result.family.id, - dimension=dim.name, - tier=dim.tier, - parent_run_id=run.id, - population_size=2, - num_generations=1, - status="pending", - created_at=_dt.now(_UTC), - ) - ) - - -@router.post("/evolve", response_model=EvolveResponse) -async def start_evolution(req: EvolveRequest) -> EvolveResponse: - """Start a new evolution run and return its ID + WebSocket URL. - - Validates the request, creates an EvolutionRun, persists it, and spawns - a background task that runs the evolution loop. Returns immediately — - the client subscribes to the WebSocket to watch progress. - """ - # Invite gating — returns True if gating is disabled OR the code is valid - if not invite_code_valid(req.invite_code): - raise HTTPException( - status_code=403, - detail="This platform is invite-only. Enter a valid invite code or request one.", - ) - - # Mode-specific validation - if req.mode == Mode.domain and not req.specialization: - raise HTTPException(status_code=400, detail="domain mode requires 'specialization'") - if req.mode == Mode.meta: - raise HTTPException(status_code=501, detail="meta mode is v1.1, not yet supported") - - # Ensure DB is initialized (idempotent) - await init_db() - - run = EvolutionRun( - id=str(uuid.uuid4()), - mode=req.mode.value, - specialization=req.specialization or "", - population_size=req.population_size, - num_generations=req.num_generations, - max_budget_usd=req.max_budget_usd, - status="pending", - created_at=datetime.now(UTC), - ) - - # v2.0 — Taxonomist classification before evolution starts. Best-effort: - # the run still proceeds in molecular mode if the LLM call fails or no - # API key is available, so we never block submission on classification. - await _classify_run_via_taxonomist(run, req.evolution_mode) - - await save_run(run) - - # Spawn background task — store reference so it isn't GC'd - task = asyncio.create_task(run_evolution(run)) - registry.set_task(run.id, task) - logger.info("run=%s started: spec=%s pop=%d gens=%d", - run.id[:8], run.specialization[:60], run.population_size, run.num_generations) - - # Cleanup callback removes the task from the registry when it finishes - def _cleanup(t: asyncio.Task) -> None: - registry.clear_task(run.id) - exc = t.exception() if not t.cancelled() else None - if exc: - logger.error("run=%s task failed: %s", run.id[:8], exc) - else: - logger.info("run=%s task completed", run.id[:8]) - - task.add_done_callback(_cleanup) - - return EvolveResponse(run_id=run.id, ws_url=f"/ws/evolve/{run.id}") - - -# --------------------------------------------------------------------------- -# Fork-and-evolve: start a run from an existing Skill (registry seed or upload) -# --------------------------------------------------------------------------- - - -class EvolveFromParentRequest(BaseModel): - parent_source: str = Field(..., description='"registry", "upload", or "generated"') - parent_id: str = Field("", description="skill_id (registry) or upload_id (upload)") - specialization: str | None = None - population_size: int = 5 - num_generations: int = 3 - max_budget_usd: float = 10.0 - invite_code: str | None = None - # For parent_source="generated" — inline skill content - skill_md_content: str | None = None - supporting_files: dict[str, str] | None = None - - -@router.post("/evolve/from-parent", response_model=EvolveResponse) -async def start_evolution_from_parent(req: EvolveFromParentRequest) -> EvolveResponse: - """Start a new evolution run using an existing Skill as the gen-0 parent. - - Supports two parent sources: - - ``registry``: ``parent_id`` is a skill_id inside the seed-library run - (or any other run's skill). Resolved via get_run(seed-library). - - ``upload``: ``parent_id`` is an upload_id from POST /api/uploads/skill. - Resolved via the in-memory upload cache. - - The parent is stashed in the ``RunRegistry`` (see ``engine/run_registry.py``) - keyed by the new run's id. The evolution engine picks it up at gen 0 spawn - time and routes through ``spawner.spawn_from_parent()`` instead of - ``spawn_gen0()``. - """ - if not invite_code_valid(req.invite_code): - raise HTTPException( - status_code=403, - detail="This platform is invite-only. Enter a valid invite code or request one.", - ) - - # Resolve the parent genome - if req.parent_source == "registry": - # Search the seed-library run first, then fall back to any run - parent = None - seed_run = await get_run("seed-library") - if seed_run: - for gen in seed_run.generations: - for sk in gen.skills: - if sk.id == req.parent_id: - parent = sk - break - if parent: - break - if parent is None: - raise HTTPException( - status_code=404, - detail=f"registry skill {req.parent_id!r} not found", - ) - effective_spec = req.specialization or ( - parent.frontmatter.get("description", "")[:200] - if isinstance(parent.frontmatter, dict) - else "" - ) - elif req.parent_source == "upload": - parent = get_upload(req.parent_id) - if parent is None: - raise HTTPException( - status_code=404, detail=f"upload {req.parent_id!r} not found or expired" - ) - effective_spec = req.specialization or "User-uploaded Skill (evolved)" - elif req.parent_source == "generated": - if not req.skill_md_content: - raise HTTPException( - status_code=400, - detail="generated source requires skill_md_content", - ) - parent = SkillGenome( - id=str(uuid.uuid4()), - generation=0, - skill_md_content=req.skill_md_content, - supporting_files=req.supporting_files or {}, - frontmatter={}, - traits=[], - maturity="draft", - ) - effective_spec = req.specialization or "AI-generated skill" - else: - raise HTTPException( - status_code=400, - detail=f"parent_source must be 'registry', 'upload', or 'generated', got {req.parent_source!r}", - ) - - await init_db() - - run = EvolutionRun( - id=str(uuid.uuid4()), - mode="domain", - specialization=effective_spec, - population_size=req.population_size, - num_generations=req.num_generations, - max_budget_usd=req.max_budget_usd, - status="pending", - created_at=datetime.now(UTC), - ) - await save_run(run) - - # Stash the parent so the engine's gen-0 spawn picks it up - registry.stash_parent(run.id, parent) - - # Clear the upload cache so we don't leak memory - if req.parent_source == "upload": - clear_upload(req.parent_id) - - task = asyncio.create_task(run_evolution(run)) - registry.set_task(run.id, task) - - def _cleanup(t: asyncio.Task) -> None: - registry.clear_task(run.id) - - task.add_done_callback(_cleanup) +from skillforge.models import SkillGenome - return EvolveResponse(run_id=run.id, ws_url=f"/ws/evolve/{run.id}") +logger = logging.getLogger("skillforge.api.runs") +router = APIRouter() @router.post("/runs/{run_id}/cancel") diff --git a/tests/test_api.py b/tests/test_api.py index 356be4c..952012d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -80,9 +80,9 @@ def test_root_health_check(client): def test_evolve_creates_run(client): with ( - patch("skillforge.api.routes.init_db", new_callable=AsyncMock), - patch("skillforge.api.routes.save_run", new_callable=AsyncMock), - patch("skillforge.api.routes.run_evolution", new_callable=AsyncMock) as mock_evo, + patch("skillforge.api.routes.evolve.init_db", new_callable=AsyncMock), + patch("skillforge.api.routes.evolve.save_run", new_callable=AsyncMock), + patch("skillforge.api.routes.evolve.run_evolution", new_callable=AsyncMock) as mock_evo, ): mock_evo.return_value = None @@ -136,7 +136,7 @@ def test_evolve_rejects_domain_mode_without_specialization(client): def test_get_run_returns_404_if_missing(client): - with patch("skillforge.api.routes.get_run", new_callable=AsyncMock, return_value=None): + with patch("skillforge.api.routes.runs.get_run", new_callable=AsyncMock, return_value=None): resp = client.get("/api/runs/nonexistent") assert resp.status_code == 404 @@ -150,7 +150,7 @@ def test_get_run_returns_detail(client): skill = _make_skill() run = _make_run(best_skill=skill) - with patch("skillforge.api.routes.get_run", new_callable=AsyncMock, return_value=run): + with patch("skillforge.api.routes.runs.get_run", new_callable=AsyncMock, return_value=run): resp = client.get(f"/api/runs/{run.id}") assert resp.status_code == 200 @@ -172,7 +172,7 @@ def test_get_run_returns_detail(client): def test_list_runs_returns_array(client): runs = [_make_run(run_id="run-1"), _make_run(run_id="run-2")] - with patch("skillforge.api.routes.list_runs", new_callable=AsyncMock, return_value=runs): + with patch("skillforge.api.routes.runs.list_runs", new_callable=AsyncMock, return_value=runs): resp = client.get("/api/runs") assert resp.status_code == 200 @@ -193,8 +193,8 @@ def test_export_skill_md_format(client): run = _make_run(best_skill=skill) with ( - patch("skillforge.api.routes.get_run", new_callable=AsyncMock, return_value=run), - patch("skillforge.api.routes.export_skill_md", return_value="fake md content"), + patch("skillforge.api.routes.runs.get_run", new_callable=AsyncMock, return_value=run), + patch("skillforge.api.routes.runs.export_skill_md", return_value="fake md content"), ): resp = client.get(f"/api/runs/{run.id}/export?format=skill_md") @@ -213,8 +213,8 @@ def test_export_skill_dir_format_returns_zip(client): run = _make_run(best_skill=skill) with ( - patch("skillforge.api.routes.get_run", new_callable=AsyncMock, return_value=run), - patch("skillforge.api.routes.export_skill_zip", return_value=b"PK...fake zip"), + patch("skillforge.api.routes.runs.get_run", new_callable=AsyncMock, return_value=run), + patch("skillforge.api.routes.runs.export_skill_zip", return_value=b"PK...fake zip"), ): resp = client.get(f"/api/runs/{run.id}/export?format=skill_dir") @@ -229,7 +229,7 @@ def test_export_skill_dir_format_returns_zip(client): def test_export_404_on_missing_run(client): - with patch("skillforge.api.routes.get_run", new_callable=AsyncMock, return_value=None): + with patch("skillforge.api.routes.runs.get_run", new_callable=AsyncMock, return_value=None): resp = client.get("/api/runs/nonexistent/export") assert resp.status_code == 404 @@ -242,7 +242,7 @@ def test_export_404_on_missing_run(client): def test_export_400_on_no_best_skill(client): run = _make_run(best_skill=None) - with patch("skillforge.api.routes.get_run", new_callable=AsyncMock, return_value=run): + with patch("skillforge.api.routes.runs.get_run", new_callable=AsyncMock, return_value=run): resp = client.get(f"/api/runs/{run.id}/export") assert resp.status_code == 400 @@ -266,8 +266,8 @@ def test_lineage_returns_nodes_and_edges(client): ] with ( - patch("skillforge.api.routes.get_run", new_callable=AsyncMock, return_value=run), - patch("skillforge.api.routes.get_lineage", new_callable=AsyncMock, return_value=fake_edges), + patch("skillforge.api.routes.runs.get_run", new_callable=AsyncMock, return_value=run), + patch("skillforge.api.routes.runs.get_lineage", new_callable=AsyncMock, return_value=fake_edges), ): resp = client.get(f"/api/runs/{run.id}/lineage") diff --git a/tests/test_evolve_taxonomist_integration.py b/tests/test_evolve_taxonomist_integration.py index 7cac99b..36cb8f1 100644 --- a/tests/test_evolve_taxonomist_integration.py +++ b/tests/test_evolve_taxonomist_integration.py @@ -166,7 +166,7 @@ async def _seed_db(): "skillforge.agents.taxonomist.classify_and_decompose", new=AsyncMock(return_value=mock_output), ), patch( - "skillforge.api.routes.run_evolution", + "skillforge.api.routes.evolve.run_evolution", new=AsyncMock(return_value=None), ): resp = client.post( @@ -232,7 +232,7 @@ async def _seed_db(): "skillforge.agents.taxonomist.classify_and_decompose", new=AsyncMock(return_value=mock_output), ), patch( - "skillforge.api.routes.run_evolution", + "skillforge.api.routes.evolve.run_evolution", new=AsyncMock(return_value=None), ): resp = client.post( @@ -272,7 +272,7 @@ def test_evolve_taxonomist_failure_falls_back_to_molecular(client: TestClient): "skillforge.agents.taxonomist.classify_and_decompose", new=AsyncMock(side_effect=RuntimeError("simulated LLM crash")), ), patch( - "skillforge.api.routes.run_evolution", + "skillforge.api.routes.evolve.run_evolution", new=AsyncMock(return_value=None), ): resp = client.post( @@ -318,7 +318,7 @@ def test_evolve_skips_taxonomist_when_no_api_key(client: TestClient, monkeypatch "skillforge.agents.taxonomist.classify_and_decompose", new=classify_mock, ), patch( - "skillforge.api.routes.run_evolution", + "skillforge.api.routes.evolve.run_evolution", new=AsyncMock(return_value=None), ): resp = client.post( diff --git a/tests/test_seeds.py b/tests/test_seeds.py index 151eb5e..d2d62ce 100644 --- a/tests/test_seeds.py +++ b/tests/test_seeds.py @@ -405,14 +405,14 @@ def test_evolve_from_parent_registry_happy_path(client): with ( patch( - "skillforge.api.routes.get_run", + "skillforge.api.routes.evolve.get_run", new_callable=AsyncMock, return_value=seed_run, ), - patch("skillforge.api.routes.init_db", new_callable=AsyncMock), - patch("skillforge.api.routes.save_run", new_callable=AsyncMock), + patch("skillforge.api.routes.evolve.init_db", new_callable=AsyncMock), + patch("skillforge.api.routes.evolve.save_run", new_callable=AsyncMock), patch( - "skillforge.api.routes.run_evolution", new_callable=AsyncMock + "skillforge.api.routes.evolve.run_evolution", new_callable=AsyncMock ), ): resp = client.post( @@ -449,12 +449,12 @@ def test_evolve_from_parent_registry_unknown_skill_returns_404(client): with ( patch( - "skillforge.api.routes.get_run", + "skillforge.api.routes.evolve.get_run", new_callable=AsyncMock, return_value=seed_run, ), - patch("skillforge.api.routes.init_db", new_callable=AsyncMock), - patch("skillforge.api.routes.save_run", new_callable=AsyncMock), + patch("skillforge.api.routes.evolve.init_db", new_callable=AsyncMock), + patch("skillforge.api.routes.evolve.save_run", new_callable=AsyncMock), ): resp = client.post( "/api/evolve/from-parent", @@ -472,12 +472,12 @@ def test_evolve_from_parent_registry_no_seed_run_returns_404(client): """If get_run('seed-library') returns None, fork still 404s cleanly.""" with ( patch( - "skillforge.api.routes.get_run", + "skillforge.api.routes.evolve.get_run", new_callable=AsyncMock, return_value=None, ), - patch("skillforge.api.routes.init_db", new_callable=AsyncMock), - patch("skillforge.api.routes.save_run", new_callable=AsyncMock), + patch("skillforge.api.routes.evolve.init_db", new_callable=AsyncMock), + patch("skillforge.api.routes.evolve.save_run", new_callable=AsyncMock), ): resp = client.post( "/api/evolve/from-parent", @@ -499,11 +499,11 @@ def test_evolve_from_parent_upload_unknown_id_returns_404(client): """Unknown upload_id → 404.""" with ( patch( - "skillforge.api.routes.get_upload", + "skillforge.api.routes.evolve.get_upload", return_value=None, ), - patch("skillforge.api.routes.init_db", new_callable=AsyncMock), - patch("skillforge.api.routes.save_run", new_callable=AsyncMock), + patch("skillforge.api.routes.evolve.init_db", new_callable=AsyncMock), + patch("skillforge.api.routes.evolve.save_run", new_callable=AsyncMock), ): resp = client.post( "/api/evolve/from-parent", diff --git a/tests/test_uploads.py b/tests/test_uploads.py index 9e41673..7eb17a9 100644 --- a/tests/test_uploads.py +++ b/tests/test_uploads.py @@ -501,9 +501,9 @@ def test_upload_then_fork_round_trip(client): assert uploads_module.get_upload(upload_id) is not None with ( - patch("skillforge.api.routes.init_db", new_callable=AsyncMock), - patch("skillforge.api.routes.save_run", new_callable=AsyncMock), - patch("skillforge.api.routes.run_evolution", new_callable=AsyncMock), + patch("skillforge.api.routes.evolve.init_db", new_callable=AsyncMock), + patch("skillforge.api.routes.evolve.save_run", new_callable=AsyncMock), + patch("skillforge.api.routes.evolve.run_evolution", new_callable=AsyncMock), ): fork_resp = client.post( "/api/evolve/from-parent",