docs: preserve reviewed NetKingdom session artifacts
This commit is contained in:
parent
bd041baf9b
commit
e305e2262d
50 changed files with 8591 additions and 0 deletions
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"]
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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(),
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
from hub_core.schemas.doi import DoICriterion, DoIReport, DoISummaryEntry
|
||||
|
||||
__all__ = [
|
||||
"DoICriterion",
|
||||
"DoIReport",
|
||||
"DoISummaryEntry",
|
||||
]
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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]
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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] = []
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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]
|
||||
|
|
@ -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]
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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]
|
||||
|
|
@ -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]
|
||||
|
|
@ -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
|
||||
|
|
@ -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] = {}
|
||||
|
|
@ -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
|
||||
|
|
@ -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]
|
||||
|
|
@ -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] = []
|
||||
|
|
@ -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)
|
||||
|
|
@ -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] = []
|
||||
|
|
@ -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",
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue