scope refactoring

This commit is contained in:
2026-05-01 01:47:14 +02:00
parent fc725ec65f
commit b8eb744e79
5 changed files with 118 additions and 10 deletions

View File

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

View File

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

View File

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

View File

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

View File

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