docs: preserve reviewed NetKingdom session artifacts
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Reject generated-only commits / generated-commit-guard (push) Successful in 11s

This commit is contained in:
codex 2026-11-01 22:56:50 +01:00
parent bd041baf9b
commit e305e2262d
50 changed files with 8591 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,7 @@
from hub_core.schemas.doi import DoICriterion, DoIReport, DoISummaryEntry
__all__ = [
"DoICriterion",
"DoIReport",
"DoISummaryEntry",
]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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] = {}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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