From b77bae709b639224f32210c172638166dd455cd6 Mon Sep 17 00:00:00 2001 From: openclaw Date: Mon, 23 Mar 2026 17:05:11 +0000 Subject: [PATCH] =?UTF-8?q?feat(impl=5Ftracker):=20add=20ImplTrackerServic?= =?UTF-8?q?e=20and=20REST=20API=20=E2=80=94=20progress=20evaluation=20and?= =?UTF-8?q?=20manual=20override?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- backend/app/main.py | 7 ++ .../impl_tracker/application/services.py | 58 ++++++++++++ .../impl_tracker/interfaces/http/router.py | 93 +++++++++++++++++++ backend/tests/test_api_impl_tracker.py | 46 +++++++++ backend/tests/test_impl_tracker.py | 59 ++++++++++++ 5 files changed, 263 insertions(+) create mode 100644 backend/tests/test_api_impl_tracker.py create mode 100644 backend/tests/test_impl_tracker.py diff --git a/backend/app/main.py b/backend/app/main.py index 940b57e..84c707b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -15,6 +15,8 @@ from app.modules.graph.application.services import GraphService from app.modules.graph.interfaces.http.router import router as graph_router, init_router as init_graph_router from app.modules.editor.application.services import EditorService from app.modules.editor.interfaces.http.router import router as editor_router, init_router as init_editor_router +from app.modules.impl_tracker.application.services import ImplTrackerService +from app.modules.impl_tracker.interfaces.http.router import router as impl_tracker_router, init_router as init_impl_tracker_router def create_app() -> FastAPI: @@ -40,11 +42,16 @@ def create_app() -> FastAPI: editor_service = EditorService(scan_service) init_editor_router(project_service, scan_service, editor_service) + # Wire Impl-tracker module + impl_tracker_service = ImplTrackerService() + init_impl_tracker_router(project_service, scan_service, impl_tracker_service) + # Register routers app.include_router(project_router, prefix="/api") app.include_router(scanner_router, prefix="/api") app.include_router(graph_router, prefix="/api") app.include_router(editor_router, prefix="/api") + app.include_router(impl_tracker_router, prefix="/api") # Health check @app.get("/api/health") diff --git a/backend/app/modules/impl_tracker/application/services.py b/backend/app/modules/impl_tracker/application/services.py index e69de29..74f3b2b 100644 --- a/backend/app/modules/impl_tracker/application/services.py +++ b/backend/app/modules/impl_tracker/application/services.py @@ -0,0 +1,58 @@ +from datetime import datetime, timezone + +from app.modules.impl_tracker.domain.entities import ImplProgress +from app.modules.impl_tracker.infrastructure.code_scanner import scan_code_directory +from app.modules.project.domain.entities import Project +from app.modules.scanner.domain.entities import ScanResult + + +class ImplTrackerService: + def __init__(self) -> None: + self._cache: dict[str, list[ImplProgress]] = {} + self._manual_overrides: dict[str, dict[str, float]] = {} # project_id -> {module_id: percentage} + + def evaluate(self, project: Project, scan_result: ScanResult) -> list[ImplProgress]: + progress_list: list[ImplProgress] = [] + now = datetime.now(timezone.utc) + + if not project.code_dir: + # No code dir -> all modules at 0% + for mod in scan_result.modules: + progress_list.append(ImplProgress( + module_id=mod.module_id, percentage=0.0, source="auto", evaluated_at=now, + )) + else: + code_structure = scan_code_directory(project.code_dir, scan_result) + for mod in scan_result.modules: + if mod.module_id in code_structure.matched_modules: + percentage = 50.0 # Basic: module directory exists + else: + percentage = 0.0 + progress_list.append(ImplProgress( + module_id=mod.module_id, percentage=percentage, source="auto", evaluated_at=now, + )) + + # Apply manual overrides + overrides = self._manual_overrides.get(project.id, {}) + for p in progress_list: + if p.module_id in overrides: + p.percentage = overrides[p.module_id] + p.source = "manual" + + self._cache[project.id] = progress_list + return progress_list + + def get_progress(self, project_id: str) -> list[ImplProgress] | None: + return self._cache.get(project_id) + + def set_manual_progress(self, project_id: str, module_id: str, percentage: float) -> None: + if project_id not in self._manual_overrides: + self._manual_overrides[project_id] = {} + self._manual_overrides[project_id][module_id] = percentage + # Update cache if exists + if project_id in self._cache: + for p in self._cache[project_id]: + if p.module_id == module_id: + p.percentage = percentage + p.source = "manual" + p.evaluated_at = datetime.now(timezone.utc) diff --git a/backend/app/modules/impl_tracker/interfaces/http/router.py b/backend/app/modules/impl_tracker/interfaces/http/router.py index e69de29..f3ab3b6 100644 --- a/backend/app/modules/impl_tracker/interfaces/http/router.py +++ b/backend/app/modules/impl_tracker/interfaces/http/router.py @@ -0,0 +1,93 @@ +"""Impl-tracker HTTP router — progress evaluation and manual override endpoints.""" + +from __future__ import annotations + +from dataclasses import asdict +from datetime import datetime, timezone + +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from app.modules.impl_tracker.application.services import ImplTrackerService +from app.modules.impl_tracker.domain.entities import ImplProgress +from app.modules.project.application.services import ProjectService +from app.modules.scanner.application.services import ScanService + +router = APIRouter(prefix="/projects/{project_id}/impl-progress", tags=["impl-tracker"]) + +_project_service: ProjectService | None = None +_scan_service: ScanService | None = None +_impl_tracker_service: ImplTrackerService | None = None + + +def init_router( + project_service: ProjectService, + scan_service: ScanService, + impl_tracker_service: ImplTrackerService, +) -> None: + global _project_service, _scan_service, _impl_tracker_service + _project_service = project_service + _scan_service = scan_service + _impl_tracker_service = impl_tracker_service + + +class ManualProgressRequest(BaseModel): + percentage: float + + +def _progress_to_dict(p) -> dict: + d = asdict(p) + d["evaluated_at"] = p.evaluated_at.isoformat() + return d + + +@router.post("") +def evaluate_progress(project_id: str): + """Evaluate implementation progress for all modules.""" + project = _project_service.get_project(project_id) + scan_result = _scan_service.get_latest_scan(project_id) + if scan_result is None: + return JSONResponse( + status_code=404, + content={"detail": "No scan available. Run POST /scan first."}, + ) + progress = _impl_tracker_service.evaluate(project, scan_result) + return [_progress_to_dict(p) for p in progress] + + +@router.get("") +def get_progress(project_id: str): + """Get cached implementation progress.""" + _project_service.get_project(project_id) # Ensure project exists (raises 404) + progress = _impl_tracker_service.get_progress(project_id) + if progress is None: + return JSONResponse( + status_code=404, + content={"detail": "No progress evaluated yet. Run POST /impl-progress first."}, + ) + return [_progress_to_dict(p) for p in progress] + + +@router.put("/{module_id}") +def set_manual_progress(project_id: str, module_id: str, body: ManualProgressRequest): + """Set manual progress override for a module.""" + _project_service.get_project(project_id) # Ensure project exists (raises 404) + _impl_tracker_service.set_manual_progress(project_id, module_id, body.percentage) + + # Return the updated progress entry + progress = _impl_tracker_service.get_progress(project_id) + if progress: + for p in progress: + if p.module_id == module_id: + return _progress_to_dict(p) + + # If no cached progress, return a constructed response + return _progress_to_dict( + ImplProgress( + module_id=module_id, + percentage=body.percentage, + source="manual", + evaluated_at=datetime.now(timezone.utc), + ) + ) diff --git a/backend/tests/test_api_impl_tracker.py b/backend/tests/test_api_impl_tracker.py new file mode 100644 index 0000000..f524871 --- /dev/null +++ b/backend/tests/test_api_impl_tracker.py @@ -0,0 +1,46 @@ +import pytest + +DESIGN_DIR = "/workspace/arch-design-agent-skill-dashboard/design" + + +@pytest.fixture +def project_id(client): + r = client.post("/api/projects", json={"name": "test", "design_dir": DESIGN_DIR}) + return r.json()["id"] + + +def test_evaluate_progress(client, project_id): + # Need to scan first + client.post(f"/api/projects/{project_id}/scan") + r = client.post(f"/api/projects/{project_id}/impl-progress") + assert r.status_code == 200 + data = r.json() + assert isinstance(data, list) + assert len(data) > 0 + assert "module_id" in data[0] + assert "percentage" in data[0] + + +def test_get_progress_not_evaluated(client, project_id): + r = client.get(f"/api/projects/{project_id}/impl-progress") + assert r.status_code == 404 + + +def test_get_progress_after_evaluate(client, project_id): + client.post(f"/api/projects/{project_id}/scan") + client.post(f"/api/projects/{project_id}/impl-progress") + r = client.get(f"/api/projects/{project_id}/impl-progress") + assert r.status_code == 200 + assert isinstance(r.json(), list) + + +def test_set_manual_progress(client, project_id): + client.post(f"/api/projects/{project_id}/scan") + client.post(f"/api/projects/{project_id}/impl-progress") + r = client.put( + f"/api/projects/{project_id}/impl-progress/MOD-PROJECT", + json={"percentage": 80.0}, + ) + assert r.status_code == 200 + assert r.json()["percentage"] == 80.0 + assert r.json()["source"] == "manual" diff --git a/backend/tests/test_impl_tracker.py b/backend/tests/test_impl_tracker.py new file mode 100644 index 0000000..dafc2e9 --- /dev/null +++ b/backend/tests/test_impl_tracker.py @@ -0,0 +1,59 @@ +import pytest +from datetime import datetime + +from app.modules.impl_tracker.application.services import ImplTrackerService +from app.modules.project.domain.entities import Project +from app.modules.scanner.application.services import ScanService + + +@pytest.fixture +def impl_service(): + return ImplTrackerService() + + +@pytest.fixture +def scan_result(): + svc = ScanService() + project = Project( + id="test", name="test", + design_dir="/workspace/arch-design-agent-skill-dashboard/design", + code_dir=None, created_at=datetime(2026, 1, 1), + ) + return svc.scan(project) + + +@pytest.fixture +def test_project(): + return Project( + id="test", name="test", + design_dir="/workspace/arch-design-agent-skill-dashboard/design", + code_dir=None, created_at=datetime(2026, 1, 1), + ) + + +def test_evaluate_no_code_dir(impl_service, test_project, scan_result): + progress = impl_service.evaluate(test_project, scan_result) + assert len(progress) > 0 + assert all(p.percentage == 0.0 for p in progress) + assert all(p.source == "auto" for p in progress) + + +def test_get_progress_before_evaluate(impl_service): + assert impl_service.get_progress("nonexistent") is None + + +def test_get_progress_after_evaluate(impl_service, test_project, scan_result): + impl_service.evaluate(test_project, scan_result) + cached = impl_service.get_progress("test") + assert cached is not None + assert len(cached) > 0 + + +def test_set_manual_progress(impl_service, test_project, scan_result): + impl_service.evaluate(test_project, scan_result) + impl_service.set_manual_progress("test", "MOD-PROJECT", 75.0) + cached = impl_service.get_progress("test") + mod_progress = [p for p in cached if p.module_id == "MOD-PROJECT"] + assert len(mod_progress) == 1 + assert mod_progress[0].percentage == 75.0 + assert mod_progress[0].source == "manual"