diff --git a/api/doi_engine.py b/api/doi_engine.py index 1b79756..9490cd7 100644 --- a/api/doi_engine.py +++ b/api/doi_engine.py @@ -257,6 +257,11 @@ def _resolve_path(repo: dict) -> str: return "" +def resolve_repo_path(repo: dict) -> str: + """Resolve the repo path using the same host-aware rules as DoI checks.""" + return _resolve_path(repo) + + def _get_sync(api_base: str, path: str, params: dict | None = None) -> object: url = f"{api_base}{path}" if params: diff --git a/api/routers/repos.py b/api/routers/repos.py index 47bd9fc..ca22271 100644 --- a/api/routers/repos.py +++ b/api/routers/repos.py @@ -13,7 +13,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from api.config import settings from api.database import get_session -from api.doi_engine import compute_fingerprint, evaluate as _doi_evaluate, evaluate_scope_health +from api.doi_engine import ( + compute_fingerprint, + evaluate as _doi_evaluate, + evaluate_scope_health, + resolve_repo_path, +) from api.models.doi_cache import DOICache from api.models.domain import Domain from api.models.interface_change import InterfaceChange @@ -31,6 +36,7 @@ from api.schemas.managed_repo import ( RepoDispatch, RepoPathRegister, RepoRead, + RepoScopeHealth, RepoUpdate, ScopeIssueDetail, ) @@ -346,6 +352,53 @@ async def get_repo_by_id( return repo +@router.get("/scope-health", response_model=list[RepoScopeHealth]) +async def list_repo_scope_health( + needs_review: bool | None = None, + reachable_only: bool = False, + session: AsyncSession = Depends(get_session), +) -> list[RepoScopeHealth]: + """Return machine-readable SCOPE.md health for active repos. + + Repo-scoping uses this to refresh only repos and SCOPE.md sections that + need attention, without guessing from free-text DoI output. + """ + result = await session.execute( + select(ManagedRepo, Domain.slug) + .join(Domain, Domain.id == ManagedRepo.domain_id) + .where(ManagedRepo.status == "active") + .order_by(ManagedRepo.slug) + ) + + entries: list[RepoScopeHealth] = [] + for repo, domain_slug in result.all(): + repo_dict = _repo_doi_dict(repo, domain_slug) + resolved_path = resolve_repo_path(repo_dict) + scope_issue_details = [ + ScopeIssueDetail(**issue) + for issue in evaluate_scope_health(repo_dict) + ] + scope_needs_review = any( + issue.id in {"C5a", "C5b", "C5c"} and issue.status in {"fail", "warn"} + for issue in scope_issue_details + ) + entry = RepoScopeHealth( + repo_slug=repo.slug, + domain_slug=domain_slug, + local_path=resolved_path or repo.local_path, + path_available=bool(resolved_path), + scope_needs_review=scope_needs_review, + scope_issue_details=scope_issue_details, + ) + if needs_review is not None and entry.scope_needs_review != needs_review: + continue + if reachable_only and not entry.path_available: + continue + entries.append(entry) + + return entries + + @router.get("/{slug}", response_model=RepoRead) async def get_repo( slug: str, @@ -496,15 +549,7 @@ async def get_repo_dispatch( domain_obj = domain_result.scalar_one_or_none() scope_issue_details = [ ScopeIssueDetail(**issue) - for issue in evaluate_scope_health({ - "slug": repo.slug, - "domain_slug": domain_obj.slug if domain_obj else None, - "local_path": repo.local_path, - "remote_url": repo.remote_url, - "host_paths": repo.host_paths or {}, - "last_sbom_at": str(repo.last_sbom_at) if repo.last_sbom_at else None, - "updated_at": str(repo.updated_at) if repo.updated_at else "", - }) + for issue in evaluate_scope_health(_repo_doi_dict(repo, domain_obj.slug if domain_obj else None)) ] scope_needs_review = any( issue.id in {"C5a", "C5b", "C5c"} and issue.status in {"fail", "warn"} @@ -576,3 +621,15 @@ async def _get_repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo: if repo is None: raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found") return repo + + +def _repo_doi_dict(repo: ManagedRepo, domain_slug: str | None) -> dict: + return { + "slug": repo.slug, + "domain_slug": domain_slug, + "local_path": repo.local_path, + "remote_url": repo.remote_url, + "host_paths": repo.host_paths or {}, + "last_sbom_at": str(repo.last_sbom_at) if repo.last_sbom_at else None, + "updated_at": str(repo.updated_at) if repo.updated_at else "", + } diff --git a/api/schemas/managed_repo.py b/api/schemas/managed_repo.py index 15ace6c..1189061 100644 --- a/api/schemas/managed_repo.py +++ b/api/schemas/managed_repo.py @@ -98,3 +98,12 @@ class RepoDispatch(BaseModel): scope_needs_review: bool scope_issue_details: list[ScopeIssueDetail] last_state_synced_at: datetime | None + + +class RepoScopeHealth(BaseModel): + repo_slug: str + domain_slug: str | None = None + local_path: str | None = None + path_available: bool + scope_needs_review: bool + scope_issue_details: list[ScopeIssueDetail] diff --git a/mcp_server/TOOLS.md b/mcp_server/TOOLS.md index 886eb6f..d732a0d 100644 --- a/mcp_server/TOOLS.md +++ b/mcp_server/TOOLS.md @@ -132,6 +132,23 @@ Domains are now first-class DB entities. Use `list_domains()` to discover availa | `register_repo(domain_slug, name, ...)` | `slug?`; `local_path?`; `remote_url?` | Register a git repo under a domain. | | `update_repo_path(repo_slug, path, host?)` | `repo_slug`: e.g. `"marki-docx"`; `path`: absolute local path; `host`: defaults to current hostname | Register this machine's local path for a repo. Use when the same repo lives at different paths on different machines (e.g. `/home/worsch/…` vs `/home/tegwick/…`). The consistency checker prefers this over `local_path`. | +### SCOPE.md Health Contract + +Repo dispatch includes `scope_needs_review` and `scope_issue_details`. +`scope_issue_details` reports C5a/C5b/C5c with machine-readable +`missing_sections`, `invalid_capability_blocks`, and `needs_refresh_sections`. + +For ecosystem refresh, call: + +```bash +curl "http://127.0.0.1:8000/repos/scope-health?needs_review=true&reachable_only=true" +``` + +The list response returns active repos with `repo_slug`, `domain_slug`, +`local_path`, `path_available`, `scope_needs_review`, and the same +`scope_issue_details` shape. Repo-scoping should use `needs_refresh_sections` +to update only the affected SCOPE.md sections. + --- ## Agent Inbox Tools diff --git a/tests/test_doi_scope_health.py b/tests/test_doi_scope_health.py index ddd7094..ab7ea7e 100644 --- a/tests/test_doi_scope_health.py +++ b/tests/test_doi_scope_health.py @@ -134,3 +134,23 @@ class TestRepoDispatchScopeHealth: "C5b": "pass", "C5c": "pass", } + + async def test_scope_health_list_filters_reachable_repos_needing_review(self, client, tmp_path): + await _create_domain(client) + stub_path = tmp_path / "stub" + valid_path = tmp_path / "valid" + stub_path.mkdir() + valid_path.mkdir() + (stub_path / "SCOPE.md").write_text("# SCOPE\n", encoding="utf-8") + (valid_path / "SCOPE.md").write_text(VALID_SCOPE, encoding="utf-8") + await _create_repo(client, "scopedom", stub_path, slug="stub-list") + await _create_repo(client, "scopedom", valid_path, slug="valid-list") + + r = await client.get("/repos/scope-health?needs_review=true&reachable_only=true") + assert r.status_code == 200, r.text + body = r.json() + + assert [entry["repo_slug"] for entry in body] == ["stub-list"] + assert body[0]["path_available"] is True + by_id = {issue["id"]: issue for issue in body[0]["scope_issue_details"]} + assert by_id["C5b"]["needs_refresh_sections"]