feat(impl_tracker): add ImplTrackerService and REST API — progress evaluation and manual override

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
openclaw 2026-03-23 17:05:11 +00:00
parent e523d5b31c
commit b77bae709b
5 changed files with 263 additions and 0 deletions

View File

@ -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")

View File

@ -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)

View File

@ -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),
)
)

View File

@ -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"

View File

@ -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"