diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/repos.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/repos.py new file mode 100644 index 00000000..af88a8c1 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/repos.py @@ -0,0 +1,992 @@ +import asyncio +import json +import os +import re +import socket +import subprocess +import sys +import uuid +from datetime import datetime, timezone +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException, Response, status +from sqlalchemy import case, func, or_, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import joinedload, noload + +from api.config import settings +from api.database import get_session +from api.events import EventEnvelope, publish_event +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 +from api.models.managed_repo import ManagedRepo +from api.models.repo_goal import RepoGoal +from api.models.repository_rename import RepositorySlug +from api.models.tpsc import TPSCSnapshot +from api.models.task import Task +from api.models.workplan import Workplan +from api.schemas.doi import DoICriterion, DoIReport, DoISummaryEntry +from api.schemas.managed_repo import ( + DispatchTask, + DispatchWorkplan, + PendingInterfaceChange, + RepoCreate, + RepoDispatch, + RepoOnboardRequest, + RepoOnboardResult, + RepoPathRegister, + RepoRead, + RepoScopeHealth, + RepoUpdate, + ScopeIssueDetail, + classification_fields_set, + validate_repo_classification_fields, +) +from api.services.sbom_nexus import SBOMNexusError +from api.services.sbom_nexus import get_json as get_sbom_nexus_json +from api.services.sbom_nexus import reads_from_nexus +from api.services.repository_identity import stage_initial_repository_identity +from api.services.repository_aliases import ( + RepositorySlugResolution, + affected_slug_predicate, + repository_resolutions_for_repositories, + repository_resolution_for_id, + resolve_repository_slug, + stale_external_references, +) +from hub_core.routers.repos import create_repos_router + +router = APIRouter(prefix="/repos", tags=["repos"]) + + +async def _publish_repo_registered(repo: ManagedRepo, body: RepoCreate, domain: Domain) -> None: + subject = "org.statehub.repo.registered" + envelope = EventEnvelope.new( + subject, + attributes={ + "repo_id": str(repo.id), + "repo_slug": repo.slug, + "domain_slug": domain.slug, + "remote_url": repo.remote_url, + "local_path": repo.local_path, + }, + ) + asyncio.create_task(publish_event(subject, envelope)) + + +def _core_repo_router(**route_flags) -> APIRouter: + return create_repos_router( + get_session, + prefix="", + domain_model=Domain, + repo_model=ManagedRepo, + repo_create_schema=RepoCreate, + repo_update_schema=RepoUpdate, + repo_read_schema=RepoRead, + repo_path_register_schema=RepoPathRegister, + list_noload_fields=("goals",), + create_extension_fields=( + "topic_id", + "category", + "secondary_domains", + "capability_tags", + "business_stake", + "business_mechanics", + "classified_at", + "classified_by", + "standard_version", + ), + after_register=_publish_repo_registered, + **route_flags, + ) + + +router.include_router( + _core_repo_router(include_collection_routes=False, include_slug_routes=False) +) + + +@router.get("/", response_model=list[RepoRead]) +async def list_repos( + response: Response, + domain: str | None = None, + category: str | None = None, + capability_tag: str | None = None, + business_stake: str | None = None, + session: AsyncSession = Depends(get_session), +) -> list[RepoRead]: + """List repos with optional domain and classification filters.""" + response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30" + q = ( + select(ManagedRepo) + .options( + noload("*"), + joinedload(ManagedRepo.domain).noload("*"), + ) + .order_by(ManagedRepo.name) + ) + if domain: + domain_result = await session.execute(select(Domain.id).where(Domain.slug == domain)) + domain_id = domain_result.scalar_one_or_none() + if domain_id is None: + raise HTTPException(status_code=404, detail=f"Domain '{domain}' not found") + q = q.where( + or_( + ManagedRepo.domain_id == domain_id, + ManagedRepo.secondary_domains.contains([domain]), + ) + ) + if category: + q = q.where(ManagedRepo.category == category) + if capability_tag: + q = q.where(ManagedRepo.capability_tags.contains([capability_tag])) + if business_stake: + q = q.where(ManagedRepo.business_stake.contains([business_stake])) + result = await session.execute(q) + repositories = list(result.scalars().all()) + resolutions = await repository_resolutions_for_repositories(session, repositories) + return await _project_repo_reads(session, repositories, resolutions=resolutions) + + +@router.post("/", response_model=RepoRead, status_code=status.HTTP_201_CREATED) +async def register_repo( + body: RepoCreate, + session: AsyncSession = Depends(get_session), +) -> RepoRead: + domain_result = await session.execute(select(Domain).where(Domain.slug == body.domain_slug)) + domain_obj = domain_result.scalar_one_or_none() + if domain_obj is None: + raise HTTPException(status_code=404, detail=f"Domain '{body.domain_slug}' not found") + existing = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == body.slug)) + claimed_slug = await session.execute( + select(RepositorySlug).where(RepositorySlug.slug == body.slug) + ) + if existing.scalar_one_or_none() or claimed_slug.scalar_one_or_none(): + raise HTTPException(status_code=409, detail=f"Repo slug '{body.slug}' already exists") + + payload = body.model_dump() + validate_repo_classification_fields( + domain_slug=body.domain_slug, + fields=payload, + require_complete=classification_fields_set(payload), + ) + repo = ManagedRepo( + domain_id=domain_obj.id, + slug=body.slug, + name=body.name, + local_path=body.local_path, + host_paths=body.host_paths, + remote_url=body.remote_url, + git_fingerprint=body.git_fingerprint, + description=body.description, + topic_id=body.topic_id, + category=body.category, + secondary_domains=body.secondary_domains, + capability_tags=body.capability_tags, + business_stake=body.business_stake, + business_mechanics=body.business_mechanics, + classified_at=body.classified_at, + classified_by=body.classified_by, + standard_version=body.standard_version, + ) + session.add(repo) + await session.flush() + stage_initial_repository_identity(session, repo) + try: + await session.commit() + except IntegrityError as exc: + await session.rollback() + raise HTTPException( + status_code=409, + detail=f"Repo slug '{body.slug}' is already claimed", + ) from exc + await session.refresh(repo) + await _publish_repo_registered(repo, body, domain_obj) + return (await _project_repo_reads(session, [repo]))[0] + + +@router.post("/onboard", response_model=RepoOnboardResult) +async def onboard_repo(body: RepoOnboardRequest) -> RepoOnboardResult: + """Run the local repo onboarding script for an accessible working copy. + + The dashboard uses this for the "Add Repo" action. The path must be visible + from the State Hub host, either as a local checkout or through an ops-bridge + mounted/exposed working copy. Keep the API agent-profile based so future + native coding agents can gain their own profiles without changing callers. + """ + project_path = Path(body.project_path).expanduser() + if not project_path.exists() or not project_path.is_dir(): + raise HTTPException( + status_code=400, + detail=f"project_path is not an accessible directory: {body.project_path}", + ) + if not (project_path / ".git").exists(): + raise HTTPException( + status_code=400, + detail=f"project_path does not look like a git working copy: {body.project_path}", + ) + + script = Path(__file__).parent.parent.parent / "scripts" / "register_project.sh" + cmd = ["bash", str(script), body.domain_slug, str(project_path)] + if body.agent_profile == "codex": + cmd.append("--codex") + if body.additional: + cmd.append("--additional") + + env = { + **os.environ, + "API_BASE": settings.api_base, + "CUSTODIAN_SKIP_SBOM_PROMPT": "true", + } + result = await asyncio.to_thread( + subprocess.run, + cmd, + cwd=str(script.parent.parent), + env=env, + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + timeout=180, + ) + stdout = result.stdout or "" + stderr = result.stderr or "" + if result.returncode != 0: + raise HTTPException( + status_code=500, + detail={ + "message": "Repo onboarding failed.", + "command": cmd, + "stdout": stdout, + "stderr": stderr, + }, + ) + + repo_slug = None + match = re.search(r"Repo slug:\s+([a-z0-9][a-z0-9-]*)", stdout) + if match: + repo_slug = match.group(1) + + return RepoOnboardResult( + ok=True, + repo_slug=repo_slug, + agent_profile=body.agent_profile, + command=cmd, + stdout=stdout, + stderr=stderr, + ) + + +def _todo_md_entry(repo_slug: str, todo_path: Path, mtime: datetime) -> dict: + if mtime.tzinfo is None: + mtime = mtime.replace(tzinfo=timezone.utc) + age_days = max(0, (datetime.now(timezone.utc) - mtime).days) + return { + "repo_slug": repo_slug, + "has_todo": True, + "todo_path": str(todo_path), + "mtime": mtime.isoformat(), + "age_days": age_days, + } + + +@router.get("/todo-md-staleness") +async def list_todo_md_staleness( + stale_days: int = 6, + session: AsyncSession = Depends(get_session), +) -> dict: + """Scan registered repos for TODO.md files unchanged longer than stale_days. + + Used by activity-core daily stale-review automation. Only repos with an + accessible checkout and an existing TODO.md are included. + """ + if stale_days < 1: + raise HTTPException(status_code=400, detail="stale_days must be >= 1") + + result = await session.execute( + select(ManagedRepo, Domain.slug) + .join(Domain, Domain.id == ManagedRepo.domain_id) + .where(ManagedRepo.status == "active") + .order_by(ManagedRepo.slug) + ) + + all_with_todo: list[dict] = [] + scanned = 0 + for repo, _domain_slug in result.all(): + scanned += 1 + repo_dict = _repo_doi_dict(repo, _domain_slug) + resolved_path = resolve_repo_path(repo_dict) + if not resolved_path: + continue + todo_path = Path(resolved_path) / "TODO.md" + if not todo_path.is_file(): + continue + mtime = datetime.fromtimestamp(todo_path.stat().st_mtime, tz=timezone.utc) + all_with_todo.append(_todo_md_entry(repo.slug, todo_path, mtime)) + + stale_entries = [e for e in all_with_todo if e["age_days"] >= stale_days] + stale_count = len(stale_entries) + worst = max(stale_entries, key=lambda e: e["age_days"], default=None) + payload: dict = { + "stale_days": stale_days, + "repos": stale_entries, + "stale_count": stale_count, + "total_with_todo": len(all_with_todo), + "total_scanned": scanned, + "worst_repo_slug": worst["repo_slug"] if worst else None, + "worst_age_days": worst["age_days"] if worst else None, + "worst_mtime": worst["mtime"] if worst else None, + } + if worst: + payload.update(worst) + return payload + + +@router.get("/doi/summary", response_model=list[DoISummaryEntry]) +async def doi_summary(session: AsyncSession = Depends(get_session)) -> list[DoISummaryEntry]: + """Return DoI tier for all active repos, worst tier first. + + Results are cached in doi_cache. A repo is only re-evaluated when its + fingerprint changes (repo record updated, new TPSC snapshot, goal change, + or a key file mtime changes on disk). + """ + repos_result = await session.execute( + select(ManagedRepo).where(ManagedRepo.status == "active").order_by(ManagedRepo.name) + ) + repos = list(repos_result.scalars().all()) + sbom_projections = await _sbom_projection_map() + repo_ids = [r.id for r in repos] + id_to_slug = {r.id: r.slug for r in repos} + + # ── Bulk DB queries for fingerprint inputs ──────────────────────────────── + domains_result = await session.execute(select(Domain)) + domain_obj_map = {d.id: d for d in domains_result.scalars().all()} + domain_map = {d.id: d.slug for d in domain_obj_map.values()} + domain_status = {d.slug: d.status for d in domain_obj_map.values()} + + # Latest TPSC snapshot timestamp per repo (for fingerprint + C9 count) + tpsc_result = await session.execute( + select(TPSCSnapshot.repo_id, + func.count().label("cnt"), + func.max(TPSCSnapshot.snapshot_at).label("latest")) + .where(TPSCSnapshot.repo_id.in_(repo_ids)) + .group_by(TPSCSnapshot.repo_id) + ) + tpsc_by_id = {row.repo_id: row for row in tpsc_result} + tpsc_snap_counts = {id_to_slug[rid]: row.cnt for rid, row in tpsc_by_id.items() if rid in id_to_slug} + tpsc_snap_latest = {id_to_slug[rid]: str(row.latest) for rid, row in tpsc_by_id.items() if rid in id_to_slug} + + # Latest goal updated_at + active count per repo (for fingerprint + C10) + goals_result = await session.execute( + select(RepoGoal.repo_id, + func.count().label("total"), + func.sum(case((RepoGoal.status == "active", 1), else_=0)).label("active_cnt"), + func.max(RepoGoal.updated_at).label("latest")) + .where(RepoGoal.repo_id.in_(repo_ids)) + .group_by(RepoGoal.repo_id) + ) + goals_by_id = {row.repo_id: row for row in goals_result} + active_goal_counts = {id_to_slug[rid]: int(row.active_cnt or 0) for rid, row in goals_by_id.items() if rid in id_to_slug} + goals_latest = {id_to_slug[rid]: str(row.latest) for rid, row in goals_by_id.items() if rid in id_to_slug} + + # Load existing cache rows + cache_result = await session.execute( + select(DOICache).where(DOICache.repo_id.in_(repo_ids)) + ) + cache_by_repo_id = {row.repo_id: row for row in cache_result.scalars().all()} + # ───────────────────────────────────────────────────────────────────────── + + prefetch = { + "domain_status": domain_status, + "tpsc_snap_counts": tpsc_snap_counts, + "active_goal_counts": active_goal_counts, + } + + async def _get_or_refresh(repo: ManagedRepo) -> DoISummaryEntry: + slug = repo.slug + repo_dict = { + "slug": slug, + "domain_slug": domain_map.get(repo.domain_id), + "local_path": repo.local_path, + "remote_url": repo.remote_url, + "host_paths": repo.host_paths or {}, + "last_sbom_at": _projected_last_sbom_at(repo, sbom_projections), + "updated_at": str(repo.updated_at) if repo.updated_at else "", + } + fp = compute_fingerprint( + repo_dict, + tpsc_snap_latest.get(slug), + goals_latest.get(slug), + ) + + cached = cache_by_repo_id.get(repo.id) + if cached and cached.fingerprint == fp: + # Cache hit — return stored result + return DoISummaryEntry( + repo_slug=slug, + domain_slug=domain_map.get(repo.domain_id), + tier=cached.tier, + core_pass=cached.core_pass, + standard_pass=cached.standard_pass, + full_pass=cached.full_pass, + checked_at=cached.checked_at.isoformat(), + ) + + # Cache miss — evaluate and store + report = await _doi_evaluate(repo_dict, skip_consistency=True, prefetch=prefetch) + now = datetime.now(tz=timezone.utc) + if cached: + cached.tier = report.tier + cached.core_pass = report.core_pass + cached.standard_pass = report.standard_pass + cached.full_pass = report.full_pass + cached.criteria = [{"id": c.id, "label": c.label, "tier": c.tier, + "status": c.status, "detail": c.detail} + for c in report.criteria] + cached.fingerprint = fp + cached.checked_at = now + cached.updated_at = now + else: + session.add(DOICache( + repo_id=repo.id, + tier=report.tier, + core_pass=report.core_pass, + standard_pass=report.standard_pass, + full_pass=report.full_pass, + criteria=[{"id": c.id, "label": c.label, "tier": c.tier, + "status": c.status, "detail": c.detail} + for c in report.criteria], + fingerprint=fp, + checked_at=now, + updated_at=now, + )) + + return DoISummaryEntry( + repo_slug=slug, + domain_slug=domain_map.get(repo.domain_id), + tier=report.tier, + core_pass=report.core_pass, + standard_pass=report.standard_pass, + full_pass=report.full_pass, + checked_at=now.isoformat(), + ) + + entries: list[DoISummaryEntry] = list(await asyncio.gather(*[_get_or_refresh(r) for r in repos])) + await session.commit() + + tier_order = {"none": 0, "core": 1, "standard": 2, "full": 3} + entries.sort(key=lambda e: tier_order.get(e.tier, 0)) + return entries + + +@router.get("/{slug}/doi", response_model=DoIReport) +async def get_repo_doi( + slug: str, + force_refresh: bool = False, + session: AsyncSession = Depends(get_session), +) -> DoIReport: + """Evaluate the 14 DoI criteria for a single repo (full check including C7/C13). + + Results are cached by fingerprint. Pass ?force_refresh=true to bypass the cache. + """ + resolution = await resolve_repository_slug(session, slug) + repo = resolution.repo + sbom_projections = await _sbom_projection_map() + domain_result = await session.execute(select(Domain).where(Domain.id == repo.domain_id)) + domain_obj = domain_result.scalar_one_or_none() + + # Fingerprint inputs for this single repo + tpsc_row = (await session.execute( + select(func.count().label("cnt"), func.max(TPSCSnapshot.snapshot_at).label("latest")) + .where(TPSCSnapshot.repo_id == repo.id) + )).one() + goal_row = (await session.execute( + select(func.max(RepoGoal.updated_at).label("latest")) + .where(RepoGoal.repo_id == repo.id) + )).one() + + repo_dict = { + "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": _projected_last_sbom_at(repo, sbom_projections), + "updated_at": str(repo.updated_at) if repo.updated_at else "", + } + fp = compute_fingerprint(repo_dict, str(tpsc_row.latest) if tpsc_row.latest else None, + str(goal_row.latest) if goal_row.latest else None) + + # Check cache (unless force_refresh) + cached = (await session.execute( + select(DOICache).where(DOICache.repo_id == repo.id) + )).scalar_one_or_none() + + if not force_refresh and cached and cached.fingerprint == fp and cached.criteria: + return DoIReport( + repo_slug=repo.slug, + tier=cached.tier, + core_pass=cached.core_pass, + standard_pass=cached.standard_pass, + full_pass=cached.full_pass, + checked_at=cached.checked_at.isoformat(), + criteria=[DoICriterion(**c) for c in cached.criteria], + ) + + # Full evaluation (includes C7/C13 consistency subprocesses) + report = await _doi_evaluate(repo_dict) + now = datetime.now(tz=timezone.utc) + criteria_json = [{"id": c.id, "label": c.label, "tier": c.tier, + "status": c.status, "detail": c.detail} for c in report.criteria] + if cached: + cached.tier = report.tier; cached.core_pass = report.core_pass + cached.standard_pass = report.standard_pass; cached.full_pass = report.full_pass + cached.criteria = criteria_json; cached.fingerprint = fp + cached.checked_at = now; cached.updated_at = now + else: + session.add(DOICache(repo_id=repo.id, tier=report.tier, + core_pass=report.core_pass, standard_pass=report.standard_pass, + full_pass=report.full_pass, criteria=criteria_json, + fingerprint=fp, checked_at=now, updated_at=now)) + await session.commit() + + return DoIReport( + repo_slug=report.repo_slug, tier=report.tier, + core_pass=report.core_pass, standard_pass=report.standard_pass, + full_pass=report.full_pass, checked_at=report.checked_at, + criteria=[DoICriterion(id=c.id, label=c.label, tier=c.tier, + status=c.status, detail=c.detail) for c in report.criteria], + ) + + +@router.get("/by-id/{repo_id}", response_model=RepoRead) +async def get_repo_by_id( + repo_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> RepoRead: + repo = await session.get(ManagedRepo, repo_id) + if repo is None: + raise HTTPException(status_code=404, detail=f"Repo '{repo_id}' not found") + return (await _project_repo_reads(session, [repo]))[0] + + +@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.patch("/{slug}", response_model=RepoRead) +async def update_repo_with_classification( + slug: str, + body: RepoUpdate, + session: AsyncSession = Depends(get_session), +) -> RepoRead: + """Patch repo metadata including classification spine fields.""" + resolution = await resolve_repository_slug(session, slug) + repo = resolution.repo + payload = body.model_dump(exclude_unset=True) + requested_domain_slug = payload.pop("domain_slug", None) + if requested_domain_slug is not None: + domain_result = await session.execute( + select(Domain).where(Domain.slug == requested_domain_slug) + ) + domain_obj = domain_result.scalar_one_or_none() + if domain_obj is None: + raise HTTPException( + status_code=404, + detail=f"Domain '{requested_domain_slug}' not found", + ) + else: + domain_result = await session.execute( + select(Domain).where(Domain.id == repo.domain_id) + ) + domain_obj = domain_result.scalar_one_or_none() + domain_slug = domain_obj.slug if domain_obj else "" + classification_requested = classification_fields_set(payload) + if classification_requested or ( + requested_domain_slug is not None and repo.category is not None + ): + merged = { + "category": payload.get("category", repo.category), + "secondary_domains": payload.get("secondary_domains", repo.secondary_domains), + "capability_tags": payload.get("capability_tags", repo.capability_tags), + "business_stake": payload.get("business_stake", repo.business_stake), + "business_mechanics": payload.get("business_mechanics", repo.business_mechanics), + } + validate_repo_classification_fields( + domain_slug=domain_slug, + fields=merged, + require_complete=True, + ) + if requested_domain_slug is not None: + repo.domain_id = domain_obj.id + for field, value in payload.items(): + setattr(repo, field, value) + await session.commit() + await session.refresh(repo) + return (await _project_repo_reads(session, [repo], requested=resolution))[0] + + +@router.get("/{slug}", response_model=RepoRead) +async def get_repo_with_sbom_projection( + slug: str, + session: AsyncSession = Depends(get_session), +) -> RepoRead: + resolution = await resolve_repository_slug(session, slug) + return ( + await _project_repo_reads( + session, + [resolution.repo], + requested=resolution, + include_stale_external=True, + ) + )[0] + + +router.include_router( + _core_repo_router( + include_collection_routes=False, + include_lookup_routes=False, + include_slug_routes=False, + ) +) + + +@router.post("/{slug}/paths", response_model=RepoRead) +async def register_repo_path( + slug: str, + body: RepoPathRegister, + session: AsyncSession = Depends(get_session), +) -> RepoRead: + resolution = await resolve_repository_slug(session, slug) + repo = resolution.repo + host_paths = dict(repo.host_paths or {}) + host_paths[body.host] = body.path + repo.host_paths = host_paths + await session.commit() + await session.refresh(repo) + return (await _project_repo_reads(session, [repo], requested=resolution))[0] + + +@router.patch("/{slug}/archive", response_model=RepoRead) +async def archive_repo( + slug: str, + session: AsyncSession = Depends(get_session), +) -> RepoRead: + resolution = await resolve_repository_slug(session, slug) + repo = resolution.repo + repo.status = "archived" + await session.commit() + await session.refresh(repo) + return (await _project_repo_reads(session, [repo], requested=resolution))[0] + + +@router.get("/{slug}/dispatch", response_model=RepoDispatch) +async def get_repo_dispatch( + slug: str, + session: AsyncSession = Depends(get_session), +) -> RepoDispatch: + """Return active workstreams, pending tasks, and goal for a repo. + + This endpoint is the foundation for autonomous agent sessions: an agent can + call it at session start to discover what work is pending without needing to + read state-hub summary or scan workplan files manually. + """ + resolution = await resolve_repository_slug(session, slug) + repo = resolution.repo + + # Active goal + goal_result = await session.execute( + select(RepoGoal) + .where(RepoGoal.repo_id == repo.id, RepoGoal.status == "active") + .order_by(RepoGoal.priority) + .limit(1) + ) + goal_obj = goal_result.scalar_one_or_none() + active_goal = None + if goal_obj: + active_goal = { + "id": str(goal_obj.id), + "title": goal_obj.title, + "description": goal_obj.description, + "priority": goal_obj.priority, + } + + # Active workstreams + ws_result = await session.execute( + select(Workplan) + .where(Workplan.repo_id == repo.id, Workplan.status == "active") + .order_by(Workplan.created_at) + ) + workstreams = list(ws_result.scalars().all()) + + dispatch_workstreams: list[DispatchWorkplan] = [] + all_interventions: list[DispatchTask] = [] + + for ws in workstreams: + task_result = await session.execute( + select(Task) + .where(Task.workplan_id == ws.id, Task.status.in_(["todo", "progress"])) + .order_by(Task.created_at) + ) + tasks = list(task_result.scalars().all()) + + pending = [ + DispatchTask( + id=t.id, + title=t.title, + priority=t.priority, + status=t.status, + needs_human=t.needs_human, + ) + for t in tasks + ] + interventions = [t for t in pending if t.needs_human] + all_interventions.extend(interventions) + + dispatch_workstreams.append( + DispatchWorkplan( + id=ws.id, + title=ws.title, + status=ws.status, + pending_tasks=pending, + ) + ) + + # Published interface changes that affect this repo and are not yet resolved + ic_result = await session.execute( + select(InterfaceChange).where( + InterfaceChange.status == "published", + affected_slug_predicate( + InterfaceChange.affected_repo_slugs, resolution.slug_values + ), + ).order_by(InterfaceChange.published_at.desc()) + ) + pending_changes = [ + PendingInterfaceChange( + id=ic.id, + title=ic.title, + change_type=ic.change_type, + interface_type=ic.interface_type, + origin_repo_slug=ic.repo.slug, + affected_paths=ic.affected_paths or [], + planned_for=ic.planned_for, + published_at=ic.published_at, + ) + for ic in ic_result.scalars().all() + ] + + domain_result = await session.execute(select(Domain).where(Domain.id == repo.domain_id)) + domain_obj = domain_result.scalar_one_or_none() + scope_issue_details = [ + ScopeIssueDetail(**issue) + 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"} + for issue in scope_issue_details + ) + + return RepoDispatch( + repo_slug=resolution.canonical_slug, + requested_slug=resolution.requested_slug, + canonical_slug=resolution.canonical_slug, + slug_status=resolution.slug_status, + aliases=list(resolution.aliases), + stale_external_references=await stale_external_references(session, resolution), + active_goal=active_goal, + active_workplans=dispatch_workstreams, + human_interventions=all_interventions, + pending_interface_changes=pending_changes, + scope_needs_review=scope_needs_review, + scope_issue_details=scope_issue_details, + last_state_synced_at=repo.last_state_synced_at, + ) + + +@router.post("/{slug}/sync") +async def sync_repo_consistency( + slug: str, + fix: bool = True, + session: AsyncSession = Depends(get_session), +) -> dict: + """Run ADR-001 consistency check (and optional --fix) for a repo via HTTP. + + Intended for non-Claude-Code agents (e.g. Codex) that cannot use MCP tools + but need to sync workplan file state to the state-hub DB after making changes. + + Returns the raw JSON output from consistency_check.py. + Query param ?fix=false to run check-only without writing. + """ + resolution = await resolve_repository_slug(session, slug) + repo = resolution.repo + + hostname = socket.gethostname() + host_paths = repo.host_paths or {} + repo_path = host_paths.get(hostname) + if not repo_path or not Path(repo_path).exists(): + raise HTTPException( + status_code=503, + detail=( + f"No accessible path for repo '{repo.slug}' on host '{hostname}'. " + f"Register with: POST /repos/{repo.slug}/paths/" + ), + ) + + script = Path(__file__).parent.parent.parent / "scripts" / "consistency_check.py" + cmd = [sys.executable, str(script), "--repo", repo.slug, "--json", + "--api-base", settings.api_base] + if fix: + cmd.append("--fix") + + result = await asyncio.to_thread( + subprocess.run, cmd, capture_output=True, text=True + ) + + try: + return json.loads(result.stdout) + except Exception: + raise HTTPException( + status_code=500, + detail=f"Consistency check failed: {result.stderr or result.stdout or '(no output)'}", + ) + + +async def _get_repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo: + resolution = await resolve_repository_slug(session, slug) + return resolution.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 "", + } + + +async def _sbom_projection_map() -> dict[str, datetime | None]: + if not reads_from_nexus(): + return {} + try: + repositories = await get_sbom_nexus_json("/repositories/") + except SBOMNexusError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc + return { + repository["slug"]: ( + datetime.fromisoformat(repository["last_attempt_at"].replace("Z", "+00:00")) + if repository.get("last_attempt_at") + else None + ) + for repository in repositories + } + + +def _projected_last_sbom_at( + repo: ManagedRepo, + projections: dict[str, datetime | None], +) -> str | None: + if repo.slug in projections: + projected = projections[repo.slug] + return projected.isoformat() if projected else None + return str(repo.last_sbom_at) if repo.last_sbom_at else None + + +async def _project_repo_reads( + session: AsyncSession, + repositories: list[ManagedRepo], + *, + requested: RepositorySlugResolution | None = None, + resolutions: dict[uuid.UUID, RepositorySlugResolution] | None = None, + include_stale_external: bool = False, +) -> list[RepoRead]: + projections = await _sbom_projection_map() + result: list[RepoRead] = [] + for repository in repositories: + resolution = ( + requested + if requested is not None and requested.repo.id == repository.id + else ( + resolutions[repository.id] + if resolutions is not None + else await repository_resolution_for_id(session, repository) + ) + ) + read = RepoRead.model_validate(repository) + read = read.model_copy( + update={ + "requested_slug": resolution.requested_slug, + "canonical_slug": resolution.canonical_slug, + "slug_status": resolution.slug_status, + "aliases": list(resolution.aliases), + "stale_external_references": ( + await stale_external_references(session, resolution) + if include_stale_external + else [] + ), + } + ) + if repository.slug in projections: + read = read.model_copy( + update={ + "last_sbom_at": projections[repository.slug], + "sbom_source": "sbom-nexus" + if projections[repository.slug] + else None, + } + ) + result.append(read) + return result diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/repository_renames.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/repository_renames.py new file mode 100644 index 00000000..cfdef315 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/repository_renames.py @@ -0,0 +1,359 @@ +from __future__ import annotations + +import asyncio +import uuid +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.encoders import jsonable_encoder +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.events import EventEnvelope, publish_event +from api.models.repository_rename import RepositoryRenameOperation +from api.schemas.repository_rename import ( + ForgeIdentityVerifyRequest, + RepositoryRenameOperationCreate, + RepositoryRenameOperationRead, + RepositoryRenamePhaseApply, + RepositoryRenamePreflightRead, + RepositoryRenamePreflightRequest, + RepositoryRenameRollbackPreflightRead, + RepositoryRenameRollbackRequest, + RepositoryRenameVerificationRead, +) +from api.services.forge_repository import ( + ForgeRepositoryGateway, + get_forge_repository_gateway, +) +from api.services.repository_rename import ( + RenameLifecycleError, + apply_phase, + apply_rollback, + build_preflight, + create_operation, + list_operations, + load_operation, + load_operation_by_id, + rollback_preflight, + verify_forge_identity, + verify_operation, +) + + +router = APIRouter(prefix="/repos/{repo_id}", tags=["repository-renames"]) +operation_router = APIRouter( + prefix="/repository-renames", tags=["repository-renames"] +) + + +def repository_rename_events( + operation: RepositoryRenameOperation, + *, + outcome: str, + no_op: bool = False, + verification: dict[str, Any] | None = None, +) -> list[tuple[str, EventEnvelope]]: + """Build credential-free lifecycle events from the durable journal.""" + telemetry = (operation.evidence or {}).get("telemetry") or {} + phase_durations = telemetry.get("phase_durations_ms") or {} + attributes = { + "operation_id": str(operation.id), + "repo_id": str(operation.repo_id), + "forge_repository_id": operation.expected_forge_repository_id, + "old_slug": operation.old_slug, + "new_slug": operation.new_slug, + "phase": operation.phase, + "actor": operation.actor, + "expected_source_commit": operation.expected_source_commit, + "outcome": outcome, + "no_op": no_op, + "phase_duration_ms": int(phase_durations.get(operation.phase) or 0), + "retries": int(telemetry.get("retries") or 0), + "failures": int(telemetry.get("failures") or 0), + "rollback_attempts": int(telemetry.get("rollback_attempts") or 0), + "rollback_outcome": telemetry.get("rollback_outcome"), + "verification_outcome": ( + "passed" if verification and verification.get("ok") else + "failed" if verification else + telemetry.get("verification_outcome", "pending") + ), + "error_code": operation.error_code if outcome == "failed" else None, + "evidence_ref": f"repository-renames/operations/{operation.id}", + } + subject = ( + "org.statehub.repo.rename.failed" + if outcome == "failed" + else "org.statehub.repo.rename.verified" + if verification is not None + else "org.statehub.repo.rename.rolled_back" + if operation.phase == "rolled-back" + else "org.statehub.repo.rename.phase" + ) + events = [(subject, EventEnvelope.new(subject, attributes=attributes))] + if operation.phase == "completed" and outcome == "succeeded": + renamed_subject = "org.statehub.repo.renamed" + events.append( + (renamed_subject, EventEnvelope.new(renamed_subject, attributes=attributes)) + ) + return events + + +def _publish_rename_events( + operation: RepositoryRenameOperation, + *, + outcome: str, + no_op: bool = False, + verification: dict[str, Any] | None = None, +) -> None: + for subject, envelope in repository_rename_events( + operation, outcome=outcome, no_op=no_op, verification=verification + ): + asyncio.create_task(publish_event(subject, envelope)) + + +def _raise(exc: RenameLifecycleError) -> None: + raise HTTPException( + status_code=exc.status_code, + detail=jsonable_encoder( + {"code": exc.code, "message": str(exc), "details": exc.details} + ), + ) from exc + + +def _operation_read(operation: RepositoryRenameOperation, *, no_op: bool = False) -> dict[str, Any]: + return { + "id": operation.id, + "repo_id": operation.repo_id, + "phase": operation.phase, + "old_slug": operation.old_slug, + "new_slug": operation.new_slug, + "expected_forge_repository_id": operation.expected_forge_repository_id, + "expected_source_commit": operation.expected_source_commit, + "expected_default_branch": operation.expected_default_branch, + "actor": operation.actor, + "phase_changed_at": operation.phase_changed_at, + "preflighted_at": operation.preflighted_at, + "preflight_expires_at": operation.preflight_expires_at, + "completed_at": operation.completed_at, + "rolled_back_at": operation.rolled_back_at, + "evidence": operation.evidence, + "error_code": operation.error_code, + "error_message": operation.error_message, + "error_details": operation.error_details, + "error_at": operation.error_at, + "no_op": no_op, + } + + +@router.post("/forge-identity/verify") +async def verify_repository_forge_identity( + repo_id: uuid.UUID, + body: ForgeIdentityVerifyRequest, + session: AsyncSession = Depends(get_session), + gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway), +) -> dict[str, Any]: + try: + identity = await verify_forge_identity(session, gateway, repo_id, body) + except RenameLifecycleError as exc: + _raise(exc) + return { + "id": identity.id, + "repo_id": identity.repo_id, + "provider": identity.provider, + "forge_instance": identity.forge_instance, + "forge_owner": identity.forge_owner, + "forge_repository_id": identity.forge_repository_id, + "verification_state": identity.verification_state, + "verified_at": identity.verified_at, + "verified_by": identity.verified_by, + "verification_evidence": identity.verification_evidence, + } + + +@router.post("/rename/preflight", response_model=RepositoryRenamePreflightRead) +async def repository_rename_preflight( + repo_id: uuid.UUID, + body: RepositoryRenamePreflightRequest, + session: AsyncSession = Depends(get_session), + gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway), +) -> dict[str, Any]: + try: + return await build_preflight(session, gateway, repo_id, body) + except RenameLifecycleError as exc: + _raise(exc) + + +@router.post( + "/rename/operations", + response_model=RepositoryRenameOperationRead, + status_code=status.HTTP_201_CREATED, +) +async def create_repository_rename_operation( + repo_id: uuid.UUID, + body: RepositoryRenameOperationCreate, + session: AsyncSession = Depends(get_session), + gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway), +) -> dict[str, Any]: + try: + operation, no_op = await create_operation(session, gateway, repo_id, body) + except RenameLifecycleError as exc: + _raise(exc) + _publish_rename_events(operation, outcome="succeeded", no_op=no_op) + return _operation_read(operation, no_op=no_op) + + +@operation_router.get( + "/operations/{operation_id}", response_model=RepositoryRenameOperationRead +) +async def get_repository_rename_operation_by_id( + operation_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> dict[str, Any]: + """Resolve an operation journal without requiring its repository UUID.""" + try: + operation = await load_operation_by_id(session, operation_id) + except RenameLifecycleError as exc: + _raise(exc) + return _operation_read(operation) + + +@router.get( + "/rename/operations", response_model=list[RepositoryRenameOperationRead] +) +async def list_repository_rename_operations( + repo_id: uuid.UUID, + active_only: bool = False, + session: AsyncSession = Depends(get_session), +) -> list[dict[str, Any]]: + try: + operations = await list_operations(session, repo_id, active_only=active_only) + except RenameLifecycleError as exc: + _raise(exc) + return [_operation_read(operation) for operation in operations] + + +@router.get( + "/rename/operations/{operation_id}", response_model=RepositoryRenameOperationRead +) +async def get_repository_rename_operation( + repo_id: uuid.UUID, + operation_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> dict[str, Any]: + try: + operation = await load_operation(session, repo_id, operation_id) + except RenameLifecycleError as exc: + _raise(exc) + return _operation_read(operation) + + +@router.post( + "/rename/operations/{operation_id}/phases/{phase}", + response_model=RepositoryRenameOperationRead, +) +async def apply_repository_rename_phase( + repo_id: uuid.UUID, + operation_id: uuid.UUID, + phase: str, + body: RepositoryRenamePhaseApply, + session: AsyncSession = Depends(get_session), + gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway), +) -> dict[str, Any]: + try: + operation, no_op = await apply_phase( + session, gateway, repo_id, operation_id, phase, body + ) + except RenameLifecycleError as exc: + failed = await session.get(RepositoryRenameOperation, operation_id) + if failed is not None: + _publish_rename_events(failed, outcome="failed") + _raise(exc) + _publish_rename_events(operation, outcome="succeeded", no_op=no_op) + return _operation_read(operation, no_op=no_op) + + +@router.get( + "/rename/operations/{operation_id}/verify", + response_model=RepositoryRenameVerificationRead, +) +async def verify_repository_rename_operation( + repo_id: uuid.UUID, + operation_id: uuid.UUID, + session: AsyncSession = Depends(get_session), + gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway), +) -> dict[str, Any]: + try: + operation = await load_operation(session, repo_id, operation_id) + verification = await verify_operation(session, gateway, operation) + except RenameLifecycleError as exc: + _raise(exc) + _publish_rename_events( + operation, + outcome="succeeded" if verification["ok"] else "failed", + verification=verification, + ) + return verification + + +@router.post( + "/rename/operations/{operation_id}/rollback-preflight", + response_model=RepositoryRenameRollbackPreflightRead, +) +async def preflight_repository_rename_rollback( + repo_id: uuid.UUID, + operation_id: uuid.UUID, + body: RepositoryRenameRollbackRequest, + session: AsyncSession = Depends(get_session), + gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway), +) -> dict[str, Any]: + try: + operation, report = await rollback_preflight( + session, + gateway, + repo_id, + operation_id, + expected_phase=body.expected_phase, + confirmation=body.confirmation, + ) + except RenameLifecycleError as exc: + failed = await session.get(RepositoryRenameOperation, operation_id) + if failed is not None: + _publish_rename_events(failed, outcome="failed") + _raise(exc) + _publish_rename_events(operation, outcome="succeeded") + return { + "operation_id": operation.id, + "repo_id": operation.repo_id, + **report, + "operation": _operation_read(operation), + } + + +@router.post( + "/rename/operations/{operation_id}/rollback", + response_model=RepositoryRenameOperationRead, +) +async def rollback_repository_rename_operation( + repo_id: uuid.UUID, + operation_id: uuid.UUID, + body: RepositoryRenameRollbackRequest, + session: AsyncSession = Depends(get_session), + gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway), +) -> dict[str, Any]: + try: + operation, no_op = await apply_rollback( + session, + gateway, + repo_id, + operation_id, + expected_phase=body.expected_phase, + confirmation=body.confirmation, + ) + except RenameLifecycleError as exc: + failed = await session.get(RepositoryRenameOperation, operation_id) + if failed is not None: + _publish_rename_events(failed, outcome="failed") + _raise(exc) + _publish_rename_events(operation, outcome="succeeded", no_op=no_op) + return _operation_read(operation, no_op=no_op) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/review_contracts.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/review_contracts.py new file mode 100644 index 00000000..19d2bd8d --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/review_contracts.py @@ -0,0 +1,190 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.models.review_contract import ReviewContract, ReviewReceipt +from api.schemas.review_contract import ( + ReviewAggregateRead, + ReviewContractProject, + ReviewContractRead, + ReviewReceiptRead, + ReviewReceiptSubmit, +) +from api.services.review_contracts import ( + active_contract, + aggregate, + digest_document, + normalize_contract, + utc_now, + validate_receipt, +) + +router = APIRouter(prefix="/review-contracts", tags=["review-contracts"]) + + +@router.post( + "/projections", + response_model=ReviewContractRead, + status_code=status.HTTP_201_CREATED, +) +async def project_contract( + body: ReviewContractProject, + session: AsyncSession = Depends(get_session), +) -> ReviewContract: + document, contract_digest = normalize_contract(body.contract) + existing = ( + await session.execute( + select(ReviewContract).where( + ReviewContract.contract_digest == contract_digest + ) + ) + ).scalar_one_or_none() + if existing: + if ( + existing.source_repo != body.source.repo + or existing.source_path != body.source.path + or existing.source_revision != body.source.revision + ): + raise HTTPException( + 409, "contract digest is already projected from a different source" + ) + return existing + contract_key = document["contract_key"] + await session.execute( + update(ReviewContract) + .where( + ReviewContract.contract_key == contract_key, ReviewContract.active.is_(True) + ) + .values(active=False) + ) + contract = ReviewContract( + contract_key=contract_key, + schema_version="review-contract/v1", + contract_digest=contract_digest, + source_repo=body.source.repo, + source_path=body.source.path, + source_revision=body.source.revision, + document=document, + active=True, + required_for_decision=body.required_for_decision, + decision_id=body.decision_id, + workplan_id=body.workplan_id, + task_id=body.task_id, + projected_at=utc_now(), + ) + session.add(contract) + try: + await session.commit() + except IntegrityError as exc: + await session.rollback() + raise HTTPException( + 409, "source revision is already projected with different content" + ) from exc + await session.refresh(contract) + return contract + + +@router.get("/", response_model=list[ReviewContractRead]) +async def list_contracts( + contract_key: str | None = Query(None), + active: bool | None = Query(None), + decision_id: uuid.UUID | None = Query(None), + session: AsyncSession = Depends(get_session), +) -> list[ReviewContract]: + query = select(ReviewContract).order_by(ReviewContract.projected_at.desc()) + if contract_key: + query = query.where(ReviewContract.contract_key == contract_key) + if active is not None: + query = query.where(ReviewContract.active == active) + if decision_id: + query = query.where(ReviewContract.decision_id == decision_id) + return list((await session.execute(query)).scalars()) + + +@router.get("/{contract_key}", response_model=ReviewContractRead) +async def get_active_contract( + contract_key: str, + session: AsyncSession = Depends(get_session), +) -> ReviewContract: + return await active_contract(session, contract_key) + + +@router.get("/{contract_key}/aggregate", response_model=ReviewAggregateRead) +async def get_aggregate( + contract_key: str, + session: AsyncSession = Depends(get_session), +) -> ReviewAggregateRead: + return await aggregate(session, await active_contract(session, contract_key)) + + +@router.get("/{contract_key}/receipts", response_model=list[ReviewReceiptRead]) +async def list_receipts( + contract_key: str, + include_stale: bool = Query(True), + session: AsyncSession = Depends(get_session), +) -> list[ReviewReceipt]: + contract = await active_contract(session, contract_key) + query = ( + select(ReviewReceipt) + .join(ReviewContract) + .where( + ReviewContract.contract_key == contract_key + if include_stale + else ReviewReceipt.contract_id == contract.id + ) + .order_by(ReviewReceipt.submitted_at) + ) + return list((await session.execute(query)).scalars()) + + +@router.post( + "/{contract_key}/receipts", + response_model=ReviewReceiptRead, + status_code=status.HTTP_201_CREATED, +) +async def submit_receipt( + contract_key: str, + body: ReviewReceiptSubmit, + session: AsyncSession = Depends(get_session), +) -> ReviewReceipt: + contract = await active_contract(session, contract_key) + document = validate_receipt(contract, body) + receipt_digest = digest_document(document) + existing = ( + await session.execute( + select(ReviewReceipt).where(ReviewReceipt.receipt_digest == receipt_digest) + ) + ).scalar_one_or_none() + if existing: + return existing + receipt = ReviewReceipt( + contract_id=contract.id, + owner_id=body.owner_id, + actor=body.actor, + disposition=body.disposition, + contract_digest=body.contract_digest, + receipt_digest=receipt_digest, + artifact_hashes=body.artifact_hashes, + assertion_ids=body.assertion_ids, + checks=body.checks, + note=body.note, + source_repo=body.source.repo, + source_path=body.source.path, + source_revision=body.source.revision, + submitted_at=utc_now(), + document=document, + ) + session.add(receipt) + try: + await session.commit() + except IntegrityError as exc: + await session.rollback() + raise HTTPException( + 409, "receipt source revision already contains different evidence" + ) from exc + await session.refresh(receipt) + return receipt diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/sbom.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/sbom.py new file mode 100644 index 00000000..a5cb5501 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/sbom.py @@ -0,0 +1,428 @@ +import uuid +import logging +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from sqlalchemy import and_, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.models.managed_repo import ManagedRepo +from api.models.sbom_entry import Ecosystem, SBOMEntry +from api.models.sbom_snapshot import SBOMSnapshot +from api.schemas.sbom import ( + LicenceGroup, + LicenceReport, + SBOMEntryRead, + SBOMIngest, + SBOMRepoView, + SBOMSnapshotDetail, + SBOMSnapshotRead, +) +from api.services.legacy_meter import identity_from_request, record_legacy_usage +from api.services.sbom_nexus import ( + SBOMNexusError, + get_json, + post_json, + reads_from_nexus, + writes_to_nexus, +) +from api.services.repository_aliases import resolve_repository_slug + +router = APIRouter(prefix="/sbom", tags=["sbom"]) +logger = logging.getLogger(__name__) + +_COPYLEFT_PATTERNS = {"GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL"} + + +def _is_copyleft(spdx: str | None) -> bool: + if not spdx: + return False + upper = spdx.upper() + return any(pat in upper for pat in _COPYLEFT_PATTERNS) + + +def _latest_snapshot_ids_subquery(): + """Subquery returning the latest SBOMSnapshot.id per repo.""" + max_at_sq = ( + select(SBOMSnapshot.repo_id, func.max(SBOMSnapshot.snapshot_at).label("max_at")) + .group_by(SBOMSnapshot.repo_id) + .subquery("max_snap_at") + ) + return ( + select(SBOMSnapshot.id) + .join( + max_at_sq, + and_( + SBOMSnapshot.repo_id == max_at_sq.c.repo_id, + SBOMSnapshot.snapshot_at == max_at_sq.c.max_at, + ), + ) + .subquery("latest_snap_ids") + ) + + +@router.post("/ingest/") +async def ingest_sbom( + body: SBOMIngest, + request: Request, + session: AsyncSession = Depends(get_session), +) -> dict: + """Create a new SBOM snapshot for a repo. Previous snapshots are retained.""" + resolution = await resolve_repository_slug(session, body.repo_slug) + repo = resolution.repo + if writes_to_nexus(): + nexus_body = body.model_dump(mode="json") + nexus_body["repo_slug"] = resolution.canonical_slug + payload = await _nexus_post("/sbom/ingest/", body=nexus_body) + try: + snapshot_at = datetime.fromisoformat( + payload["snapshot_at"].replace("Z", "+00:00") + ) + result = { + "repo_slug": payload["repo_slug"], + "snapshot_id": payload["snapshot_id"], + "ingested": payload["ingested"], + "snapshot_at": payload["snapshot_at"], + } + except (AttributeError, KeyError, TypeError, ValueError) as exc: + raise HTTPException( + status_code=502, + detail="SBOM Nexus returned an invalid ingest response", + ) from exc + repo.last_sbom_at = snapshot_at + repo.sbom_source = "sbom-nexus" + await session.commit() + await _meter_compat(session, request, "POST", "/sbom/ingest/") + return result + + now = datetime.now(tz=timezone.utc) + + snap = SBOMSnapshot( + repo_id=repo.id, + snapshot_at=now, + source="manual", + entry_count=len(body.entries), + created_at=now, + ) + session.add(snap) + await session.flush() # materialise snap.id before creating entries + + for entry in body.entries: + sbom = SBOMEntry( + repo_id=repo.id, + snapshot_id=snap.id, + package_name=entry.package_name, + package_version=entry.package_version, + ecosystem=entry.ecosystem, + license_spdx=entry.license_spdx, + is_direct=entry.is_direct, + is_dev=entry.is_dev, + snapshot_at=now, + created_at=now, + ) + session.add(sbom) + + repo.last_sbom_at = now + if not repo.sbom_source: + repo.sbom_source = "manual" + + await session.commit() + await _meter_compat(session, request, "POST", "/sbom/ingest/") + return { + "repo_slug": resolution.canonical_slug, + "snapshot_id": str(snap.id), + "ingested": len(body.entries), + "snapshot_at": now.isoformat(), + } + + +@router.get("/snapshots/", response_model=list[SBOMSnapshotRead]) +async def list_snapshots( + request: Request, + repo_slug: str | None = Query(None), + session: AsyncSession = Depends(get_session), +) -> list[SBOMSnapshotRead]: + """List SBOM snapshots, newest first. Optionally filter by repo.""" + await _meter_compat(session, request, "GET", "/sbom/snapshots/") + if reads_from_nexus(): + if repo_slug: + repo_slug = ( + await resolve_repository_slug(session, repo_slug) + ).canonical_slug + payload = await _nexus_get( + "/sbom/snapshots/", + params={"repo_slug": repo_slug} if repo_slug else None, + ) + return await _translate_snapshots(payload, session) + + q = select(SBOMSnapshot).order_by(SBOMSnapshot.snapshot_at.desc()) + if repo_slug: + repo = await _get_repo_by_slug(repo_slug, session) + q = q.where(SBOMSnapshot.repo_id == repo.id) + result = await session.execute(q) + return [SBOMSnapshotRead.model_validate(s) for s in result.scalars().all()] + + +@router.get("/snapshots/{snapshot_id}", response_model=SBOMSnapshotDetail) +async def get_snapshot( + snapshot_id: uuid.UUID, + request: Request, + session: AsyncSession = Depends(get_session), +) -> SBOMSnapshotDetail: + """Get a snapshot with its full entry list.""" + await _meter_compat(session, request, "GET", "/sbom/snapshots/{snapshot_id}") + if reads_from_nexus(): + payload = await _nexus_get(f"/sbom/snapshots/{snapshot_id}") + repo_ids = await _local_repo_ids([payload], session) + repo_id = repo_ids[payload["repo_slug"]] + translated = _translate_snapshot(payload, repo_id) + translated["entries"] = [ + _translate_entry(entry, repo_id) for entry in payload.get("entries", []) + ] + return SBOMSnapshotDetail.model_validate(translated) + + snap = await session.get(SBOMSnapshot, snapshot_id) + if snap is None: + raise HTTPException(status_code=404, detail=f"Snapshot '{snapshot_id}' not found") + result = await session.execute( + select(SBOMEntry) + .where(SBOMEntry.snapshot_id == snapshot_id) + .order_by(SBOMEntry.package_name) + ) + entries = list(result.scalars().all()) + return SBOMSnapshotDetail( + id=snap.id, + repo_id=snap.repo_id, + snapshot_at=snap.snapshot_at, + source=snap.source, + entry_count=snap.entry_count, + created_at=snap.created_at, + entries=[SBOMEntryRead.model_validate(e) for e in entries], + ) + + +@router.get("/") +async def list_sbom_entries( + request: Request, + repo_slug: str | None = Query(None), + ecosystem: Ecosystem | None = Query(None), + license_spdx: str | None = Query(None), + is_direct: bool | None = Query(None), + is_dev: bool | None = Query(None), + session: AsyncSession = Depends(get_session), +) -> list[SBOMEntryRead]: + """Return entries from the latest snapshot per repo (default) or filter by repo.""" + await _meter_compat(session, request, "GET", "/sbom/") + if reads_from_nexus(): + if repo_slug: + repo_slug = ( + await resolve_repository_slug(session, repo_slug) + ).canonical_slug + params = { + key: value + for key, value in { + "repo_slug": repo_slug, + "ecosystem": ecosystem.value if ecosystem is not None else None, + "license_spdx": license_spdx, + "is_direct": is_direct, + "is_dev": is_dev, + }.items() + if value is not None + } + payload = await _nexus_get("/sbom/", params=params) + repo_ids = await _local_repo_ids(payload, session) + return [ + SBOMEntryRead.model_validate(_translate_entry(entry, repo_ids[entry["repo_slug"]])) + for entry in payload + ] + + if repo_slug: + repo = await _get_repo_by_slug(repo_slug, session) + latest_snap_id_sq = ( + select(SBOMSnapshot.id) + .where(SBOMSnapshot.repo_id == repo.id) + .order_by(SBOMSnapshot.snapshot_at.desc()) + .limit(1) + .scalar_subquery() + ) + q = select(SBOMEntry).where(SBOMEntry.snapshot_id == latest_snap_id_sq) + else: + latest_ids_sq = _latest_snapshot_ids_subquery() + q = select(SBOMEntry).where(SBOMEntry.snapshot_id.in_(select(latest_ids_sq.c.id))) + + if ecosystem is not None: + q = q.where(SBOMEntry.ecosystem == ecosystem) + if license_spdx: + q = q.where(SBOMEntry.license_spdx == license_spdx) + if is_direct is not None: + q = q.where(SBOMEntry.is_direct == is_direct) + if is_dev is not None: + q = q.where(SBOMEntry.is_dev == is_dev) + q = q.order_by(SBOMEntry.package_name) + result = await session.execute(q) + return [SBOMEntryRead.model_validate(e) for e in result.scalars().all()] + + +@router.get("/report/licences/", response_model=LicenceReport) +async def licence_report( + request: Request, + session: AsyncSession = Depends(get_session), +) -> LicenceReport: + """Group latest-snapshot SBOM entries by SPDX licence identifier, flag copyleft.""" + await _meter_compat(session, request, "GET", "/sbom/report/licences/") + if reads_from_nexus(): + payload = await _nexus_get("/sbom/report/licences/") + return LicenceReport.model_validate(payload) + + latest_ids_sq = _latest_snapshot_ids_subquery() + rows = await session.execute( + select(SBOMEntry, ManagedRepo.slug) + .join(ManagedRepo, ManagedRepo.id == SBOMEntry.repo_id) + .where(SBOMEntry.snapshot_id.in_(select(latest_ids_sq.c.id))) + ) + groups: dict[str | None, dict] = {} + copyleft_direct_count = 0 + for entry, repo_slug in rows.all(): + key = entry.license_spdx + if key not in groups: + groups[key] = {"count": 0, "repos": set()} + groups[key]["count"] += 1 + groups[key]["repos"].add(repo_slug) + if _is_copyleft(key) and entry.is_direct and not entry.is_dev: + copyleft_direct_count += 1 + + licence_groups = [ + LicenceGroup( + license_spdx=lic, + count=info["count"], + repos=sorted(info["repos"]), + is_copyleft=_is_copyleft(lic), + ) + for lic, info in sorted(groups.items(), key=lambda x: -x[1]["count"]) + ] + return LicenceReport(groups=licence_groups, copyleft_direct_count=copyleft_direct_count) + + +@router.get("/{repo_slug}", response_model=SBOMRepoView) +async def get_repo_sbom( + repo_slug: str, + request: Request, + session: AsyncSession = Depends(get_session), +) -> SBOMRepoView: + """Return the latest snapshot entries for a specific repo.""" + resolution = await resolve_repository_slug(session, repo_slug) + repo = resolution.repo + await _meter_compat(session, request, "GET", "/sbom/{repo_slug}") + if reads_from_nexus(): + payload = await _nexus_get(f"/sbom/{resolution.canonical_slug}") + payload["entries"] = [ + _translate_entry(entry, repo.id) for entry in payload.get("entries", []) + ] + return SBOMRepoView.model_validate(payload) + + latest_snap_id_sq = ( + select(SBOMSnapshot.id) + .where(SBOMSnapshot.repo_id == repo.id) + .order_by(SBOMSnapshot.snapshot_at.desc()) + .limit(1) + .scalar_subquery() + ) + rows = await session.execute( + select(SBOMEntry) + .where(SBOMEntry.snapshot_id == latest_snap_id_sq) + .order_by(SBOMEntry.package_name) + ) + entries = list(rows.scalars().all()) + return SBOMRepoView( + repo_slug=resolution.canonical_slug, + last_sbom_at=repo.last_sbom_at, + entry_count=len(entries), + entries=[SBOMEntryRead.model_validate(e) for e in entries], + ) + + +async def _get_repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo: + resolution = await resolve_repository_slug(session, slug) + return resolution.repo + + +async def _nexus_get(path: str, *, params: dict | None = None): + try: + return await get_json(path, params=params) + except SBOMNexusError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc + + +async def _nexus_post(path: str, *, body: dict): + try: + return await post_json(path, body=body) + except SBOMNexusError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc + + +async def _local_repo_ids(items: list[dict], session: AsyncSession) -> dict[str, uuid.UUID]: + slugs = {item.get("repo_slug") for item in items} + if None in slugs: + raise HTTPException(status_code=502, detail="SBOM Nexus response omitted repo_slug") + repo_ids: dict[str, uuid.UUID] = {} + for slug in slugs: + resolution = await resolve_repository_slug(session, slug, required=False) + if resolution is not None: + repo_ids[slug] = resolution.repo.id + missing = sorted(slugs - repo_ids.keys()) + if missing: + raise HTTPException( + status_code=502, + detail=f"SBOM Nexus returned repositories absent from State Hub: {', '.join(missing)}", + ) + return repo_ids + + +async def _translate_snapshots( + items: list[dict], session: AsyncSession +) -> list[SBOMSnapshotRead]: + repo_ids = await _local_repo_ids(items, session) + return [ + SBOMSnapshotRead.model_validate(_translate_snapshot(item, repo_ids[item["repo_slug"]])) + for item in items + ] + + +def _translate_snapshot(item: dict, repo_id: uuid.UUID) -> dict: + translated = {**item, "repo_id": repo_id} + source = translated.get("source") + if ( + translated.get("legacy_id") + and isinstance(source, str) + and source.startswith("state-hub:") + ): + translated["source"] = source.removeprefix("state-hub:") + return translated + + +def _translate_entry(item: dict, repo_id: uuid.UUID) -> dict: + return {**item, "repo_id": repo_id} + + +async def _meter_compat( + session: AsyncSession, + request: Request, + method: str, + route: str, +) -> None: + """Meter the compatibility surface without making route success depend on it.""" + interface_key = f"rest_api:{method} {route}" + try: + await record_legacy_usage( + session, + interface_key=interface_key, + interface_kind="rest_api", + replacement_ref="sbom-nexus:/sbom/", + owner_component="state-hub.sbom-compat", + replacement_verified=reads_from_nexus(), + identity=identity_from_request(request), + ) + except Exception: + await session.rollback() + logger.warning("legacy-meter failed to record %s", interface_key, exc_info=True) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/services.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/services.py new file mode 100644 index 00000000..982ab1bf --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/services.py @@ -0,0 +1,147 @@ +"""Two-dimension service catalog API (STATE-WP-0062). + +Read/write surface over service_catalog and its per-dimension extension tables. +The four service classes are queried by combining the hosting_type and +development_type filters. The legacy /tpsc routes remain for third-party +dependency snapshots; this router is the source of truth for the catalog itself. +""" +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from api.database import get_session +from api.models.service_catalog import ( + ServiceCatalog, + ServiceCloud, + ServiceFirstParty, + ServiceSelfHosted, + ServiceThirdParty, +) +from api.schemas.service import ServiceCatalogRead, ServiceUpsert +from api.services.repository_aliases import resolve_repository_slug + +router = APIRouter(prefix="/services", tags=["services"]) + +_HOSTING = {"self_hosted", "cloud_hosted"} +_DEVELOPMENT = {"first_party", "third_party"} + +_WITH_EXTENSIONS = ( + selectinload(ServiceCatalog.third_party), + selectinload(ServiceCatalog.first_party), + selectinload(ServiceCatalog.cloud), + selectinload(ServiceCatalog.self_hosted), +) + + +@router.get("/catalog", response_model=list[ServiceCatalogRead]) +async def list_services( + hosting_type: str | None = None, + development_type: str | None = None, + maturity_level: int | None = None, + status: str | None = None, + repo_slug: str | None = None, + session: AsyncSession = Depends(get_session), +) -> list[ServiceCatalog]: + q = select(ServiceCatalog).options(*_WITH_EXTENSIONS) + if hosting_type: + q = q.where(ServiceCatalog.hosting_type == hosting_type) + if development_type: + q = q.where(ServiceCatalog.development_type == development_type) + if maturity_level is not None: + q = q.where(ServiceCatalog.maturity_level == maturity_level) + if status: + q = q.where(ServiceCatalog.status == status) + if repo_slug: + resolution = await resolve_repository_slug(session, repo_slug) + q = q.join(ServiceFirstParty).where( + ServiceFirstParty.repo_id == resolution.repo.id + ) + q = q.order_by(ServiceCatalog.name.asc()) + result = await session.execute(q) + return list(result.scalars().all()) + + +@router.get("/{slug}", response_model=ServiceCatalogRead) +async def get_service( + slug: str, + session: AsyncSession = Depends(get_session), +) -> ServiceCatalog: + svc = await _resolve(slug, session) + if svc is None: + raise HTTPException(status_code=404, detail=f"Service '{slug}' not found") + return svc + + +@router.post("/catalog", response_model=ServiceCatalogRead, status_code=status.HTTP_201_CREATED) +async def upsert_service( + body: ServiceUpsert, + session: AsyncSession = Depends(get_session), +) -> ServiceCatalog: + if body.hosting_type not in _HOSTING: + raise HTTPException(status_code=422, detail=f"hosting_type must be one of {sorted(_HOSTING)}") + if body.development_type not in _DEVELOPMENT: + raise HTTPException(status_code=422, detail=f"development_type must be one of {sorted(_DEVELOPMENT)}") + + svc = await _resolve(body.slug, session) + if svc is None: + svc = ServiceCatalog(slug=body.slug) + session.add(svc) + + for field in ("name", "owner_or_provider", "category", "description", + "website_url", "status", "hosting_type", "development_type", + "maturity_level"): + setattr(svc, field, getattr(body, field)) + + await _apply_extensions(svc, body, session) + await session.commit() + + return await _resolve(body.slug, session) + + +# ── Helpers ────────────────────────────────────────────────────────────────── + +async def _resolve(slug: str, session: AsyncSession) -> ServiceCatalog | None: + result = await session.execute( + select(ServiceCatalog).where(ServiceCatalog.slug == slug).options(*_WITH_EXTENSIONS) + ) + return result.scalar_one_or_none() + + +async def _upsert_ext(model, service_id: uuid.UUID, data: dict, session: AsyncSession) -> None: + """Create or update a 1:1 extension row keyed by service_id. + + Fetched via session.get (not the relationship attribute) so we never trigger + a lazy relationship load on a freshly-created core row in async context. + """ + current = await session.get(model, service_id) + if current is None: + current = model(service_id=service_id) + session.add(current) + for k, v in data.items(): + setattr(current, k, v) + + +async def _apply_extensions(svc: ServiceCatalog, body: ServiceUpsert, session: AsyncSession) -> None: + # Ensure svc.id is available for new rows. + await session.flush() + + if body.third_party is not None: + await _upsert_ext(ServiceThirdParty, svc.id, body.third_party.model_dump(), session) + if body.cloud is not None: + await _upsert_ext(ServiceCloud, svc.id, body.cloud.model_dump(), session) + if body.self_hosted is not None: + await _upsert_ext(ServiceSelfHosted, svc.id, body.self_hosted.model_dump(), session) + if body.first_party is not None: + data = body.first_party.model_dump(exclude={"repo_slug"}) + if body.first_party.repo_slug and not data.get("repo_id"): + resolution = await resolve_repository_slug( + session, body.first_party.repo_slug + ) + data["repo_id"] = resolution.repo.id + await _upsert_ext(ServiceFirstParty, svc.id, data, session) + + +__all__ = ["router"] diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/state.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/state.py new file mode 100644 index 00000000..05c90006 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/state.py @@ -0,0 +1,1235 @@ +import time +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response +from fastapi.responses import JSONResponse +from sqlalchemy import func, select, text +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import noload, selectinload + +from api.config import settings +from api.database import get_session +from api.services.message_receipts import standing_notice_digests +from api.services.schema_state import schema_state +from api.flow_defs import assertion_result_to_dict, load_flow +from api.models.capability_request import CapabilityRequest +from api.models.contribution import Contribution, ContributionStatus, ContributionType +from api.models.decision import Decision, DecisionStatus, DecisionType +from api.models.domain import Domain +from api.models.extension_point import ExtensionPoint +from api.models.managed_repo import ManagedRepo +from api.models.progress_event import ProgressEvent +from api.models.sbom_entry import SBOMEntry +from api.models.sbom_snapshot import SBOMSnapshot +from api.models.task import Task, TaskPriority, TaskStatus +from api.models.technical_debt import TechnicalDebt +from api.models.topic import Topic, TopicStatus +from api.models.suggestion import OPEN_SUGGESTION_STAGES, Suggestion +from api.models.workplan import Workplan +from api.models.workplan_dependency import WorkplanDependency +from api.schemas.suggestion import RankedSuggestionDigest +from api.services.suggestion_relevance import bump_suggestions_for_next_steps +from api.services.suggestion_wsjf import compute_wsjf, suggestion_sort_key +from api.schemas.decision import DecisionRead +from api.schemas.domain import DomainSummary +from api.schemas.progress_event import ProgressEventRead +from api.schemas.state import ( + DashboardOverview, + DashboardSourceMeta, + DashboardWorkplanRow, + DecisionTotals, + NextStep, + StateSummary, + TaskTotals, + Totals, + TopicTotals, + WorkstreamTotals, +) +from api.schemas.task import TaskRead +from api.schemas.topic import TopicRead, TopicWithWorkstreams +from api.schemas.workstream import WorkstreamRead, WorkstreamWithTaskCounts, WorkstreamWithDeps +from api.schemas.workstream_dependency import WorkstreamDepStub +from api.routers.workstreams import _workplan_index +from api.services.summary_cache import ( + apply_progress_section, + fetch_summary_revision, + get_summary_cache, + register_summary_cache_invalidation, +) +from api.services.ops_run_projection import get_ops_run_projection +from api.services.sbom_nexus import get_json as get_sbom_nexus_json +from api.services.sbom_nexus import reads_from_nexus + + +def _dual_workplan_refs( + workplan_id, + title: str | None, + slug: str | None, +) -> dict: + """Dual-write workplan-first and legacy workstream fields for NextStep.""" + return { + "workplan_id": workplan_id, + "workplan_title": title, + "workplan_slug": slug, + "workstream_id": workplan_id, + "workstream_title": title, + "workstream_slug": slug, + } +from api.task_status import TERMINAL_TASK_STATUSES, status_value +from api.work_record_flavor import ( + RESIDUAL_FLAVOR, + WORK_RECORD_FLAVORS, + is_residual_flavor, + normalize_flavor, +) +from api.workplan_status import ( + CLOSED_WORKPLAN_STATUSES, + OPEN_WORKPLAN_STATUSES, + normalize_workplan_status, +) +from task_flow_engine import FlowEngine + +router = APIRouter(prefix="/state", tags=["state"]) + +_OVERVIEW_CACHE: DashboardOverview | None = None +_OVERVIEW_CACHE_AT: float = 0.0 +_OVERVIEW_TTL = 10.0 + + +def _summary_cache_headers( + response: Response, + *, + cache_status: str, + revision: str, +) -> None: + response.headers["X-StateHub-Cache"] = cache_status + response.headers["X-StateHub-Revision"] = revision + response.headers["Cache-Control"] = "max-age=15, stale-while-revalidate=120" + + +def _apply_summary_flavor_view( + summary: StateSummary, + *, + include_residuals: bool, + flavor: str | None, +) -> StateSummary: + """Default views omit residual workplans; totals.residual_open stays honest.""" + wanted = normalize_flavor(flavor) + if wanted is not None and wanted not in WORK_RECORD_FLAVORS: + raise HTTPException( + status_code=422, + detail=( + f"Unknown work-record flavor {wanted!r}; " + f"expected one of {', '.join(WORK_RECORD_FLAVORS)}" + ), + ) + show_residuals = include_residuals or wanted == RESIDUAL_FLAVOR + residual_rows = list(summary.residual_open_workplans) + open_rows = list(summary.open_workplans) + if wanted is not None: + open_rows = [row for row in open_rows if row.flavor == wanted] + residual_rows = [row for row in residual_rows if row.flavor == wanted] + return summary.model_copy( + update={ + "open_workplans": open_rows, + "residual_open_workplans": residual_rows if show_residuals else [], + } + ) + + +async def _live_summary_sections( + session: AsyncSession, *, refresh: bool = False +) -> dict[str, object]: + """Sections computed per request, outside the revision-keyed cache.""" + return { + "ops_runs": await get_ops_run_projection(refresh=refresh), + # STATE-WP-0093 D5: receipts are written on inbox reads, which do not + # bump the summary revision, so standing-notice counts are live. + "standing_notices": await standing_notice_digests(session), + } + + +@router.get("/summary", response_model=StateSummary) +async def get_summary( + request: Request, + response: Response, + session: AsyncSession = Depends(get_session), + refresh: bool = False, + include_residuals: bool = Query(False), + flavor: str | None = Query(None), +) -> StateSummary: + revision = await fetch_summary_revision(session) + revision_token = revision.combined_fingerprint() + force_refresh = refresh or "no-cache" in request.headers.get("cache-control", "") + + cache = get_summary_cache() + cache_status, cached = cache.resolve(revision, force_refresh=force_refresh) + + if cache_status == "hit-revision" and cached is not None: + _summary_cache_headers(response, cache_status="hit-revision", revision=revision_token) + return _apply_summary_flavor_view( + cached.model_copy(update=await _live_summary_sections(session)), + include_residuals=include_residuals, + flavor=flavor, + ) + + if cache_status == "progress-section" and cached is not None: + result = await apply_progress_section(session, cached, revision) + _summary_cache_headers(response, cache_status="hit-revision", revision=revision_token) + return _apply_summary_flavor_view( + result.model_copy(update=await _live_summary_sections(session)), + include_residuals=include_residuals, + flavor=flavor, + ) + + if cache_status == "stale" and cached is not None: + cache.schedule_refresh(revision) + _summary_cache_headers(response, cache_status="stale", revision=revision_token) + return _apply_summary_flavor_view( + cached.model_copy(update=await _live_summary_sections(session)), + include_residuals=include_residuals, + flavor=flavor, + ) + + result = await build_state_summary(session) + cache.store(result, revision) + _summary_cache_headers(response, cache_status="miss", revision=revision_token) + return _apply_summary_flavor_view( + result.model_copy(update=await _live_summary_sections(session, refresh=force_refresh)), + include_residuals=include_residuals, + flavor=flavor, + ) + + +async def build_state_summary(session: AsyncSession) -> StateSummary: + """Build the full state summary snapshot (cache miss / forced refresh).""" + # Run all queries sequentially on one session. + # AsyncSession does not support concurrent operations (no gather on same session). + + topics_rows = await session.execute( + select(Topic) + .options( + selectinload(Topic.domain), + noload(Topic.workplans), + noload(Topic.decisions), + noload(Topic.progress_events), + ) + .where(Topic.status != TopicStatus.archived) + .order_by(Topic.created_at) + ) + topics = list(topics_rows.scalars().all()) + topic_ids = [t.id for t in topics] + + topic_workstreams: dict = {t.id: [] for t in topics} + if topic_ids: + topic_ws_rows = await session.execute( + select( + Workplan.topic_id, + Workplan.id, + Workplan.slug, + Workplan.title, + Workplan.status, + Workplan.owner, + Workplan.due_date, + ) + .where(Workplan.topic_id.in_(topic_ids)) + .order_by(Workplan.created_at) + ) + for topic_id, ws_id, slug, title, status, owner, due_date in topic_ws_rows: + topic_workstreams.setdefault(topic_id, []).append({ + "id": ws_id, + "slug": slug, + "title": title, + "status": status, + "owner": owner, + "due_date": due_date, + }) + + blocking_rows = await session.execute( + select(Decision) + .where(Decision.decision_type == DecisionType.pending) + .where(Decision.status.in_([DecisionStatus.open, DecisionStatus.escalated])) + .order_by(Decision.deadline.asc().nullslast(), Decision.created_at) + ) + blocking = list(blocking_rows.scalars().all()) + + waiting_rows = await session.execute( + select(Task).options(noload("*")).where(Task.status == TaskStatus.wait).order_by(Task.created_at) + ) + waiting = list(waiting_rows.scalars().all()) + + recent_rows = await session.execute( + select(ProgressEvent).options(noload("*")).order_by(ProgressEvent.created_at.desc()).limit(20) + ) + recent = list(recent_rows.scalars().all()) + + open_ws_rows = await session.execute( + select(Workplan) + .options(noload("*")) + .where(Workplan.status.in_(OPEN_WORKPLAN_STATUSES)) + .order_by(Workplan.due_date.asc().nullslast(), Workplan.created_at) + ) + open_ws = list(open_ws_rows.scalars().all()) + + # Task counts per workplan (used to enrich open_workplans) + task_per_ws: dict = {} + task_statuses_per_ws: dict = {} + for ws_id, tstat, cnt in await session.execute( + select(Task.workplan_id, Task.status, func.count()).group_by(Task.workplan_id, Task.status) + ): + task_per_ws.setdefault(ws_id, {})[tstat] = cnt + task_statuses_per_ws.setdefault(ws_id, []).extend([status_value(tstat)] * cnt) + + # Dependency graph for open workstreams + open_ws_ids = [w.id for w in open_ws] + dep_rows = [] + if open_ws_ids: + dep_result = await session.execute( + select(WorkplanDependency).where( + (WorkplanDependency.from_workplan_id.in_(open_ws_ids)) + | (WorkplanDependency.to_workplan_id.in_(open_ws_ids)) + ) + ) + dep_rows = list(dep_result.scalars().all()) + + # Build a slug+title lookup for all workstreams referenced in deps + dep_ws_ids = set() + dep_task_ids = set() + for d in dep_rows: + dep_ws_ids.add(d.from_workplan_id) + if d.to_workplan_id: + dep_ws_ids.add(d.to_workplan_id) + if d.to_task_id: + dep_task_ids.add(d.to_task_id) + ws_lookup: dict = {w.id: w for w in open_ws} + extra_ids = dep_ws_ids - set(ws_lookup.keys()) + if extra_ids: + extra_rows = await session.execute( + select(Workplan).options(noload("*")).where(Workplan.id.in_(extra_ids)) + ) + for w in extra_rows.scalars(): + ws_lookup[w.id] = w + task_lookup: dict = {} + if dep_task_ids: + task_rows = await session.execute(select(Task).where(Task.id.in_(dep_task_ids))) + task_lookup = {t.id: t for t in task_rows.scalars().all()} + + # Index: workstream_id → (depends_on stubs, blocks stubs) + dep_index: dict = {w.id: {"depends_on": [], "blocks": []} for w in open_ws} + for d in dep_rows: + from_id, to_id, task_id = d.from_workplan_id, d.to_workplan_id, d.to_task_id + if from_id in dep_index and to_id and to_id in ws_lookup: + dep_index[from_id]["depends_on"].append(WorkstreamDepStub( + dep_id=d.id, + target_type="workstream", + relationship_type=d.relationship_type, + workstream_id=to_id, + workstream_slug=ws_lookup[to_id].slug, + workstream_title=ws_lookup[to_id].title, + description=d.description, + )) + if from_id in dep_index and task_id and task_id in task_lookup: + dep_index[from_id]["depends_on"].append(WorkstreamDepStub( + dep_id=d.id, + target_type="task", + relationship_type=d.relationship_type, + task_id=task_id, + task_title=task_lookup[task_id].title, + description=d.description, + )) + if to_id and to_id in dep_index and from_id in ws_lookup: + dep_index[to_id]["blocks"].append(WorkstreamDepStub( + dep_id=d.id, + target_type="workstream", + relationship_type=d.relationship_type, + workstream_id=from_id, + workstream_slug=ws_lookup[from_id].slug, + workstream_title=ws_lookup[from_id].title, + description=d.description, + )) + + workplan_flow = load_flow("workplan") + flow_engine = FlowEngine() + effective_status: dict = {} + blocked_reasons: dict = {} + for w in open_ws: + flow_obj = { + "status": w.status, + "workstation": w.status, + "tasks": [{"status": status} for status in task_statuses_per_ws.get(w.id, [])], + "dependencies": [ + {"workstation": normalize_workplan_status(ws_lookup[d.to_workplan_id].status)} + for d in dep_rows + if d.from_workplan_id == w.id and d.to_workplan_id and d.to_workplan_id in ws_lookup + ], + } + flow_result = flow_engine.evaluate(flow_obj, workplan_flow) + effective_status[w.id] = "blocked" if flow_result.exit_blocked else w.status + blocked_reasons[w.id] = [ + assertion_result_to_dict(item) for item in flow_result.blocking_assertions + ] + + # Totals — one GROUP BY per table + topic_counts = {r[0]: r[1] for r in await session.execute( + select(Topic.status, func.count()).group_by(Topic.status) + )} + ws_counts = {r[0]: r[1] for r in await session.execute( + select(Workplan.status, func.count()).group_by(Workplan.status) + )} + task_counts = {r[0]: r[1] for r in await session.execute( + select(Task.status, func.count()).group_by(Task.status) + )} + dec_counts = {r[0]: r[1] for r in await session.execute( + select(Decision.status, func.count()).group_by(Decision.status) + )} + + totals = Totals( + topics=TopicTotals( + active=topic_counts.get(TopicStatus.active, 0), + paused=topic_counts.get(TopicStatus.paused, 0), + archived=topic_counts.get(TopicStatus.archived, 0), + total=sum(topic_counts.values()), + ), + workstreams=WorkstreamTotals( + proposed=ws_counts.get("proposed", 0), + ready=ws_counts.get("ready", 0) + ws_counts.get("todo", 0), + active=sum(1 for status in effective_status.values() if status == "active"), + blocked=sum(1 for status in effective_status.values() if status == "blocked"), + backlog=ws_counts.get("backlog", 0), + finished=( + ws_counts.get("finished", 0) + + ws_counts.get("completed", 0) + + ws_counts.get("accepted", 0) + ), + archived=ws_counts.get("archived", 0), + total=sum(ws_counts.values()), + residual_open=0, + ), + tasks=TaskTotals( + wait=task_counts.get(TaskStatus.wait, 0), + todo=task_counts.get(TaskStatus.todo, 0), + progress=task_counts.get(TaskStatus.progress, 0), + done=task_counts.get(TaskStatus.done, 0), + cancel=task_counts.get(TaskStatus.cancel, 0), + total=sum(task_counts.values()), + ), + decisions=DecisionTotals( + open=dec_counts.get(DecisionStatus.open, 0), + resolved=dec_counts.get(DecisionStatus.resolved, 0), + escalated=dec_counts.get(DecisionStatus.escalated, 0), + superseded=dec_counts.get(DecisionStatus.superseded, 0), + total=sum(dec_counts.values()), + ), + ) + + next_steps, _ = await _derive_next_steps(session) + + # Domain summary stats + domain_summaries = await _build_domain_summaries(session) + + # Contribution counts (by type and status) + contrib_type_counts = {r[0].value: r[1] for r in await session.execute( + select(Contribution.type, func.count()).group_by(Contribution.type) + )} + contrib_status_counts = {r[0].value: r[1] for r in await session.execute( + select(Contribution.status, func.count()).group_by(Contribution.status) + )} + contribution_counts = {**contrib_type_counts, **contrib_status_counts} + + licence_risk_count, _, _ = await _sbom_metrics(session) + + # Open capability requests (non-terminal statuses) + open_cap_req_count = (await session.execute( + select(func.count()).select_from(CapabilityRequest).where( + CapabilityRequest.status.in_(["requested", "accepted", "in_progress", "ready_for_review"]) + ) + )).scalar() or 0 + + ranked_suggestions = await _ranked_suggestion_digest(session, limit=10) + + open_workplan_rows = [ + WorkstreamWithDeps( + **{ + **WorkstreamRead.model_validate(w).model_dump(), + "status": effective_status.get(w.id, w.status), + }, + tasks_total=sum(task_per_ws.get(w.id, {}).values()), + tasks_wait=task_per_ws.get(w.id, {}).get(TaskStatus.wait, 0), + tasks_todo=task_per_ws.get(w.id, {}).get(TaskStatus.todo, 0), + tasks_progress=task_per_ws.get(w.id, {}).get(TaskStatus.progress, 0), + tasks_done=task_per_ws.get(w.id, {}).get(TaskStatus.done, 0), + tasks_cancel=task_per_ws.get(w.id, {}).get(TaskStatus.cancel, 0), + depends_on=dep_index.get(w.id, {}).get("depends_on", []), + blocks=dep_index.get(w.id, {}).get("blocks", []), + blocked_reasons=blocked_reasons.get(w.id, []), + ) + for w in open_ws + ] + relevant_rows = [ + row for row, w in zip(open_workplan_rows, open_ws) + if not is_residual_flavor(w.flavor) + ] + residual_rows = [ + row for row, w in zip(open_workplan_rows, open_ws) + if is_residual_flavor(w.flavor) + ] + totals.workstreams.residual_open = len(residual_rows) + + result = StateSummary( + generated_at=datetime.now(tz=timezone.utc), + totals=totals, + topics=[ + TopicWithWorkstreams( + **TopicRead.model_validate(t).model_dump(), + workstreams=topic_workstreams.get(t.id, []), + ) + for t in topics + ], + blocking_decisions=[DecisionRead.model_validate(d) for d in blocking], + waiting_tasks=[TaskRead.model_validate(t) for t in waiting], + blocked_tasks=[TaskRead.model_validate(t) for t in waiting], + recent_progress=[ProgressEventRead.model_validate(e) for e in recent], + next_steps=next_steps, + domains=domain_summaries, + contribution_counts=contribution_counts, + licence_risk_count=licence_risk_count, + open_capability_requests=open_cap_req_count, + ranked_suggestions=ranked_suggestions, + open_workplans=relevant_rows, + residual_open_workplans=residual_rows, + ) + return result + + +get_summary_cache().configure(build_state_summary) +register_summary_cache_invalidation() + + +@router.get("/overview", response_model=DashboardOverview) +async def get_overview( + request: Request, + response: Response, + session: AsyncSession = Depends(get_session), +) -> DashboardOverview: + """Bounded dashboard overview read model. + + This is intentionally narrower than /state/summary. The dashboard overview + needs counts, recent rows, and chart-ready workplan rows; it does not need + full task or workplan lists transferred to the browser on every poll. + """ + global _OVERVIEW_CACHE, _OVERVIEW_CACHE_AT + no_cache = "no-cache" in request.headers.get("cache-control", "") + if not no_cache and _OVERVIEW_CACHE is not None and (time.monotonic() - _OVERVIEW_CACHE_AT) < _OVERVIEW_TTL: + response.headers["X-StateHub-Cache"] = "hit" + response.headers["Cache-Control"] = "max-age=10, stale-while-revalidate=30" + return _OVERVIEW_CACHE + + response.headers["X-StateHub-Cache"] = "miss" + response.headers["Cache-Control"] = "max-age=10, stale-while-revalidate=30" + result = await _build_dashboard_overview(session) + _OVERVIEW_CACHE = result + _OVERVIEW_CACHE_AT = time.monotonic() + return result + + +async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview: + topics_rows = await session.execute( + select(Topic) + .options( + selectinload(Topic.domain), + noload(Topic.workplans), + noload(Topic.decisions), + noload(Topic.progress_events), + ) + .where(Topic.status != TopicStatus.archived) + .order_by(Topic.created_at) + ) + topics = list(topics_rows.scalars().all()) + topic_map = {topic.id: topic for topic in topics} + + workstream_rows = await session.execute( + select(Workplan) + .options(noload("*")) + .order_by( + Workplan.planning_priority.asc().nullslast(), + Workplan.planning_order.asc().nullslast(), + Workplan.updated_at.desc(), + ) + ) + workstreams_all = list(workstream_rows.scalars().all()) + + topic_workstreams: dict = {t.id: [] for t in topics} + for w in sorted(workstreams_all, key=lambda item: item.created_at): + if w.topic_id not in topic_workstreams: + continue + topic_workstreams[w.topic_id].append({ + "id": w.id, + "slug": w.slug, + "title": w.title, + "status": w.status, + "owner": w.owner, + "due_date": w.due_date, + }) + + repo_rows = await session.execute( + select(ManagedRepo.id, ManagedRepo.slug, Domain.slug) + .join(Domain, Domain.id == ManagedRepo.domain_id) + .order_by(ManagedRepo.slug) + ) + repo_map = { + repo_id: {"slug": repo_slug, "domain_slug": domain_slug} + for repo_id, repo_slug, domain_slug in repo_rows + } + + task_counts_by_ws: dict = {} + task_statuses_per_ws: dict = {} + task_totals_by_status: dict[str, int] = {} + for ws_id, task_status, count in await session.execute( + select(Task.workplan_id, Task.status, func.count()).group_by(Task.workplan_id, Task.status) + ): + status = status_value(task_status) + task_counts_by_ws.setdefault(ws_id, {"done": 0, "progress": 0, "wait": 0, "todo": 0, "total": 0}) + task_counts_by_ws[ws_id]["total"] += count + if status in {"done", "progress", "wait", "todo"}: + task_counts_by_ws[ws_id][status] += count + task_statuses_per_ws.setdefault(ws_id, []).extend([status] * count) + task_totals_by_status[status] = task_totals_by_status.get(status, 0) + count + + open_ws = [ + w for w in workstreams_all + if normalize_workplan_status(w.status) in OPEN_WORKPLAN_STATUSES + ] + residual_open = sum(1 for w in open_ws if is_residual_flavor(w.flavor)) + open_ws_ids = [w.id for w in open_ws] + dep_rows = [] + if open_ws_ids: + dep_result = await session.execute( + select(WorkplanDependency).where( + (WorkplanDependency.from_workplan_id.in_(open_ws_ids)) + | (WorkplanDependency.to_workplan_id.in_(open_ws_ids)) + ) + ) + dep_rows = list(dep_result.scalars().all()) + + ws_lookup = {w.id: w for w in workstreams_all} + workplan_flow = load_flow("workplan") + flow_engine = FlowEngine() + effective_status: dict = {} + for w in open_ws: + flow_obj = { + "status": w.status, + "workstation": w.status, + "tasks": [{"status": status} for status in task_statuses_per_ws.get(w.id, [])], + "dependencies": [ + {"workstation": normalize_workplan_status(ws_lookup[d.to_workplan_id].status)} + for d in dep_rows + if d.from_workplan_id == w.id and d.to_workplan_id and d.to_workplan_id in ws_lookup + ], + } + flow_result = flow_engine.evaluate(flow_obj, workplan_flow) + effective_status[w.id] = "blocked" if flow_result.exit_blocked else normalize_workplan_status(w.status) + + topic_counts = {r[0]: r[1] for r in await session.execute( + select(Topic.status, func.count()).group_by(Topic.status) + )} + ws_counts = {r[0]: r[1] for r in await session.execute( + select(Workplan.status, func.count()).group_by(Workplan.status) + )} + dec_counts = {r[0]: r[1] for r in await session.execute( + select(Decision.status, func.count()).group_by(Decision.status) + )} + + totals = Totals( + topics=TopicTotals( + active=topic_counts.get(TopicStatus.active, 0), + paused=topic_counts.get(TopicStatus.paused, 0), + archived=topic_counts.get(TopicStatus.archived, 0), + total=sum(topic_counts.values()), + ), + workstreams=WorkstreamTotals( + proposed=ws_counts.get("proposed", 0), + ready=ws_counts.get("ready", 0) + ws_counts.get("todo", 0), + active=sum(1 for status in effective_status.values() if status == "active"), + blocked=sum(1 for status in effective_status.values() if status == "blocked"), + backlog=ws_counts.get("backlog", 0), + finished=( + ws_counts.get("finished", 0) + + ws_counts.get("completed", 0) + + ws_counts.get("accepted", 0) + ), + archived=ws_counts.get("archived", 0), + total=sum(ws_counts.values()), + residual_open=residual_open, + ), + tasks=TaskTotals( + wait=task_totals_by_status.get("wait", 0), + todo=task_totals_by_status.get("todo", 0), + progress=task_totals_by_status.get("progress", 0), + done=task_totals_by_status.get("done", 0), + cancel=task_totals_by_status.get("cancel", 0), + total=sum(task_totals_by_status.values()), + ), + decisions=DecisionTotals( + open=dec_counts.get(DecisionStatus.open, 0), + resolved=dec_counts.get(DecisionStatus.resolved, 0), + escalated=dec_counts.get(DecisionStatus.escalated, 0), + superseded=dec_counts.get(DecisionStatus.superseded, 0), + total=sum(dec_counts.values()), + ), + ) + + blocking_rows = await session.execute( + select(Decision) + .where(Decision.decision_type == DecisionType.pending) + .where(Decision.status.in_([DecisionStatus.open, DecisionStatus.escalated])) + .order_by(Decision.deadline.asc().nullslast(), Decision.created_at) + ) + blocking = list(blocking_rows.scalars().all()) + + waiting_rows = await session.execute( + select(Task).options(noload("*")).where(Task.status == TaskStatus.wait).order_by(Task.created_at) + ) + waiting = list(waiting_rows.scalars().all()) + + recent_rows = await session.execute( + select(ProgressEvent).options(noload("*")).order_by(ProgressEvent.created_at.desc()).limit(20) + ) + recent = list(recent_rows.scalars().all()) + + milestone_rows = await session.execute( + select(ProgressEvent) + .options(noload("*")) + .where(ProgressEvent.event_type == "milestone") + .where(ProgressEvent.summary.like("Project registered with State Hub:%")) + .order_by(ProgressEvent.created_at.desc()) + .limit(500) + ) + registration_milestones = list(milestone_rows.scalars().all()) + + contrib_type_counts = {r[0].value: r[1] for r in await session.execute( + select(Contribution.type, func.count()).group_by(Contribution.type) + )} + contrib_status_counts = {r[0].value: r[1] for r in await session.execute( + select(Contribution.status, func.count()).group_by(Contribution.status) + )} + contribution_counts = {**contrib_type_counts, **contrib_status_counts} + + licence_risk_count, snapshot_count, package_total = await _sbom_metrics(session) + + open_cap_req_count = (await session.execute( + select(func.count()).select_from(CapabilityRequest).where( + CapabilityRequest.status.in_(["requested", "accepted", "in_progress", "ready_for_review"]) + ) + )).scalar() or 0 + + sources: dict[str, DashboardSourceMeta] = {} + try: + workplan_index = await _workplan_index(refresh=False, session=session) + workplan_map = workplan_index.get("workstreams", {}) + index_meta = workplan_index.get("_meta", {}) + sources["workplan_index"] = DashboardSourceMeta( + ok=not bool(index_meta.get("last_error")), + stale=bool(index_meta.get("stale")), + cache_age_seconds=index_meta.get("cache_age_seconds"), + refresh_in_progress=bool(index_meta.get("refresh_in_progress")), + error=index_meta.get("last_error"), + ) + except Exception as exc: + workplan_map = {} + sources["workplan_index"] = DashboardSourceMeta(ok=False, error=str(exc)) + + workplan_rows: list[DashboardWorkplanRow] = [] + for w in workstreams_all: + repo = repo_map.get(w.repo_id) + topic = topic_map.get(w.topic_id) + workplan = workplan_map.get(str(w.id), {}) + counts = task_counts_by_ws.get(w.id, {"done": 0, "progress": 0, "wait": 0, "todo": 0, "total": 0}) + workplan_rows.append(DashboardWorkplanRow( + id=w.id, + title=w.title, + status=normalize_workplan_status(w.status), + flavor=w.flavor, + domain=repo["domain_slug"] if repo else (topic.domain_slug if topic else "unknown"), + repo_label=repo["slug"] if repo else workplan.get("repo_slug", "unassigned"), + workplan_filename=workplan.get("filename"), + workplan_relative_path=workplan.get("relative_path"), + workplan_archived=bool(workplan.get("archived", False)), + health_labels=workplan.get("health_labels", []), + href=f"./workstreams/{w.id}", + done=counts.get("done", 0), + progress=counts.get("progress", 0), + wait=counts.get("wait", 0), + todo=counts.get("todo", 0), + total=counts.get("total", 0), + created_at=w.created_at, + updated_at=w.updated_at, + )) + + return DashboardOverview( + generated_at=datetime.now(tz=timezone.utc), + totals=totals, + topics=[ + TopicWithWorkstreams( + **TopicRead.model_validate(t).model_dump(), + workstreams=topic_workstreams.get(t.id, []), + ) + for t in topics + ], + blocking_decisions=[DecisionRead.model_validate(d) for d in blocking], + waiting_tasks=[TaskRead.model_validate(t) for t in waiting], + blocked_tasks=[TaskRead.model_validate(t) for t in waiting], + recent_progress=[ProgressEventRead.model_validate(e) for e in recent], + next_steps=(await _derive_next_steps(session))[0], + contribution_counts=contribution_counts, + licence_risk_count=licence_risk_count, + open_capability_requests=open_cap_req_count, + sbom_snapshot_count=int(snapshot_count or 0), + sbom_package_total=int(package_total or 0), + registration_milestones=[ProgressEventRead.model_validate(e) for e in registration_milestones], + workplan_rows=workplan_rows, + sources=sources, + diagnostics={ + "workplan_row_count": len(workplan_rows), + "task_count_strategy": "grouped", + }, + ) + + +async def _sbom_metrics(session: AsyncSession) -> tuple[int, int, int]: + """Return compatibility summary metrics from the selected SBOM authority.""" + if reads_from_nexus(): + snapshots = await get_sbom_nexus_json("/sbom/snapshots/") + report = await get_sbom_nexus_json("/sbom/report/licences/") + return ( + int(report.get("copyleft_direct_count") or 0), + len(snapshots), + sum(int(snapshot.get("entry_count") or 0) for snapshot in snapshots), + ) + + copyleft_patterns = ("GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL") + rows = await session.execute( + select(SBOMEntry.license_spdx) + .where(SBOMEntry.is_direct.is_(True)) + .where(SBOMEntry.is_dev.is_(False)) + ) + licence_risk_count = sum( + 1 + for (licence,) in rows.all() + if licence and any(pattern in licence.upper() for pattern in copyleft_patterns) + ) + snapshot_count, package_total = ( + await session.execute( + select( + func.count(SBOMSnapshot.id), + func.coalesce(func.sum(SBOMSnapshot.entry_count), 0), + ) + ) + ).one() + return licence_risk_count, int(snapshot_count or 0), int(package_total or 0) + + +async def _build_domain_summaries(session: AsyncSession) -> list[DomainSummary]: + """Compute per-domain stats for the state summary.""" + domains_rows = await session.execute( + select(Domain).options(noload("*")).where(Domain.status == "active").order_by(Domain.name) + ) + domains = list(domains_rows.scalars().all()) + + # Repo counts per domain + repo_counts = {r[0]: r[1] for r in await session.execute( + select(ManagedRepo.domain_id, func.count()) + .where(ManagedRepo.status == "active") + .group_by(ManagedRepo.domain_id) + )} + + # Active workstream counts per domain (join through topics) + ws_per_domain = {} + for domain_id, cnt in await session.execute( + select(Topic.domain_id, func.count(Workplan.id)) + .join(Workplan, Workplan.topic_id == Topic.id) + .where(Workplan.status.in_(["active", "blocked"])) + .group_by(Topic.domain_id) + ): + ws_per_domain[domain_id] = cnt + + # EP counts per domain id (via FK) + ep_counts = {r[0]: r[1] for r in await session.execute( + select(ExtensionPoint.domain_id, func.count()).group_by(ExtensionPoint.domain_id) + )} + + # TD counts per domain id (via FK) + td_counts = {r[0]: r[1] for r in await session.execute( + select(TechnicalDebt.domain_id, func.count()).group_by(TechnicalDebt.domain_id) + )} + + return [ + DomainSummary( + slug=d.slug, + name=d.name, + repo_count=repo_counts.get(d.id, 0), + active_workstream_count=ws_per_domain.get(d.id, 0), + ep_count=ep_counts.get(d.id, 0), + td_count=td_counts.get(d.id, 0), + ) + for d in domains + ] + + +@router.get("/deps", response_model=list[WorkstreamWithDeps]) +async def get_deps( + session: AsyncSession = Depends(get_session), + include_residuals: bool = Query(False), +) -> list[WorkstreamWithDeps]: + """Lightweight dep-graph endpoint: open workstreams with their dependency edges only. + + Returns the same structure as open_workplans in /state/summary but skips + the 10-table full-summary computation. Task counts are omitted (all zero). + Used by workstreams.md and dependencies.md which only need dep edges. + """ + open_ws_rows = await session.execute( + select(Workplan) + .options(noload("*")) + .where(Workplan.status.in_(OPEN_WORKPLAN_STATUSES)) + .order_by(Workplan.due_date.asc().nullslast(), Workplan.created_at) + ) + open_ws = list(open_ws_rows.scalars().all()) + if not include_residuals: + open_ws = [w for w in open_ws if not is_residual_flavor(w.flavor)] + + open_ws_ids = [w.id for w in open_ws] + dep_rows = [] + if open_ws_ids: + dep_result = await session.execute( + select(WorkplanDependency).where( + (WorkplanDependency.from_workplan_id.in_(open_ws_ids)) + | (WorkplanDependency.to_workplan_id.in_(open_ws_ids)) + ) + ) + dep_rows = list(dep_result.scalars().all()) + + dep_ws_ids: set = set() + dep_task_ids: set = set() + open_ws_id_set = set(open_ws_ids) + for d in dep_rows: + dep_ws_ids.add(d.from_workplan_id) + if d.to_workplan_id: + dep_ws_ids.add(d.to_workplan_id) + if d.to_task_id: + dep_task_ids.add(d.to_task_id) + if d.from_task_id and d.from_workplan_id in open_ws_id_set: + dep_task_ids.add(d.from_task_id) + + ws_lookup: dict = {w.id: w for w in open_ws} + extra_ids = dep_ws_ids - set(ws_lookup.keys()) + if extra_ids: + extra_rows = await session.execute( + select(Workplan).options(noload("*")).where(Workplan.id.in_(extra_ids)) + ) + for w in extra_rows.scalars(): + ws_lookup[w.id] = w + + task_lookup: dict = {} + if dep_task_ids: + task_rows = await session.execute(select(Task).options(noload("*")).where(Task.id.in_(dep_task_ids))) + task_lookup = {t.id: t for t in task_rows.scalars().all()} + + dep_index: dict = {w.id: {"depends_on": [], "blocks": []} for w in open_ws} + for d in dep_rows: + from_id, to_id, task_id = d.from_workplan_id, d.to_workplan_id, d.to_task_id + # Task-scoped edge (CUST-WP-0074): the waiting task on the from side, + # only surfaced on the workplan's own depends_on stubs. + from_task = task_lookup.get(d.from_task_id) if d.from_task_id else None + from_task_fields = { + "from_task_id": d.from_task_id, + "from_task_title": from_task.title if from_task is not None else None, + } + if from_id in dep_index and to_id and to_id in ws_lookup: + dep_index[from_id]["depends_on"].append(WorkstreamDepStub( + dep_id=d.id, target_type="workstream", relationship_type=d.relationship_type, + workstream_id=to_id, workstream_slug=ws_lookup[to_id].slug, + workstream_title=ws_lookup[to_id].title, description=d.description, + **from_task_fields, + )) + if from_id in dep_index and task_id and task_id in task_lookup: + dep_index[from_id]["depends_on"].append(WorkstreamDepStub( + dep_id=d.id, target_type="task", relationship_type=d.relationship_type, + task_id=task_id, task_title=task_lookup[task_id].title, description=d.description, + **from_task_fields, + )) + if to_id and to_id in dep_index and from_id in ws_lookup: + dep_index[to_id]["blocks"].append(WorkstreamDepStub( + dep_id=d.id, target_type="workstream", relationship_type=d.relationship_type, + workstream_id=from_id, workstream_slug=ws_lookup[from_id].slug, + workstream_title=ws_lookup[from_id].title, description=d.description, + )) + + return [ + WorkstreamWithDeps( + **WorkstreamRead.model_validate(w).model_dump(), + depends_on=dep_index[w.id]["depends_on"], + blocks=dep_index[w.id]["blocks"], + ) + for w in open_ws + ] + + +_PRIORITY_RANK = { + TaskPriority.critical: 0, + TaskPriority.high: 1, + TaskPriority.medium: 2, + TaskPriority.low: 3, +} + + +async def _ranked_suggestion_digest( + session: AsyncSession, + *, + limit: int = 10, +) -> list[RankedSuggestionDigest]: + rows = ( + await session.execute( + select(Suggestion).where(Suggestion.stage.in_(OPEN_SUGGESTION_STAGES)) + ) + ).scalars().all() + ranked = sorted(rows, key=suggestion_sort_key)[:limit] + return [ + RankedSuggestionDigest( + id=s.id, + title=s.title, + stage=s.stage, + domain_slug=s.domain_slug, + origin_ref=s.origin_ref, + relevance=s.relevance, + wsjf=compute_wsjf(s), + last_requested_at=s.last_requested_at, + ) + for s in ranked + ] + + +async def _derive_next_steps(session: AsyncSession) -> tuple[list[NextStep], list[Suggestion]]: + """Derive contextual next-action suggestions from current hub state. + + Two signal sources: + 1. Recently resolved decisions (last 7 days) → first open task in same workstream + 2. Workstreams whose every dependency is now finished -> first todo task in that workstream + """ + steps: list[NextStep] = [] + seen_task_ids: set = set() + + # ── Signal 1: recently resolved decisions ──────────────────────────────── + cutoff = datetime.now(tz=timezone.utc) - timedelta(days=7) + resolved_rows = await session.execute( + select(Decision) + .options(noload("*")) + .where(Decision.status == DecisionStatus.resolved) + .where(Decision.decided_at >= cutoff) + .where(Decision.workplan_id.isnot(None)) + .order_by(Decision.decided_at.desc()) + .limit(20) + ) + for decision in resolved_rows.scalars().all(): + open_tasks_rows = await session.execute( + select(Task) + .options(noload("*")) + .where(Task.workplan_id == decision.workplan_id) + .where(Task.status.in_([TaskStatus.todo, TaskStatus.progress, TaskStatus.wait])) + ) + open_tasks = list(open_tasks_rows.scalars().all()) + if not open_tasks: + continue + task = min(open_tasks, key=lambda t: (_PRIORITY_RANK.get(t.priority, 99), t.created_at)) + if task.id in seen_task_ids: + continue + ws = await session.get(Workplan, decision.workplan_id, options=[noload("*")]) + if ws is not None and is_residual_flavor(ws.flavor): + continue + if is_residual_flavor(task.flavor): + continue + domain_slug = await _get_domain_slug_for_workplan(ws, session) + steps.append(NextStep( + type="resolved_decision", + domain=domain_slug, + **_dual_workplan_refs( + ws.id if ws else None, + ws.title if ws else None, + ws.slug if ws else None, + ), + task_id=task.id, + task_title=task.title, + message=( + f"Decision '{decision.title}' was resolved → " + f"'{task.title}' is the next open task in '{ws.title if ws else '?'}'" + ), + )) + seen_task_ids.add(task.id) + + # ── Signal 2: cleared dependencies ────────────────────────────────────── + all_dep_rows = await session.execute( + select( + WorkplanDependency.from_workplan_id, + WorkplanDependency.to_workplan_id, + ).where(WorkplanDependency.to_workplan_id.isnot(None)) + ) + all_deps = all_dep_rows.all() + + # Group from_workplan_id → set of to_workplan_ids + dep_map: dict = {} + dep_ws_ids = set() + for from_ws_id, to_ws_id in all_deps: + dep_map.setdefault(from_ws_id, set()).add(to_ws_id) + dep_ws_ids.add(from_ws_id) + dep_ws_ids.add(to_ws_id) + + ws_info = {} + if dep_ws_ids: + ws_rows = await session.execute( + select( + Workplan.id, + Workplan.status, + Workplan.title, + Workplan.slug, + Workplan.topic_id, + Workplan.flavor, + ).where(Workplan.id.in_(dep_ws_ids)) + ) + ws_info = { + ws_id: { + "status": status, + "title": title, + "slug": slug, + "topic_id": topic_id, + "flavor": flavor, + } + for ws_id, status, title, slug, topic_id, flavor in ws_rows + } + + ready_from_ws_ids = [ + from_ws_id + for from_ws_id, to_ws_ids in dep_map.items() + if normalize_workplan_status(ws_info.get(from_ws_id, {}).get("status")) in OPEN_WORKPLAN_STATUSES + and all( + normalize_workplan_status(ws_info.get(to_id, {}).get("status")) in CLOSED_WORKPLAN_STATUSES + for to_id in to_ws_ids + ) + ] + + todo_by_ws: dict = {} + if ready_from_ws_ids: + todo_rows = await session.execute( + select(Task) + .options(noload("*")) + .where(Task.workplan_id.in_(ready_from_ws_ids)) + .where(Task.status == TaskStatus.todo) + ) + for task in todo_rows.scalars().all(): + todo_by_ws.setdefault(task.workplan_id, []).append(task) + + for from_ws_id in ready_from_ws_ids: + from_ws = ws_info.get(from_ws_id, {}) + if is_residual_flavor(from_ws.get("flavor")): + continue + todo_tasks = todo_by_ws.get(from_ws_id, []) + if not todo_tasks: + continue + task = min(todo_tasks, key=lambda t: (_PRIORITY_RANK.get(t.priority, 99), t.created_at)) + if task.id in seen_task_ids: + continue + if is_residual_flavor(task.flavor): + continue + domain_slug = await _get_domain_slug_for_topic(from_ws.get("topic_id"), session) + _blocker_slugs = [] + for tid in dep_map[from_ws_id]: + if tid in ws_info: + _blocker_slugs.append(ws_info[tid]["slug"]) + blocker_slugs = ", ".join(_blocker_slugs) + steps.append(NextStep( + type="dependency_cleared", + domain=domain_slug, + **_dual_workplan_refs(from_ws_id, from_ws["title"], from_ws["slug"]), + task_id=task.id, + task_title=task.title, + message=( + f"All dependencies of '{from_ws['title']}' are finished ({blocker_slugs}) -> " + f"'{task.title}' is ready to start" + ), + )) + seen_task_ids.add(task.id) + + # Signal 3: open demand-weighted suggestions (needed-but-unmet backlog) + open_suggestions = sorted( + ( + await session.execute( + select(Suggestion).where(Suggestion.stage.in_(OPEN_SUGGESTION_STAGES)) + ) + ).scalars().all(), + key=suggestion_sort_key, + )[:5] + for suggestion in open_suggestions: + steps.append(NextStep( + type="open_suggestion", + domain=suggestion.domain_slug, + **_dual_workplan_refs(suggestion.workplan_id, None, suggestion.origin_ref), + task_id=None, + task_title=None, + message=( + f"Gated need '{suggestion.title}' (stage={suggestion.stage.value}, " + f"relevance={suggestion.relevance}) — vet or promote when unblocked" + ), + )) + + return steps, open_suggestions + + +async def _get_domain_slug_for_workplan(ws: Workplan | None, session: AsyncSession) -> str | None: + """Get the domain slug for a workplan via its topic.""" + if ws is None or ws.topic_id is None: + return None + return await _get_domain_slug_for_topic(ws.topic_id, session) + + +async def _get_domain_slug_for_topic(topic_id, session: AsyncSession) -> str | None: + """Get the domain slug for a topic id.""" + if topic_id is None: + return None + topic = await session.get(Topic, topic_id, options=[noload("*")]) + if topic is None or topic.domain_id is None: + return None + domain = await session.get(Domain, topic.domain_id, options=[noload("*")]) + return domain.slug if domain else None + + +@router.get("/next_steps", response_model=list[NextStep]) +async def get_next_steps(session: AsyncSession = Depends(get_session)) -> list[NextStep]: + """Derive contextual next-action suggestions from current hub state. + + Returns suggestions based on: + - Recently resolved decisions → first open task in the same workstream + - Workstreams whose every dependency workstream is now finished -> first todo task + - Open demand-weighted suggestions (gated needs accruing relevance) + """ + steps, surfaced = await _derive_next_steps(session) + await bump_suggestions_for_next_steps(session, surfaced) + await session.commit() + return steps + + +@router.get("/health") +async def health_check(session: AsyncSession = Depends(get_session)) -> dict: + try: + await session.execute(text("SELECT 1")) + return { + "status": "ok", + "db": "connected", + # Identity, so a caller can verify it reached the hub it meant to. + "instance_role": settings.state_hub_instance_role, + "instance_label": settings.state_hub_instance_label, + # Surfaced, not merely logged: a schema behind the code is a fault + # the operator must be able to see (STATE-WP-0083-T07). + "schema": await schema_state(session), + } + except Exception as exc: + return JSONResponse( + status_code=503, + content={"status": "error", "db": str(exc)}, + ) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/suggestions.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/suggestions.py new file mode 100644 index 00000000..ed5df8c4 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/suggestions.py @@ -0,0 +1,45 @@ +"""Retired: the suggestion backlog. + +Slice E1 of the State Hub retirement (`STATE-WP-0079-T05`). The capability was +superseded by the **intake** work-record entity; mutations were retired +2026-07-21 under `CUST-WP-0061-T06`, and the reads were kept live only so the +historical record stayed reachable. + +That history is now archived at +`the-custodian/docs/archived-suggestion-backlog.md` — all 10 suggestions, 10 +notes and 5 relevance bumps, every one closed as `declined` during the intake +migration and none promoted. With a durable record outside this repo, the read +surface has no remaining job, so the whole router answers 410. + +The `suggestions`, `suggestion_notes` and `suggestion_relevance_bumps` tables +are deliberately left in place: they are `retire`/`archive` in `SHR-INV-0001` +and are captured by the final dump at `STATE-WP-0079-T06`. Dropping them here +would remove data ahead of the dump for no gain. +""" + +from fastapi import APIRouter, HTTPException + +router = APIRouter(prefix="/suggestions", tags=["suggestions"]) + +_DETAIL = ( + "suggestions are retired (STATE-WP-0079-T05, slice E1). " + "Use POST /intakes/ for new discovery work. The historical backlog is " + "archived at the-custodian/docs/archived-suggestion-backlog.md — see also " + "the-custodian/intake-legacy-suggestions-migration.md and " + "canon/standards/work-record-types_v0.1.md." +) + + +def _retired() -> HTTPException: + return HTTPException(status_code=410, detail=_DETAIL) + + +@router.api_route( + "/{path:path}", + methods=["GET", "POST", "PATCH", "PUT", "DELETE"], + include_in_schema=False, +) +@router.api_route("/", methods=["GET", "POST", "PATCH", "PUT", "DELETE"]) +async def suggestions_retired(path: str = "") -> None: + """Every suggestion route is retired; see module docstring.""" + raise _retired() diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/tasks.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/tasks.py new file mode 100644 index 00000000..1edc1c90 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/tasks.py @@ -0,0 +1,410 @@ +import uuid +from datetime import date + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.models.progress_event import ProgressEvent +from api.models.task import Task, TaskStatus +from api.models.token_event import TokenEvent +from api.models.workplan import Workplan +from api.schemas.task import ( + TaskCountRead, + TaskCreate, + TaskRead, + TaskStatusBulkSync, + TaskStatusBulkSyncRead, + TaskUpdate, +) +from api.services.legacy_compat import meter_legacy_body_from_model, meter_legacy_query_param +from api.services.lifecycle import status_value, transition_task_status +from api.services.wait_kind import annotate_tasks +from api.task_status import normalize_task_status + +router = APIRouter(prefix="/tasks", tags=["tasks"]) + + +@router.get("/", response_model=list[TaskRead]) +async def list_tasks( + request: Request, + response: Response, + workplan_id: uuid.UUID | None = None, + workstream_id: uuid.UUID | None = None, + status: str | None = None, + assignee: str | None = None, + needs_human: bool | None = Query(None), + priority: str | None = None, + due_date_before: date | None = None, + limit: int | None = Query(None, ge=1, le=5000), + offset: int = Query(0, ge=0), + session: AsyncSession = Depends(get_session), +) -> list[Task]: + if workstream_id is not None and workplan_id is None: + await meter_legacy_query_param( + session=session, + request=request, + response=response, + method="GET", + route="/tasks/", + replacement_ref="/tasks/?workplan_id=", + ) + q = select(Task) + scope_id = workplan_id or workstream_id + if scope_id: + q = q.where(Task.workplan_id == scope_id) + if status: + q = q.where(Task.status == TaskStatus(normalize_task_status(status))) + if assignee: + q = q.where(Task.assignee == assignee) + if needs_human is not None: + q = q.where(Task.needs_human == needs_human) + if priority: + q = q.where(Task.priority == priority) + if due_date_before is not None: + q = q.where(Task.due_date <= due_date_before) + q = q.order_by(Task.created_at) + if offset: + q = q.offset(offset) + if limit is not None: + q = q.limit(limit) + result = await session.execute(q) + return list(await annotate_tasks(session, list(result.scalars().all()))) + + +@router.get("/counts", response_model=list[TaskCountRead]) +async def count_tasks( + request: Request, + response: Response, + workplan_id: uuid.UUID | None = None, + workstream_id: uuid.UUID | None = None, + status: str | None = None, + session: AsyncSession = Depends(get_session), +) -> list[TaskCountRead]: + if workstream_id is not None and workplan_id is None: + await meter_legacy_query_param( + session=session, + request=request, + response=response, + method="GET", + route="/tasks/counts", + replacement_ref="/tasks/counts?workplan_id=", + ) + q = select(Task.workplan_id, Task.status, func.count()).group_by(Task.workplan_id, Task.status) + scope_id = workplan_id or workstream_id + if scope_id: + q = q.where(Task.workplan_id == scope_id) + if status: + q = q.where(Task.status == TaskStatus(normalize_task_status(status))) + rows = await session.execute(q) + return [ + TaskCountRead(workplan_id=ws_id, status=task_status, count=count) + for ws_id, task_status, count in rows + ] + + +@router.post("/", response_model=TaskRead, status_code=status.HTTP_201_CREATED) +async def create_task( + request: Request, + response: Response, + body: TaskCreate, + session: AsyncSession = Depends(get_session), +) -> Task: + await meter_legacy_body_from_model( + body, + session=session, + request=request, + response=response, + method="POST", + route="/tasks/", + replacement_ref="POST /tasks/ with workplan_id", + ) + task = Task(**body.model_dump()) + session.add(task) + if status_value(task.status) == "progress": + ws = await session.get(Workplan, task.workplan_id) + transition_task_status( + task, + task.status, + parent_workstream=ws, + previous_task_status="todo", + ) + await session.commit() + await session.refresh(task) + return task + + +@router.post("/bulk-status-sync", response_model=TaskStatusBulkSyncRead) +async def bulk_status_sync( + body: TaskStatusBulkSync, + session: AsyncSession = Depends(get_session), +) -> TaskStatusBulkSyncRead: + seen: set[uuid.UUID] = set() + duplicate_ids: list[str] = [] + tasks_by_id: dict[uuid.UUID, Task] = {} + missing_ids: list[str] = [] + + for update in body.updates: + if update.task_id in seen: + duplicate_ids.append(str(update.task_id)) + continue + seen.add(update.task_id) + task = await session.get(Task, update.task_id) + if task is None: + missing_ids.append(str(update.task_id)) + else: + tasks_by_id[update.task_id] = task + + if duplicate_ids: + raise HTTPException( + status_code=400, + detail={"message": "duplicate task_id values are not allowed", "task_ids": duplicate_ids}, + ) + if missing_ids: + raise HTTPException( + status_code=404, + detail={"message": "one or more tasks were not found", "task_ids": missing_ids}, + ) + + updated: list[Task] = [] + events: list[ProgressEvent] = [] + author = body.author or "custodian" + # Cache repo resolution for dual-run writeback (RMGR-WP-0003) + repo_cache: dict = {} + for update in body.updates: + task = tasks_by_id[update.task_id] + previous_status = status_value(task.status) + target_status = status_value(update.status) + if update.blocking_reason is not None: + task.blocking_reason = update.blocking_reason + ws = await session.get(Workplan, task.workplan_id) + transition_task_status( + task, + update.status, + parent_workstream=ws, + previous_task_status=previous_status, + ) + if target_status != previous_status: + try: + from api.models.managed_repo import ManagedRepo + from api.services.repo_manager_dual_run import try_writeback_for_task + from api.services.workplan_files import resolve_repo_path + + repo_id = ws.repo_id if ws else None + if repo_id is not None: + if repo_id not in repo_cache: + repo_cache[repo_id] = await session.get(ManagedRepo, repo_id) + repo = repo_cache[repo_id] + if repo is not None: + try_writeback_for_task( + repo_path=resolve_repo_path(repo), + repo_slug=repo.slug, + task_id=str(task.id), + status=target_status, + reason="state-hub bulk-status-sync dual-run", + ) + except Exception: + import logging + + logging.getLogger(__name__).exception( + "repo-manager dual-run bulk writeback failed for task %s", + update.task_id, + ) + event = ProgressEvent( + task_id=task.id, + workplan_id=task.workplan_id, + event_type="task_status_changed", + summary=f"Task status -> {target_status}: {task.title}", + author=author, + session_id=body.session_id, + detail={ + "bulk_status_sync": True, + "previous_status": previous_status, + "status": target_status, + "blocking_reason": update.blocking_reason, + }, + ) + session.add(event) + updated.append(task) + events.append(event) + + await session.commit() + for task in updated: + await session.refresh(task) + for event in events: + await session.refresh(event) + + return TaskStatusBulkSyncRead( + updated=updated, + progress_event_ids=[event.id for event in events], + ) + + +@router.get("/{task_id}", response_model=TaskRead) +async def get_task( + task_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> Task: + task = await session.get(Task, task_id) + if task is None: + raise HTTPException(status_code=404, detail="Task not found") + await annotate_tasks(session, [task]) + return task + + +@router.patch("/{task_id}", response_model=TaskRead) +async def update_task( + task_id: uuid.UUID, + body: TaskUpdate, + session: AsyncSession = Depends(get_session), +) -> Task: + task = await session.get(Task, task_id) + if task is None: + raise HTTPException(status_code=404, detail="Task not found") + + previous_status = status_value(task.status) + + # Separate token fields from task fields + token_field_names = { + "tokens_in", + "tokens_out", + "workplan_tokens_in", + "workplan_tokens_out", + "token_note", + "model", + "agent", + "session_id", + "suppress_token_event", + } + update_data = body.model_dump(exclude_unset=True) + token_data = {k: update_data.pop(k) for k in list(update_data.keys()) if k in token_field_names} + suppress_token_event = bool(token_data.pop("suppress_token_event", False)) + status_update = update_data.pop("status", None) + new_status = status_value(status_update) if status_update is not None else None + + for field, value in update_data.items(): + setattr(task, field, value) + if new_status is not None: + ws = await session.get(Workplan, task.workplan_id) + transition_task_status( + task, + status_update, + parent_workstream=ws, + previous_task_status=previous_status, + ) + # RMGR-WP-0002 dual-run: optional checkout writeback via repo-manager + if new_status != previous_status: + try: + from api.models.managed_repo import ManagedRepo + from api.services.repo_manager_dual_run import try_writeback_for_task + from api.services.workplan_files import resolve_repo_path + + ws_full = ws or await session.get(Workplan, task.workplan_id) + repo = None + if ws_full and ws_full.repo_id: + repo = await session.get(ManagedRepo, ws_full.repo_id) + if repo is not None: + repo_path = resolve_repo_path(repo) + try_writeback_for_task( + repo_path=repo_path, + repo_slug=repo.slug, + task_id=str(task.id), + status=new_status, + reason="state-hub PATCH /tasks dual-run", + ) + except Exception: + # Dual-run must not break native DB update path + import logging + + logging.getLogger(__name__).exception( + "repo-manager dual-run writeback failed for task %s", task_id + ) + await session.commit() + await session.refresh(task) + + # Token event — three-tier logic, only for an intentional transition to done. + if ( + new_status == "done" + and previous_status != "done" + and not suppress_token_event + ): + if "tokens_in" in token_data and "tokens_out" in token_data: + # Tier 1: exact counts — default note "measured"; caller may override with token_note + tin = token_data["tokens_in"] + tout = token_data["tokens_out"] + tnote = token_data.get("token_note") or "measured" + measurement_kind = "measured" + source_provider = "manual" + confidence = 1.0 + source_id = f"task:{task_id}:manual" + raw_metadata = {"input_source": "task_status_patch"} + elif "workplan_tokens_in" in token_data and "workplan_tokens_out" in token_data: + # Tier 2: prorate workplan total across task count + count_result = await session.execute( + select(func.count(Task.id)).where(Task.workplan_id == task.workplan_id) + ) + task_count = max(count_result.scalar() or 1, 1) + tin = token_data["workplan_tokens_in"] // task_count + tout = token_data["workplan_tokens_out"] // task_count + tnote = "workplan" + measurement_kind = "allocated" + source_provider = "manual" + confidence = 0.7 + source_id = f"task:{task_id}:workplan-allocation" + raw_metadata = { + "allocation_method": "workplan_prorated", + "workplan_tokens_in": token_data["workplan_tokens_in"], + "workplan_tokens_out": token_data["workplan_tokens_out"], + "task_count": task_count, + } + else: + # Tier 3: heuristic fallback + tin, tout, tnote = 1000, 500, "heuristic" + measurement_kind = "estimated" + source_provider = "task_fallback" + confidence = 0.35 + source_id = f"task:{task_id}:heuristic" + raw_metadata = {"estimation_method": "fixed_task_done_fallback"} + + # Resolve repo_id via workstream + ws = await session.get(Workplan, task.workplan_id) + repo_id = ws.repo_id if ws else None + + event = TokenEvent( + task_id=task_id, + workplan_id=task.workplan_id, + repo_id=repo_id, + tokens_in=tin, + tokens_out=tout, + model=token_data.get("model"), + agent=token_data.get("agent"), + session_id=token_data.get("session_id"), + ref_type="task", + ref_id=str(task_id), + note=tnote, + measurement_kind=measurement_kind, + source_provider=source_provider, + source_id=source_id, + confidence=confidence, + raw_total_tokens=tin + tout, + raw_metadata=raw_metadata, + ) + session.add(event) + await session.commit() + + return task + + +@router.delete("/{task_id}", response_model=TaskRead) +async def cancel_task( + task_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> Task: + task = await session.get(Task, task_id) + if task is None: + raise HTTPException(status_code=404, detail="Task not found") + transition_task_status(task, TaskStatus.cancel) + await session.commit() + await session.refresh(task) + return task diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/technical_debt.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/technical_debt.py new file mode 100644 index 00000000..b8f9e777 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/technical_debt.py @@ -0,0 +1,140 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.models.domain import Domain +from api.models.technical_debt import TDNote, TDStatus, TechnicalDebt +from api.schemas.technical_debt import TDCreate, TDNoteCreate, TDNoteRead, TDRead, TDUpdate + +router = APIRouter(prefix="/technical-debt", tags=["technical-debt"]) + + +async def _resolve_domain_id(slug: str, session: AsyncSession) -> uuid.UUID: + """Resolve a domain slug to its UUID, raising 422 if unknown.""" + row = await session.execute( + select(Domain.id).where(Domain.slug == slug, Domain.status == "active") + ) + domain_id = row.scalar_one_or_none() + if domain_id is None: + valid = [r[0] for r in (await session.execute( + select(Domain.slug).where(Domain.status == "active") + )).all()] + raise HTTPException( + status_code=422, + detail=f"Unknown domain '{slug}'. Valid domains: {sorted(valid)}", + ) + return domain_id + + +@router.get("/", response_model=list[TDRead]) +async def list_td( + domain: str | None = None, + status: str | None = None, # str to accept both legacy and workflow values + debt_type: str | None = None, + severity: str | None = None, + session: AsyncSession = Depends(get_session), +) -> list[TechnicalDebt]: + q = select(TechnicalDebt) + if domain: + domain_id = await _resolve_domain_id(domain, session) + q = q.where(TechnicalDebt.domain_id == domain_id) + if status: + q = q.where(TechnicalDebt.status == status) + if debt_type: + q = q.where(TechnicalDebt.debt_type == debt_type) + if severity: + q = q.where(TechnicalDebt.severity == severity) + q = q.order_by(TechnicalDebt.created_at) + result = await session.execute(q) + return list(result.scalars().all()) + + +@router.post("/", response_model=TDRead, status_code=status.HTTP_201_CREATED) +async def create_td( + body: TDCreate, + session: AsyncSession = Depends(get_session), +) -> TechnicalDebt: + domain_id = await _resolve_domain_id(body.domain, session) + data = body.model_dump(exclude={"domain"}) + data["domain_id"] = domain_id + td = TechnicalDebt(**data) + session.add(td) + await session.commit() + await session.refresh(td) + return td + + +@router.get("/{td_id}", response_model=TDRead) +async def get_td( + td_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> TechnicalDebt: + td = await session.get(TechnicalDebt, td_id) + if td is None: + raise HTTPException(status_code=404, detail="Technical debt item not found") + return td + + +@router.patch("/{td_id}", response_model=TDRead) +async def update_td( + td_id: uuid.UUID, + body: TDUpdate, + session: AsyncSession = Depends(get_session), +) -> TechnicalDebt: + td = await session.get(TechnicalDebt, td_id) + if td is None: + raise HTTPException(status_code=404, detail="Technical debt item not found") + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(td, field, value) + await session.commit() + await session.refresh(td) + return td + + +@router.delete("/{td_id}", response_model=TDRead) +async def defer_td( + td_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> TechnicalDebt: + td = await session.get(TechnicalDebt, td_id) + if td is None: + raise HTTPException(status_code=404, detail="Technical debt item not found") + td.status = TDStatus.deferred + await session.commit() + await session.refresh(td) + return td + + +# ── Notes ───────────────────────────────────────────────────────────────────── + +@router.get("/{td_id}/notes", response_model=list[TDNoteRead]) +async def list_notes( + td_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> list[TDNote]: + td = await session.get(TechnicalDebt, td_id) + if td is None: + raise HTTPException(status_code=404, detail="Technical debt item not found") + result = await session.execute( + select(TDNote).where(TDNote.td_id == td_id).order_by(TDNote.created_at) + ) + return list(result.scalars().all()) + + +@router.post("/{td_id}/notes", response_model=TDNoteRead, status_code=status.HTTP_201_CREATED) +async def add_note( + td_id: uuid.UUID, + body: TDNoteCreate, + session: AsyncSession = Depends(get_session), +) -> TDNote: + td = await session.get(TechnicalDebt, td_id) + if td is None: + raise HTTPException(status_code=404, detail="Technical debt item not found") + note = TDNote(td_id=td_id, **body.model_dump()) + session.add(note) + await session.commit() + await session.refresh(note) + return note diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/token_events.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/token_events.py new file mode 100644 index 00000000..802e173c --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/token_events.py @@ -0,0 +1,666 @@ +import uuid +from collections import defaultdict +from datetime import datetime +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.models.managed_repo import ManagedRepo +from api.models.task import Task +from api.models.token_event import TokenEvent +from api.models.workplan import Workplan +from api.services.legacy_compat import meter_legacy_query_param +from api.services.repository_aliases import resolve_repository_slug +from api.schemas.token_event import ( + RepoTokenSummary, + TokenAggregateRow, + TokenAggregateSummary, + TokenEventCreate, + TokenEventPatch, + TokenEventRead, + TokenQualitySummary, + TokenSummary, +) + +router = APIRouter(prefix="/token-events", tags=["token-events"]) + +DEFAULT_CONFIDENCE = { + "measured": 1.0, + "allocated": 0.70, + "estimated": 0.35, + "superseded": 0.0, +} + +SOURCE_PARSER_DEFAULTS = { + "codex_session": "codex-desktop-v1", + "claude_transcript": "claude-transcript-v1", + "llm_connect": "llm-connect-v1", +} + + +def _event_total(event: TokenEvent) -> int: + return event.tokens_in + event.tokens_out + + +def _infer_measurement_kind(data: dict[str, Any]) -> str: + if data.get("measurement_kind"): + return str(data["measurement_kind"]) + note = data.get("note") + if note == "heuristic_superseded_by_codex_backfill": + return "superseded" + if note == "workplan": + return "allocated" + if note == "heuristic": + return "estimated" + if note == "measured" or str(note or "").startswith("backfill:codex-session"): + return "measured" + provider = data.get("source_provider") + if provider in {"codex_session", "claude_transcript", "llm_connect"}: + return "measured" + return "estimated" + + +def _infer_source_provider(data: dict[str, Any], measurement_kind: str) -> str: + if data.get("source_provider"): + return str(data["source_provider"]) + note = data.get("note") + ref_id = str(data.get("ref_id") or "") + agent = str(data.get("agent") or "").lower() + if note == "heuristic": + return "task_fallback" + if ref_id.startswith("codex:") or str(note or "").startswith("backfill:codex-session"): + return "codex_session" + if measurement_kind == "measured" and "claude" in agent: + return "claude_transcript" + return "manual" + + +def _apply_event_defaults(data: dict[str, Any]) -> dict[str, Any]: + measurement_kind = _infer_measurement_kind(data) + source_provider = _infer_source_provider(data, measurement_kind) + data["measurement_kind"] = measurement_kind + data["source_provider"] = source_provider + + if not data.get("source_id") and source_provider in {"codex_session", "claude_transcript", "llm_connect"}: + source_id = data.get("ref_id") or data.get("session_id") + if source_id: + data["source_id"] = str(source_id) + + if not data.get("source_created_at") and data.get("created_at") and data.get("source_id"): + data["source_created_at"] = data["created_at"] + + data.setdefault("confidence", DEFAULT_CONFIDENCE.get(measurement_kind, 0.35)) + data.setdefault("cached_input_tokens", 0) + data.setdefault("reasoning_output_tokens", 0) + data.setdefault("raw_total_tokens", (data.get("tokens_in") or 0) + (data.get("tokens_out") or 0)) + data.setdefault("raw_metadata", {}) + if source_provider in SOURCE_PARSER_DEFAULTS: + data.setdefault("parser_version", SOURCE_PARSER_DEFAULTS[source_provider]) + return data + + +async def _populate_relationship_defaults(data: dict[str, Any], session: AsyncSession) -> dict[str, Any]: + # Auto-populate workstream_id from task if not provided + if data.get("task_id") and not data.get("workplan_id"): + task = await session.get(Task, data["task_id"]) + if task: + data["workplan_id"] = task.workplan_id + + # Auto-populate repo_id from workstream if not provided + if data.get("workplan_id") and not data.get("repo_id"): + ws = await session.get(Workplan, data["workplan_id"]) + if ws and ws.repo_id: + data["repo_id"] = ws.repo_id + return data + + +async def _find_source_event(data: dict[str, Any], session: AsyncSession) -> TokenEvent | None: + source_id = data.get("source_id") + if not source_id: + return None + result = await session.execute( + select(TokenEvent).where( + TokenEvent.measurement_kind == data["measurement_kind"], + TokenEvent.source_provider == data["source_provider"], + TokenEvent.source_id == source_id, + ) + ) + return result.scalar_one_or_none() + + +async def _create_or_upsert_event(data: dict[str, Any], session: AsyncSession) -> TokenEvent: + data = _apply_event_defaults(data) + data = await _populate_relationship_defaults(data, session) + + existing = await _find_source_event(data, session) + if existing is not None: + for field, value in data.items(): + setattr(existing, field, value) + await session.commit() + await session.refresh(existing) + return existing + + event = TokenEvent(**data) + session.add(event) + await session.commit() + await session.refresh(event) + return event + + +def _filter_query( + q, + *, + task_id: uuid.UUID | None = None, + workplan_id: uuid.UUID | None = None, + workstream_id: uuid.UUID | None = None, + repo_id: uuid.UUID | None = None, + ref_type: str | None = None, + ref_id: str | None = None, + model: str | None = None, + agent: str | None = None, + note: str | None = None, + measurement_kind: str | None = None, + source_provider: str | None = None, + since: datetime | None = None, + until: datetime | None = None, + include_superseded: bool = True, + unattributed: bool = False, +): + if task_id: + q = q.where(TokenEvent.task_id == task_id) + scope_id = workplan_id or workstream_id + if scope_id: + q = q.where(TokenEvent.workplan_id == scope_id) + if repo_id: + q = q.where(TokenEvent.repo_id == repo_id) + if ref_type: + q = q.where(TokenEvent.ref_type == ref_type) + if ref_id: + q = q.where(TokenEvent.ref_id == ref_id) + if model: + q = q.where(TokenEvent.model == model) + if agent: + q = q.where(TokenEvent.agent == agent) + if note: + q = q.where(TokenEvent.note == note) + if measurement_kind: + q = q.where(TokenEvent.measurement_kind == measurement_kind) + if source_provider: + q = q.where(TokenEvent.source_provider == source_provider) + if since: + q = q.where(TokenEvent.created_at >= since) + if until: + q = q.where(TokenEvent.created_at < until) + if not include_superseded: + q = q.where(TokenEvent.measurement_kind != "superseded") + if unattributed: + q = q.where( + TokenEvent.repo_id.is_(None), + TokenEvent.workplan_id.is_(None), + TokenEvent.task_id.is_(None), + ) + return q + + +@router.post("/", response_model=TokenEventRead, status_code=status.HTTP_201_CREATED) +async def create_token_event( + body: TokenEventCreate, + session: AsyncSession = Depends(get_session), +) -> TokenEvent: + data = body.model_dump(exclude_none=True) + return await _create_or_upsert_event(data, session) + + +@router.post("/upsert", response_model=TokenEventRead) +async def upsert_token_event( + body: TokenEventCreate, + session: AsyncSession = Depends(get_session), +) -> TokenEvent: + data = body.model_dump(exclude_none=True) + return await _create_or_upsert_event(data, session) + + +@router.get("/summary/", response_model=TokenSummary) +async def get_token_summary( + scope: str = Query(..., description="task|workstream|repo|commit|release|session"), + id: str = Query(..., description="FK value or ref_id depending on scope"), + session: AsyncSession = Depends(get_session), +) -> TokenSummary: + q = select(TokenEvent) + + if scope == "task": + try: + uid = uuid.UUID(id) + except ValueError: + raise HTTPException(status_code=422, detail="id must be a valid UUID for scope=task") + q = q.where(TokenEvent.task_id == uid) + elif scope == "workstream": + try: + uid = uuid.UUID(id) + except ValueError: + raise HTTPException(status_code=422, detail="id must be a valid UUID for scope=workstream") + q = q.where(TokenEvent.workplan_id == uid) + elif scope == "repo": + try: + uid = uuid.UUID(id) + except ValueError: + raise HTTPException(status_code=422, detail="id must be a valid UUID for scope=repo") + q = q.where(TokenEvent.repo_id == uid) + elif scope in ("commit", "release", "session"): + q = q.where(TokenEvent.ref_type == scope, TokenEvent.ref_id == id) + else: + raise HTTPException(status_code=422, detail=f"Unknown scope: {scope!r}") + + result = await session.execute(q) + events = list(result.scalars().all()) + + tokens_in = sum(e.tokens_in for e in events) + tokens_out = sum(e.tokens_out for e in events) + + by_model: dict[str, int] = defaultdict(int) + by_agent: dict[str, int] = defaultdict(int) + by_measurement_kind: dict[str, int] = defaultdict(int) + by_source_provider: dict[str, int] = defaultdict(int) + for e in events: + total = _event_total(e) + if e.model: + by_model[e.model] += total + if e.agent: + by_agent[e.agent] += total + by_measurement_kind[e.measurement_kind] += total + by_source_provider[e.source_provider] += total + + return TokenSummary( + scope=scope, + scope_id=id, + tokens_in=tokens_in, + tokens_out=tokens_out, + tokens_total=tokens_in + tokens_out, + event_count=len(events), + by_model=dict(by_model), + by_agent=dict(by_agent), + by_measurement_kind=dict(by_measurement_kind), + by_source_provider=dict(by_source_provider), + ) + + +@router.get("/by-repo/", response_model=list[RepoTokenSummary]) +async def get_tokens_by_repo( + repo_slug: str | None = None, + measurement_kind: str | None = None, + source_provider: str | None = None, + since: datetime | None = None, + until: datetime | None = None, + include_superseded: bool = Query(True), + session: AsyncSession = Depends(get_session), +) -> list[RepoTokenSummary]: + """Aggregate token consumption per repo, resolving via the full graph. + + Resolution order for each event: + 1. token_events.repo_id (direct) + 2. → workstreams.repo_id (via workstream_id) + 3. → task.workplan_id → workstreams.repo_id (via task_id) + + Only events that resolve to a repo are included. + """ + requested_repo_id = None + if repo_slug: + requested_repo_id = ( + await resolve_repository_slug(session, repo_slug) + ).repo.id + + # Fetch all events, workstreams, repos in three queries (avoids N+1) + events_result = await session.execute( + _filter_query( + select(TokenEvent), + measurement_kind=measurement_kind, + source_provider=source_provider, + since=since, + until=until, + include_superseded=include_superseded, + ) + ) + events = list(events_result.scalars().all()) + + ws_result = await session.execute(select(Workplan)) + ws_map: dict[uuid.UUID, Workplan] = {w.id: w for w in ws_result.scalars().all()} + + task_result = await session.execute(select(Task)) + task_map: dict[uuid.UUID, Task] = {t.id: t for t in task_result.scalars().all()} + + repo_result = await session.execute(select(ManagedRepo)) + repo_map: dict[uuid.UUID, ManagedRepo] = {r.id: r for r in repo_result.scalars().all()} + + def resolve_repo_id(e: TokenEvent) -> uuid.UUID | None: + if e.repo_id: + return e.repo_id + ws_id = e.workplan_id + if not ws_id and e.task_id and e.task_id in task_map: + ws_id = task_map[e.task_id].workplan_id + if ws_id and ws_id in ws_map: + return ws_map[ws_id].repo_id + return None + + groups: dict[uuid.UUID, dict] = {} + for e in events: + rid = resolve_repo_id(e) + if not rid or rid not in repo_map: + continue + if requested_repo_id is not None and rid != requested_repo_id: + continue + if rid not in groups: + groups[rid] = { + "repo_id": rid, + "repo_slug": repo_map[rid].slug, + "tokens_in": 0, + "tokens_out": 0, + "event_count": 0, + "by_model": defaultdict(int), + "by_note": defaultdict(int), + "by_measurement_kind": defaultdict(int), + "by_source_provider": defaultdict(int), + } + g = groups[rid] + g["tokens_in"] += e.tokens_in + g["tokens_out"] += e.tokens_out + g["event_count"] += 1 + total = _event_total(e) + if e.model: + g["by_model"][e.model] += total + g["by_note"][e.note or "unknown"] += total + g["by_measurement_kind"][e.measurement_kind] += total + g["by_source_provider"][e.source_provider] += total + + return [ + RepoTokenSummary( + **{k: (dict(v) if isinstance(v, defaultdict) else v) for k, v in g.items()}, + tokens_total=g["tokens_in"] + g["tokens_out"], + ) + for g in sorted(groups.values(), key=lambda x: -(x["tokens_in"] + x["tokens_out"])) + ] + + +@router.get("/aggregate/", response_model=TokenAggregateSummary) +async def get_token_aggregate( + measurement_kind: str | None = None, + source_provider: str | None = None, + since: datetime | None = None, + until: datetime | None = None, + include_superseded: bool = Query(False), + session: AsyncSession = Depends(get_session), +) -> TokenAggregateSummary: + events_result = await session.execute( + _filter_query( + select(TokenEvent), + measurement_kind=measurement_kind, + source_provider=source_provider, + since=since, + until=until, + include_superseded=include_superseded, + ) + ) + events = list(events_result.scalars().all()) + + ws_result = await session.execute(select(Workplan)) + ws_map: dict[uuid.UUID, Workplan] = {w.id: w for w in ws_result.scalars().all()} + + task_result = await session.execute(select(Task)) + task_map: dict[uuid.UUID, Task] = {t.id: t for t in task_result.scalars().all()} + + repo_result = await session.execute(select(ManagedRepo)) + repo_map: dict[uuid.UUID, ManagedRepo] = {r.id: r for r in repo_result.scalars().all()} + + def resolve_repo_id(e: TokenEvent) -> uuid.UUID | None: + if e.repo_id: + return e.repo_id + ws_id = e.workplan_id + if not ws_id and e.task_id and e.task_id in task_map: + ws_id = task_map[e.task_id].workplan_id + if ws_id and ws_id in ws_map: + return ws_map[ws_id].repo_id + return None + + def add(groups: dict[str, dict[str, Any]], key: str | None, label: str | None, e: TokenEvent) -> None: + if not key: + return + if key not in groups: + groups[key] = { + "scope_id": key, + "label": label, + "tokens_in": 0, + "tokens_out": 0, + "event_count": 0, + "by_measurement_kind": defaultdict(int), + "by_source_provider": defaultdict(int), + } + row = groups[key] + total = _event_total(e) + row["tokens_in"] += e.tokens_in + row["tokens_out"] += e.tokens_out + row["event_count"] += 1 + row["by_measurement_kind"][e.measurement_kind] += total + row["by_source_provider"][e.source_provider] += total + + by_repo: dict[str, dict[str, Any]] = {} + by_workstream: dict[str, dict[str, Any]] = {} + by_task: dict[str, dict[str, Any]] = {} + by_model: dict[str, dict[str, Any]] = {} + by_measurement_kind: dict[str, int] = defaultdict(int) + by_source_provider: dict[str, int] = defaultdict(int) + + first_event_at = last_event_at = last_ingested_at = None + tokens_in = tokens_out = 0 + for e in events: + total = _event_total(e) + tokens_in += e.tokens_in + tokens_out += e.tokens_out + by_measurement_kind[e.measurement_kind] += total + by_source_provider[e.source_provider] += total + + if first_event_at is None or e.created_at < first_event_at: + first_event_at = e.created_at + if last_event_at is None or e.created_at > last_event_at: + last_event_at = e.created_at + if last_ingested_at is None or e.ingested_at > last_ingested_at: + last_ingested_at = e.ingested_at + + rid = resolve_repo_id(e) + repo = repo_map.get(rid) if rid else None + add(by_repo, str(rid) if rid else None, repo.slug if repo else None, e) + + ws_id = e.workplan_id or (task_map[e.task_id].workplan_id if e.task_id in task_map else None) + ws = ws_map.get(ws_id) if ws_id else None + add(by_workstream, str(ws_id) if ws_id else None, ws.title if ws else None, e) + + task = task_map.get(e.task_id) if e.task_id else None + add(by_task, str(e.task_id) if e.task_id else None, task.title if task else None, e) + + add(by_model, e.model or "unknown", e.model or "unknown", e) + + def rows(groups: dict[str, dict[str, Any]]) -> list[TokenAggregateRow]: + result = [] + for row in groups.values(): + result.append( + TokenAggregateRow( + **{k: (dict(v) if isinstance(v, defaultdict) else v) for k, v in row.items()}, + tokens_total=row["tokens_in"] + row["tokens_out"], + ) + ) + return sorted(result, key=lambda item: -item.tokens_total) + + return TokenAggregateSummary( + tokens_in=tokens_in, + tokens_out=tokens_out, + tokens_total=tokens_in + tokens_out, + event_count=len(events), + first_event_at=first_event_at, + last_event_at=last_event_at, + last_ingested_at=last_ingested_at, + by_repo=rows(by_repo), + by_workstream=rows(by_workstream), + by_task=rows(by_task), + by_model=rows(by_model), + by_measurement_kind=dict(by_measurement_kind), + by_source_provider=dict(by_source_provider), + ) + + +@router.get("/quality/", response_model=TokenQualitySummary) +async def get_token_quality( + since: datetime | None = None, + until: datetime | None = None, + session: AsyncSession = Depends(get_session), +) -> TokenQualitySummary: + result = await session.execute(_filter_query(select(TokenEvent), since=since, until=until)) + events = list(result.scalars().all()) + + by_measurement_kind: dict[str, int] = defaultdict(int) + by_source_provider: dict[str, int] = defaultdict(int) + source_counts: dict[tuple[str, str, str], int] = defaultdict(int) + last_codex_ingested_at = None + last_claude_ingested_at = None + + fallback_count = 0 + unattributed_measured_count = 0 + missing_provenance_count = 0 + for e in events: + by_measurement_kind[e.measurement_kind] += 1 + by_source_provider[e.source_provider] += 1 + if e.source_id: + source_counts[(e.measurement_kind, e.source_provider, e.source_id)] += 1 + if e.source_provider == "task_fallback" or e.note == "heuristic": + fallback_count += 1 + if e.measurement_kind == "measured" and not (e.repo_id or e.workplan_id or e.task_id): + unattributed_measured_count += 1 + if e.measurement_kind == "measured" and not e.source_id: + missing_provenance_count += 1 + if e.source_provider == "codex_session" and ( + last_codex_ingested_at is None or e.ingested_at > last_codex_ingested_at + ): + last_codex_ingested_at = e.ingested_at + if e.source_provider == "claude_transcript" and ( + last_claude_ingested_at is None or e.ingested_at > last_claude_ingested_at + ): + last_claude_ingested_at = e.ingested_at + + duplicate_source_count = sum(1 for count in source_counts.values() if count > 1) + return TokenQualitySummary( + event_count=len(events), + measured_event_count=by_measurement_kind.get("measured", 0), + estimated_event_count=by_measurement_kind.get("estimated", 0), + allocated_event_count=by_measurement_kind.get("allocated", 0), + superseded_event_count=by_measurement_kind.get("superseded", 0), + fallback_event_count=fallback_count, + unattributed_measured_event_count=unattributed_measured_count, + missing_provenance_event_count=missing_provenance_count, + duplicate_source_count=duplicate_source_count, + last_codex_ingested_at=last_codex_ingested_at, + last_claude_ingested_at=last_claude_ingested_at, + last_reconciliation_at=None, + by_measurement_kind=dict(by_measurement_kind), + by_source_provider=dict(by_source_provider), + ) + + +@router.patch("/{event_id}", response_model=TokenEventRead) +async def patch_token_event( + event_id: uuid.UUID, + body: TokenEventPatch, + session: AsyncSession = Depends(get_session), +) -> TokenEvent: + event = await session.get(TokenEvent, event_id) + if event is None: + raise HTTPException(status_code=404, detail="Token event not found") + data = body.model_dump(exclude_none=True) + if "note" in data or "measurement_kind" in data or "source_provider" in data: + merged = { + "tokens_in": data.get("tokens_in", event.tokens_in), + "tokens_out": data.get("tokens_out", event.tokens_out), + "note": data.get("note", event.note), + "agent": data.get("agent", event.agent), + "ref_id": data.get("ref_id", event.ref_id), + "session_id": data.get("session_id", event.session_id), + "measurement_kind": data.get("measurement_kind", event.measurement_kind), + "source_provider": data.get("source_provider", event.source_provider), + "source_id": data.get("source_id", event.source_id), + } + inferred = _apply_event_defaults({k: v for k, v in merged.items() if v is not None}) + data.setdefault("measurement_kind", inferred["measurement_kind"]) + data.setdefault("source_provider", inferred["source_provider"]) + data.setdefault("confidence", inferred["confidence"]) + if inferred.get("source_id"): + data.setdefault("source_id", inferred["source_id"]) + for field, value in data.items(): + setattr(event, field, value) + await session.commit() + await session.refresh(event) + return event + + +@router.get("/{event_id}", response_model=TokenEventRead) +async def get_token_event( + event_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> TokenEvent: + event = await session.get(TokenEvent, event_id) + if event is None: + raise HTTPException(status_code=404, detail="Token event not found") + return event + + +@router.get("/", response_model=list[TokenEventRead]) +async def list_token_events( + request: Request, + response: Response, + task_id: uuid.UUID | None = None, + workplan_id: uuid.UUID | None = None, + workstream_id: uuid.UUID | None = None, + repo_id: uuid.UUID | None = None, + ref_type: str | None = None, + ref_id: str | None = None, + model: str | None = None, + agent: str | None = None, + note: str | None = None, + measurement_kind: str | None = None, + source_provider: str | None = None, + since: datetime | None = None, + until: datetime | None = None, + include_superseded: bool = Query(True), + unattributed: bool = False, + offset: int = Query(0, ge=0), + limit: int = Query(100, le=1000), + session: AsyncSession = Depends(get_session), +) -> list[TokenEvent]: + if workstream_id is not None and workplan_id is None: + await meter_legacy_query_param( + session=session, + request=request, + response=response, + method="GET", + route="/token-events/", + replacement_ref="/token-events/?workplan_id=", + ) + q = _filter_query( + select(TokenEvent), + task_id=task_id, + workplan_id=workplan_id, + workstream_id=workstream_id, + repo_id=repo_id, + ref_type=ref_type, + ref_id=ref_id, + model=model, + agent=agent, + note=note, + measurement_kind=measurement_kind, + source_provider=source_provider, + since=since, + until=until, + include_superseded=include_superseded, + unattributed=unattributed, + ) + q = q.order_by(TokenEvent.created_at.desc()).offset(offset).limit(limit) + result = await session.execute(q) + return list(result.scalars().all()) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/topics.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/topics.py new file mode 100644 index 00000000..fd715f55 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/topics.py @@ -0,0 +1,107 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Response, status +from sqlalchemy import select +from sqlalchemy.orm import noload +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.models.domain import Domain +from api.models.topic import Topic, TopicStatus +from api.schemas.topic import TopicCreate, TopicRead, TopicUpdate, TopicWithWorkstreams + +router = APIRouter(prefix="/topics", tags=["topics"]) + + +async def _resolve_domain_id(domain_slug: str, session: AsyncSession) -> uuid.UUID: + """Resolve a domain slug to its UUID. Raises 404 if not found.""" + result = await session.execute(select(Domain).where(Domain.slug == domain_slug)) + domain = result.scalar_one_or_none() + if domain is None: + raise HTTPException(status_code=404, detail=f"Domain '{domain_slug}' not found") + return domain.id + + +@router.get("/", response_model=list[TopicRead]) +async def list_topics( + response: Response, + status: TopicStatus | None = None, + session: AsyncSession = Depends(get_session), +) -> list[Topic]: + response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30" + q = select(Topic).options( + noload(Topic.workplans), + noload(Topic.decisions), + noload(Topic.progress_events), + ) + if status: + q = q.where(Topic.status == status) + q = q.order_by(Topic.created_at) + result = await session.execute(q) + return list(result.scalars().all()) + + +@router.post("/", response_model=TopicRead, status_code=status.HTTP_201_CREATED) +async def create_topic( + body: TopicCreate, + session: AsyncSession = Depends(get_session), +) -> Topic: + domain_id = await _resolve_domain_id(body.domain, session) + existing = await session.execute(select(Topic).where(Topic.slug == body.slug)) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=409, detail=f"Topic slug '{body.slug}' already exists") + topic = Topic( + slug=body.slug, + title=body.title, + description=body.description, + domain_id=domain_id, + status=body.status, + ) + session.add(topic) + await session.commit() + await session.refresh(topic) + return topic + + +@router.get("/{topic_id}", response_model=TopicWithWorkstreams) +async def get_topic( + topic_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> Topic: + topic = await session.get(Topic, topic_id) + if topic is None: + raise HTTPException(status_code=404, detail="Topic not found") + return topic + + +@router.patch("/{topic_id}", response_model=TopicRead) +async def update_topic( + topic_id: uuid.UUID, + body: TopicUpdate, + session: AsyncSession = Depends(get_session), +) -> Topic: + topic = await session.get(Topic, topic_id) + if topic is None: + raise HTTPException(status_code=404, detail="Topic not found") + updates = body.model_dump(exclude_unset=True) + if "domain" in updates: + topic.domain_id = await _resolve_domain_id(updates.pop("domain"), session) + for field, value in updates.items(): + setattr(topic, field, value) + await session.commit() + await session.refresh(topic) + return topic + + +@router.delete("/{topic_id}", response_model=TopicRead) +async def archive_topic( + topic_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> Topic: + topic = await session.get(Topic, topic_id) + if topic is None: + raise HTTPException(status_code=404, detail="Topic not found") + topic.status = TopicStatus.archived + await session.commit() + await session.refresh(topic) + return topic diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/tpsc.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/tpsc.py new file mode 100644 index 00000000..79728c67 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/tpsc.py @@ -0,0 +1,14 @@ +from api.database import get_session +from api.models.managed_repo import ManagedRepo +from api.models.tpsc import TPSCCatalog, TPSCEntry, TPSCSnapshot +from hub_core.routers.tpsc import create_tpsc_router + +router = create_tpsc_router( + get_session, + repo_model=ManagedRepo, + catalog_model=TPSCCatalog, + snapshot_model=TPSCSnapshot, + entry_model=TPSCEntry, +) + +__all__ = ["router"] diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/work_record_projection.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/work_record_projection.py new file mode 100644 index 00000000..abe66226 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/work_record_projection.py @@ -0,0 +1,152 @@ +"""Primary-only fast path for one forge-derived repository projection.""" + +from __future__ import annotations + +import asyncio + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.config import settings +from api.database import get_session +from api.models.task import Task +from api.models.workplan import Workplan +from api.models.workplan_dependency import WorkplanDependency +from api.schemas.work_record_projection import ( + RepositoryProjectionReconcile, + RepositoryProjectionSnapshot, +) +from api.services.forge_projection import ( + ForgeDeriveError, + ForgeUnreadableError, + derive_from_forge, + reset_repository_projection, +) +from api.services.repository_aliases import resolve_repository_slug + +router = APIRouter(prefix="/repos", tags=["repository-work-record-projection"]) + + +@router.get( + "/{slug}/work-record-projection/snapshot", + response_model=RepositoryProjectionSnapshot, +) +async def repository_work_record_snapshot( + slug: str, + session: AsyncSession = Depends(get_session), +) -> RepositoryProjectionSnapshot: + """Return all consistency-check inputs for one repository in one request.""" + resolution = await resolve_repository_slug(session, slug) + workplans = list( + ( + await session.execute( + select(Workplan) + .where(Workplan.repo_id == resolution.repo.id) + .order_by(Workplan.slug) + ) + ).scalars() + ) + workplan_ids = [workplan.id for workplan in workplans] + tasks: list[Task] = [] + dependencies: list[WorkplanDependency] = [] + if workplan_ids: + tasks = list( + ( + await session.execute( + select(Task) + .where(Task.workplan_id.in_(workplan_ids)) + .order_by(Task.workplan_id, Task.id) + ) + ).scalars() + ) + dependencies = list( + ( + await session.execute( + select(WorkplanDependency) + .where(WorkplanDependency.from_workplan_id.in_(workplan_ids)) + .order_by( + WorkplanDependency.from_workplan_id, WorkplanDependency.id + ) + ) + ).scalars() + ) + return RepositoryProjectionSnapshot( + schema="state-hub.repository-projection-snapshot.v1", + repo_slug=resolution.canonical_slug, + repo_id=resolution.repo.id, + workplans=workplans, + tasks=tasks, + dependencies=dependencies, + ) + + +@router.post("/{slug}/work-record-projection/reconcile") +async def reconcile_repository_work_records( + slug: str, + body: RepositoryProjectionReconcile, + session: AsyncSession = Depends(get_session), +) -> dict: + """Derive an exact pushed commit centrally and apply it transactionally.""" + if settings.state_hub_instance_role != "primary": + raise HTTPException( + status_code=409, + detail={ + "message": "repository projection writes require the primary State Hub", + "instance_role": settings.state_hub_instance_role, + "instance_label": settings.state_hub_instance_label, + }, + ) + + try: + derived = await asyncio.to_thread(derive_from_forge, slug) + except ForgeUnreadableError as exc: + raise HTTPException( + status_code=424, + detail={ + "message": "repository is unreadable from the forge", + "detail": str(exc)[:300], + }, + ) from exc + except ForgeDeriveError as exc: + raise HTTPException( + status_code=502, + detail={ + "message": "forge projection derivation failed", + "detail": str(exc)[:300], + }, + ) from exc + + if derived.commit.lower() != body.expected_commit: + raise HTTPException( + status_code=409, + detail={ + "message": "forge default branch is not at the expected commit", + "expected_commit": body.expected_commit, + "derived_commit": derived.commit, + }, + ) + + outcome = await reset_repository_projection( + session, + slug, + acknowledge_retirements=body.acknowledge_retirements, + expected_retirement_uuids=body.expected_retirement_uuids, + derived=derived, + ) + if outcome.status in {"applied", "noop"} or outcome.released: + await session.commit() + else: + await session.rollback() + + from api.routers.workstreams import _invalidate_workplan_index_cache + + _invalidate_workplan_index_cache() + return { + "schema": "state-hub.repository-projection-reconcile.v1", + "instance_role": settings.state_hub_instance_role, + "instance_label": settings.state_hub_instance_label, + "expected_commit": body.expected_commit, + "derived_commit": derived.commit, + "outcome": outcome.to_dict(), + } diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/workstream_dependencies.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/workstream_dependencies.py new file mode 100644 index 00000000..ad6434dc --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/workstream_dependencies.py @@ -0,0 +1,192 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.models.task import Task +from api.models.workplan import Workplan +from api.models.workplan_dependency import WorkplanDependency +from api.schemas.workplan_dependency import WorkplanDependencyCreate, WorkplanDependencyRead +from api.routers.workstreams import _legacy_key +from api.services.legacy_compat import retire_legacy_route + +router = APIRouter(prefix="/workstreams", tags=["dependencies"]) +workplan_router = APIRouter(prefix="/workplans", tags=["dependencies"]) + + +async def _create_dependency( + *, + workplan_id: uuid.UUID, + body: WorkplanDependencyCreate, + session: AsyncSession, +) -> WorkplanDependency: + if await session.get(Workplan, workplan_id) is None: + raise HTTPException(status_code=404, detail="from workplan not found") + + has_workplan_target = body.to_workplan_id is not None + has_task_target = body.to_task_id is not None + if has_workplan_target == has_task_target: + raise HTTPException(status_code=422, detail="provide exactly one dependency target") + + if body.to_workplan_id and await session.get(Workplan, body.to_workplan_id) is None: + raise HTTPException(status_code=404, detail="target workplan not found") + if body.to_task_id and await session.get(Task, body.to_task_id) is None: + raise HTTPException(status_code=404, detail="target task not found") + if workplan_id == body.to_workplan_id: + raise HTTPException(status_code=422, detail="a workplan cannot depend on itself") + if body.from_task_id is not None: + from_task = await session.get(Task, body.from_task_id) + if from_task is None or from_task.workplan_id != workplan_id: + raise HTTPException(status_code=404, detail="from task not found in this workplan") + if body.from_task_id == body.to_task_id: + raise HTTPException(status_code=422, detail="a task cannot depend on itself") + + dep = WorkplanDependency( + from_workplan_id=workplan_id, + from_task_id=body.from_task_id, + to_workplan_id=body.to_workplan_id, + to_task_id=body.to_task_id, + relationship_type=body.relationship_type, + description=body.description, + ) + session.add(dep) + await session.commit() + await session.refresh(dep) + return dep + + +async def _list_dependencies( + *, + workplan_id: uuid.UUID, + session: AsyncSession, +) -> list[WorkplanDependency]: + if await session.get(Workplan, workplan_id) is None: + raise HTTPException(status_code=404, detail="workplan not found") + rows = await session.execute( + select(WorkplanDependency).where( + (WorkplanDependency.from_workplan_id == workplan_id) + | (WorkplanDependency.to_workplan_id == workplan_id) + ) + ) + return list(rows.scalars().all()) + + +async def _delete_dependency( + *, + workplan_id: uuid.UUID, + dep_id: uuid.UUID, + session: AsyncSession, +) -> None: + dep = await session.get(WorkplanDependency, dep_id) + if dep is None: + raise HTTPException(status_code=404, detail="dependency not found") + if dep.from_workplan_id != workplan_id: + raise HTTPException(status_code=403, detail="dependency does not belong to this workplan") + await session.delete(dep) + await session.commit() + + +@router.post( + "/{workstream_id}/dependencies/", + status_code=status.HTTP_410_GONE, +) +async def create_dependency( + request: Request, + response: Response, + workstream_id: uuid.UUID, + body: WorkplanDependencyCreate, + session: AsyncSession = Depends(get_session), +) -> None: + """Legacy alias retired — use POST /workplans/{workplan_id}/dependencies/.""" + await retire_legacy_route( + session=session, + request=request, + response=response, + interface_key=_legacy_key("POST", "/workstreams/{workstream_id}/dependencies/"), + replacement_ref="/workplans/{workplan_id}/dependencies/", + detail="Legacy POST /workstreams/{workstream_id}/dependencies/ retired; " + "use POST /workplans/{workplan_id}/dependencies/", + ) + + +@workplan_router.post( + "/{workplan_id}/dependencies/", + response_model=WorkplanDependencyRead, + status_code=status.HTTP_201_CREATED, +) +async def create_workplan_dependency( + workplan_id: uuid.UUID, + body: WorkplanDependencyCreate, + session: AsyncSession = Depends(get_session), +) -> WorkplanDependency: + return await _create_dependency(workplan_id=workplan_id, body=body, session=session) + + +@router.get( + "/{workstream_id}/dependencies/", + status_code=status.HTTP_410_GONE, +) +async def list_dependencies( + request: Request, + response: Response, + workstream_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> None: + """Legacy alias retired — use GET /workplans/{workplan_id}/dependencies/.""" + await retire_legacy_route( + session=session, + request=request, + response=response, + interface_key=_legacy_key("GET", "/workstreams/{workstream_id}/dependencies/"), + replacement_ref="/workplans/{workplan_id}/dependencies/", + detail="Legacy GET /workstreams/{workstream_id}/dependencies/ retired; " + "use GET /workplans/{workplan_id}/dependencies/", + ) + + +@workplan_router.get( + "/{workplan_id}/dependencies/", + response_model=list[WorkplanDependencyRead], +) +async def list_workplan_dependencies( + workplan_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> list[WorkplanDependency]: + return await _list_dependencies(workplan_id=workplan_id, session=session) + + +@router.delete( + "/{workstream_id}/dependencies/{dep_id}", + status_code=status.HTTP_410_GONE, +) +async def delete_dependency( + request: Request, + response: Response, + workstream_id: uuid.UUID, + dep_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> None: + """Legacy alias retired — use DELETE /workplans/{workplan_id}/dependencies/{dep_id}.""" + await retire_legacy_route( + session=session, + request=request, + response=response, + interface_key=_legacy_key("DELETE", "/workstreams/{workstream_id}/dependencies/{dep_id}"), + replacement_ref="/workplans/{workplan_id}/dependencies/{dep_id}", + detail="Legacy DELETE /workstreams/{workstream_id}/dependencies/{dep_id} retired; " + "use DELETE /workplans/{workplan_id}/dependencies/{dep_id}", + ) + + +@workplan_router.delete( + "/{workplan_id}/dependencies/{dep_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def delete_workplan_dependency( + workplan_id: uuid.UUID, + dep_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> None: + await _delete_dependency(workplan_id=workplan_id, dep_id=dep_id, session=session) \ No newline at end of file diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/workstreams.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/workstreams.py new file mode 100644 index 00000000..5a5bc3de --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/workstreams.py @@ -0,0 +1,558 @@ +import asyncio +import logging +import uuid +import socket +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import yaml +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status +from sqlalchemy import or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.events import EventEnvelope, publish_event +from api.models.managed_repo import ManagedRepo +from api.models.workplan import Workplan +from api.schemas.workplan import ( + WorkplanBindingsSync, + WorkplanCreate, + WorkplanRead, + WorkplanUpdate, +) +from api.services.lifecycle import transition_workplan_status +from api.services.legacy_compat import retire_legacy_route +from api.services.wait_kind import annotate_workplans +from api.work_record_flavor import ( + RESIDUAL_FLAVOR, + WORK_RECORD_FLAVORS, + normalize_flavor, +) +from api.workplan_status import ( + is_supported_workplan_status, + normalize_workplan_status, + ready_review_status, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/workstreams", tags=["workstreams"]) +workplan_router = APIRouter(prefix="/workplans", tags=["workplans"]) + +_INDEX_CACHE: dict[str, Any] | None = None +_INDEX_CACHE_AT: float = 0.0 +_INDEX_TTL = 30.0 +_INDEX_REFRESH_TASK: asyncio.Task | None = None +_INDEX_LAST_ERROR: str | None = None + +_COMPLETED_WORKPLAN_EVENT = "org.statehub.workplan.completed" + + +def _repo_path(repo: ManagedRepo) -> Path | None: + hostname = socket.gethostname() + candidates = [] + host_paths = repo.host_paths or {} + if host_paths.get(hostname): + candidates.append(host_paths[hostname]) + if repo.local_path: + candidates.append(repo.local_path) + for raw in candidates: + path = Path(raw).expanduser() + if path.is_dir(): + return path + return None + + +def _frontmatter(path: Path) -> dict[str, Any]: + try: + text = path.read_text(encoding="utf-8") + except OSError: + return {} + if not text.startswith("---\n"): + return {} + end = text.find("\n---", 4) + if end == -1: + return {} + + try: + return yaml.safe_load(text[4:end].strip()) or {} + except yaml.YAMLError: + return {} + + +def _legacy_key(method: str, route: str) -> str: + return f"rest_api:{method} {route}" + + +async def _list_workplans( + *, + topic_id: uuid.UUID | None, + repo_id: uuid.UUID | None, + repo_goal_id: uuid.UUID | None, + status_filter: str | None, + owner: str | None, + slug: str | None, + flavor: str | None = None, + include_residuals: bool = True, + session: AsyncSession, +) -> list[Workplan]: + q = select(Workplan) + if topic_id: + q = q.where(Workplan.topic_id == topic_id) + if repo_id: + q = q.where(Workplan.repo_id == repo_id) + if repo_goal_id: + q = q.where(Workplan.repo_goal_id == repo_goal_id) + if status_filter: + normalised_status = normalize_workplan_status(status_filter) + if not is_supported_workplan_status(status_filter): + raise HTTPException(status_code=422, detail=f"Unsupported workplan status '{status_filter}'") + q = q.where(Workplan.status == normalised_status) + if owner: + q = q.where(Workplan.owner == owner) + if slug: + q = q.where(Workplan.slug == slug) + wanted = normalize_flavor(flavor) + if wanted is not None: + if wanted not in WORK_RECORD_FLAVORS: + raise HTTPException( + status_code=422, + detail=( + f"Unknown work-record flavor {wanted!r}; " + f"expected one of {', '.join(WORK_RECORD_FLAVORS)}" + ), + ) + q = q.where(Workplan.flavor == wanted) + elif not include_residuals: + q = q.where(or_(Workplan.flavor.is_(None), Workplan.flavor != RESIDUAL_FLAVOR)) + q = q.order_by( + Workplan.planning_priority.asc().nullslast(), + Workplan.planning_order.asc().nullslast(), + Workplan.updated_at.desc(), + ) + result = await session.execute(q) + return list(result.scalars().all()) + + +async def _build_workplan_index(session: AsyncSession) -> dict[str, Any]: + result = await session.execute( + select(ManagedRepo).where(ManagedRepo.status == "active").order_by(ManagedRepo.slug) + ) + index: dict[str, Any] = {} + for repo in result.scalars().all(): + root = _repo_path(repo) + if root is None: + continue + for directory, archived in ( + (root / "workplans", False), + (root / "workplans" / "archived", True), + ): + if not directory.is_dir(): + continue + for path in sorted(directory.glob("*.md")): + data = _frontmatter(path) + workplan_id = data.get("state_hub_workstream_id") or data.get("state_hub_workplan_id") + if not workplan_id: + continue + file_status = normalize_workplan_status(data.get("status", "")) + review = ( + ready_review_status( + root, + data.get("reviewed_against_commit"), + data.get("context_paths"), + ) + if file_status == "ready" + else None + ) + index[str(workplan_id)] = { + "filename": path.name, + "relative_path": str(path.relative_to(root)), + "repo_slug": repo.slug, + "archived": archived, + "status": file_status or None, + "needs_review": bool(review and review.needs_review), + "health_labels": ["needs_review"] if review and review.needs_review else [], + } + await _merge_db_backing_index(session, index) + return {"workplans": index, "workstreams": index} + + +async def _merge_db_backing_index(session: AsyncSession, index: dict[str, Any]) -> None: + """Fill index gaps from DB-backed file bindings synced by fix-consistency.""" + result = await session.execute( + select(Workplan, ManagedRepo.slug) + .join(ManagedRepo, Workplan.repo_id == ManagedRepo.id) + .where(Workplan.backing_filename.isnot(None)) + ) + for wp, repo_slug in result.all(): + key = str(wp.id) + if key in index: + continue + index[key] = { + "filename": wp.backing_filename, + "relative_path": wp.backing_relative_path, + "repo_slug": repo_slug, + "archived": bool(wp.backing_archived), + "status": normalize_workplan_status(wp.status) if wp.status else None, + "needs_review": False, + "health_labels": [], + } + + +def _invalidate_workplan_index_cache() -> None: + global _INDEX_CACHE, _INDEX_CACHE_AT + _INDEX_CACHE = None + _INDEX_CACHE_AT = 0.0 + + +def _index_with_meta(*, stale: bool, refresh_in_progress: bool) -> dict[str, Any]: + age = time.monotonic() - _INDEX_CACHE_AT if _INDEX_CACHE_AT else None + return { + **(_INDEX_CACHE or {"workplans": {}, "workstreams": {}}), + "_meta": { + "generated_at": _INDEX_CACHE.get("_meta", {}).get("generated_at") if _INDEX_CACHE else None, + "stale": stale, + "cache_age_seconds": round(age, 3) if age is not None else None, + "refresh_in_progress": refresh_in_progress, + "last_error": _INDEX_LAST_ERROR, + }, + } + + +async def _refresh_workplan_index_background() -> None: + global _INDEX_CACHE, _INDEX_CACHE_AT, _INDEX_LAST_ERROR + from api.database import async_session_factory + + try: + async with async_session_factory() as session: + index = await _build_workplan_index(session) + index["_meta"] = { + "generated_at": datetime.now(timezone.utc).isoformat(), + "stale": False, + "cache_age_seconds": 0.0, + "refresh_in_progress": False, + "last_error": None, + } + _INDEX_CACHE = index + _INDEX_CACHE_AT = time.monotonic() + _INDEX_LAST_ERROR = None + except Exception as exc: + _INDEX_LAST_ERROR = str(exc) + + +def _ensure_index_refresh_started() -> None: + global _INDEX_REFRESH_TASK + if _INDEX_REFRESH_TASK is not None and not _INDEX_REFRESH_TASK.done(): + return + _INDEX_REFRESH_TASK = asyncio.create_task(_refresh_workplan_index_background()) + + +async def _workplan_index( + *, + refresh: bool, + session: AsyncSession, +) -> dict[str, Any]: + """Map file-backed workplan ids to their local workplan filenames.""" + global _INDEX_CACHE, _INDEX_CACHE_AT, _INDEX_LAST_ERROR + cache_age = time.monotonic() - _INDEX_CACHE_AT if _INDEX_CACHE_AT else None + if not refresh and _INDEX_CACHE is not None and cache_age is not None and cache_age < _INDEX_TTL: + refresh_running = _INDEX_REFRESH_TASK is not None and not _INDEX_REFRESH_TASK.done() + return _index_with_meta(stale=False, refresh_in_progress=refresh_running) + + if not refresh and _INDEX_CACHE is not None: + _ensure_index_refresh_started() + return _index_with_meta(stale=True, refresh_in_progress=True) + + index = await _build_workplan_index(session) + index["_meta"] = { + "generated_at": datetime.now(timezone.utc).isoformat(), + "stale": False, + "cache_age_seconds": 0.0, + "refresh_in_progress": False, + "last_error": None, + } + _INDEX_CACHE = index + _INDEX_CACHE_AT = time.monotonic() + _INDEX_LAST_ERROR = None + return _INDEX_CACHE + + +async def _create_workplan( + *, + body: WorkplanCreate, + session: AsyncSession, +) -> Workplan: + wp = Workplan(**body.model_dump()) + session.add(wp) + await session.commit() + await session.refresh(wp) + return wp + + +async def _get_workplan( + *, + workplan_id: uuid.UUID, + session: AsyncSession, +) -> Workplan: + wp = await session.get(Workplan, workplan_id) + if wp is None: + raise HTTPException(status_code=404, detail="Workplan not found") + return wp + + +async def _update_workplan( + *, + workplan_id: uuid.UUID, + body: WorkplanUpdate, + session: AsyncSession, +) -> Workplan: + wp = await session.get(Workplan, workplan_id) + if wp is None: + raise HTTPException(status_code=404, detail="Workplan not found") + update_data = body.model_dump(exclude_unset=True) + status_update = update_data.pop("status", None) + prev_status = wp.status + for field, value in update_data.items(): + setattr(wp, field, value) + if status_update is not None: + transition_workplan_status(wp, status_update) + await session.commit() + await session.refresh(wp) + + if normalize_workplan_status(prev_status) != "finished" and wp.status == "finished": + await _publish_completion_events(wp, session) + + return wp + + +async def _archive_workplan( + *, + workplan_id: uuid.UUID, + session: AsyncSession, +) -> Workplan: + wp = await session.get(Workplan, workplan_id) + if wp is None: + raise HTTPException(status_code=404, detail="Workplan not found") + transition_workplan_status(wp, "archived") + await session.commit() + await session.refresh(wp) + return wp + + +async def _publish_completion_events(wp: Workplan, session: AsyncSession) -> None: + workplan_envelope = EventEnvelope.new( + _COMPLETED_WORKPLAN_EVENT, + attributes={ + "workplan_id": str(wp.id), + "slug": wp.slug, + "title": wp.title, + "topic_id": str(wp.topic_id) if wp.topic_id else None, + "repo_id": str(wp.repo_id) if wp.repo_id else None, + "repo_goal_id": str(wp.repo_goal_id) if wp.repo_goal_id else None, + }, + ) + asyncio.create_task(publish_event(_COMPLETED_WORKPLAN_EVENT, workplan_envelope)) + + +@router.get("/", status_code=status.HTTP_410_GONE) +async def list_workstreams( + request: Request, + response: Response, + topic_id: uuid.UUID | None = None, + repo_id: uuid.UUID | None = None, + repo_goal_id: uuid.UUID | None = None, + status: str | None = None, + owner: str | None = None, + slug: str | None = None, + session: AsyncSession = Depends(get_session), +) -> None: + await retire_legacy_route( + session=session, + request=request, + response=response, + interface_key=_legacy_key("GET", "/workstreams/"), + replacement_ref="/workplans/", + detail="Legacy GET /workstreams/ retired; use GET /workplans/", + ) + + +@workplan_router.get("/", response_model=list[WorkplanRead]) +async def list_workplans( + topic_id: uuid.UUID | None = None, + repo_id: uuid.UUID | None = None, + repo_goal_id: uuid.UUID | None = None, + status: str | None = None, + owner: str | None = None, + slug: str | None = None, + flavor: str | None = Query(None), + include_residuals: bool = Query(True), + session: AsyncSession = Depends(get_session), +) -> list[Workplan]: + workplans = await _list_workplans( + topic_id=topic_id, + repo_id=repo_id, + repo_goal_id=repo_goal_id, + status_filter=status, + owner=owner, + slug=slug, + flavor=flavor, + include_residuals=include_residuals, + session=session, + ) + return list(await annotate_workplans(session, workplans)) + + +@router.get("/workplan-index", status_code=status.HTTP_410_GONE) +async def workplan_index( + request: Request, + response: Response, + refresh: bool = Query(False, description="Force cache invalidation"), + session: AsyncSession = Depends(get_session), +) -> None: + await retire_legacy_route( + session=session, + request=request, + response=response, + interface_key=_legacy_key("GET", "/workstreams/workplan-index"), + replacement_ref="/workplans/index", + detail="Legacy GET /workstreams/workplan-index retired; use GET /workplans/index", + ) + + +@workplan_router.get("/index") +async def workplan_index_preferred( + refresh: bool = Query(False, description="Force cache invalidation"), + session: AsyncSession = Depends(get_session), +) -> dict[str, Any]: + return await _workplan_index(refresh=refresh, session=session) + + +@workplan_router.put("/index/bindings") +async def sync_workplan_bindings( + body: WorkplanBindingsSync, + session: AsyncSession = Depends(get_session), +) -> dict[str, int]: + """Upsert workstation workplan file bindings for remote API index fallback.""" + synced_at = datetime.now(timezone.utc) + updated = 0 + requested_ids = {entry.workplan_id for entry in body.bindings} + rows = await session.execute(select(Workplan).where(Workplan.id.in_(requested_ids))) + workplans = {workplan.id: workplan for workplan in rows.scalars().all()} + for entry in body.bindings: + wp = workplans.get(entry.workplan_id) + if wp is None: + continue + wp.backing_filename = entry.filename + wp.backing_relative_path = entry.relative_path + wp.backing_archived = entry.archived + wp.backing_synced_at = synced_at + updated += 1 + await session.commit() + _invalidate_workplan_index_cache() + return {"updated": updated, "received": len(body.bindings)} + + +@router.post("/", status_code=status.HTTP_410_GONE) +async def create_workstream( + request: Request, + response: Response, + body: WorkplanCreate, + session: AsyncSession = Depends(get_session), +) -> None: + await retire_legacy_route( + session=session, + request=request, + response=response, + interface_key=_legacy_key("POST", "/workstreams/"), + replacement_ref="/workplans/", + detail="Legacy POST /workstreams/ retired; use POST /workplans/", + ) + + +@workplan_router.post("/", response_model=WorkplanRead, status_code=status.HTTP_201_CREATED) +async def create_workplan( + body: WorkplanCreate, + session: AsyncSession = Depends(get_session), +) -> Workplan: + return await _create_workplan(body=body, session=session) + + +@router.get("/{workstream_id}", status_code=status.HTTP_410_GONE) +async def get_workstream( + request: Request, + response: Response, + workstream_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> None: + await retire_legacy_route( + session=session, + request=request, + response=response, + interface_key=_legacy_key("GET", "/workstreams/{workstream_id}"), + replacement_ref="/workplans/{workplan_id}", + detail="Legacy GET /workstreams/{workstream_id} retired; use GET /workplans/{workplan_id}", + ) + + +@workplan_router.get("/{workplan_id}", response_model=WorkplanRead) +async def get_workplan( + workplan_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> Workplan: + wp = await _get_workplan(workplan_id=workplan_id, session=session) + await annotate_workplans(session, [wp]) + return wp + + +@router.patch("/{workstream_id}", status_code=status.HTTP_410_GONE) +async def update_workstream( + request: Request, + response: Response, + workstream_id: uuid.UUID, + body: WorkplanUpdate, + session: AsyncSession = Depends(get_session), +) -> None: + await retire_legacy_route( + session=session, + request=request, + response=response, + interface_key=_legacy_key("PATCH", "/workstreams/{workstream_id}"), + replacement_ref="/workplans/{workplan_id}", + detail="Legacy PATCH /workstreams/{workstream_id} retired; use PATCH /workplans/{workplan_id}", + ) + + +@workplan_router.patch("/{workplan_id}", response_model=WorkplanRead) +async def update_workplan( + workplan_id: uuid.UUID, + body: WorkplanUpdate, + session: AsyncSession = Depends(get_session), +) -> Workplan: + return await _update_workplan(workplan_id=workplan_id, body=body, session=session) + + +@router.delete("/{workstream_id}", status_code=status.HTTP_410_GONE) +async def archive_workstream( + request: Request, + response: Response, + workstream_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> None: + await retire_legacy_route( + session=session, + request=request, + response=response, + interface_key=_legacy_key("DELETE", "/workstreams/{workstream_id}"), + replacement_ref="DELETE /workplans/{workplan_id}", + detail="Legacy DELETE /workstreams/{workstream_id} retired; use DELETE /workplans/{workplan_id}", + ) + + +@workplan_router.delete("/{workplan_id}", response_model=WorkplanRead) +async def archive_workplan( + workplan_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> Workplan: + return await _archive_workplan(workplan_id=workplan_id, session=session) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/__init__.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/__init__.py new file mode 100644 index 00000000..4556ee89 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/__init__.py @@ -0,0 +1,21 @@ +from api.schemas.topic import TopicCreate, TopicUpdate, TopicRead, TopicWithWorkstreams +from api.schemas.workplan import WorkplanCreate, WorkplanUpdate, WorkplanRead +from api.schemas.workstream import WorkstreamCreate, WorkstreamUpdate, WorkstreamRead +from api.schemas.task import TaskCreate, TaskUpdate, TaskRead +from api.schemas.decision import DecisionCreate, DecisionUpdate, DecisionRead +from api.schemas.progress_event import ProgressEventCreate, ProgressEventRead +from api.schemas.state import StateSummary, Totals, TopicTotals, WorkstreamTotals, TaskTotals, DecisionTotals +from api.schemas.extension_point import EPCreate, EPUpdate, EPRead +from api.schemas.technical_debt import TDCreate, TDUpdate, TDRead + +__all__ = [ + "TopicCreate", "TopicUpdate", "TopicRead", "TopicWithWorkstreams", + "WorkplanCreate", "WorkplanUpdate", "WorkplanRead", + "WorkstreamCreate", "WorkstreamUpdate", "WorkstreamRead", + "TaskCreate", "TaskUpdate", "TaskRead", + "DecisionCreate", "DecisionUpdate", "DecisionRead", + "ProgressEventCreate", "ProgressEventRead", + "StateSummary", "Totals", "TopicTotals", "WorkstreamTotals", "TaskTotals", "DecisionTotals", + "EPCreate", "EPUpdate", "EPRead", + "TDCreate", "TDUpdate", "TDRead", +] diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/agent_message.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/agent_message.py new file mode 100644 index 00000000..baf0856d --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/agent_message.py @@ -0,0 +1,78 @@ +"""Agent message schemas. + +The base shapes come from hub-core. STATE-WP-0093 extends them locally with +broadcast kinds, expiry, supersede and per-reader receipt fields; direct +messages keep the hub-core semantics (the new fields stay at their defaults). +""" +import uuid +from datetime import datetime + +from hub_core.schemas.agent_message import MessageCreate as _HubMessageCreate +from hub_core.schemas.agent_message import MessageRead as _HubMessageRead +from hub_core.schemas.agent_message import MessageReply +from pydantic import BaseModel + + +class MessageCreate(_HubMessageCreate): + # Only meaningful for broadcasts: news | standing. A broadcast without a + # kind defaults to ``news``; direct messages must leave it unset or + # ``message``. + kind: str | None = None + expires_at: datetime | None = None + supersedes_id: uuid.UUID | None = None + + +class MessageRead(_HubMessageRead): + kind: str = "message" + expires_at: datetime | None = None + supersedes_id: uuid.UUID | None = None + # Per-reader receipt state; populated for broadcasts when the request is + # scoped to a reader (``to_agent`` on list, ``reader`` on mark-read/ack). + # For such responses ``read_at`` is the reader's own read time. + reader: str | None = None + delivered_at: datetime | None = None + acknowledged_at: datetime | None = None + + +class MessageMarkRead(BaseModel): + reader: str | None = None + ack: bool = False + + +class MessageAck(BaseModel): + agent: str + + +class NoticeStatus(BaseModel): + id: uuid.UUID + from_agent: str + subject: str + body: str + created_at: datetime + expires_at: datetime | None = None + supersedes_id: uuid.UUID | None = None + acknowledged: list[str] = [] + delivered_only: list[str] = [] + unreached: list[str] = [] + acked_count: int = 0 + delivered_only_count: int = 0 + unreached_count: int = 0 + + +class StandingNoticeDigest(BaseModel): + id: uuid.UUID + subject: str + expires_at: datetime | None = None + acked: int = 0 + pending: int = 0 + + +__all__ = [ + "MessageAck", + "MessageCreate", + "MessageMarkRead", + "MessageRead", + "MessageReply", + "NoticeStatus", + "StandingNoticeDigest", +] diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/capability_request.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/capability_request.py new file mode 100644 index 00000000..4552a279 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/capability_request.py @@ -0,0 +1,94 @@ +import uuid +from datetime import datetime + +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field + +from hub_core.schemas.capability import ( + CapabilityRequestDispute, + CapabilityRequestStatusPatch, + CatalogCreate, + CatalogPatch, + CatalogRead, +) + + +# --------------------------------------------------------------------------- +# Capability Request schemas +# --------------------------------------------------------------------------- + +class CapabilityRequestCreate(BaseModel): + title: str + description: str | None = None + capability_type: str + priority: str = "medium" + requesting_domain: str # slug, resolved to domain_id in router + requesting_agent: str + requesting_workplan_id: uuid.UUID | None = Field( + default=None, + validation_alias=AliasChoices("requesting_workplan_id", "requesting_workstream_id"), + ) + blocking_task_id: uuid.UUID | None = None + + +class CapabilityRequestAccept(BaseModel): + fulfilling_agent: str + fulfilling_workplan_id: uuid.UUID | None = Field( + default=None, + validation_alias=AliasChoices("fulfilling_workplan_id", "fulfilling_workstream_id"), + ) + + +class CapabilityRequestPatch(BaseModel): + catalog_entry_id: uuid.UUID | None = None + priority: str | None = None + blocking_task_id: uuid.UUID | None = None + fulfilling_workplan_id: uuid.UUID | None = Field( + default=None, + validation_alias=AliasChoices("fulfilling_workplan_id", "fulfilling_workstream_id"), + ) + + +class CapabilityRequestReroute(BaseModel): + note: str + rerouted_by: str + domain: str | None = None # slug — used if catalog_entry_id not given + catalog_entry_id: uuid.UUID | None = None # preferred: re-derives domain + + +class CapabilityRequestRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + title: str + description: str | None = None + capability_type: str + priority: str + status: str + requesting_domain_slug: str + requesting_agent: str + requesting_workplan_id: uuid.UUID | None = None + fulfilling_domain_slug: str | None = None + fulfilling_agent: str | None = None + fulfilling_workplan_id: uuid.UUID | None = None + blocking_task_id: uuid.UUID | None = None + catalog_entry_id: uuid.UUID | None = None + resolution_note: str | None = None + routing_note: str | None = None + dispute_reason: str | None = None + disputed_by: str | None = None + dispute_suggested_domain: str | None = None + disputed_at: datetime | None = None + accepted_at: datetime | None = None + completed_at: datetime | None = None + created_at: datetime + updated_at: datetime + + @computed_field # type: ignore[prop-decorator] + @property + def requesting_workstream_id(self) -> uuid.UUID | None: + return self.requesting_workplan_id + + @computed_field # type: ignore[prop-decorator] + @property + def fulfilling_workstream_id(self) -> uuid.UUID | None: + return self.fulfilling_workplan_id diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/compat.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/compat.py new file mode 100644 index 00000000..37de0b8d --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/compat.py @@ -0,0 +1,58 @@ +"""Shared Pydantic field helpers for workplan / workstream compatibility.""" +from __future__ import annotations + +import uuid +from typing import Any + +from pydantic import AliasChoices, Field, computed_field, model_validator + + +def workplan_id_field(*, default: uuid.UUID | None = None) -> uuid.UUID | None: + return Field( + default=default, + validation_alias=AliasChoices("workplan_id", "workstream_id"), + ) + + +class LegacyWorkstreamIdBodyMixin: + """Detect POST/PUT bodies that used legacy ``workstream_id`` without ``workplan_id``.""" + + used_legacy_workstream_id: bool = Field(default=False, exclude=True) + + @model_validator(mode="before") + @classmethod + def _detect_legacy_workstream_id(cls, data: Any) -> Any: + if isinstance(data, dict): + if data.get("workstream_id") is not None and data.get("workplan_id") is None: + return {**data, "used_legacy_workstream_id": True} + return data + + +class WorkplanIdCompatMixin: + """Accept ``workplan_id`` or legacy ``workstream_id`` on input; emit both on output.""" + + workplan_id: uuid.UUID = workplan_id_field() + + @computed_field # type: ignore[prop-decorator] + @property + def workstream_id(self) -> uuid.UUID: + return self.workplan_id + + +class WorkplanIdCreateMixin(LegacyWorkstreamIdBodyMixin): + workplan_id: uuid.UUID | None = workplan_id_field(default=None) + + @model_validator(mode="after") + def _require_workplan_id(self): + if self.workplan_id is None: + raise ValueError("workplan_id is required") + return self + + +class OptionalWorkplanIdCompatMixin: + workplan_id: uuid.UUID | None = workplan_id_field(default=None) + + @computed_field # type: ignore[prop-decorator] + @property + def workstream_id(self) -> uuid.UUID | None: + return self.workplan_id \ No newline at end of file diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/consistency_sweep.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/consistency_sweep.py new file mode 100644 index 00000000..cdda9313 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/consistency_sweep.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import uuid +from datetime import datetime + +from pydantic import BaseModel, Field + + +class ConsistencySweepIssueSummary(BaseModel): + fail: int = 0 + automation_error: int = 0 + warn: int = 0 + info: int = 0 + + +class ConsistencySweepRepoResult(BaseModel): + repo_slug: str + repo_path: str + result: str + summary: ConsistencySweepIssueSummary + fixes_applied: list[str] = Field(default_factory=list) + + +class ConsistencySweepRemoteAllGenerate(BaseModel): + max_seconds: int = Field( + default=300, + ge=0, + le=3600, + description="Wall-clock budget for the remote-all sweep (0 disables)", + ) + source: str = Field( + default="api", + description="Runner label stored on progress events (local-timer, activity-core, api)", + ) + + +class ConsistencySweepRemoteAllRun(BaseModel): + started_at: datetime + completed_at: datetime + max_seconds: int + source: str + exit_code: int + automation_error: bool = False + lock_skipped: bool + repos_processed: list[ConsistencySweepRepoResult] = Field(default_factory=list) + skipped_clean: list[str] = Field(default_factory=list) + skipped_missing: list[str] = Field(default_factory=list) + skipped_budget: list[str] = Field(default_factory=list) + progress_event_id: uuid.UUID | None = None \ No newline at end of file diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/contribution.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/contribution.py new file mode 100644 index 00000000..98a355ae --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/contribution.py @@ -0,0 +1,53 @@ +import uuid +from datetime import datetime + +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field + +from api.models.contribution import ContributionStatus, ContributionType + + +class ContributionCreate(BaseModel): + type: ContributionType + target_org: str | None = None + target_repo: str | None = None + slug: str | None = None + title: str + body_path: str | None = None + related_topic_id: uuid.UUID | None = None + related_workplan_id: uuid.UUID | None = Field( + default=None, + validation_alias=AliasChoices("related_workplan_id", "related_workstream_id"), + ) + repo_id: uuid.UUID | None = None + notes: str | None = None + + +class ContributionStatusPatch(BaseModel): + status: ContributionStatus + notes: str | None = None + + +class ContributionRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + type: ContributionType + target_org: str | None = None + target_repo: str | None = None + slug: str | None = None + title: str + status: ContributionStatus + body_path: str | None = None + related_topic_id: uuid.UUID | None = None + related_workplan_id: uuid.UUID | None = None + repo_id: uuid.UUID | None = None + submitted_at: datetime | None = None + resolved_at: datetime | None = None + notes: str | None = None + created_at: datetime + updated_at: datetime + + @computed_field # type: ignore[prop-decorator] + @property + def related_workstream_id(self) -> uuid.UUID | None: + return self.related_workplan_id diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/decision.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/decision.py new file mode 100644 index 00000000..edba07ed --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/decision.py @@ -0,0 +1,69 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, model_validator + +from api.models.decision import DecisionStatus, DecisionType +from api.schemas.compat import LegacyWorkstreamIdBodyMixin, OptionalWorkplanIdCompatMixin +from pydantic import AliasChoices, Field + + +class DecisionCreate(LegacyWorkstreamIdBodyMixin, BaseModel): + id: uuid.UUID | None = None + topic_id: uuid.UUID | None = None + workplan_id: uuid.UUID | None = Field( + default=None, + validation_alias=AliasChoices("workplan_id", "workstream_id"), + ) + title: str + description: str | None = None + decision_type: DecisionType = DecisionType.pending + status: DecisionStatus = DecisionStatus.open + rationale: str | None = None + decided_by: str | None = None + decided_at: datetime | None = None + deadline: datetime | None = None + escalation_note: str | None = None + + @model_validator(mode="after") + def topic_or_workplan_required(self) -> "DecisionCreate": + if self.topic_id is None and self.workplan_id is None: + raise ValueError("At least one of topic_id or workplan_id must be set") + return self + + +class DecisionResolve(BaseModel): + rationale: str + decided_by: str + write_log: bool = True # append to DECISIONS.md in the registered project directory + + +class DecisionUpdate(BaseModel): + title: str | None = None + description: str | None = None + decision_type: DecisionType | None = None + status: DecisionStatus | None = None + rationale: str | None = None + decided_by: str | None = None + decided_at: datetime | None = None + deadline: datetime | None = None + escalation_note: str | None = None + superseded_by: uuid.UUID | None = None + + +class DecisionRead(OptionalWorkplanIdCompatMixin, BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + topic_id: uuid.UUID | None = None + title: str + description: str | None = None + decision_type: DecisionType + status: DecisionStatus + rationale: str | None = None + decided_by: str | None = None + decided_at: datetime | None = None + deadline: datetime | None = None + escalation_note: str | None = None + superseded_by: uuid.UUID | None = None + created_at: datetime + updated_at: datetime diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/doi.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/doi.py new file mode 100644 index 00000000..8a882b9a --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/doi.py @@ -0,0 +1,7 @@ +from hub_core.schemas.doi import DoICriterion, DoIReport, DoISummaryEntry + +__all__ = [ + "DoICriterion", + "DoIReport", + "DoISummaryEntry", +] diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/domain.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/domain.py new file mode 100644 index 00000000..6fc90f27 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/domain.py @@ -0,0 +1,45 @@ +import uuid + +from pydantic import BaseModel, ConfigDict + +from hub_core.schemas.domain import DomainCreate, DomainRead, DomainRename, DomainUpdate + + +class RepoStub(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + slug: str + name: str + local_path: str | None = None + remote_url: str | None = None + status: str + + +class DomainDetail(DomainRead): + """Domain with entity counts and repo list.""" + topic_count: int = 0 + workstream_count: int = 0 + ep_count: int = 0 + td_count: int = 0 + repos: list[RepoStub] = [] + + +class DomainSummary(BaseModel): + """Lightweight domain stats for the state summary.""" + slug: str + name: str + repo_count: int = 0 + active_workstream_count: int = 0 + ep_count: int = 0 + td_count: int = 0 + + +__all__ = [ + "DomainCreate", + "DomainDetail", + "DomainRead", + "DomainRename", + "DomainSummary", + "DomainUpdate", + "RepoStub", +] diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/domain_goal.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/domain_goal.py new file mode 100644 index 00000000..04b87e6d --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/domain_goal.py @@ -0,0 +1,31 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from api.models.domain_goal import DomainGoalStatus + + +class DomainGoalCreate(BaseModel): + domain_id: uuid.UUID + title: str + description: str + status: str = DomainGoalStatus.active.value + + +class DomainGoalUpdate(BaseModel): + title: str | None = None + description: str | None = None + status: str | None = None + + +class DomainGoalRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + domain_id: uuid.UUID + domain_slug: str + title: str + description: str + status: str + created_at: datetime + updated_at: datetime diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/execution.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/execution.py new file mode 100644 index 00000000..68f282ff --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/execution.py @@ -0,0 +1,108 @@ +import uuid +from datetime import datetime +from typing import Literal + +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field + + +ExecutionState = Literal["manual", "queued", "scheduled", "launching", "paused", "completed", "cancelled"] +LaunchMode = Literal["manual", "queued", "scheduled", "immediate"] +ConcurrencyMode = Literal["sequential", "parallel"] +LaunchRequestStatus = Literal["requested", "accepted", "completed", "cancelled"] + + +class ExecutionIntentUpdate(BaseModel): + execution_state: ExecutionState | None = None + launch_mode: LaunchMode | None = None + concurrency_mode: ConcurrencyMode | None = None + queue_rank: int | None = None + execution_group: str | None = None + scheduled_for: datetime | None = None + + +class ExecutionIntentRead(BaseModel): + workplan_id: uuid.UUID + + @computed_field # type: ignore[prop-decorator] + @property + def workstream_id(self) -> uuid.UUID: + return self.workplan_id + execution_state: ExecutionState + launch_mode: LaunchMode + concurrency_mode: ConcurrencyMode + queue_rank: int | None = None + execution_group: str | None = None + scheduled_for: datetime | None = None + + +class WorkplanQueueItem(BaseModel): + workplan_id: uuid.UUID + slug: str + title: str + status: str + repo_id: uuid.UUID | None = None + planning_priority: str | None = None + planning_order: int | None = None + execution_state: ExecutionState + launch_mode: LaunchMode + concurrency_mode: ConcurrencyMode + queue_rank: int | None = None + execution_group: str | None = None + scheduled_for: datetime | None = None + eligible: bool + blocked_by_workplan_ids: list[uuid.UUID] = Field(default_factory=list) + + @computed_field # type: ignore[prop-decorator] + @property + def blocked_by_workstream_ids(self) -> list[uuid.UUID]: + return self.blocked_by_workplan_ids + blocked_by_task_ids: list[uuid.UUID] = Field(default_factory=list) + sort_key: list[str | int] = Field(default_factory=list) + + +class LaunchRequestCreate(BaseModel): + workplan_id: uuid.UUID = Field(validation_alias=AliasChoices("workplan_id", "workstream_id")) + requested_by: str = "dashboard" + requested_actor: str | None = None + launch_mode: LaunchMode = "queued" + concurrency_mode: ConcurrencyMode = "sequential" + priority: str | None = None + repo_id: uuid.UUID | None = None + branch_preference: str | None = None + immediate_pickup: bool = False + notes: str | None = None + request_metadata: dict = Field(default_factory=dict) + + +class LaunchRequestRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + workplan_id: uuid.UUID + requested_by: str + requested_actor: str | None = None + launch_mode: LaunchMode + + @computed_field # type: ignore[prop-decorator] + @property + def workstream_id(self) -> uuid.UUID: + return self.workplan_id + concurrency_mode: ConcurrencyMode + priority: str | None = None + repo_id: uuid.UUID | None = None + branch_preference: str | None = None + immediate_pickup: bool + status: LaunchRequestStatus + notes: str | None = None + request_metadata: dict = Field(default_factory=dict) + created_at: datetime + updated_at: datetime + + +class ExecutionSemantics(BaseModel): + execution_states: dict[str, str] + launch_modes: dict[str, str] + concurrency_modes: dict[str, str] + state_hub_responsibility: list[str] + activity_core_responsibility: list[str] + launch_requests_accepted: bool = False + replacements: dict[str, str] = Field(default_factory=dict) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/extension_point.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/extension_point.py new file mode 100644 index 00000000..3820c9a5 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/extension_point.py @@ -0,0 +1,60 @@ +import uuid +from datetime import datetime + +from pydantic import AliasChoices, BaseModel, ConfigDict, Field + +from api.models.extension_point import EPStatus + +VALID_PRIORITIES = {"low", "medium", "high", "critical"} + + +class EPCreate(BaseModel): + ep_id: str | None = None + domain: str # slug; router resolves to domain_id FK + title: str + description: str | None = None + location: str | None = None + ep_type: str = "other" + status: EPStatus = EPStatus.open + priority: str = "medium" + topic_id: uuid.UUID | None = None + workplan_id: uuid.UUID | None = Field( + default=None, + validation_alias=AliasChoices("workplan_id", "workstream_id"), + ) + + +class EPUpdate(BaseModel): + ep_id: str | None = None + title: str | None = None + description: str | None = None + location: str | None = None + ep_type: str | None = None + status: EPStatus | None = None + priority: str | None = None + workplan_id: uuid.UUID | None = Field( + default=None, + validation_alias=AliasChoices("workplan_id", "workstream_id"), + ) + + +class EPRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + ep_id: str | None = None + domain_slug: str # derived from domain relationship + title: str + description: str | None = None + location: str | None = None + ep_type: str + status: EPStatus + priority: str + topic_id: uuid.UUID | None = None + workplan_id: uuid.UUID | None = None + created_at: datetime + + @property + def workstream_id(self) -> uuid.UUID | None: + return self.workplan_id + updated_at: datetime diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/fabric_graph.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/fabric_graph.py new file mode 100644 index 00000000..6afe417c --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/fabric_graph.py @@ -0,0 +1,380 @@ +import uuid +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + + +MappingFit = Literal["direct", "partial", "conflict", "gap", "unknown"] +EvidenceState = Literal["observed", "declared", "inferred", "proposed", "gap"] +FabricGraphApiVersion = Literal["railiance.fabric/v1alpha1", "railiance.fabric/v1alpha2"] +ActorRole = Literal["king", "lord", "tenant", "operator", "steward"] +OwnershipResolution = Literal["explicit", "inherited", "unresolved", "ambiguous"] +EvidenceReviewState = Literal["accepted", "candidate", "needs_review", "rejected"] +RelationshipCategory = Literal[ + "containment", + "ownership", + "technical", + "utility", + "accounting", + "evidence", +] + + +class FabricGraphSource(BaseModel): + model_config = ConfigDict(extra="allow") + + repo: str | None = None + producer: str | None = None + registry: str | None = None + # Accepted-snapshot-set revisions include the algorithm and digest, for + # example ``snapshot-set:sha256:<64 hex chars>``. They are intentionally + # longer than a Git SHA but still bounded before persistence. + commit: str | None = Field(default=None, max_length=255) + path: str | None = None + generation_reason: str | None = None + + +class FabricGraphNetkingdomPayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + id: str + name: str + king_actor_id: str + + +class FabricGraphActorPayload(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + kind: Literal["FabricActor"] + role: ActorRole + name: str + + +class FabricGraphFabricPayload(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + kind: Literal["Fabric", "Subfabric"] + name: str + netkingdom_id: str + parent_fabric_id: str | None = None + lord_actor_id: str | None = None + tenant_actor_id: str | None = None + status: str + boundary: dict[str, Any] = Field(default_factory=dict) + evidence_refs: list[dict[str, Any]] = Field(default_factory=list) + + +class FabricGraphContainmentPayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + netkingdom_id: str + fabric_id: str + subfabric_id: str | None = None + environment: str | None = None + deployment_scenario_id: str | None = None + + +class FabricGraphOwnershipPayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + owner_actor_id: str + owner_role: ActorRole + resolution: OwnershipResolution + inherited_from: str | None = None + supporting_actor_ids: list[str] = Field(default_factory=list) + + +class FabricGraphAccountingPayload(BaseModel): + model_config = ConfigDict(extra="allow") + + cost_center_id: str | None = None + profit_center_id: str | None = None + provider_profit_center_id: str | None = None + consumer_cost_center_id: str | None = None + allocation_model: str | None = None + payment_schema_id: str | None = None + metering_basis: str | None = None + valid_from: str | None = None + valid_until: str | None = None + + +class FabricGraphDeploymentOverlayPayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + deployment_environment: str | None = None + deployment_scenario: str | None = None + routing_authority: str | None = None + access_zone: str | None = None + policy_authority: str | None = None + exposure_class: str | None = None + route_evidence: dict[str, str | int | float | bool | None] = Field(default_factory=dict) + + +class FabricGraphEvidencePayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + state: EvidenceState + review_state: EvidenceReviewState + confidence: float | None = Field(default=None, ge=0, le=1) + refs: list[dict[str, Any]] = Field(default_factory=list) + + +class FabricGraphUtilitySidePayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + owner_actor_id: str + fabric_id: str + subfabric_id: str | None = None + + +class FabricGraphBoundaryPayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + crosses_fabric_boundary: bool + crosses_subfabric_boundary: bool + + +class FabricGraphUtilityPayload(BaseModel): + model_config = ConfigDict(extra="allow") + + utility_type: str + contract_id: str | None = None + payment_schema_id: str | None = None + metering_basis: str | None = None + business_model: str | None = None + + +class FabricGraphUnresolvedPayload(BaseModel): + model_config = ConfigDict(extra="allow") + + target_id: str + kind: str + severity: str | None = None + message: str + evidence_refs: list[dict[str, Any]] = Field(default_factory=list) + + +class FabricGraphNodePayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + id: str + kind: str + name: str + repo: str | None = None + domain: str | None = None + lifecycle: str | None = None + containment: FabricGraphContainmentPayload | None = None + ownership: FabricGraphOwnershipPayload | None = None + accounting: FabricGraphAccountingPayload | None = None + deployment_overlay: FabricGraphDeploymentOverlayPayload | None = None + evidence: FabricGraphEvidencePayload | None = None + canon_category: str | None = None + canon_anchor: str | None = None + mapping_fit: MappingFit | None = None + evidence_state: EvidenceState | None = None + attributes: dict[str, Any] = Field(default_factory=dict) + + +class FabricGraphEdgePayload(BaseModel): + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + from_graph_id: str = Field(alias="from") + to_graph_id: str = Field(alias="to") + edge_type: str = Field(alias="type") + id: str | None = None + relationship_category: RelationshipCategory | None = None + canonical_type: str | None = None + canon_anchor: str | None = None + mapping_fit: MappingFit | None = None + display_only: bool | None = None + evidence_state: EvidenceState | None = None + provider: FabricGraphUtilitySidePayload | None = None + consumer: FabricGraphUtilitySidePayload | None = None + boundary: FabricGraphBoundaryPayload | None = None + utility: FabricGraphUtilityPayload | None = None + accounting: FabricGraphAccountingPayload | None = None + deployment_overlay: FabricGraphDeploymentOverlayPayload | None = None + evidence: FabricGraphEvidencePayload | None = None + attributes: dict[str, Any] = Field(default_factory=dict) + + +class FabricGraphExportPayload(BaseModel): + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + api_version: FabricGraphApiVersion = Field(alias="apiVersion") + kind: Literal["FabricGraphExport"] + schema_version: Literal["financial-fabric-v1"] | None = None + generated_at: datetime | None = None + source: FabricGraphSource | None = None + compatibility: dict[str, Any] = Field(default_factory=dict) + netkingdom: FabricGraphNetkingdomPayload | None = None + actors: list[FabricGraphActorPayload] = Field(default_factory=list) + fabrics: list[FabricGraphFabricPayload] = Field(default_factory=list) + nodes: list[FabricGraphNodePayload] + edges: list[FabricGraphEdgePayload] + unresolved: list[FabricGraphUnresolvedPayload] = Field(default_factory=list) + + +class FabricGraphPullRequest(BaseModel): + source_repo_slug: str = "railiance-fabric" + source_url: str = "http://127.0.0.1:8765/exports/state-hub" + requested_by: str = "operator" + + +class FabricGraphImportRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + source_repo_slug: str + source_url: str | None = None + source_commit: str | None = None + source_path: str | None = None + api_version: str | None = None + schema_version: str | None = None + export_kind: str | None = None + exported_at: datetime | None = None + netkingdom_id: str | None = None + king_actor_id: str | None = None + actor_count: int + fabric_count: int + unresolved_count: int + compatibility: dict + content_hash: str + node_count: int + edge_count: int + validation_status: str + error_details: dict | None = None + is_latest: bool + last_seen_at: datetime | None = None + created_at: datetime + updated_at: datetime + + +class FabricGraphIngestResult(BaseModel): + import_run: FabricGraphImportRead + created: bool + idempotent: bool + node_count: int + edge_count: int + + +class FabricGraphActivationRequest(BaseModel): + requested_by: str = "operator" + + +class FabricGraphActivationResult(BaseModel): + import_run: FabricGraphImportRead + previous_import_id: uuid.UUID | None = None + activated: bool + idempotent: bool + + +class FabricGraphNodeRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + import_id: uuid.UUID + source_repo_slug: str + graph_id: str + kind: str + name: str + repo_slug: str + domain_slug: str + lifecycle: str + canonical_type: str | None = None + canon_category: str | None = None + canon_anchor: str | None = None + mapping_fit: str | None = None + evidence_state: str | None = None + evidence_review_state: str | None = None + evidence_confidence: float | None = None + netkingdom_id: str | None = None + fabric_id: str | None = None + subfabric_id: str | None = None + environment: str | None = None + deployment_scenario_id: str | None = None + owner_actor_id: str | None = None + owner_role: str | None = None + ownership_resolution: str | None = None + cost_center_id: str | None = None + profit_center_id: str | None = None + display_only: bool + attributes: dict + raw_json: dict + + +class FabricGraphEdgeRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + import_id: uuid.UUID + source_repo_slug: str + edge_key: str + from_graph_id: str + to_graph_id: str + edge_type: str + canonical_type: str | None = None + canon_anchor: str | None = None + mapping_fit: str | None = None + evidence_state: str | None = None + evidence_review_state: str | None = None + evidence_confidence: float | None = None + relationship_category: str | None = None + provider_owner_actor_id: str | None = None + provider_fabric_id: str | None = None + provider_subfabric_id: str | None = None + consumer_owner_actor_id: str | None = None + consumer_fabric_id: str | None = None + consumer_subfabric_id: str | None = None + crosses_fabric_boundary: bool | None = None + crosses_subfabric_boundary: bool | None = None + utility_type: str | None = None + utility_contract_id: str | None = None + utility_payment_schema_id: str | None = None + utility_metering_basis: str | None = None + utility_business_model: str | None = None + cost_center_id: str | None = None + profit_center_id: str | None = None + provider_profit_center_id: str | None = None + consumer_cost_center_id: str | None = None + display_only: bool + attributes: dict + raw_json: dict + + +class FabricGraphSummary(BaseModel): + source_repo_slug: str + latest_import: FabricGraphImportRead | None = None + node_count: int + edge_count: int + schema_version: str | None = None + netkingdom_id: str | None = None + actor_count: int + fabric_count: int + unresolved_count: int + nodes_by_domain: dict[str, int] + nodes_by_repo: dict[str, int] + nodes_by_canon_category: dict[str, int] + nodes_by_fabric: dict[str, int] + nodes_by_subfabric: dict[str, int] + nodes_by_owner_actor: dict[str, int] + nodes_by_owner_role: dict[str, int] + nodes_by_ownership_resolution: dict[str, int] + edges_by_canonical_type: dict[str, int] + edges_by_relationship_category: dict[str, int] + utility_edges_by_provider_owner: dict[str, int] + utility_edges_by_consumer_owner: dict[str, int] + utility_edges_by_business_model: dict[str, int] + nodes_by_evidence_state: dict[str, int] + edges_by_evidence_state: dict[str, int] + nodes_by_mapping_fit: dict[str, int] + edges_by_mapping_fit: dict[str, int] + tenant_utilities_without_payment_schema: int + nodes_without_accounting_attribution: int + unresolved_ownership_count: int + unresolved_accounting_count: int + example_nodes: list[FabricGraphNodeRead] + example_edges: list[FabricGraphEdgeRead] diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/identifier_migration.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/identifier_migration.py new file mode 100644 index 00000000..8b066290 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/identifier_migration.py @@ -0,0 +1,39 @@ +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class SealedProjectionRepairSubmit(BaseModel): + plan: dict[str, Any] + repo_slug: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{0,99}$") + unit: dict[str, Any] + expected_plan_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + source_revision: str = Field(pattern=r"^[0-9a-f]{40}$") + source_fingerprint: str = Field(pattern=r"^[0-9a-f]{64}$") + source_clean: bool + source_synchronized: bool + primary_confirmed: bool + projection_identity: str = Field(min_length=1, max_length=200) + + +class SealedProjectionRepairReceiptRead(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + schema_version: str = Field(alias="schema") + outcome: str + repo_slug: str + repository_id: str + workplan_record_id: str + workplan_old_id: str + task_records: list[tuple[str, str]] + plan_sha256: str + source_revision: str + source_fingerprint: str + projection_identity: str + observed_at: str + + +class IdentifierMigrationApplySubmit(BaseModel): + plan: dict[str, Any] + expected_plan_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + primary_confirmed: bool diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/inbox_feed.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/inbox_feed.py new file mode 100644 index 00000000..8c78b0ba --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/inbox_feed.py @@ -0,0 +1,68 @@ +"""Inbox change-feed envelopes (docs/inbox-change-feed-contract.md, v1).""" +from __future__ import annotations + +import re +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel + +CONTRACT = "statehub.inbox-feed.v1" +CURSOR_VERSION = "v1" + +_CURSOR_RE = re.compile(r"^(v\d+):(\d+)$") + + +class CursorError(ValueError): + """Malformed cursor (400).""" + + +class ForeignCursorError(ValueError): + """Cursor of another contract version (409).""" + + +def encode_cursor(revision: int) -> str: + return f"{CURSOR_VERSION}:{int(revision)}" + + +def decode_cursor(cursor: str) -> int: + match = _CURSOR_RE.match(cursor or "") + if match is None: + raise CursorError(f"malformed cursor {cursor!r}") + if match.group(1) != CURSOR_VERSION: + raise ForeignCursorError( + f"cursor {cursor!r} belongs to another contract version; re-snapshot" + ) + return int(match.group(2)) + + +class FeedChange(BaseModel): + revision: int + entity: Literal["message", "receipt", "tombstone"] + key: str + op: Literal["upsert", "delete"] + state: dict[str, Any] + + +class FeedPage(BaseModel): + contract: str = CONTRACT + source_instance: str | None + source_time: datetime + high_water: str + next_cursor: str + has_more: bool + compacted_before: str + changes: list[FeedChange] + + +class FeedSnapshot(BaseModel): + contract: str = CONTRACT + source_instance: str | None + source_time: datetime + cursor: str + compacted_before: str + messages: list[dict[str, Any]] + receipts: list[dict[str, Any]] + tombstones: list[dict[str, Any]] + count: int + content_hash: str diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/intake.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/intake.py new file mode 100644 index 00000000..54ff1ca9 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/intake.py @@ -0,0 +1,89 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, model_validator + +from api.models.intake import IntakeLane, IntakeOutcome, IntakeStatus + + +class IntakeCreate(BaseModel): + id: uuid.UUID | None = None + topic_id: uuid.UUID | None = None + workplan_id: uuid.UUID | None = None + repo_id: uuid.UUID | None = None + title: str + description: str | None = None + lane: IntakeLane = IntakeLane.green + origin: str | None = None + origin_ref: str | None = None + source_repo_path: str | None = None + + @model_validator(mode="after") + def scope_required(self) -> "IntakeCreate": + if self.topic_id is None and self.workplan_id is None and self.repo_id is None: + raise ValueError("At least one of topic_id, workplan_id, or repo_id must be set") + return self + + +class IntakeUpdate(BaseModel): + title: str | None = None + description: str | None = None + lane: IntakeLane | None = None + status: IntakeStatus | None = None + origin: str | None = None + origin_ref: str | None = None + routed_note: str | None = None + + +class IntakeRoute(BaseModel): + """Move an intake from open/vetted into routed — the state that makes + it eligible for the promotion transition (CUST-WP-0061-T03).""" + + routed_note: str | None = None + + +class IntakeClose(BaseModel): + outcome: IntakeOutcome + promoted_to: str | None = None + note: str | None = None + + @model_validator(mode="after") + def promoted_requires_target(self) -> "IntakeClose": + if self.outcome == IntakeOutcome.promoted and not self.promoted_to: + raise ValueError("outcome=promoted requires promoted_to") + return self + + +class IntakeNoteCreate(BaseModel): + content: str + author: str | None = None + + +class IntakeNoteRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + author: str | None = None + content: str + created_at: datetime + + +class IntakeRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + topic_id: uuid.UUID | None = None + workplan_id: uuid.UUID | None = None + repo_id: uuid.UUID | None = None + title: str + description: str | None = None + lane: IntakeLane + status: IntakeStatus + outcome: IntakeOutcome | None = None + origin: str | None = None + origin_ref: str | None = None + promoted_to: str | None = None + source_repo_path: str | None = None + routed_note: str | None = None + closed_at: datetime | None = None + created_at: datetime + updated_at: datetime + notes: list[IntakeNoteRead] = [] diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/interface_change.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/interface_change.py new file mode 100644 index 00000000..35c87b8c --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/interface_change.py @@ -0,0 +1,66 @@ +import uuid +from datetime import date, datetime + +from pydantic import BaseModel, ConfigDict + + +class InterfaceChangeCreate(BaseModel): + repo_slug: str + interface_type: str # rest_api | mcp_tool | cli | schema | capability + change_type: str # breaking | additive | deprecation | removal + title: str + description: str + affected_paths: list[str] = [] + affected_repo_slugs: list[str] = [] + planned_for: date | None = None + author: str = "custodian" + + +class InterfaceChangePatch(BaseModel): + title: str | None = None + description: str | None = None + affected_paths: list[str] | None = None + affected_repo_slugs: list[str] | None = None + planned_for: date | None = None + + +class InterfaceChangeRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + repo_id: uuid.UUID + repo_slug: str + interface_type: str + change_type: str + title: str + description: str + affected_paths: list[str] + affected_repo_slugs: list[str] + status: str + planned_for: date | None + published_at: datetime | None + resolved_at: datetime | None + author: str + created_at: datetime + updated_at: datetime + + @classmethod + def from_orm_with_slug(cls, obj) -> "InterfaceChangeRead": + return cls( + id=obj.id, + repo_id=obj.repo_id, + repo_slug=obj.repo.slug, + interface_type=obj.interface_type, + change_type=obj.change_type, + title=obj.title, + description=obj.description, + affected_paths=obj.affected_paths or [], + affected_repo_slugs=obj.affected_repo_slugs or [], + status=obj.status, + planned_for=obj.planned_for, + published_at=obj.published_at, + resolved_at=obj.resolved_at, + author=obj.author, + created_at=obj.created_at, + updated_at=obj.updated_at, + ) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/legacy_meter.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/legacy_meter.py new file mode 100644 index 00000000..487fc210 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/legacy_meter.py @@ -0,0 +1,106 @@ +import uuid +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + + +LegacyInterfaceKind = Literal[ + "rest_api", + "mcp_tool", + "procedure_call", + "event_subject", + "cli", + "dashboard_route", + "schema_field", +] +LegacyInterfaceStatus = Literal["legacy", "retirement_candidate", "retired"] + + +class LegacyInterfaceRegister(BaseModel): + interface_key: str + interface_kind: LegacyInterfaceKind = "rest_api" + replacement_ref: str + owner_component: str = "state-hub" + replacement_verified: bool = False + manual_hold: bool = False + hold_reason: str | None = None + notes: str | None = None + + +class LegacyInterfacePatch(BaseModel): + replacement_ref: str | None = None + owner_component: str | None = None + status: LegacyInterfaceStatus | None = None + replacement_verified: bool | None = None + manual_hold: bool | None = None + hold_reason: str | None = None + notes: str | None = None + + +class LegacyUsageRecord(BaseModel): + interface_key: str + interface_kind: LegacyInterfaceKind = "rest_api" + replacement_ref: str + owner_component: str = "state-hub" + replacement_verified: bool = False + tenant_key: str | None = None + user_key: str | None = None + component_key: str | None = None + observed_at: datetime | None = None + call_count: int = Field(default=1, ge=1) + + +class LegacyInterfaceRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + interface_key: str + interface_kind: str + legacy_since: datetime + replacement_ref: str + owner_component: str + status: str + replacement_verified: bool + manual_hold: bool + hold_reason: str | None = None + notes: str | None = None + retired_at: datetime | None = None + created_at: datetime + updated_at: datetime + + +class LegacyUsageCounters(BaseModel): + calls: int = 0 + tenant_count: int = 0 + user_count: int = 0 + component_count: int = 0 + tenants: dict[str, int] = Field(default_factory=dict) + users: dict[str, int] = Field(default_factory=dict) + components: dict[str, int] = Field(default_factory=dict) + + +class LegacyInterfaceSummary(BaseModel): + interface: LegacyInterfaceRead + all_time: LegacyUsageCounters + window: LegacyUsageCounters + last_seen_at: datetime | None = None + retirement_candidate: bool + retirement_reason: str + + +class LegacyUsageSummary(BaseModel): + generated_at: datetime + window_start: datetime + window_end: datetime + interfaces: list[LegacyInterfaceSummary] + + +class LegacyWeeklyReview(BaseModel): + generated_at: datetime + window_start: datetime + window_end: datetime + cadence: str = "weekly" + activity_core_handoff: dict[str, str] + interfaces: list[LegacyInterfaceSummary] + retirement_candidates: list[LegacyInterfaceSummary] diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/managed_repo.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/managed_repo.py new file mode 100644 index 00000000..ff1f5cf4 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/managed_repo.py @@ -0,0 +1,190 @@ +import uuid +from datetime import date, datetime +from typing import Any, Literal + +from fastapi import HTTPException +from pydantic import BaseModel, Field, model_validator + +from api.classification import validate_classification +from hub_core.schemas.managed_repo import ( + RepoCreate as CoreRepoCreate, + RepoPathRegister, + RepoRead as CoreRepoRead, +) + + +class ClassificationFields(BaseModel): + category: str | None = None + secondary_domains: list[str] | None = None + capability_tags: list[str] | None = None + business_stake: list[str] | None = None + business_mechanics: list[str] | None = None + classified_at: date | None = None + classified_by: str | None = None + standard_version: str | None = None + + +def classification_fields_set(data: dict[str, Any]) -> bool: + keys = ( + "category", + "secondary_domains", + "capability_tags", + "business_stake", + "business_mechanics", + "classified_at", + "classified_by", + "standard_version", + ) + return any(data.get(key) is not None for key in keys) + + +def validate_repo_classification_fields( + *, + domain_slug: str, + fields: dict[str, Any], + require_complete: bool = False, +) -> dict[str, Any]: + """Validate classification fields and return normalized values for persistence.""" + if not classification_fields_set(fields) and not require_complete: + return fields + + block = { + "category": fields.get("category"), + "domain": domain_slug, + "secondary_domains": fields.get("secondary_domains") or [], + "capability_tags": fields.get("capability_tags") or [], + "business_stake": fields.get("business_stake") or [], + "business_mechanics": fields.get("business_mechanics") or [], + } + if require_complete or fields.get("category") is not None: + if block["category"] is None: + raise HTTPException(status_code=422, detail="`category` is required when classification is provided") + if classification_fields_set(fields) and block["category"] is not None: + errors, warnings = validate_classification(block) + if errors: + raise HTTPException(status_code=422, detail={"classification_errors": errors, "warnings": warnings}) + return fields + + +class RepoCreate(CoreRepoCreate, ClassificationFields): + topic_id: uuid.UUID | None = None + + @model_validator(mode="after") + def validate_classification_on_create(self) -> "RepoCreate": + validate_repo_classification_fields( + domain_slug=self.domain_slug, + fields=self.model_dump(), + require_complete=classification_fields_set(self.model_dump()), + ) + return self + + +class RepoUpdate(ClassificationFields): + domain_slug: str | None = None + name: str | None = None + local_path: str | None = None + remote_url: str | None = None + git_fingerprint: str | None = None + description: str | None = None + topic_id: uuid.UUID | None = None + last_state_synced_at: datetime | None = None + + +class RepoOnboardRequest(BaseModel): + """Start scripted onboarding for a working copy that is visible to State Hub.""" + domain_slug: str + project_path: str + agent_profile: Literal["claude-code", "codex"] = "codex" + additional: bool = False + + +class RepoOnboardResult(BaseModel): + ok: bool + repo_slug: str | None = None + agent_profile: str + command: list[str] + stdout: str = "" + stderr: str = "" + + +class RepoRead(CoreRepoRead, ClassificationFields): + topic_id: uuid.UUID | None = None + sbom_source: str | None = None + last_sbom_at: datetime | None = None + last_state_synced_at: datetime | None = None + created_at: datetime + updated_at: datetime + requested_slug: str | None = None + canonical_slug: str | None = None + slug_status: Literal["canonical", "alias"] = "canonical" + aliases: list[str] = Field(default_factory=list) + stale_external_references: list[dict[str, Any]] = Field(default_factory=list) + + +class DispatchTask(BaseModel): + id: uuid.UUID + title: str + priority: str + status: str + needs_human: bool + + +class DispatchWorkplan(BaseModel): + id: uuid.UUID + title: str + status: str + pending_tasks: list[DispatchTask] + + +# Legacy alias +DispatchWorkstream = DispatchWorkplan + + +class PendingInterfaceChange(BaseModel): + id: uuid.UUID + title: str + change_type: str + interface_type: str + origin_repo_slug: str + affected_paths: list[str] + planned_for: date | None + published_at: datetime | None + + +class ScopeIssueDetail(BaseModel): + id: str + label: str + status: str + detail: str + missing_sections: list[str] = Field(default_factory=list) + invalid_capability_blocks: list[dict[str, Any]] = Field(default_factory=list) + needs_refresh_sections: list[str] = Field(default_factory=list) + + +class RepoDispatch(BaseModel): + repo_slug: str + requested_slug: str + canonical_slug: str + slug_status: Literal["canonical", "alias"] + aliases: list[str] = Field(default_factory=list) + stale_external_references: list[dict[str, Any]] = Field(default_factory=list) + active_goal: dict[str, Any] | None + active_workplans: list[DispatchWorkplan] + human_interventions: list[DispatchTask] + pending_interface_changes: list[PendingInterfaceChange] + scope_needs_review: bool + scope_issue_details: list[ScopeIssueDetail] + last_state_synced_at: datetime | None + + @property + def active_workstreams(self) -> list[DispatchWorkplan]: + return self.active_workplans + + +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/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/ops_run.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/ops_run.py new file mode 100644 index 00000000..6cad88dc --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/ops_run.py @@ -0,0 +1,27 @@ +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field + + +class OpsRunProjectionItem(BaseModel): + id: str + definition: str | None = None + target_repo: str | None = None + state: str + lease: dict[str, Any] | None = None + updated_at: datetime | None = None + last_error: str | None = None + + +class OpsRunProjection(BaseModel): + available: bool = False + stale: bool = False + source: str = "activity-core" + generated_at: datetime + open: int = 0 + claimed: int = 0 + failed_24h: int = 0 + stuck_open_or_claimed: int = 0 + items: list[OpsRunProjectionItem] = Field(default_factory=list) + error: str | None = None diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/progress_event.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/progress_event.py new file mode 100644 index 00000000..2f7c1e3c --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/progress_event.py @@ -0,0 +1,36 @@ +import uuid +from datetime import datetime +from typing import Any + +from pydantic import AliasChoices, BaseModel, ConfigDict, Field + +from api.schemas.compat import LegacyWorkstreamIdBodyMixin, OptionalWorkplanIdCompatMixin + + +class ProgressEventCreate(LegacyWorkstreamIdBodyMixin, BaseModel): + topic_id: uuid.UUID | None = None + workplan_id: uuid.UUID | None = Field( + default=None, + validation_alias=AliasChoices("workplan_id", "workstream_id"), + ) + task_id: uuid.UUID | None = None + decision_id: uuid.UUID | None = None + event_type: str + summary: str + detail: dict[str, Any] | None = None + author: str | None = None + session_id: str | None = None + + +class ProgressEventRead(OptionalWorkplanIdCompatMixin, BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + topic_id: uuid.UUID | None = None + task_id: uuid.UUID | None = None + decision_id: uuid.UUID | None = None + event_type: str + summary: str + detail: dict[str, Any] | None = None + author: str | None = None + session_id: str | None = None + created_at: datetime \ No newline at end of file diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/recently_on_scope.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/recently_on_scope.py new file mode 100644 index 00000000..fb007a59 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/recently_on_scope.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import uuid +from datetime import datetime + +from pydantic import BaseModel, Field + + +class RecentlyOnScopeGenerate(BaseModel): + range: str = Field(default="1h", description="Time range such as 15m, 1h, 6h, or 1d") + since: datetime | None = None + until: datetime | None = None + + +class RecentlyOnScopeSourceCounts(BaseModel): + progress_events: int = 0 + decisions: int = 0 + workstreams: int = 0 + tasks: int = 0 + repos: int = 0 + attention_items: int = 0 + + +class RecentlyOnScopeReportMetadata(BaseModel): + id: str + domain_slug: str + range: str + since: datetime + until: datetime + generated_at: datetime + path: str + source_counts: RecentlyOnScopeSourceCounts + + +class RecentlyOnScopeGeneratedReport(RecentlyOnScopeReportMetadata): + markdown: str + + +class RecentlyOnScopeHourlyGenerate(RecentlyOnScopeGenerate): + active_only: bool = True + include_attention: bool = False + + +class RecentlyOnScopeSkippedDomain(BaseModel): + domain_slug: str + reason: str + source_counts: RecentlyOnScopeSourceCounts + + +class RecentlyOnScopeFailedDomain(BaseModel): + domain_slug: str + error: str + + +class RecentlyOnScopeHourlyRun(BaseModel): + range: str + since: datetime + until: datetime + generated_at: datetime + active_only: bool + include_attention: bool + generated: list[RecentlyOnScopeReportMetadata] = Field(default_factory=list) + skipped: list[RecentlyOnScopeSkippedDomain] = Field(default_factory=list) + failed: list[RecentlyOnScopeFailedDomain] = Field(default_factory=list) + progress_event_id: uuid.UUID | None = None diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/reconciliation.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/reconciliation.py new file mode 100644 index 00000000..702d5efe --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/reconciliation.py @@ -0,0 +1,44 @@ +import uuid +from typing import Literal + +from pydantic import BaseModel + +from api.services.reconciliation import ReconciliationClass + + +TargetType = Literal["workstream", "task"] + + +class StateChangeRequest(BaseModel): + target_type: TargetType + target_id: uuid.UUID + target_status: str + actor: str = "dashboard" + intent: str | None = None + expected_current_status: str | None = None + file_backed: bool | None = None + archived_file: bool | None = None + task_linked: bool | None = None + tasks_terminal: bool | None = None + blocking_reason: str | None = None + apply: bool = False + + +class StateChangeResponse(BaseModel): + target_type: TargetType + target_id: uuid.UUID + actor: str + intent: str | None = None + current_status: str + target_status: str + file_backed: bool + archived_file: bool + task_linked: bool | None = None + tasks_terminal: bool | None = None + reconciliation_class: ReconciliationClass + reason: str + follow_up: str + write_through_result: Literal["not_attempted", "applied", "not_applicable"] = "not_attempted" + workplan_path: str | None = None + reconciliation_record_id: uuid.UUID | None = None + conflict: bool = False diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/repo_goal.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/repo_goal.py new file mode 100644 index 00000000..b2f69cac --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/repo_goal.py @@ -0,0 +1,37 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from api.models.repo_goal import RepoGoalStatus + + +class RepoGoalCreate(BaseModel): + repo_id: uuid.UUID + domain_goal_id: uuid.UUID | None = None + title: str + description: str + priority: int = 100 + status: str = RepoGoalStatus.active.value + + +class RepoGoalUpdate(BaseModel): + title: str | None = None + description: str | None = None + priority: int | None = None + status: str | None = None + domain_goal_id: uuid.UUID | None = None + + +class RepoGoalRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + repo_id: uuid.UUID + repo_slug: str + domain_goal_id: uuid.UUID | None = None + title: str + description: str + priority: int + status: str + created_at: datetime + updated_at: datetime diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/repository_rename.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/repository_rename.py new file mode 100644 index 00000000..aed40831 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/repository_rename.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, Field + + +class EdgeWriteEvidence(BaseModel): + id: str + status: str = "queued" + source_host: str | None = None + path: str | None = None + + +class RepositoryRenamePreflightRequest(BaseModel): + new_slug: str = Field(min_length=1, max_length=100, pattern=r"^[a-z0-9][a-z0-9-]*$") + queued_edge_writes: list[EdgeWriteEvidence] = Field(default_factory=list) + + +class RepositoryRenamePreflightRead(BaseModel): + schema_version: Literal["state-hub.repository-rename-preflight.v1"] + repo_id: uuid.UUID + old_slug: str + new_slug: str + safe_to_apply: bool + blockers: list[dict[str, Any]] + warnings: list[dict[str, Any]] + current: dict[str, Any] + target: dict[str, Any] + baselines: dict[str, Any] + active_work: dict[str, Any] + affected: dict[str, Any] + queued_edge_writes: list[dict[str, Any]] + proposed_mutations: list[dict[str, Any]] + retained_history: list[dict[str, Any]] + report_checksum: str + preflight_token: str | None + preflighted_at: datetime + expires_at: datetime | None + + +class ForgeIdentityVerifyRequest(BaseModel): + provider: Literal["forgejo"] = "forgejo" + forge_instance: str + forge_owner: str + forge_repository_id: int = Field(gt=0) + verified_by: str = Field(min_length=1, max_length=160) + + +class RepositoryRenameOperationCreate(BaseModel): + operation_id: uuid.UUID | None = None + new_slug: str = Field(min_length=1, max_length=100, pattern=r"^[a-z0-9][a-z0-9-]*$") + preflight_token: str + confirmation: str + actor: str = Field(min_length=1, max_length=160) + queued_edge_writes: list[EdgeWriteEvidence] = Field(default_factory=list) + + +class RepositoryRenamePhaseApply(BaseModel): + expected_phase: str + confirmation: str + checks: dict[str, bool] = Field(default_factory=dict) + evidence: dict[str, Any] = Field(default_factory=dict) + + +class RepositoryRenameRollbackRequest(BaseModel): + expected_phase: str + confirmation: str + + +class RepositoryRenameOperationRead(BaseModel): + id: uuid.UUID + repo_id: uuid.UUID + phase: str + old_slug: str + new_slug: str + expected_forge_repository_id: int + expected_source_commit: str + expected_default_branch: str + actor: str + phase_changed_at: datetime + preflighted_at: datetime | None + preflight_expires_at: datetime | None + completed_at: datetime | None + rolled_back_at: datetime | None + evidence: dict[str, Any] + error_code: str | None + error_message: str | None + error_details: dict[str, Any] | None + error_at: datetime | None + no_op: bool = False + + +class RepositoryRenameVerificationRead(BaseModel): + operation_id: uuid.UUID + repo_id: uuid.UUID + phase: str + ok: bool + checks: list[dict[str, Any]] + baseline_checksum: str + current_checksum: str + relationship_checksums: dict[str, dict[str, str]] + + +class RepositoryRenameRollbackPreflightRead(BaseModel): + operation_id: uuid.UUID + repo_id: uuid.UUID + rollback_from_phase: str + safe_to_rollback: bool + blockers: list[dict[str, Any]] + irreversible: list[dict[str, Any]] + operation: RepositoryRenameOperationRead diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/review_contract.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/review_contract.py new file mode 100644 index 00000000..c5556fdc --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/review_contract.py @@ -0,0 +1,96 @@ +import uuid +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class SourceRef(BaseModel): + repo: str = Field(min_length=1, max_length=100) + path: str = Field(min_length=1, max_length=1000) + revision: str = Field(pattern=r"^[0-9a-f]{40,64}$") + + +class ReviewContractProject(BaseModel): + contract: dict[str, Any] + source: SourceRef + decision_id: uuid.UUID | None = None + workplan_id: uuid.UUID | None = None + task_id: uuid.UUID | None = None + required_for_decision: bool = False + + +class ReviewContractRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + contract_key: str + schema_version: str + contract_digest: str + source_repo: str + source_path: str + source_revision: str + document: dict[str, Any] + active: bool + required_for_decision: bool + decision_id: uuid.UUID | None + workplan_id: uuid.UUID | None + task_id: uuid.UUID | None + projected_at: datetime + created_at: datetime + updated_at: datetime + + +class ReviewReceiptSubmit(BaseModel): + owner_id: str = Field(min_length=1, max_length=160) + actor: str = Field(min_length=2, max_length=160) + disposition: Literal["approve", "request_changes"] + contract_digest: str = Field(pattern=r"^[0-9a-f]{64}$") + artifact_hashes: dict[str, str] = Field(default_factory=dict) + assertion_ids: list[str] = Field(default_factory=list) + checks: list[dict[str, Any]] = Field(default_factory=list) + note: str | None = Field(default=None, max_length=2000) + source: SourceRef + + +class ReviewReceiptRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + contract_id: uuid.UUID + owner_id: str + actor: str + disposition: str + contract_digest: str + receipt_digest: str + artifact_hashes: dict[str, str] + assertion_ids: list[str] + checks: list[dict[str, Any]] + note: str | None + source_repo: str + source_path: str + source_revision: str + submitted_at: datetime + created_at: datetime + + +class OwnerReviewState(BaseModel): + owner_id: str + status: Literal["missing", "approved", "request_changes", "stale"] + receipt_id: uuid.UUID | None = None + receipt_digest: str | None = None + submitted_at: datetime | None = None + + +class GateReviewState(BaseModel): + gate_id: str + policy: Literal["all_required"] + owners: list[str] + satisfied: bool + + +class ReviewAggregateRead(BaseModel): + contract_key: str + contract_digest: str + satisfied: bool + authorizes_execution: Literal[False] = False + owners: list[OwnerReviewState] + gates: list[GateReviewState] diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/sbom.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/sbom.py new file mode 100644 index 00000000..51f00d7f --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/sbom.py @@ -0,0 +1,78 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from api.models.sbom_entry import Ecosystem + + +class SBOMEntryCreate(BaseModel): + package_name: str + package_version: str | None = None + ecosystem: Ecosystem + license_spdx: str | None = None + is_direct: bool = True + is_dev: bool = False + + +class SBOMIngest(BaseModel): + repo_slug: str + entries: list[SBOMEntryCreate] + + +class SBOMEntryRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + repo_id: uuid.UUID + snapshot_id: uuid.UUID + package_name: str + package_version: str | None = None + ecosystem: Ecosystem + license_spdx: str | None = None + is_direct: bool + is_dev: bool + snapshot_at: datetime + created_at: datetime + + +class SBOMSnapshotRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + repo_id: uuid.UUID + snapshot_at: datetime + source: str | None = None + entry_count: int + created_at: datetime + + +class SBOMSnapshotDetail(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + repo_id: uuid.UUID + snapshot_at: datetime + source: str | None = None + entry_count: int + created_at: datetime + entries: list[SBOMEntryRead] = [] + + +class LicenceGroup(BaseModel): + license_spdx: str | None + count: int + repos: list[str] + is_copyleft: bool + + +class LicenceReport(BaseModel): + groups: list[LicenceGroup] + copyleft_direct_count: int + + +class SBOMRepoView(BaseModel): + repo_slug: str + last_sbom_at: datetime | None = None + entry_count: int + entries: list[SBOMEntryRead] diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/service.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/service.py new file mode 100644 index 00000000..7ef2202a --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/service.py @@ -0,0 +1,116 @@ +"""Schemas for the two-dimension service catalog (STATE-WP-0062).""" +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +# ── Extension read models ──────────────────────────────────────────────────── + +class ServiceThirdPartyRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + pricing_model: str + upstream_packages: list | None = None + upstream_contacts: list | None = None + source_url: str | None = None + support_url: str | None = None + license: str | None = None + + +class ServiceFirstPartyRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + repo_id: uuid.UUID | None = None + owning_domain: str | None = None + + +class ServiceCloudRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + gdpr_maturity: str + gdpr_notes: str | None = None + dpa_available: bool + tos_url: str | None = None + privacy_policy_url: str | None = None + data_processing_regions: list | None = None + data_retention_notes: str | None = None + + +class ServiceSelfHostedRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + helix_instance: str | None = None + host_node: str | None = None + deployment_ref: str | None = None + runbook_ref: str | None = None + upstream_oss_project: str | None = None + + +class ServiceCatalogRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + slug: str + name: str + owner_or_provider: str | None = None + category: str | None = None + description: str | None = None + website_url: str | None = None + status: str + hosting_type: str + development_type: str + maturity_level: int | None = None + created_at: datetime + updated_at: datetime + third_party: ServiceThirdPartyRead | None = None + first_party: ServiceFirstPartyRead | None = None + cloud: ServiceCloudRead | None = None + self_hosted: ServiceSelfHostedRead | None = None + + +# ── Write (upsert) models ──────────────────────────────────────────────────── + +class ServiceThirdPartyIn(BaseModel): + pricing_model: str = "unknown" + upstream_packages: list | None = None + upstream_contacts: list | None = None + source_url: str | None = None + support_url: str | None = None + license: str | None = None + + +class ServiceFirstPartyIn(BaseModel): + repo_id: uuid.UUID | None = None + repo_slug: str | None = None + owning_domain: str | None = None + + +class ServiceCloudIn(BaseModel): + gdpr_maturity: str = "unknown" + gdpr_notes: str | None = None + dpa_available: bool = False + tos_url: str | None = None + privacy_policy_url: str | None = None + data_processing_regions: list | None = None + data_retention_notes: str | None = None + + +class ServiceSelfHostedIn(BaseModel): + helix_instance: str | None = None + host_node: str | None = None + deployment_ref: str | None = None + runbook_ref: str | None = None + upstream_oss_project: str | None = None + + +class ServiceUpsert(BaseModel): + slug: str + name: str + owner_or_provider: str | None = None + category: str | None = None + description: str | None = None + website_url: str | None = None + status: str = "active" + hosting_type: str # self_hosted | cloud_hosted + development_type: str # first_party | third_party + maturity_level: int | None = None + third_party: ServiceThirdPartyIn | None = None + first_party: ServiceFirstPartyIn | None = None + cloud: ServiceCloudIn | None = None + self_hosted: ServiceSelfHostedIn | None = None diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/state.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/state.py new file mode 100644 index 00000000..7a826ee4 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/state.py @@ -0,0 +1,147 @@ +import uuid +from datetime import datetime +from typing import Any + +from pydantic import BaseModel + +from api.schemas.decision import DecisionRead +from api.schemas.domain import DomainSummary +from api.schemas.progress_event import ProgressEventRead +from api.schemas.task import TaskRead +from api.schemas.topic import TopicWithWorkstreams +from api.schemas.suggestion import RankedSuggestionDigest +from api.schemas.agent_message import StandingNoticeDigest +from api.schemas.workstream import WorkstreamWithDeps +from api.schemas.ops_run import OpsRunProjection + + +class TopicTotals(BaseModel): + active: int = 0 + paused: int = 0 + archived: int = 0 + total: int = 0 + + +class WorkstreamTotals(BaseModel): + proposed: int = 0 + ready: int = 0 + active: int = 0 + blocked: int = 0 + backlog: int = 0 + finished: int = 0 + archived: int = 0 + total: int = 0 + residual_open: int = 0 + + +class TaskTotals(BaseModel): + wait: int = 0 + todo: int = 0 + progress: int = 0 + done: int = 0 + cancel: int = 0 + total: int = 0 + + +class DecisionTotals(BaseModel): + open: int = 0 + resolved: int = 0 + escalated: int = 0 + superseded: int = 0 + total: int = 0 + + +class Totals(BaseModel): + topics: TopicTotals + workstreams: WorkstreamTotals + tasks: TaskTotals + decisions: DecisionTotals + + +class NextStep(BaseModel): + """A derived suggestion pointing to where work should happen next. + + Suggestions are never persisted — they are computed on demand from + current hub state: recently resolved decisions, newly unblocked tasks, + cleared dependencies. + """ + type: str # unblocked_task | resolved_decision | dependency_cleared + domain: str | None = None + workplan_id: uuid.UUID | None = None + workplan_title: str | None = None + workplan_slug: str | None = None + workstream_id: uuid.UUID | None = None + workstream_title: str | None = None + workstream_slug: str | None = None + task_id: uuid.UUID | None = None + task_title: str | None = None + message: str # plain-language explanation + + +class StateSummary(BaseModel): + generated_at: datetime + totals: Totals + topics: list[TopicWithWorkstreams] + blocking_decisions: list[DecisionRead] + waiting_tasks: list[TaskRead] + blocked_tasks: list[TaskRead] = [] + recent_progress: list[ProgressEventRead] + open_workplans: list[WorkstreamWithDeps] = [] + residual_open_workplans: list[WorkstreamWithDeps] = [] + next_steps: list[NextStep] = [] + domains: list[DomainSummary] = [] + contribution_counts: dict[str, int] = {} + licence_risk_count: int = 0 + open_capability_requests: int = 0 + ranked_suggestions: list[RankedSuggestionDigest] = [] + ops_runs: OpsRunProjection | None = None + standing_notices: list[StandingNoticeDigest] = [] + + +class DashboardWorkplanRow(BaseModel): + id: uuid.UUID + title: str + status: str + flavor: str | None = None + domain: str = "unknown" + repo_label: str = "unassigned" + workplan_filename: str | None = None + workplan_relative_path: str | None = None + workplan_archived: bool = False + health_labels: list[str] = [] + href: str + done: int = 0 + progress: int = 0 + wait: int = 0 + todo: int = 0 + total: int = 0 + created_at: datetime + updated_at: datetime + + +class DashboardSourceMeta(BaseModel): + ok: bool = True + stale: bool = False + cache_age_seconds: float | None = None + refresh_in_progress: bool = False + error: str | None = None + + +class DashboardOverview(BaseModel): + generated_at: datetime + totals: Totals + topics: list[TopicWithWorkstreams] + blocking_decisions: list[DecisionRead] + waiting_tasks: list[TaskRead] + blocked_tasks: list[TaskRead] = [] + recent_progress: list[ProgressEventRead] + next_steps: list[NextStep] = [] + contribution_counts: dict[str, int] = {} + licence_risk_count: int = 0 + open_capability_requests: int = 0 + sbom_snapshot_count: int = 0 + sbom_package_total: int = 0 + registration_milestones: list[ProgressEventRead] = [] + workplan_rows: list[DashboardWorkplanRow] = [] + sources: dict[str, DashboardSourceMeta] = {} + diagnostics: dict[str, Any] = {} diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/suggestion.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/suggestion.py new file mode 100644 index 00000000..64cc1f14 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/suggestion.py @@ -0,0 +1,96 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + +from api.models.suggestion import SuggestionStage + + +class SuggestionNoteRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + suggestion_id: uuid.UUID + stage: str + author: str | None = None + content: str + created_at: datetime + + +class SuggestionRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + domain_id: uuid.UUID + domain_slug: str = "" + topic_id: uuid.UUID | None = None + workplan_id: uuid.UUID | None = None + title: str + description: str | None = None + origin: str | None = None + origin_ref: str | None = None + stage: SuggestionStage + relevance: int + relevance_events: int + last_requested_at: datetime | None = None + base_value: float + job_size: float + relevance_weight: float + promoted_task_id: uuid.UUID | None = None + cost_of_delay: float | None = None + wsjf: float | None = None + created_at: datetime + updated_at: datetime + notes: list[SuggestionNoteRead] = Field(default_factory=list) + + +class SuggestionCreate(BaseModel): + domain: str + title: str + description: str | None = None + topic_id: uuid.UUID | None = None + workplan_id: uuid.UUID | None = None + origin: str | None = None + origin_ref: str | None = None + base_value: float = 3.0 + job_size: float = 3.0 + relevance_weight: float = 1.0 + + +class SuggestionVet(BaseModel): + author: str | None = None + note: str + base_value: float | None = None + job_size: float | None = None + relevance_weight: float | None = None + workplan_id: uuid.UUID | None = None + + +class SuggestionDecline(BaseModel): + author: str | None = None + note: str + + +class SuggestionPromote(BaseModel): + author: str | None = None + note: str | None = None + task_title: str | None = None + task_description: str | None = None + task_priority: str = "medium" + task_status: str = "wait" + + +class SuggestionBumpRelevance(BaseModel): + reason: str | None = None + author: str | None = None + + +class RankedSuggestionDigest(BaseModel): + id: uuid.UUID + title: str + stage: SuggestionStage + domain_slug: str + origin_ref: str | None = None + relevance: int + wsjf: float + last_requested_at: datetime | None = None \ No newline at end of file diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/task.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/task.py new file mode 100644 index 00000000..eb4fb890 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/task.py @@ -0,0 +1,158 @@ +import uuid +from datetime import date, datetime +from typing import Self + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from api.models.task import TaskPriority, TaskStatus +from api.schemas.compat import WorkplanIdCompatMixin, WorkplanIdCreateMixin +from api.task_status import normalize_task_status +from api.work_record_flavor import WORK_RECORD_FLAVORS, normalize_flavor + + +class TaskStatusMixin(BaseModel): + @field_validator("status", mode="before", check_fields=False) + @classmethod + def _normalize_status(cls, value): + if value is None: + return value + return normalize_task_status(value) + + +class TaskFlavorMixin(BaseModel): + @field_validator("flavor", mode="before", check_fields=False) + @classmethod + def _normalize_flavor(cls, value): + flavor = normalize_flavor(value) + if flavor is not None and flavor not in WORK_RECORD_FLAVORS: + raise ValueError( + f"Unknown work-record flavor {flavor!r}; " + f"expected one of {', '.join(WORK_RECORD_FLAVORS)}" + ) + return flavor + + +class TaskCreate(TaskStatusMixin, TaskFlavorMixin, WorkplanIdCreateMixin): + id: uuid.UUID | None = None + record_id: str | None = None + flavor: str | None = None + title: str + description: str | None = None + status: TaskStatus = TaskStatus.todo + priority: TaskPriority = TaskPriority.medium + assignee: str | None = None + due_date: date | None = None + blocking_reason: str | None = None + needs_human: bool = False + intervention_note: str | None = None + decision_id: str | None = None + # Bounded by tasks.blocked_on String(200); longer text belongs in blocking_reason. + blocked_on: str | None = Field(default=None, max_length=200) + wait_until: date | None = None + parent_task_id: uuid.UUID | None = None + + @model_validator(mode="after") + def intervention_note_required_when_flagged(self) -> Self: + if self.needs_human and not self.intervention_note: + raise ValueError("intervention_note is required when needs_human is True") + return self + + +class TaskUpdate(TaskStatusMixin, TaskFlavorMixin): + title: str | None = None + flavor: str | None = None + description: str | None = None + status: TaskStatus | None = None + priority: TaskPriority | None = None + assignee: str | None = None + due_date: date | None = None + blocking_reason: str | None = None + needs_human: bool | None = None + intervention_note: str | None = None + decision_id: str | None = None + # Bounded by tasks.blocked_on String(200); longer text belongs in blocking_reason. + blocked_on: str | None = Field(default=None, max_length=200) + wait_until: date | None = None + parent_task_id: uuid.UUID | None = None + # Token passthrough — three tiers (highest precision wins): + # 1. tokens_in + tokens_out → exact counts; note defaults to "measured" + # 2. workplan_tokens_in + workplan_tokens_out → prorated across task count (note="workplan") + # 3. neither provided, status=done → heuristic 1000/500 (note="heuristic") + # token_note overrides the auto-assigned note for Tier 1 only (e.g. "userbased") + # suppress_token_event lets file/cache sync update status without recording usage. + tokens_in: int | None = None + tokens_out: int | None = None + workplan_tokens_in: int | None = None + workplan_tokens_out: int | None = None + token_note: str | None = None + model: str | None = None + agent: str | None = None + session_id: str | None = None + suppress_token_event: bool | None = None + + @model_validator(mode="after") + def blocking_reason_required_when_human_waiting(self) -> Self: + if self.status == TaskStatus.wait and self.needs_human and not self.blocking_reason: + raise ValueError("blocking_reason is required when a human-blocked task is waiting") + return self + + @model_validator(mode="after") + def intervention_note_required_when_flagged(self) -> Self: + if self.needs_human and not self.intervention_note: + raise ValueError("intervention_note is required when needs_human is True") + return self + + +class TaskStatusBulkUpdate(TaskStatusMixin): + task_id: uuid.UUID + status: TaskStatus + blocking_reason: str | None = None + + +class TaskStatusBulkSync(BaseModel): + updates: list[TaskStatusBulkUpdate] + author: str | None = "custodian" + session_id: str | None = None + + @field_validator("updates") + @classmethod + def updates_required(cls, value: list[TaskStatusBulkUpdate]): + if not value: + raise ValueError("at least one task status update is required") + return value + + +class TaskRead(TaskStatusMixin, WorkplanIdCompatMixin): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + record_id: str | None = None + flavor: str | None = None + title: str + description: str | None = None + status: TaskStatus + priority: TaskPriority + assignee: str | None = None + due_date: date | None = None + blocking_reason: str | None = None + needs_human: bool + intervention_note: str | None = None + decision_id: str | None = None + blocked_on: str | None = None + wait_until: date | None = None + parent_task_id: uuid.UUID | None = None + # Derived (CUST-WP-0074 rule 5): "external" | "human" | "both" | + # "unqualified" while status is wait, else None. Set by + # api.services.wait_kind.annotate_tasks on read routes. + wait_kind: str | None = None + created_at: datetime + updated_at: datetime + + +class TaskCountRead(TaskStatusMixin, WorkplanIdCompatMixin): + status: TaskStatus + count: int + + +class TaskStatusBulkSyncRead(BaseModel): + updated: list[TaskRead] + progress_event_ids: list[uuid.UUID] diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/technical_debt.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/technical_debt.py new file mode 100644 index 00000000..c25cf085 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/technical_debt.py @@ -0,0 +1,77 @@ +import uuid +from datetime import datetime + +from pydantic import AliasChoices, BaseModel, ConfigDict, Field + +from api.models.technical_debt import TDStatus + +VALID_SEVERITIES = {"low", "medium", "high", "critical"} + + +class TDNoteCreate(BaseModel): + step: str + author: str | None = None + content: str + + +class TDNoteRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + td_id: uuid.UUID + step: str + author: str | None = None + content: str + created_at: datetime + + +class TDCreate(BaseModel): + td_id: str | None = None + domain: str # slug; router resolves to domain_id FK + title: str + description: str | None = None + location: str | None = None + debt_type: str = "other" + severity: str = "medium" + status: TDStatus = TDStatus.open + topic_id: uuid.UUID | None = None + workplan_id: uuid.UUID | None = Field( + default=None, + validation_alias=AliasChoices("workplan_id", "workstream_id"), + ) + + +class TDUpdate(BaseModel): + title: str | None = None + description: str | None = None + location: str | None = None + debt_type: str | None = None + severity: str | None = None + status: TDStatus | None = None + workplan_id: uuid.UUID | None = Field( + default=None, + validation_alias=AliasChoices("workplan_id", "workstream_id"), + ) + + +class TDRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + td_id: str | None = None + domain_slug: str # derived from domain relationship + title: str + description: str | None = None + location: str | None = None + debt_type: str + severity: str + status: TDStatus + topic_id: uuid.UUID | None = None + workplan_id: uuid.UUID | None = None + created_at: datetime + + @property + def workstream_id(self) -> uuid.UUID | None: + return self.workplan_id + updated_at: datetime + notes: list[TDNoteRead] = [] diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/token_event.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/token_event.py new file mode 100644 index 00000000..476ed8e5 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/token_event.py @@ -0,0 +1,178 @@ +import uuid +from datetime import datetime +from typing import Any + +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field + +from api.schemas.compat import OptionalWorkplanIdCompatMixin + + +class TokenEventCreate(BaseModel): + tokens_in: int + tokens_out: int + task_id: uuid.UUID | None = None + workplan_id: uuid.UUID | None = Field( + default=None, + validation_alias=AliasChoices("workplan_id", "workstream_id"), + ) + repo_id: uuid.UUID | None = None + session_id: str | None = None + model: str | None = None + agent: str | None = None + ref_type: str | None = None + ref_id: str | None = None + note: str | None = None + created_at: datetime | None = None + measurement_kind: str | None = None + source_provider: str | None = None + source_id: str | None = None + source_path: str | None = None + source_created_at: datetime | None = None + parser_version: str | None = None + confidence: float | None = None + cached_input_tokens: int | None = None + reasoning_output_tokens: int | None = None + raw_total_tokens: int | None = None + cost_estimated_usd: float | None = None + raw_metadata: dict[str, Any] | None = None + + +class TokenEventRead(OptionalWorkplanIdCompatMixin, BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + tokens_in: int + tokens_out: int + task_id: uuid.UUID | None = None + repo_id: uuid.UUID | None = None + session_id: str | None = None + model: str | None = None + agent: str | None = None + ref_type: str | None = None + ref_id: str | None = None + note: str | None = None + measurement_kind: str + source_provider: str + source_id: str | None = None + source_path: str | None = None + source_created_at: datetime | None = None + ingested_at: datetime + parser_version: str | None = None + confidence: float + cached_input_tokens: int + reasoning_output_tokens: int + raw_total_tokens: int | None = None + cost_estimated_usd: float | None = None + raw_metadata: dict[str, Any] = Field(default_factory=dict) + created_at: datetime + + @computed_field + @property + def tokens_total(self) -> int: + return self.tokens_in + self.tokens_out + + @computed_field + @property + def token_evidence_total(self) -> int: + return (self.raw_total_tokens or self.tokens_in + self.tokens_out) + + +class TokenSummary(BaseModel): + scope: str + scope_id: str + tokens_in: int + tokens_out: int + tokens_total: int + event_count: int + by_model: dict[str, int] + by_agent: dict[str, int] + by_measurement_kind: dict[str, int] = Field(default_factory=dict) + by_source_provider: dict[str, int] = Field(default_factory=dict) + + +class TokenEventPatch(BaseModel): + tokens_in: int | None = None + tokens_out: int | None = None + task_id: uuid.UUID | None = None + workplan_id: uuid.UUID | None = Field( + default=None, + validation_alias=AliasChoices("workplan_id", "workstream_id"), + ) + repo_id: uuid.UUID | None = None + session_id: str | None = None + note: str | None = None + model: str | None = None + agent: str | None = None + ref_type: str | None = None + ref_id: str | None = None + created_at: datetime | None = None + measurement_kind: str | None = None + source_provider: str | None = None + source_id: str | None = None + source_path: str | None = None + source_created_at: datetime | None = None + ingested_at: datetime | None = None + parser_version: str | None = None + confidence: float | None = None + cached_input_tokens: int | None = None + reasoning_output_tokens: int | None = None + raw_total_tokens: int | None = None + cost_estimated_usd: float | None = None + raw_metadata: dict[str, Any] | None = None + + +class RepoTokenSummary(BaseModel): + repo_id: uuid.UUID + repo_slug: str + tokens_in: int + tokens_out: int + tokens_total: int + event_count: int + by_model: dict[str, int] + by_note: dict[str, int] + by_measurement_kind: dict[str, int] = Field(default_factory=dict) + by_source_provider: dict[str, int] = Field(default_factory=dict) + + +class TokenAggregateRow(BaseModel): + scope_id: str + label: str | None = None + tokens_in: int + tokens_out: int + tokens_total: int + event_count: int + by_measurement_kind: dict[str, int] = Field(default_factory=dict) + by_source_provider: dict[str, int] = Field(default_factory=dict) + + +class TokenAggregateSummary(BaseModel): + tokens_in: int + tokens_out: int + tokens_total: int + event_count: int + first_event_at: datetime | None = None + last_event_at: datetime | None = None + last_ingested_at: datetime | None = None + by_repo: list[TokenAggregateRow] = Field(default_factory=list) + by_workstream: list[TokenAggregateRow] = Field(default_factory=list) + by_task: list[TokenAggregateRow] = Field(default_factory=list) + by_model: list[TokenAggregateRow] = Field(default_factory=list) + by_measurement_kind: dict[str, int] = Field(default_factory=dict) + by_source_provider: dict[str, int] = Field(default_factory=dict) + + +class TokenQualitySummary(BaseModel): + event_count: int + measured_event_count: int + estimated_event_count: int + allocated_event_count: int + superseded_event_count: int + fallback_event_count: int + unattributed_measured_event_count: int + missing_provenance_event_count: int + duplicate_source_count: int + last_codex_ingested_at: datetime | None = None + last_claude_ingested_at: datetime | None = None + last_reconciliation_at: datetime | None = None + by_measurement_kind: dict[str, int] = Field(default_factory=dict) + by_source_provider: dict[str, int] = Field(default_factory=dict) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/topic.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/topic.py new file mode 100644 index 00000000..b0a1e6bc --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/topic.py @@ -0,0 +1,47 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from api.models.topic import TopicStatus + + +class TopicCreate(BaseModel): + slug: str + title: str + description: str | None = None + domain: str # domain slug — resolved to domain_id in the router + status: TopicStatus = TopicStatus.active + + +class TopicUpdate(BaseModel): + title: str | None = None + description: str | None = None + status: TopicStatus | None = None + domain: str | None = None # domain slug — resolved to domain_id in the router + + +class WorkstreamStub(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + slug: str + title: str + status: str + owner: str | None = None + due_date: datetime | None = None + + +class TopicRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + slug: str + title: str + description: str | None = None + domain_slug: str | None = None # resolved from FK relationship via @property + status: TopicStatus + created_at: datetime + updated_at: datetime + + +class TopicWithWorkstreams(TopicRead): + workstreams: list[WorkstreamStub] = [] diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/tpsc.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/tpsc.py new file mode 100644 index 00000000..cd9c0b76 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/schemas/tpsc.py @@ -0,0 +1,29 @@ +from hub_core.schemas.tpsc import ( + AuthType, + GDPRMaturity, + GDPR_WARNING_LEVELS, + PricingModel, + TPSCCatalogCreate, + TPSCCatalogRead, + TPSCEntryCreate, + TPSCEntryRead, + TPSCGDPRReport, + TPSCGDPRWarning, + TPSCIngestRequest, + TPSCSnapshotRead, +) + +__all__ = [ + "AuthType", + "GDPRMaturity", + "GDPR_WARNING_LEVELS", + "PricingModel", + "TPSCCatalogCreate", + "TPSCCatalogRead", + "TPSCEntryCreate", + "TPSCEntryRead", + "TPSCGDPRReport", + "TPSCGDPRWarning", + "TPSCIngestRequest", + "TPSCSnapshotRead", +]