This commit is contained in:
valenti
2026-09-08 07:21:34 -04:00
commit a4b224da8e
169 changed files with 27483 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
from .router import api_v1_router
__all__ = ["api_v1_router"]
+109
View File
@@ -0,0 +1,109 @@
"""CRUD APIs for Artifacts — the heart of the ASF domain model."""
from __future__ import annotations
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from asf_domain import (
Artifact,
ArtifactCreate,
ArtifactUpdate,
ArtifactType,
ArtifactRelationship,
)
from ...repositories import NotFoundError, TenantMismatchError
from .deps import artifact_service, relationship_service
from ...services import ArtifactService, RelationshipService
router = APIRouter()
@router.get("", response_model=list[Artifact], summary="List artifacts (tenant scoped)")
async def list_artifacts(
project_id: Optional[UUID] = Query(None),
type: Optional[ArtifactType] = Query(None),
status: Optional[str] = Query(None),
search: Optional[str] = Query(None, description="Case insensitive regex on title+content"),
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
svc: ArtifactService = Depends(artifact_service),
) -> list[Artifact]:
return await svc.list(
project_id=project_id,
artifact_type=type,
status=status,
search=search,
limit=limit,
offset=offset,
)
@router.post(
"",
response_model=Artifact,
status_code=status.HTTP_201_CREATED,
summary="Create a new artifact",
)
async def create_artifact(
body: ArtifactCreate,
svc: ArtifactService = Depends(artifact_service),
) -> Artifact:
try:
return await svc.create(body)
except NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.get("/{artifact_id}", response_model=Artifact, summary="Get artifact by id")
async def get_artifact(
artifact_id: UUID,
svc: ArtifactService = Depends(artifact_service),
) -> Artifact:
a = await svc.get(artifact_id)
if a is None:
raise HTTPException(status_code=404, detail=f"Artifact {artifact_id} not found")
return a
@router.patch("/{artifact_id}", response_model=Artifact, summary="Update an artifact")
async def update_artifact(
artifact_id: UUID,
body: ArtifactUpdate,
svc: ArtifactService = Depends(artifact_service),
) -> Artifact:
try:
return await svc.update(artifact_id, body)
except (NotFoundError, TenantMismatchError) as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.delete(
"/{artifact_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete an artifact",
)
async def delete_artifact(
artifact_id: UUID,
svc: ArtifactService = Depends(artifact_service),
) -> None:
try:
await svc.delete(artifact_id)
except (NotFoundError, TenantMismatchError) as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return None
@router.get(
"/{artifact_id}/relationships",
response_model=list[ArtifactRelationship],
summary="List all relationships (in + out) for an artifact — convenience wrapper for /relationships/for-artifact/{id}",
)
async def artifact_relationships(
artifact_id: UUID,
limit: int = Query(200, ge=1, le=500),
rel_svc: RelationshipService = Depends(relationship_service),
) -> list[ArtifactRelationship]:
return await rel_svc.for_artifact(artifact_id, limit=limit)
+77
View File
@@ -0,0 +1,77 @@
"""Ask ASF — command / chat interface (bootstrap).
For the bootstrap we expose:
* POST /api/v1/ask-asf/query → enqueue a Job of type AI_INTERACTIVE
and return immediately for the UI to poll.
Actual AI answer generation will be provided by asf-worker calling the
asf-ai Gateway. This endpoint never returns a fake AI answer inline.
"""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from asf_domain import Job, JobCreate, JobType, JobPriority
from .deps import job_service
from ...services import JobService
router = APIRouter()
class AskQueryRequest(BaseModel):
question: str = Field(
...,
min_length=2,
max_length=4000,
description="Natural-language query posed to ASF.",
examples=["Which services implement REQ-441?"],
)
project_id: UUID | None = Field(
None, description="Optionally constrain context to a project."
)
context_artifact_ids: list[UUID] = Field(
default_factory=list,
description="Optional list of artifact ids to anchor context.",
)
class AskQueryAccepted(BaseModel):
accepted: bool = True
job_id: UUID
message: str = (
"Query enqueued. Poll GET /api/v1/jobs/{job_id} to check status. "
"The AI execution result will appear in the job payload when completed."
)
@router.post(
"/query",
response_model=AskQueryAccepted,
status_code=status.HTTP_202_ACCEPTED,
summary="Submit a natural-language query to 'Ask ASF'. Returns a Job id you can poll.",
)
async def ask_query(
body: AskQueryRequest,
svc: JobService = Depends(job_service),
) -> AskQueryAccepted:
payload: dict = {
"kind": "ask_asf",
"question": body.question,
"context_artifact_ids": [str(i) for i in body.context_artifact_ids],
}
jc = JobCreate(
type=JobType.AI_INTERACTIVE,
priority=JobPriority.HIGH,
project_id=body.project_id,
payload=payload,
)
try:
job = await svc.submit(jc)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=500, detail=f"Failed to enqueue query: {exc}") from exc
return AskQueryAccepted(job_id=job.id)
+27
View File
@@ -0,0 +1,27 @@
"""Audit trail API (read-only in bootstrap — append-only via services)."""
from __future__ import annotations
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from asf_domain import AuditEvent
from .deps import audit_service
from ...services import AuditService
router = APIRouter()
@router.get("", response_model=list[AuditEvent], summary="List audit events (tenant scoped, newest first)")
async def list_audit(
actor_id: Optional[UUID] = Query(None),
resource_type: Optional[str] = Query(None),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
svc: AuditService = Depends(audit_service),
) -> list[AuditEvent]:
return await svc.list(
limit=limit, offset=offset, actor_id=actor_id, resource_type=resource_type
)
+157
View File
@@ -0,0 +1,157 @@
"""Per-route dependencies that construct services."""
from __future__ import annotations
from typing import AsyncIterator
from fastapi import Depends
from motor.motor_asyncio import AsyncIOMotorCollection
from ...dependencies import (
RequestContext,
get_req_ctx,
coll_orgs,
coll_projects,
coll_artifacts,
coll_relationships,
coll_jobs,
coll_audit,
)
from ...repositories import (
OrganizationRepository,
ProjectRepository,
ArtifactRepository,
RelationshipRepository,
JobRepository,
AuditRepository,
)
from ...services import (
OrganizationService,
ProjectService,
ArtifactService,
RelationshipService,
JobService,
AuditService,
OverviewService,
)
# -------- Repositories --------
async def org_repo(
c: AsyncIOMotorCollection = Depends(coll_orgs),
) -> AsyncIterator[OrganizationRepository]:
yield OrganizationRepository(c)
async def project_repo(
c: AsyncIOMotorCollection = Depends(coll_projects),
) -> AsyncIterator[ProjectRepository]:
yield ProjectRepository(c)
async def artifact_repo(
c: AsyncIOMotorCollection = Depends(coll_artifacts),
) -> AsyncIterator[ArtifactRepository]:
yield ArtifactRepository(c)
async def relationship_repo(
c: AsyncIOMotorCollection = Depends(coll_relationships),
arts: AsyncIOMotorCollection = Depends(coll_artifacts),
) -> AsyncIterator[RelationshipRepository]:
yield RelationshipRepository(c, arts)
async def job_repo(
c: AsyncIOMotorCollection = Depends(coll_jobs),
) -> AsyncIterator[JobRepository]:
yield JobRepository(c)
async def audit_repo(
c: AsyncIOMotorCollection = Depends(coll_audit),
) -> AsyncIterator[AuditRepository]:
yield AuditRepository(c)
# -------- Services (tenant-scoped) --------
async def org_service(
orgs: OrganizationRepository = Depends(org_repo),
audit: AuditRepository = Depends(audit_repo),
ctx: RequestContext = Depends(get_req_ctx),
) -> OrganizationService:
return OrganizationService(
orgs, audit,
actor_id=ctx.user_id,
preferred_org_id=ctx.tenant_id,
)
async def project_service(
projects: ProjectRepository = Depends(project_repo),
orgs: OrganizationRepository = Depends(org_repo),
audit: AuditRepository = Depends(audit_repo),
ctx: RequestContext = Depends(get_req_ctx),
) -> ProjectService:
return ProjectService(
projects, orgs, audit,
tenant_id=ctx.require_tenant(),
actor_id=ctx.user_id,
)
async def artifact_service(
artifacts: ArtifactRepository = Depends(artifact_repo),
projects: ProjectRepository = Depends(project_repo),
audit: AuditRepository = Depends(audit_repo),
ctx: RequestContext = Depends(get_req_ctx),
) -> ArtifactService:
return ArtifactService(
artifacts, projects, audit,
tenant_id=ctx.require_tenant(),
actor_id=ctx.user_id,
)
async def relationship_service(
rels: RelationshipRepository = Depends(relationship_repo),
audit: AuditRepository = Depends(audit_repo),
ctx: RequestContext = Depends(get_req_ctx),
) -> RelationshipService:
return RelationshipService(
rels, audit,
tenant_id=ctx.require_tenant(),
actor_id=ctx.user_id,
)
async def job_service(
jobs: JobRepository = Depends(job_repo),
projects: ProjectRepository = Depends(project_repo),
audit: AuditRepository = Depends(audit_repo),
ctx: RequestContext = Depends(get_req_ctx),
) -> JobService:
return JobService(
jobs, projects, audit,
tenant_id=ctx.require_tenant(),
actor_id=ctx.user_id,
)
async def audit_service(
audit: AuditRepository = Depends(audit_repo),
ctx: RequestContext = Depends(get_req_ctx),
) -> AuditService:
return AuditService(audit, tenant_id=ctx.require_tenant())
async def overview_service(
artifacts: ArtifactRepository = Depends(artifact_repo),
projects: ProjectRepository = Depends(project_repo),
jobs: JobRepository = Depends(job_repo),
ctx: RequestContext = Depends(get_req_ctx),
) -> OverviewService:
return OverviewService(
artifacts, projects, jobs,
tenant_id=ctx.require_tenant(),
)
+65
View File
@@ -0,0 +1,65 @@
"""Health + readiness probes.
``/health`` must always return 200 when the HTTP server is up (liveness).
``/health/ready`` additionally checks Mongo, RabbitMQ, Redis reachability
(readiness).
"""
from __future__ import annotations
import time
from datetime import datetime, timezone
from fastapi import APIRouter
from pydantic import BaseModel, Field
from ...config import get_settings
from ...db.mongo import get_mongo_client
router = APIRouter()
_STARTED = time.time()
class HealthResponse(BaseModel):
status: str = Field(..., examples=["ok"])
service: str = "asf-core"
version: str
environment: str
uptime_seconds: float
timestamp: str
@router.get("/health", response_model=HealthResponse, summary="Liveness probe")
async def health() -> HealthResponse:
s = get_settings()
return HealthResponse(
status="ok",
service="asf-core",
version="0.1.0",
environment=s.asf_env,
uptime_seconds=round(time.time() - _STARTED, 2),
timestamp=datetime.now(tz=timezone.utc).isoformat().replace("+00:00", "Z"),
)
class ReadyResponse(BaseModel):
ready: bool
checks: dict
@router.get("/health/ready", response_model=ReadyResponse, summary="Readiness probe")
async def ready() -> ReadyResponse:
checks: dict = {}
ok = True
# --- MongoDB ---
try:
client = get_mongo_client()
ping = await client.admin.command("ping")
checks["mongodb"] = "ok" if ping.get("ok") else "fail"
except Exception as exc: # noqa: BLE001
checks["mongodb"] = f"error: {exc}"
ok = False
return ReadyResponse(ready=ok, checks=checks)
+78
View File
@@ -0,0 +1,78 @@
"""Job APIs: submit (enqueue), query status, list."""
from __future__ import annotations
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from asf_domain import (
Job,
JobCreate,
JobUpdate,
JobStatus,
JobType,
)
from ...repositories import NotFoundError, TenantMismatchError
from .deps import job_service
from ...services import JobService
router = APIRouter()
@router.get("", response_model=list[Job], summary="List jobs (tenant scoped)")
async def list_jobs(
project_id: Optional[UUID] = Query(None),
status: Optional[JobStatus] = Query(None),
type: Optional[JobType] = Query(None),
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
svc: JobService = Depends(job_service),
) -> list[Job]:
return await svc.list(
project_id=project_id, status=status, type=type, limit=limit, offset=offset
)
@router.post(
"",
response_model=Job,
status_code=status.HTTP_202_ACCEPTED,
summary="Submit a new asynchronous job. The job record is persisted and the work is enqueued on RabbitMQ.",
)
async def submit_job(
body: JobCreate,
svc: JobService = Depends(job_service),
) -> Job:
try:
return await svc.submit(body)
except NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.get("/{job_id}", response_model=Job, summary="Get job by id")
async def get_job(
job_id: UUID,
svc: JobService = Depends(job_service),
) -> Job:
j = await svc.get(job_id)
if j is None:
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
return j
@router.patch(
"/{job_id}",
response_model=Job,
summary="Update job status / progress / result (used by workers and, in future, cancel operations).",
)
async def update_job(
job_id: UUID,
body: JobUpdate,
svc: JobService = Depends(job_service),
) -> Job:
try:
return await svc.update(job_id, body)
except (NotFoundError, TenantMismatchError) as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@@ -0,0 +1,77 @@
"""CRUD APIs for Organizations (tenants)."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from asf_domain import (
Organization,
OrganizationCreate,
OrganizationUpdate,
)
from ...repositories import NotFoundError, DuplicateError
from .deps import org_service
from ...services import OrganizationService
router = APIRouter()
@router.get("", response_model=list[Organization], summary="List organizations")
async def list_organizations(
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
svc: OrganizationService = Depends(org_service),
) -> list[Organization]:
return await svc.list(limit=limit, offset=offset)
@router.post(
"",
response_model=Organization,
status_code=status.HTTP_201_CREATED,
summary="Create a new organization (tenant)",
)
async def create_organization(
body: OrganizationCreate,
svc: OrganizationService = Depends(org_service),
) -> Organization:
try:
return await svc.create(body)
except DuplicateError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.get("/{org_id}", response_model=Organization, summary="Get an organization by id")
async def get_organization(
org_id: UUID,
svc: OrganizationService = Depends(org_service),
) -> Organization:
org = await svc.get(org_id)
if org is None:
raise HTTPException(status_code=404, detail=f"Organization {org_id} not found")
return org
@router.get("/slug/{slug}", response_model=Organization, summary="Get organization by slug")
async def get_organization_by_slug(
slug: str,
svc: OrganizationService = Depends(org_service),
) -> Organization:
org = await svc.get_by_slug(slug)
if org is None:
raise HTTPException(status_code=404, detail=f"Organization slug={slug} not found")
return org
@router.patch("/{org_id}", response_model=Organization, summary="Update an organization")
async def update_organization(
org_id: UUID,
body: OrganizationUpdate,
svc: OrganizationService = Depends(org_service),
) -> Organization:
try:
return await svc.update(org_id, body)
except NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
+41
View File
@@ -0,0 +1,41 @@
"""Overview dashboard aggregations — data for the UI Overview page."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from .deps import overview_service
from ...services import OverviewService
router = APIRouter()
class OverviewResponse(BaseModel):
tenant_id: str
total_projects: int
total_requirements: int
total_tasks: int
total_tests: int
total_deployments: int
total_commits: int
jobs_running: int
jobs_queued: int
jobs_failed_24h: int
implementation_coverage_pct: float
test_coverage_pct: float
traceability_score_pct: float
project_health_pct: float
security_findings: int
failed_builds: int
active_ai_jobs: int
featured_projects: list[dict]
mock_fields: list[str]
@router.get("", response_model=OverviewResponse, summary="Overview metrics for the tenant dashboard")
async def get_overview(
svc: OverviewService = Depends(overview_service),
) -> OverviewResponse:
stats = await svc.overview()
return OverviewResponse(**stats.to_dict())
+65
View File
@@ -0,0 +1,65 @@
"""CRUD APIs for Projects (inside a tenant)."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from asf_domain import Project, ProjectCreate, ProjectUpdate
from ...repositories import NotFoundError, DuplicateError, TenantMismatchError
from .deps import project_service
from ...services import ProjectService
router = APIRouter()
@router.get("", response_model=list[Project], summary="List projects (tenant scoped)")
async def list_projects(
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
only_active: bool = True,
svc: ProjectService = Depends(project_service),
) -> list[Project]:
return await svc.list(limit=limit, offset=offset, only_active=only_active)
@router.post(
"",
response_model=Project,
status_code=status.HTTP_201_CREATED,
summary="Create project",
)
async def create_project(
body: ProjectCreate,
svc: ProjectService = Depends(project_service),
) -> Project:
try:
return await svc.create(body)
except DuplicateError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.get("/{project_id}", response_model=Project, summary="Get project by id")
async def get_project(
project_id: UUID,
svc: ProjectService = Depends(project_service),
) -> Project:
p = await svc.get(project_id)
if p is None:
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
return p
@router.patch("/{project_id}", response_model=Project, summary="Update project")
async def update_project(
project_id: UUID,
body: ProjectUpdate,
svc: ProjectService = Depends(project_service),
) -> Project:
try:
return await svc.update(project_id, body)
except (NotFoundError, TenantMismatchError) as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@@ -0,0 +1,98 @@
"""Artifact relationship APIs — explicit edges.
All endpoints are tenant-scoped. Cross-tenant edges can never be created
because both endpoint artifacts are validated against the tenant context.
"""
from __future__ import annotations
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from asf_domain import (
ArtifactRelationship,
ArtifactRelationshipCreate,
RelationshipType,
)
from ...repositories import NotFoundError, DuplicateError, TenantMismatchError
from .deps import relationship_service
from ...services import RelationshipService
router = APIRouter()
@router.post(
"",
response_model=ArtifactRelationship,
status_code=status.HTTP_201_CREATED,
summary="Create a directed edge: source <relationship_type> target",
)
async def create_relationship(
body: ArtifactRelationshipCreate,
svc: RelationshipService = Depends(relationship_service),
) -> ArtifactRelationship:
try:
return await svc.create(body)
except DuplicateError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except TenantMismatchError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
@router.delete(
"/{relationship_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a relationship",
)
async def delete_relationship(
relationship_id: UUID,
svc: RelationshipService = Depends(relationship_service),
) -> None:
try:
await svc.delete(relationship_id)
except (NotFoundError, TenantMismatchError) as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return None
@router.get(
"/from/{artifact_id}",
response_model=list[ArtifactRelationship],
summary="Outgoing edges: artifact → *",
)
async def list_outgoing(
artifact_id: UUID,
relationship_type: Optional[RelationshipType] = Query(None),
limit: int = Query(100, ge=1, le=500),
svc: RelationshipService = Depends(relationship_service),
) -> list[ArtifactRelationship]:
return await svc.outgoing(artifact_id, relationship_type=relationship_type, limit=limit)
@router.get(
"/to/{artifact_id}",
response_model=list[ArtifactRelationship],
summary="Incoming edges: * → artifact",
)
async def list_incoming(
artifact_id: UUID,
relationship_type: Optional[RelationshipType] = Query(None),
limit: int = Query(100, ge=1, le=500),
svc: RelationshipService = Depends(relationship_service),
) -> list[ArtifactRelationship]:
return await svc.incoming(artifact_id, relationship_type=relationship_type, limit=limit)
@router.get(
"/for-artifact/{artifact_id}",
response_model=list[ArtifactRelationship],
summary="All edges touching an artifact (in + out)",
)
async def list_for_artifact(
artifact_id: UUID,
limit: int = Query(200, ge=1, le=1000),
svc: RelationshipService = Depends(relationship_service),
) -> list[ArtifactRelationship]:
return await svc.for_artifact(artifact_id, limit=limit)
+27
View File
@@ -0,0 +1,27 @@
"""Top-level /api/v1 router. Mounts every sub-router in the correct order."""
from fastapi import APIRouter
from .health import router as health_router
from .organizations import router as org_router
from .projects import router as project_router
from .artifacts import router as artifact_router
from .relationships import router as rel_router
from .jobs import router as jobs_router
from .overview import router as overview_router
from .audit import router as audit_router
from .traceability import router as traceability_router
from .ask_asf import router as ask_asf_router
api_v1_router = APIRouter()
api_v1_router.include_router(health_router, tags=["health"])
api_v1_router.include_router(org_router, prefix="/organizations", tags=["organizations"])
api_v1_router.include_router(project_router, prefix="/projects", tags=["projects"])
api_v1_router.include_router(artifact_router, prefix="/artifacts", tags=["artifacts"])
api_v1_router.include_router(rel_router, prefix="/relationships", tags=["relationships"])
api_v1_router.include_router(jobs_router, prefix="/jobs", tags=["jobs"])
api_v1_router.include_router(overview_router, prefix="/overview", tags=["overview"])
api_v1_router.include_router(traceability_router, prefix="/traceability", tags=["traceability"])
api_v1_router.include_router(audit_router, prefix="/audit", tags=["audit"])
api_v1_router.include_router(ask_asf_router, prefix="/ask-asf", tags=["ask-asf"])
@@ -0,0 +1,162 @@
"""Traceability: return graph edges + node summaries for the React Flow view.
Endpoint returns a list of ``nodes`` (artifacts) and ``edges`` (relationships)
that the frontend can feed directly into React Flow to render an
interactive traceability graph.
"""
from __future__ import annotations
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from .deps import artifact_service, relationship_service
from ...services import ArtifactService, RelationshipService
router = APIRouter()
class FlowNode(BaseModel):
id: str
type: str # artifact type: requirement, task, ...
label: str
project_id: Optional[str] = None
status: Optional[str] = None
version: int = 1
source: Optional[str] = None
class FlowEdge(BaseModel):
id: str
source: str
target: str
label: str # relationship_type, e.g. "implemented_by"
class TraceabilityGraph(BaseModel):
nodes: list[FlowNode]
edges: list[FlowEdge]
mock_fields: list[str] = []
@router.get(
"/graph",
response_model=TraceabilityGraph,
summary="Return a React-Flow-ready graph (nodes+edges) starting from a seed artifact, project, or the entire tenant.",
)
async def traceability_graph(
artifact_id: Optional[UUID] = Query(
None,
description="Seed artifact. Edges will be traversed outward (outgoing + incoming).",
),
project_id: Optional[UUID] = Query(
None,
description="Constrain the graph to a project. When set without artifact_id, returns up to `limit` artifacts.",
),
max_depth: int = Query(2, ge=1, le=5, description="BFS depth when seed artifact_id is provided."),
limit: int = Query(200, ge=1, le=1000, description="Max total nodes returned."),
artifacts: ArtifactService = Depends(artifact_service),
relationships: RelationshipService = Depends(relationship_service),
) -> TraceabilityGraph:
nodes: dict[UUID, FlowNode] = {}
edges: list[FlowEdge] = []
async def _add_art(ids: list[UUID]) -> None:
needed = [i for i in ids if i not in nodes and len(nodes) < limit]
if not needed:
return
for art in await artifacts.get_many(needed):
nodes[art.id] = FlowNode(
id=str(art.id),
type=art.type if isinstance(art.type, str) else art.type.value,
label=art.title,
project_id=str(art.project_id),
status=art.status,
version=art.version,
source=art.source,
)
if artifact_id is not None:
# BFS around the seed
frontier: set[UUID] = {artifact_id}
visited: set[UUID] = set()
for _ in range(max_depth):
current = list(frontier - visited)
if not current:
break
await _add_art(current)
visited.update(current)
next_front: set[UUID] = set()
for aid in current:
out = await relationships.outgoing(aid, limit=50)
for rel in out:
edges.append(
FlowEdge(
id=f"e-{rel.id}",
source=str(rel.source_artifact_id),
target=str(rel.target_artifact_id),
label=rel.relationship_type
if isinstance(rel.relationship_type, str)
else rel.relationship_type.value,
)
)
next_front.add(rel.target_artifact_id)
inc = await relationships.incoming(aid, limit=50)
for rel in inc:
edges.append(
FlowEdge(
id=f"e-{rel.id}",
source=str(rel.source_artifact_id),
target=str(rel.target_artifact_id),
label=rel.relationship_type
if isinstance(rel.relationship_type, str)
else rel.relationship_type.value,
)
)
next_front.add(rel.source_artifact_id)
frontier = next_front - visited
elif project_id is not None:
arts = await artifacts.list(project_id=project_id, limit=limit)
await _add_art([a.id for a in arts])
# relationships for every artifact we loaded
for a in arts:
out = await relationships.outgoing(a.id, limit=50)
for rel in out:
edges.append(
FlowEdge(
id=f"e-{rel.id}",
source=str(rel.source_artifact_id),
target=str(rel.target_artifact_id),
label=rel.relationship_type
if isinstance(rel.relationship_type, str)
else rel.relationship_type.value,
)
)
# filter edges to those whose endpoints are both in our node set
node_ids = set(nodes.keys())
edges = [e for e in edges if UUID(e.source) in node_ids and UUID(e.target) in node_ids]
else:
# No seed, no project — return tenant-wide recent artifacts (bounded)
arts = await artifacts.list(limit=limit)
await _add_art([a.id for a in arts])
for a in arts:
out = await relationships.outgoing(a.id, limit=20)
for rel in out:
edges.append(
FlowEdge(
id=f"e-{rel.id}",
source=str(rel.source_artifact_id),
target=str(rel.target_artifact_id),
label=rel.relationship_type
if isinstance(rel.relationship_type, str)
else rel.relationship_type.value,
)
)
node_ids = set(nodes.keys())
edges = [e for e in edges if UUID(e.source) in node_ids and UUID(e.target) in node_ids]
return TraceabilityGraph(nodes=list(nodes.values()), edges=edges)