first
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
"""asf-ai: ASF AI Gateway — "l'ufficio analisti".
|
||||
|
||||
*Business code NEVER calls a specific LLM provider directly.*
|
||||
It calls typed capability methods on this gateway.
|
||||
|
||||
Provider adapters (Mock, OpenAI-compat, NVIDIA, local, customer-hosted, ...)
|
||||
implement the low-level interface. Routing, priority queues, tenant AI
|
||||
policy, usage metering and cost tracking live here.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
from .capabilities import (
|
||||
CapabilityRequest,
|
||||
CapabilityResponse,
|
||||
ChatRequest,
|
||||
ChatResponse,
|
||||
CodeAnalysisRequest,
|
||||
CodeAnalysisResult,
|
||||
TestGenerationRequest,
|
||||
GeneratedTests,
|
||||
RequirementDecomposeRequest,
|
||||
RequirementDecomposition,
|
||||
TraceContext,
|
||||
)
|
||||
from .gateway import AIGateway, AIGatewayConfig
|
||||
from .providers import (
|
||||
AIProvider,
|
||||
MockProvider,
|
||||
ProviderRegistry,
|
||||
build_default_gateway,
|
||||
)
|
||||
from .usage import UsageRecord
|
||||
|
||||
__all__ = [
|
||||
"CapabilityRequest",
|
||||
"CapabilityResponse",
|
||||
"ChatRequest",
|
||||
"ChatResponse",
|
||||
"CodeAnalysisRequest",
|
||||
"CodeAnalysisResult",
|
||||
"TestGenerationRequest",
|
||||
"GeneratedTests",
|
||||
"RequirementDecomposeRequest",
|
||||
"RequirementDecomposition",
|
||||
"TraceContext",
|
||||
"AIGateway",
|
||||
"AIGatewayConfig",
|
||||
"AIProvider",
|
||||
"MockProvider",
|
||||
"ProviderRegistry",
|
||||
"build_default_gateway",
|
||||
"UsageRecord",
|
||||
]
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Typed capability requests / responses.
|
||||
|
||||
Business services construct these Pydantic models and call methods on the
|
||||
AIGateway. They never know which concrete model or provider was chosen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional, Sequence
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TraceContext(BaseModel):
|
||||
"""Context propagated through every call for audit / logging."""
|
||||
|
||||
tenant_id: UUID
|
||||
project_id: Optional[UUID] = None
|
||||
user_id: Optional[UUID] = None
|
||||
job_id: Optional[UUID] = None
|
||||
correlation_id: Optional[str] = None
|
||||
|
||||
|
||||
class CapabilityRequest(BaseModel):
|
||||
"""Base marker for every capability request supported by the gateway."""
|
||||
|
||||
trace: TraceContext
|
||||
preferred_model: Optional[str] = Field(
|
||||
None, max_length=128, description="Hint only. Gateway's routing decision is authoritative."
|
||||
)
|
||||
priority: str = "normal"
|
||||
|
||||
|
||||
class CapabilityResponse(BaseModel):
|
||||
"""Every response carries at least trace + usage."""
|
||||
|
||||
provider: str
|
||||
model_used: str
|
||||
input_tokens: Optional[int] = None
|
||||
output_tokens: Optional[int] = None
|
||||
duration_ms: Optional[int] = None
|
||||
estimated_cost: Optional[float] = None
|
||||
execution_id: UUID
|
||||
raw: Optional[dict[str, Any]] = Field(
|
||||
default=None, description="Raw provider output, preserved for debugging."
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Individual capabilities
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ChatMessage(BaseModel):
|
||||
role: str = Field(..., pattern="^(system|user|assistant|tool)$")
|
||||
content: str
|
||||
|
||||
|
||||
class ChatRequest(CapabilityRequest):
|
||||
messages: Sequence[ChatMessage]
|
||||
temperature: float = Field(0.2, ge=0.0, le=2.0)
|
||||
max_tokens: Optional[int] = Field(None, ge=1)
|
||||
context_summary: Optional[str] = Field(
|
||||
None, description="Short plaintext summary of the Context Engine assembly."
|
||||
)
|
||||
|
||||
|
||||
class ChatResponse(CapabilityResponse):
|
||||
reply: str
|
||||
references: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CodeAnalysisRequest(CapabilityRequest):
|
||||
file_path: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
code_snippet: str = Field(..., min_length=1)
|
||||
analysis_types: Sequence[str] = Field(
|
||||
default_factory=lambda: ["bugs", "complexity", "security", "style"],
|
||||
description="Which kinds of analyses to run.",
|
||||
)
|
||||
|
||||
|
||||
class Finding(BaseModel):
|
||||
severity: str = Field(..., pattern="^(info|low|medium|high|critical)$")
|
||||
kind: str
|
||||
message: str
|
||||
line_start: Optional[int] = None
|
||||
line_end: Optional[int] = None
|
||||
suggestion: Optional[str] = None
|
||||
|
||||
|
||||
class CodeAnalysisResult(CapabilityResponse):
|
||||
summary: str
|
||||
findings: list[Finding] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TestGenerationRequest(CapabilityRequest):
|
||||
file_path: Optional[str] = None
|
||||
language: str = "python"
|
||||
test_framework: str = "pytest"
|
||||
code_snippet: str = Field(..., min_length=1)
|
||||
goal: str = "Generate unit tests covering happy path + main failure modes"
|
||||
existing_tests: Optional[str] = None
|
||||
|
||||
|
||||
class GeneratedTests(CapabilityResponse):
|
||||
test_cases: list[str] = Field(default_factory=list)
|
||||
fixtures_needed: list[str] = Field(default_factory=list)
|
||||
coverage_notes: str = ""
|
||||
|
||||
|
||||
class RequirementDecomposeRequest(CapabilityRequest):
|
||||
requirement_text: str = Field(..., min_length=1)
|
||||
context: Optional[str] = None
|
||||
existing_subtasks: Sequence[str] = Field(default_factory=list)
|
||||
max_subtasks: int = Field(20, ge=1, le=100)
|
||||
|
||||
|
||||
class SubTask(BaseModel):
|
||||
title: str
|
||||
description: str
|
||||
estimated_complexity: str = Field(..., pattern="^(xs|s|m|l|xl)$")
|
||||
depends_on: list[int] = Field(
|
||||
default_factory=list,
|
||||
description="Index (0-based) of sibling subtasks this one depends on.",
|
||||
)
|
||||
|
||||
|
||||
class RequirementDecomposition(CapabilityResponse):
|
||||
subtasks: list[SubTask] = Field(default_factory=list)
|
||||
architecture_notes: list[str] = Field(default_factory=list)
|
||||
open_questions: list[str] = Field(default_factory=list)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""asf-ai settings surface (minimal; extensible)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class AISettings(BaseSettings):
|
||||
mongo_uri: str = "mongodb://172.19.40.102:27017,172.19.40.103:27017/asf?replicaSet=rs0"
|
||||
mongo_db: str = "asf"
|
||||
|
||||
default_provider: str = "mock"
|
||||
|
||||
# Provider-specific (optional — only required if the chosen provider is used)
|
||||
openai_api_key: str | None = None
|
||||
anthropic_api_key: str | None = None
|
||||
nvidia_api_key: str | None = None
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> AISettings:
|
||||
return AISettings()
|
||||
@@ -0,0 +1,185 @@
|
||||
"""AIGateway — the high-level capability surface called by business code.
|
||||
|
||||
It handles:
|
||||
1. Routing: picks which provider + model to use given the request.
|
||||
2. Policy enforcement: tenant-level AI policy (which providers/models are
|
||||
allowed; budget caps will plug in here cleanly later).
|
||||
3. Usage recording: emits UsageRecords for metering + future billing.
|
||||
4. Error translation (provider-specific → stable ASF exceptions).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable, Optional
|
||||
|
||||
import structlog
|
||||
|
||||
from .capabilities import (
|
||||
ChatRequest,
|
||||
ChatResponse,
|
||||
CodeAnalysisRequest,
|
||||
CodeAnalysisResult,
|
||||
TestGenerationRequest,
|
||||
GeneratedTests,
|
||||
RequirementDecomposeRequest,
|
||||
RequirementDecomposition,
|
||||
)
|
||||
from .providers import (
|
||||
AIProvider,
|
||||
ProviderRegistry,
|
||||
default_registry,
|
||||
)
|
||||
from .usage import UsageRecord
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AIGatewayConfig:
|
||||
default_provider: str = "mock"
|
||||
default_model_by_capability: dict[str, str] = field(default_factory=dict)
|
||||
# Hook for future tenant policy layer: given (tenant_id, capability),
|
||||
# return the provider name to route to. None → use default_provider.
|
||||
policy_selector: Optional[Callable[..., Optional[str]]] = None
|
||||
|
||||
|
||||
class AIGateway:
|
||||
"""Entry point for all ASF AI operations."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
config: Optional[AIGatewayConfig] = None,
|
||||
registry: Optional[ProviderRegistry] = None,
|
||||
) -> None:
|
||||
self._config = config or AIGatewayConfig()
|
||||
self._registry = registry or default_registry()
|
||||
self._usage_callbacks: list[Callable[[UsageRecord], None]] = []
|
||||
|
||||
# --- callback hooks (future usage metering / billing) ---
|
||||
def on_usage(self, cb: Callable[[UsageRecord], None]) -> None:
|
||||
self._usage_callbacks.append(cb)
|
||||
|
||||
# --- routing helper ---
|
||||
def _route(self, req, capability: str) -> AIProvider:
|
||||
tenant_id = str(req.trace.tenant_id) if req.trace else None
|
||||
chosen: Optional[str] = None
|
||||
if self._config.policy_selector and tenant_id:
|
||||
try:
|
||||
chosen = self._config.policy_selector(
|
||||
tenant_id=req.trace.tenant_id,
|
||||
capability=capability,
|
||||
preferred=req.preferred_model,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("ai_policy_selector_failed")
|
||||
|
||||
chosen = chosen or self._config.default_provider
|
||||
return self._registry.get(chosen)
|
||||
|
||||
def _emit_usage(self, record: UsageRecord) -> None:
|
||||
for cb in self._usage_callbacks:
|
||||
try:
|
||||
cb(record)
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("ai_usage_callback_failed", provider=record.provider)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Capability methods
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def chat(self, req: ChatRequest) -> ChatResponse:
|
||||
provider = self._route(req, "chat")
|
||||
log.debug("ai_chat_start", provider=provider.name, tenant=str(req.trace.tenant_id))
|
||||
resp = await provider.chat(req)
|
||||
self._emit_usage(
|
||||
UsageRecord(
|
||||
tenant_id=req.trace.tenant_id,
|
||||
project_id=req.trace.project_id,
|
||||
job_id=req.trace.job_id,
|
||||
execution_id=resp.execution_id,
|
||||
provider=resp.provider,
|
||||
model=resp.model_used,
|
||||
capability="chat",
|
||||
input_tokens=resp.input_tokens,
|
||||
output_tokens=resp.output_tokens,
|
||||
estimated_cost=resp.estimated_cost,
|
||||
duration_ms=resp.duration_ms,
|
||||
recorded_at=datetime.now(tz=timezone.utc),
|
||||
)
|
||||
)
|
||||
return resp
|
||||
|
||||
async def analyze_code(self, req: CodeAnalysisRequest) -> CodeAnalysisResult:
|
||||
provider = self._route(req, "analyze_code")
|
||||
resp = await provider.analyze_code(req)
|
||||
self._emit_usage(
|
||||
UsageRecord(
|
||||
tenant_id=req.trace.tenant_id,
|
||||
project_id=req.trace.project_id,
|
||||
job_id=req.trace.job_id,
|
||||
execution_id=resp.execution_id,
|
||||
provider=resp.provider,
|
||||
model=resp.model_used,
|
||||
capability="analyze_code",
|
||||
input_tokens=resp.input_tokens,
|
||||
output_tokens=resp.output_tokens,
|
||||
estimated_cost=resp.estimated_cost,
|
||||
duration_ms=resp.duration_ms,
|
||||
recorded_at=datetime.now(tz=timezone.utc),
|
||||
)
|
||||
)
|
||||
return resp
|
||||
|
||||
async def generate_tests(self, req: TestGenerationRequest) -> GeneratedTests:
|
||||
provider = self._route(req, "generate_tests")
|
||||
resp = await provider.generate_tests(req)
|
||||
self._emit_usage(
|
||||
UsageRecord(
|
||||
tenant_id=req.trace.tenant_id,
|
||||
project_id=req.trace.project_id,
|
||||
job_id=req.trace.job_id,
|
||||
execution_id=resp.execution_id,
|
||||
provider=resp.provider,
|
||||
model=resp.model_used,
|
||||
capability="generate_tests",
|
||||
input_tokens=resp.input_tokens,
|
||||
output_tokens=resp.output_tokens,
|
||||
estimated_cost=resp.estimated_cost,
|
||||
duration_ms=resp.duration_ms,
|
||||
recorded_at=datetime.now(tz=timezone.utc),
|
||||
)
|
||||
)
|
||||
return resp
|
||||
|
||||
async def decompose_requirement(
|
||||
self, req: RequirementDecomposeRequest
|
||||
) -> RequirementDecomposition:
|
||||
provider = self._route(req, "decompose_requirement")
|
||||
resp = await provider.decompose_requirement(req)
|
||||
self._emit_usage(
|
||||
UsageRecord(
|
||||
tenant_id=req.trace.tenant_id,
|
||||
project_id=req.trace.project_id,
|
||||
job_id=req.trace.job_id,
|
||||
execution_id=resp.execution_id,
|
||||
provider=resp.provider,
|
||||
model=resp.model_used,
|
||||
capability="decompose_requirement",
|
||||
input_tokens=resp.input_tokens,
|
||||
output_tokens=resp.output_tokens,
|
||||
estimated_cost=resp.estimated_cost,
|
||||
duration_ms=resp.duration_ms,
|
||||
recorded_at=datetime.now(tz=timezone.utc),
|
||||
)
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
def build_default_gateway() -> AIGateway:
|
||||
"""Default bootstrap-configured gateway — uses MockProvider, stdout usage logging."""
|
||||
gw = AIGateway()
|
||||
gw.on_usage(lambda record: log.debug("ai_usage_record", **record.model_dump(mode="json")))
|
||||
return gw
|
||||
@@ -0,0 +1,256 @@
|
||||
"""AIProvider interface + mock + registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol, Optional, Type
|
||||
from uuid import uuid4, UUID
|
||||
|
||||
import structlog
|
||||
|
||||
from .capabilities import (
|
||||
CapabilityRequest,
|
||||
CapabilityResponse,
|
||||
ChatRequest,
|
||||
ChatResponse,
|
||||
CodeAnalysisRequest,
|
||||
CodeAnalysisResult,
|
||||
TestGenerationRequest,
|
||||
GeneratedTests,
|
||||
RequirementDecomposeRequest,
|
||||
RequirementDecomposition,
|
||||
SubTask,
|
||||
Finding,
|
||||
)
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderCaps:
|
||||
supports_chat: bool = True
|
||||
supports_code_analysis: bool = True
|
||||
supports_test_generation: bool = True
|
||||
supports_decompose: bool = True
|
||||
max_input_chars: int = 200_000
|
||||
|
||||
|
||||
class AIProvider(Protocol):
|
||||
"""Low-level provider interface. The Gateway calls exactly one per request.
|
||||
|
||||
Concrete implementations: MockProvider, OpenAICompatProvider, NVIDIADGXProvider, ...
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str: ...
|
||||
@property
|
||||
def default_model(self) -> str: ...
|
||||
@property
|
||||
def capabilities(self) -> ProviderCaps: ...
|
||||
|
||||
async def chat(self, req: ChatRequest) -> ChatResponse: ...
|
||||
async def analyze_code(self, req: CodeAnalysisRequest) -> CodeAnalysisResult: ...
|
||||
async def generate_tests(self, req: TestGenerationRequest) -> GeneratedTests: ...
|
||||
async def decompose_requirement(
|
||||
self, req: RequirementDecomposeRequest
|
||||
) -> RequirementDecomposition: ...
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# MockProvider — deterministic, canned, latency-simulating responses
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
class MockProvider:
|
||||
"""Provider that always returns deterministic, predictable responses.
|
||||
|
||||
Uses:
|
||||
* unit tests of code that depends on the AI Gateway
|
||||
* local dev when no API keys are set
|
||||
* UI demo / smoke tests
|
||||
"""
|
||||
|
||||
name = "mock"
|
||||
default_model = "asf-mock-v1"
|
||||
capabilities = ProviderCaps()
|
||||
|
||||
def __init__(self, *, response_latency_ms: int = 40) -> None:
|
||||
self._latency = max(0, response_latency_ms)
|
||||
|
||||
async def _maybe_sleep(self) -> None:
|
||||
if self._latency <= 0:
|
||||
return
|
||||
import asyncio
|
||||
await asyncio.sleep(self._latency / 1000.0)
|
||||
|
||||
async def chat(self, req: ChatRequest) -> ChatResponse:
|
||||
await self._maybe_sleep()
|
||||
# Mirror the last user message back as a demo reply — never misleadingly smart
|
||||
last_user = next(
|
||||
(m.content for m in reversed(req.messages) if m.role == "user"),
|
||||
"(no user message)",
|
||||
)
|
||||
reply = (
|
||||
"[ASF Mock AI] This is a deterministic response from MockProvider. "
|
||||
f"Your last message was: {last_user[:160]!r}. "
|
||||
"To enable real AI, configure a non-mock provider in the ASF AI Gateway."
|
||||
)
|
||||
return ChatResponse(
|
||||
provider=self.name,
|
||||
model_used=self.default_model,
|
||||
execution_id=uuid4(),
|
||||
reply=reply,
|
||||
references=["mock://no-real-data"],
|
||||
input_tokens=len(last_user) // 4,
|
||||
output_tokens=len(reply) // 4,
|
||||
duration_ms=self._latency,
|
||||
estimated_cost=0.0,
|
||||
)
|
||||
|
||||
async def analyze_code(self, req: CodeAnalysisRequest) -> CodeAnalysisResult:
|
||||
await self._maybe_sleep()
|
||||
lines = [ln for ln in req.code_snippet.splitlines() if ln.strip()]
|
||||
findings: list[Finding] = []
|
||||
if "TODO" in req.code_snippet:
|
||||
findings.append(
|
||||
Finding(
|
||||
severity="info",
|
||||
kind="todo",
|
||||
message="Mock analysis found TODO comment — remember to follow up.",
|
||||
)
|
||||
)
|
||||
if "except Exception" in req.code_snippet:
|
||||
findings.append(
|
||||
Finding(
|
||||
severity="medium",
|
||||
kind="broad_exception",
|
||||
message="Broad except clause detected — consider narrowing exception types.",
|
||||
)
|
||||
)
|
||||
return CodeAnalysisResult(
|
||||
provider=self.name,
|
||||
model_used=self.default_model,
|
||||
execution_id=uuid4(),
|
||||
summary=f"Mock: analyzed {len(lines)} non-empty lines; {len(findings)} informational finding(s).",
|
||||
findings=findings,
|
||||
input_tokens=max(10, len(req.code_snippet) // 4),
|
||||
output_tokens=100,
|
||||
duration_ms=self._latency,
|
||||
estimated_cost=0.0,
|
||||
)
|
||||
|
||||
async def generate_tests(self, req: TestGenerationRequest) -> GeneratedTests:
|
||||
await self._maybe_sleep()
|
||||
test_cases = [
|
||||
"# Generated by MockProvider — placeholder skeleton\n"
|
||||
"def test_mock_happy_path():\n"
|
||||
" # TODO: replace with real assertions from a production AI provider\n"
|
||||
" assert True\n",
|
||||
"# Generated by MockProvider — placeholder skeleton\n"
|
||||
"def test_mock_failure_mode():\n"
|
||||
" # TODO: simulate and assert on failure behavior\n"
|
||||
" assert True\n",
|
||||
]
|
||||
return GeneratedTests(
|
||||
provider=self.name,
|
||||
model_used=self.default_model,
|
||||
execution_id=uuid4(),
|
||||
test_cases=test_cases,
|
||||
fixtures_needed=[
|
||||
"NOTE: Mock provider — fixtures listed are placeholders only.",
|
||||
],
|
||||
coverage_notes="Mock response. Real AI provider will target specific branches.",
|
||||
input_tokens=max(10, len(req.code_snippet) // 4),
|
||||
output_tokens=300,
|
||||
duration_ms=self._latency,
|
||||
estimated_cost=0.0,
|
||||
)
|
||||
|
||||
async def decompose_requirement(
|
||||
self, req: RequirementDecomposeRequest
|
||||
) -> RequirementDecomposition:
|
||||
await self._maybe_sleep()
|
||||
limit = max(1, min(5, req.max_subtasks))
|
||||
subtasks = [
|
||||
SubTask(
|
||||
title=f"(Mock) Clarify scope of requirement",
|
||||
description="Mock placeholder — confirm acceptance criteria with stakeholders.",
|
||||
estimated_complexity="s",
|
||||
),
|
||||
SubTask(
|
||||
title=f"(Mock) Draft architecture",
|
||||
description="Mock placeholder — identify modules, data flow, contracts.",
|
||||
estimated_complexity="m",
|
||||
depends_on=[0],
|
||||
),
|
||||
SubTask(
|
||||
title=f"(Mock) Implement feature",
|
||||
description="Mock placeholder — concrete coding work.",
|
||||
estimated_complexity="l",
|
||||
depends_on=[1],
|
||||
),
|
||||
SubTask(
|
||||
title=f"(Mock) Add tests",
|
||||
description="Mock placeholder — unit + integration coverage.",
|
||||
estimated_complexity="m",
|
||||
depends_on=[2],
|
||||
),
|
||||
SubTask(
|
||||
title=f"(Mock) Deploy + monitor",
|
||||
description="Mock placeholder — staging → production, observability.",
|
||||
estimated_complexity="s",
|
||||
depends_on=[3],
|
||||
),
|
||||
][:limit]
|
||||
return RequirementDecomposition(
|
||||
provider=self.name,
|
||||
model_used=self.default_model,
|
||||
execution_id=uuid4(),
|
||||
subtasks=subtasks,
|
||||
architecture_notes=["(Mock) Decomposition is deterministic placeholder data."],
|
||||
open_questions=[
|
||||
"(Mock) Which non-functional requirements apply?",
|
||||
"(Mock) Any existing integration contracts to preserve?",
|
||||
],
|
||||
input_tokens=max(10, len(req.requirement_text) // 4),
|
||||
output_tokens=400,
|
||||
duration_ms=self._latency,
|
||||
estimated_cost=0.0,
|
||||
)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# ProviderRegistry — maps provider name → AIProvider instance
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
class ProviderRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._providers: dict[str, AIProvider] = {}
|
||||
|
||||
def register(self, name: str, provider: AIProvider) -> None:
|
||||
self._providers[name] = provider
|
||||
|
||||
def get(self, name: str) -> AIProvider:
|
||||
if name not in self._providers:
|
||||
raise KeyError(f"Unknown AI provider: {name}. Registered: {list(self._providers)}")
|
||||
return self._providers[name]
|
||||
|
||||
def names(self) -> list[str]:
|
||||
return list(self._providers.keys())
|
||||
|
||||
|
||||
def default_registry() -> ProviderRegistry:
|
||||
r = ProviderRegistry()
|
||||
r.register("mock", MockProvider(response_latency_ms=40))
|
||||
return r
|
||||
|
||||
|
||||
def build_default_gateway():
|
||||
"""Instantiate the canonical production AIGateway (mock by default).
|
||||
|
||||
Imported lazily to avoid a cycle with ``app.gateway``.
|
||||
"""
|
||||
from .gateway import AIGateway, AIGatewayConfig
|
||||
|
||||
return AIGateway(config=AIGatewayConfig(), registry=default_registry())
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Usage record (for billing / metering later)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class UsageRecord(BaseModel):
|
||||
tenant_id: UUID
|
||||
project_id: Optional[UUID] = None
|
||||
job_id: Optional[UUID] = None
|
||||
execution_id: UUID
|
||||
provider: str
|
||||
model: str
|
||||
capability: str
|
||||
input_tokens: Optional[int] = None
|
||||
output_tokens: Optional[int] = None
|
||||
gpu_seconds: Optional[float] = None
|
||||
estimated_cost: Optional[float] = None
|
||||
duration_ms: Optional[int] = None
|
||||
recorded_at: datetime
|
||||
@@ -0,0 +1,4 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
@@ -0,0 +1,14 @@
|
||||
asf-domain==0.1.0
|
||||
asf-events==0.1.0
|
||||
asf-connectors==0.1.0
|
||||
fastapi>=0.115,<1
|
||||
uvicorn[standard]>=0.30
|
||||
pydantic>=2.7
|
||||
pydantic-settings>=2.2
|
||||
aio-pika>=9.4
|
||||
structlog>=24.4
|
||||
python-dotenv>=1.0
|
||||
httpx>=0.27
|
||||
|
||||
pytest>=8
|
||||
pytest-asyncio>=0.23
|
||||
@@ -0,0 +1,130 @@
|
||||
"""asf-ai tests — MockProvider + AIGateway routing, no network calls."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
|
||||
AI_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(AI_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(AI_ROOT))
|
||||
|
||||
TENANT_ID = UUID("99999999-0000-0000-0000-000000000001")
|
||||
PROJECT_ID = UUID("88888888-0000-0000-0000-000000000002")
|
||||
JOB_ID = UUID("77777777-0000-0000-0000-000000000003")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def trace_ctx():
|
||||
from app.capabilities import TraceContext
|
||||
return TraceContext(
|
||||
tenant_id=TENANT_ID, project_id=PROJECT_ID, job_id=JOB_ID, correlation_id="ai-trace"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_provider():
|
||||
from app.providers import MockProvider
|
||||
return MockProvider(response_latency_ms=0)
|
||||
|
||||
|
||||
class TestMockProviderChat:
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_mirrors_last_user_in_reply(self, mock_provider, trace_ctx):
|
||||
from app.capabilities import ChatRequest, ChatMessage
|
||||
req = ChatRequest(
|
||||
trace=trace_ctx,
|
||||
messages=[
|
||||
ChatMessage(role="system", content="You are helpful."),
|
||||
ChatMessage(role="user", content="what is REQ-103?"),
|
||||
],
|
||||
)
|
||||
resp = await mock_provider.chat(req)
|
||||
assert resp.provider == "mock"
|
||||
assert resp.model_used == "asf-mock-v1"
|
||||
assert resp.estimated_cost == 0.0
|
||||
assert "what is REQ-103?" in resp.reply
|
||||
assert resp.input_tokens >= 0 and resp.output_tokens > 0
|
||||
assert resp.references == ["mock://no-real-data"]
|
||||
|
||||
|
||||
class TestMockProviderCodeAnalysis:
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_code_detects_todo_and_broad_except(self, mock_provider, trace_ctx):
|
||||
from app.capabilities import CodeAnalysisRequest
|
||||
code = (
|
||||
"def foo():\n"
|
||||
" # TODO: refactor this later\n"
|
||||
" try:\n"
|
||||
" risky_call()\n"
|
||||
" except Exception:\n"
|
||||
" pass\n"
|
||||
)
|
||||
req = CodeAnalysisRequest(trace=trace_ctx, code_snippet=code, language="python")
|
||||
resp = await mock_provider.analyze_code(req)
|
||||
kinds = {f.kind for f in resp.findings}
|
||||
assert "todo" in kinds
|
||||
assert "broad_exception" in kinds
|
||||
sevs = {f.severity for f in resp.findings}
|
||||
assert "medium" in sevs # broad_exception is medium
|
||||
assert resp.summary.startswith("Mock: analyzed ")
|
||||
assert resp.input_tokens > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_code_clean_snippet_zero_findings(self, mock_provider, trace_ctx):
|
||||
from app.capabilities import CodeAnalysisRequest
|
||||
req = CodeAnalysisRequest(trace=trace_ctx, code_snippet="x = 1\ny = 2\n", language="python")
|
||||
resp = await mock_provider.analyze_code(req)
|
||||
assert resp.findings == []
|
||||
assert "0 informational finding" in resp.summary
|
||||
|
||||
|
||||
class TestMockProviderTestGeneration:
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_tests_returns_placeholders(self, mock_provider, trace_ctx):
|
||||
from app.capabilities import TestGenerationRequest
|
||||
req = TestGenerationRequest(
|
||||
trace=trace_ctx,
|
||||
code_snippet="def add(a, b): return a+b\n",
|
||||
language="python",
|
||||
test_framework="pytest",
|
||||
)
|
||||
resp = await mock_provider.generate_tests(req)
|
||||
assert len(resp.test_cases) >= 2
|
||||
assert any("test_mock_happy_path" in t for t in resp.test_cases)
|
||||
assert resp.fixtures_needed
|
||||
assert resp.coverage_notes.startswith("Mock response.")
|
||||
assert resp.output_tokens > 0
|
||||
|
||||
|
||||
class TestMockProviderDecomposeRequirement:
|
||||
@pytest.mark.asyncio
|
||||
async def test_decompose_respects_max_subtasks_cap(self, mock_provider, trace_ctx):
|
||||
from app.capabilities import RequirementDecomposeRequest
|
||||
req = RequirementDecomposeRequest(
|
||||
trace=trace_ctx, requirement_text="Build a billing page.", max_subtasks=3
|
||||
)
|
||||
resp = await mock_provider.decompose_requirement(req)
|
||||
assert len(resp.subtasks) == 3
|
||||
# graph edges sanity: second subtask depends on first
|
||||
assert 0 in (resp.subtasks[1].depends_on or [])
|
||||
assert resp.open_questions
|
||||
assert resp.architecture_notes
|
||||
|
||||
|
||||
class TestProviderRegistry:
|
||||
def test_registry_register_and_get(self):
|
||||
from app.providers import ProviderRegistry, MockProvider
|
||||
reg = ProviderRegistry()
|
||||
reg.register("m", MockProvider(response_latency_ms=0))
|
||||
assert reg.names() == ["m"]
|
||||
assert reg.get("m").name == "mock"
|
||||
|
||||
def test_registry_get_missing_raises_keyerror(self):
|
||||
from app.providers import ProviderRegistry
|
||||
reg = ProviderRegistry()
|
||||
with pytest.raises(KeyError, match="Unknown AI provider"):
|
||||
reg.get("nope")
|
||||
@@ -0,0 +1,21 @@
|
||||
"""asf-context: Context Engine — "l'impiegato che raccoglie il fascicolo completo".
|
||||
|
||||
Given an artifact / project / user question, assembles relevant context for
|
||||
AI operations. In the MVP this is pure MongoDB relationship traversal.
|
||||
|
||||
The vector / semantic retrieval logic lives behind ``SemanticStore`` protocol
|
||||
so a vector DB can be plugged in later without changing callers.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
from .context_engine import ContextEngine, AssemblyOptions, ContextAssembly
|
||||
from .semantic_store import SemanticStore, InMemorySemanticStoreMock
|
||||
|
||||
__all__ = [
|
||||
"ContextEngine",
|
||||
"AssemblyOptions",
|
||||
"ContextAssembly",
|
||||
"SemanticStore",
|
||||
"InMemorySemanticStoreMock",
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Minimal configuration surface for asf-context service.
|
||||
|
||||
Keeps parity with asf-core config. Can be grown independently.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class ContextSettings(BaseSettings):
|
||||
mongo_uri: str = "mongodb://172.19.40.102:27017,172.19.40.103:27017/asf?replicaSet=rs0"
|
||||
mongo_db: str = "asf"
|
||||
|
||||
rabbitmq_uri: str = "amqp://azurian:StrongPassword123!@172.19.40.201:5672/asf"
|
||||
rabbitmq_events_exchange: str = "asf.events"
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> ContextSettings:
|
||||
return ContextSettings()
|
||||
@@ -0,0 +1,236 @@
|
||||
"""ContextEngine: assembles "il fascicolo completo" for an AI operation.
|
||||
|
||||
Input: an artifact (or project id, or a free-text question)
|
||||
Output: a ``ContextAssembly`` bundling
|
||||
· related requirements
|
||||
· architecture decisions
|
||||
· relevant source files
|
||||
· docs
|
||||
· tests
|
||||
· deployments
|
||||
· previous AI analysis
|
||||
· incidents
|
||||
· runtime info (currently placeholder)
|
||||
|
||||
For MVP it walks the ArtifactRelationship graph in MongoDB. Semantic
|
||||
retrieval is hidden behind ``SemanticStore`` so we can add embeddings later
|
||||
without changing the engine surface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Sequence
|
||||
from uuid import UUID
|
||||
|
||||
import structlog
|
||||
|
||||
from asf_domain import Artifact, ArtifactRelationship, RelationshipType
|
||||
from .semantic_store import SemanticStore, InMemorySemanticStoreMock, SemanticHit
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssemblyOptions:
|
||||
max_depth: int = 2
|
||||
max_nodes: int = 200
|
||||
include_semantic: bool = False
|
||||
semantic_top_k: int = 10
|
||||
relationship_whitelist: Optional[Sequence[RelationshipType]] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContextAssembly:
|
||||
seed_artifact_id: Optional[UUID]
|
||||
project_id: Optional[UUID]
|
||||
tenant_id: UUID
|
||||
|
||||
# Artifacts grouped by logical concern (all are Artifact objects)
|
||||
requirements: list[Artifact] = field(default_factory=list)
|
||||
architecture_decisions: list[Artifact] = field(default_factory=list)
|
||||
tasks: list[Artifact] = field(default_factory=list)
|
||||
source_files: list[Artifact] = field(default_factory=list)
|
||||
documents: list[Artifact] = field(default_factory=list)
|
||||
tests: list[Artifact] = field(default_factory=list)
|
||||
builds: list[Artifact] = field(default_factory=list)
|
||||
deployments: list[Artifact] = field(default_factory=list)
|
||||
releases: list[Artifact] = field(default_factory=list)
|
||||
incidents: list[Artifact] = field(default_factory=list)
|
||||
previous_ai_analysis: list[Artifact] = field(default_factory=list)
|
||||
security_findings: list[Artifact] = field(default_factory=list)
|
||||
|
||||
# Raw graph for UI / debugging
|
||||
relationships_traversed: list[ArtifactRelationship] = field(default_factory=list)
|
||||
all_artifacts: list[Artifact] = field(default_factory=list)
|
||||
|
||||
semantic_hits: list[SemanticHit] = field(default_factory=list)
|
||||
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
def summary(self) -> dict:
|
||||
return {
|
||||
"seed_artifact_id": str(self.seed_artifact_id) if self.seed_artifact_id else None,
|
||||
"total_artifacts": len(self.all_artifacts),
|
||||
"total_relationships": len(self.relationships_traversed),
|
||||
"semantic_hits": len(self.semantic_hits),
|
||||
"by_type": {
|
||||
"requirements": len(self.requirements),
|
||||
"architecture_decisions": len(self.architecture_decisions),
|
||||
"tasks": len(self.tasks),
|
||||
"source_files": len(self.source_files),
|
||||
"documents": len(self.documents),
|
||||
"tests": len(self.tests),
|
||||
"builds": len(self.builds),
|
||||
"deployments": len(self.deployments),
|
||||
"releases": len(self.releases),
|
||||
"incidents": len(self.incidents),
|
||||
"previous_ai_analysis": len(self.previous_ai_analysis),
|
||||
"security_findings": len(self.security_findings),
|
||||
},
|
||||
"warnings": self.warnings,
|
||||
}
|
||||
|
||||
|
||||
class ContextEngine:
|
||||
"""Assembles contextual "fascicolo" given a seed (artifact, project, ...).
|
||||
|
||||
For the bootstrap phase we only accept explicit service-layer objects
|
||||
(relationship service + artifact service) so we can run the engine
|
||||
inside asf-core or standalone. No Mongo driver coupling here.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
relationship_service, # duck-typed: for_artifact, outgoing, incoming
|
||||
artifact_service, # duck-typed: get, get_many, list
|
||||
semantic: SemanticStore | None = None,
|
||||
) -> None:
|
||||
self._rels = relationship_service
|
||||
self._arts = artifact_service
|
||||
self._semantic = semantic or InMemorySemanticStoreMock()
|
||||
|
||||
async def assemble(
|
||||
self,
|
||||
*,
|
||||
tenant_id: UUID,
|
||||
seed_artifact_id: Optional[UUID] = None,
|
||||
project_id: Optional[UUID] = None,
|
||||
user_query: Optional[str] = None,
|
||||
options: Optional[AssemblyOptions] = None,
|
||||
) -> ContextAssembly:
|
||||
opts = options or AssemblyOptions()
|
||||
result = ContextAssembly(
|
||||
seed_artifact_id=seed_artifact_id,
|
||||
project_id=project_id,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
|
||||
visited_ids: set[UUID] = set()
|
||||
frontier: set[UUID] = set()
|
||||
|
||||
if seed_artifact_id is not None:
|
||||
frontier.add(seed_artifact_id)
|
||||
elif project_id is not None:
|
||||
arts = await self._arts.list(
|
||||
project_id=project_id, limit=opts.max_nodes
|
||||
)
|
||||
frontier = {a.id for a in arts[: opts.max_nodes]}
|
||||
result.all_artifacts.extend(arts)
|
||||
visited_ids.update(a.id for a in arts)
|
||||
else:
|
||||
result.warnings.append(
|
||||
"No seed_artifact_id nor project_id provided; context will be empty."
|
||||
)
|
||||
return result
|
||||
|
||||
# BFS walk
|
||||
for _ in range(opts.max_depth):
|
||||
current = list(frontier - visited_ids)
|
||||
if not current:
|
||||
break
|
||||
visited_ids.update(current)
|
||||
|
||||
# Load artifacts we haven't loaded yet
|
||||
need = [i for i in current if not any(a.id == i for a in result.all_artifacts)]
|
||||
if need:
|
||||
fetched = await self._arts.get_many(need)
|
||||
result.all_artifacts.extend(fetched)
|
||||
|
||||
for aid in current:
|
||||
if len(result.relationships_traversed) >= opts.max_nodes:
|
||||
break
|
||||
out = await self._rels.outgoing(
|
||||
aid, limit=max(50, opts.max_nodes // 4)
|
||||
)
|
||||
inc = await self._rels.incoming(
|
||||
aid, limit=max(50, opts.max_nodes // 4)
|
||||
)
|
||||
for rel in out + inc:
|
||||
result.relationships_traversed.append(rel)
|
||||
frontier.add(rel.source_artifact_id)
|
||||
frontier.add(rel.target_artifact_id)
|
||||
|
||||
# Ensure all referenced artifacts are loaded
|
||||
referenced: set[UUID] = set()
|
||||
for rel in result.relationships_traversed:
|
||||
referenced.add(rel.source_artifact_id)
|
||||
referenced.add(rel.target_artifact_id)
|
||||
already_have = {a.id for a in result.all_artifacts}
|
||||
missing = list(referenced - already_have)
|
||||
if missing:
|
||||
fetched = await self._arts.get_many(missing)
|
||||
result.all_artifacts.extend(fetched)
|
||||
|
||||
# Bucket artifacts by type
|
||||
_bucket(result)
|
||||
|
||||
# Optional semantic layer
|
||||
if opts.include_semantic and user_query:
|
||||
try:
|
||||
result.semantic_hits = list(
|
||||
await self._semantic.search(
|
||||
tenant_id=tenant_id,
|
||||
query=user_query,
|
||||
project_id=project_id,
|
||||
top_k=opts.semantic_top_k,
|
||||
)
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
await log.aexception("semantic_search_failed")
|
||||
result.warnings.append("Semantic search failed; using structural context only.")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_TYPE_BUCKETS = {
|
||||
"requirement": "requirements",
|
||||
"architecture_decision": "architecture_decisions",
|
||||
"task": "tasks",
|
||||
"story": "tasks",
|
||||
"epic": "tasks",
|
||||
"source_file": "source_files",
|
||||
"document": "documents",
|
||||
"test": "tests",
|
||||
"build": "builds",
|
||||
"deployment": "deployments",
|
||||
"release": "releases",
|
||||
"incident": "incidents",
|
||||
"runtime_event": "incidents",
|
||||
"ai_analysis": "previous_ai_analysis",
|
||||
"security_finding": "security_findings",
|
||||
}
|
||||
|
||||
|
||||
def _bucket(r: ContextAssembly) -> None:
|
||||
for art in r.all_artifacts:
|
||||
t = art.type if isinstance(art.type, str) else art.type.value
|
||||
bucket = _TYPE_BUCKETS.get(t)
|
||||
if bucket is not None:
|
||||
getattr(r, bucket).append(art)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Semantic / vector retrieval abstraction (stub + in-memory mock).
|
||||
|
||||
A real implementation will talk to a vector DB / embedding store and return
|
||||
"semantically similar" artifacts. For bootstrap we keep the Protocol open and
|
||||
provide a deterministic mock.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Protocol, Optional, Sequence
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
@dataclass
|
||||
class SemanticHit:
|
||||
artifact_id: UUID
|
||||
score: float
|
||||
snippet: str = ""
|
||||
metadata: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
class SemanticStore(Protocol):
|
||||
"""Future vector / embedding store interface."""
|
||||
|
||||
async def search(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
query: str,
|
||||
*,
|
||||
project_id: Optional[UUID] = None,
|
||||
top_k: int = 10,
|
||||
filters: Optional[dict] = None,
|
||||
) -> Sequence[SemanticHit]: ...
|
||||
|
||||
async def index_artifact(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
artifact_id: UUID,
|
||||
text: str,
|
||||
metadata: Optional[dict] = None,
|
||||
) -> None: ...
|
||||
|
||||
|
||||
class InMemorySemanticStoreMock(SemanticStore):
|
||||
"""Deterministic, no-op mock.
|
||||
|
||||
Useful for unit tests and for getting the Context Engine scaffolding up
|
||||
before a real vector DB is deployed. Always returns an empty hit list so
|
||||
callers gracefully degrade to structural context only.
|
||||
"""
|
||||
|
||||
async def search(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
query: str,
|
||||
*,
|
||||
project_id: Optional[UUID] = None,
|
||||
top_k: int = 10,
|
||||
filters: Optional[dict] = None,
|
||||
) -> Sequence[SemanticHit]:
|
||||
return tuple()
|
||||
|
||||
async def index_artifact(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
artifact_id: UUID,
|
||||
text: str,
|
||||
metadata: Optional[dict] = None,
|
||||
) -> None: # pragma: no cover - mock side effect
|
||||
return None
|
||||
@@ -0,0 +1,4 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
@@ -0,0 +1,15 @@
|
||||
asf-domain==0.1.0
|
||||
asf-events==0.1.0
|
||||
asf-connectors==0.1.0
|
||||
fastapi>=0.115,<1
|
||||
uvicorn[standard]>=0.30
|
||||
pydantic>=2.7
|
||||
pydantic-settings>=2.2
|
||||
motor>=3.5
|
||||
aio-pika>=9.4
|
||||
structlog>=24.4
|
||||
python-dotenv>=1.0
|
||||
httpx>=0.27
|
||||
|
||||
pytest>=8
|
||||
pytest-asyncio>=0.23
|
||||
@@ -0,0 +1,257 @@
|
||||
"""asf-context tests + relationship_service walk using in-memory repo mocks.
|
||||
|
||||
Covers:
|
||||
* In-memory FakeRelationshipRepo (outgoing / incoming / for_artifact / create / delete)
|
||||
* ContextEngine BFS 2-hop with whitelist + cap via fake services
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from asf_domain import (
|
||||
Artifact,
|
||||
ArtifactType,
|
||||
ArtifactRelationship,
|
||||
ArtifactRelationshipCreate,
|
||||
RelationshipType,
|
||||
AuditAction,
|
||||
AuditEventCreate,
|
||||
)
|
||||
|
||||
CONTEXT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(CONTEXT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(CONTEXT_ROOT))
|
||||
|
||||
TENANT_ID = UUID("11111111-0000-0000-0000-000000000001")
|
||||
ACTOR_ID = UUID("22222222-0000-0000-0000-000000000002")
|
||||
PROJ_ID = UUID("33333333-0000-0000-0000-000000000003")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# In-memory fake relationship + audit repos (matches interface subset used)
|
||||
# =========================================================================
|
||||
|
||||
class FakeRelationshipRepo:
|
||||
def __init__(self) -> None:
|
||||
self._rels: list[ArtifactRelationship] = []
|
||||
|
||||
async def create(self, tenant_id: UUID, body: ArtifactRelationshipCreate) -> ArtifactRelationship:
|
||||
from datetime import datetime, timezone
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
r = ArtifactRelationship(
|
||||
id=uuid4(),
|
||||
tenant_id=tenant_id,
|
||||
relationship_type=body.relationship_type,
|
||||
source_artifact_id=body.source_artifact_id,
|
||||
target_artifact_id=body.target_artifact_id,
|
||||
source_project_id=PROJ_ID,
|
||||
target_project_id=PROJ_ID,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
self._rels.append(r)
|
||||
return r
|
||||
|
||||
async def outgoing(
|
||||
self, tenant_id, artifact_id, *, relationship_type=None, limit=100
|
||||
) -> list[ArtifactRelationship]:
|
||||
out = [r for r in self._rels if r.tenant_id == tenant_id and r.source_artifact_id == artifact_id]
|
||||
if relationship_type is not None:
|
||||
out = [r for r in out if r.relationship_type == relationship_type]
|
||||
return out[:limit]
|
||||
|
||||
async def incoming(
|
||||
self, tenant_id, artifact_id, *, relationship_type=None, limit=100
|
||||
) -> list[ArtifactRelationship]:
|
||||
out = [r for r in self._rels if r.tenant_id == tenant_id and r.target_artifact_id == artifact_id]
|
||||
if relationship_type is not None:
|
||||
out = [r for r in out if r.relationship_type == relationship_type]
|
||||
return out[:limit]
|
||||
|
||||
async def list_for_artifact(self, tenant_id, artifact_id, *, limit=200) -> list[ArtifactRelationship]:
|
||||
out = [
|
||||
r
|
||||
for r in self._rels
|
||||
if r.tenant_id == tenant_id and (r.source_artifact_id == artifact_id or r.target_artifact_id == artifact_id)
|
||||
]
|
||||
return out[:limit]
|
||||
|
||||
async def delete(self, tenant_id, relationship_id) -> bool:
|
||||
before = len(self._rels)
|
||||
self._rels = [r for r in self._rels if not (r.tenant_id == tenant_id and r.id == relationship_id)]
|
||||
return len(self._rels) < before
|
||||
|
||||
|
||||
class FakeAuditRepo:
|
||||
def __init__(self) -> None:
|
||||
self.events: list[tuple[UUID, AuditEventCreate]] = []
|
||||
|
||||
async def append(self, tenant_id: UUID, event: AuditEventCreate) -> None:
|
||||
self.events.append((tenant_id, event))
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Lightweight "service-like" wrapper (same signatures as RelationshipService)
|
||||
# =========================================================================
|
||||
|
||||
class FakeRelService:
|
||||
"""Drop-in, dependency-free replacement for RelationshipService used by tests."""
|
||||
|
||||
def __init__(self, rels: FakeRelationshipRepo, audit: FakeAuditRepo) -> None:
|
||||
self._tenant = TENANT_ID
|
||||
self._rels = rels
|
||||
self._audit = audit
|
||||
|
||||
async def create(self, body: ArtifactRelationshipCreate) -> ArtifactRelationship:
|
||||
r = await self._rels.create(self._tenant, body)
|
||||
await self._audit.append(
|
||||
self._tenant,
|
||||
AuditEventCreate(
|
||||
action=AuditAction.CREATED,
|
||||
resource_type="artifact_relationship",
|
||||
resource_id=r.id,
|
||||
actor_id=ACTOR_ID,
|
||||
metadata={
|
||||
"source": str(r.source_artifact_id),
|
||||
"type": r.relationship_type,
|
||||
"target": str(r.target_artifact_id),
|
||||
},
|
||||
),
|
||||
)
|
||||
return r
|
||||
|
||||
async def outgoing(self, artifact_id, *, relationship_type=None, limit=100):
|
||||
return await self._rels.outgoing(self._tenant, artifact_id, relationship_type=relationship_type, limit=limit)
|
||||
|
||||
async def incoming(self, artifact_id, *, relationship_type=None, limit=100):
|
||||
return await self._rels.incoming(self._tenant, artifact_id, relationship_type=relationship_type, limit=limit)
|
||||
|
||||
async def for_artifact(self, artifact_id, *, limit=200):
|
||||
return await self._rels.list_for_artifact(self._tenant, artifact_id, limit=limit)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_rels_repo():
|
||||
return FakeRelationshipRepo()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_audit_repo():
|
||||
return FakeAuditRepo()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rel_service(fake_rels_repo, fake_audit_repo):
|
||||
return FakeRelService(fake_rels_repo, fake_audit_repo)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def chain_artifacts_ids():
|
||||
return uuid4(), uuid4(), uuid4(), uuid4()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def seeded_service(rel_service, chain_artifacts_ids):
|
||||
"""REQ --implements--> TASK --implements--> PR --generated_from--> BUILD (4 nodes, 3 edges, 2 hops from REQ)."""
|
||||
a, b, c, d = chain_artifacts_ids
|
||||
for rt, src, tgt in [
|
||||
(RelationshipType.IMPLEMENTS, a, b),
|
||||
(RelationshipType.IMPLEMENTS, b, c),
|
||||
(RelationshipType.GENERATED_FROM, c, d),
|
||||
]:
|
||||
await rel_service.create(
|
||||
ArtifactRelationshipCreate(
|
||||
source_artifact_id=src, target_artifact_id=tgt, relationship_type=rt,
|
||||
)
|
||||
)
|
||||
return rel_service, chain_artifacts_ids
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Tests
|
||||
# =========================================================================
|
||||
|
||||
def _mk_artifact(aid: UUID, t: ArtifactType, title: str) -> Artifact:
|
||||
from datetime import datetime, timezone
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
return Artifact(
|
||||
id=aid,
|
||||
tenant_id=TENANT_ID,
|
||||
project_id=PROJ_ID,
|
||||
external_id=t.name + "-" + title,
|
||||
type=t,
|
||||
title=title,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
|
||||
class TestFakeRelServiceCRUD:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_emits_audit_and_stores(self, rel_service, chain_artifacts_ids, fake_audit_repo):
|
||||
a, b, *_ = chain_artifacts_ids
|
||||
body = ArtifactRelationshipCreate(
|
||||
source_artifact_id=a, target_artifact_id=b, relationship_type=RelationshipType.RELATED_TO,
|
||||
)
|
||||
r = await rel_service.create(body)
|
||||
assert r.relationship_type == RelationshipType.RELATED_TO
|
||||
_, ev = fake_audit_repo.events[0]
|
||||
assert ev.action == AuditAction.CREATED
|
||||
assert ev.resource_type == "artifact_relationship"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_outgoing_incoming_for_artifact(self, seeded_service):
|
||||
svc, (a, b, c, d) = seeded_service
|
||||
assert len(await svc.outgoing(a)) == 1
|
||||
assert len(await svc.incoming(d)) == 1
|
||||
assert len(await svc.for_artifact(b)) == 2 # a->b + b->c
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_by_relationship_type(self, seeded_service):
|
||||
svc, (a, b, c, d) = seeded_service
|
||||
assert len(await svc.outgoing(c, relationship_type=RelationshipType.GENERATED_FROM)) == 1
|
||||
assert len(await svc.outgoing(c, relationship_type=RelationshipType.IMPLEMENTS)) == 0
|
||||
|
||||
|
||||
class TestContextEngineBFS:
|
||||
@pytest.mark.asyncio
|
||||
async def test_bfs_depth_2_stops_before_third_node(self, seeded_service, chain_artifacts_ids):
|
||||
from app.context_engine import ContextEngine, AssemblyOptions
|
||||
a, b, c, d = chain_artifacts_ids
|
||||
arts = [
|
||||
_mk_artifact(a, ArtifactType.REQUIREMENT, "A"),
|
||||
_mk_artifact(b, ArtifactType.TASK, "B"),
|
||||
_mk_artifact(c, ArtifactType.PULL_REQUEST, "C"),
|
||||
_mk_artifact(d, ArtifactType.BUILD, "D"),
|
||||
]
|
||||
|
||||
class _ArtifactSvc:
|
||||
def __init__(self): self._m = {x.id: x for x in arts}
|
||||
async def get_many(self, ids):
|
||||
return [self._m[i] for i in ids if i in self._m]
|
||||
async def list(self, **kw):
|
||||
return list(self._m.values())
|
||||
|
||||
class _RelSvc:
|
||||
def __init__(self, svc): self._svc = svc
|
||||
async def outgoing(self, aid, *, limit=100):
|
||||
return await self._svc.outgoing(aid, limit=limit)
|
||||
async def incoming(self, aid, *, limit=100):
|
||||
return await self._svc.incoming(aid, limit=limit)
|
||||
|
||||
svc, *_ = seeded_service
|
||||
engine = ContextEngine(relationship_service=_RelSvc(svc), artifact_service=_ArtifactSvc())
|
||||
opts = AssemblyOptions(max_depth=2, max_nodes=50)
|
||||
asm = await engine.assemble(tenant_id=TENANT_ID, seed_artifact_id=a, project_id=PROJ_ID, options=opts)
|
||||
summary = asm.summary()
|
||||
visited = {x.id for x in asm.all_artifacts}
|
||||
# depth=2 from a: visits a (0), b (1), c (2). NOT d (depth 3).
|
||||
assert a in visited and b in visited and c in visited
|
||||
assert d not in visited
|
||||
assert summary["total_relationships"] >= 2 # a->b + b->c traversed
|
||||
assert summary["total_artifacts"] == 3
|
||||
@@ -0,0 +1,12 @@
|
||||
"""asf-core — Core REST API service of the Azurian Software Factory.
|
||||
|
||||
Handles:
|
||||
* Organizations & Projects (tenant / sub-tenant boundaries)
|
||||
* Artifacts CRUD + queries
|
||||
* ArtifactRelationship CRUD + traversal helpers
|
||||
* Jobs (persistent lifecycle records)
|
||||
* Audit event ingestion
|
||||
* Health / readiness probes
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,3 @@
|
||||
from .router import api_v1_router
|
||||
|
||||
__all__ = ["api_v1_router"]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Typed configuration for asf-core, loaded from env vars (or .env file)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import List
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# -------- General --------
|
||||
asf_env: str = "development"
|
||||
asf_log_level: str = "INFO"
|
||||
cors_origins: List[str] = Field(default_factory=lambda: ["http://localhost:3000"])
|
||||
api_prefix: str = "/api/v1"
|
||||
|
||||
@field_validator("cors_origins", mode="before")
|
||||
@classmethod
|
||||
def _split_csv(cls, v): # noqa: D401 - allow comma separated list
|
||||
if isinstance(v, str):
|
||||
return [item.strip() for item in v.split(",") if item.strip()]
|
||||
return v
|
||||
|
||||
# -------- MongoDB --------
|
||||
mongo_uri: str = "mongodb://172.19.40.102:27017,172.19.40.103:27017/asf?replicaSet=rs0"
|
||||
mongo_db: str = "asf"
|
||||
mongo_min_pool_size: int = 5
|
||||
mongo_max_pool_size: int = 50
|
||||
|
||||
# -------- RabbitMQ --------
|
||||
rabbitmq_uri: str = "amqp://azurian:StrongPassword123!@172.19.40.201:5672/asf"
|
||||
rabbitmq_events_exchange: str = "asf.events"
|
||||
rabbitmq_jobs_exchange: str = "asf.jobs"
|
||||
queues: dict = Field(
|
||||
default_factory=lambda: {
|
||||
"ai_interactive": "asf.ai.interactive",
|
||||
"ai_background": "asf.ai.background",
|
||||
"indexing": "asf.indexing",
|
||||
"code_analysis": "asf.code_analysis",
|
||||
"testing": "asf.testing",
|
||||
"devops": "asf.devops",
|
||||
"notifications": "asf.notifications",
|
||||
}
|
||||
)
|
||||
|
||||
# -------- Redis --------
|
||||
redis_uri: str = "redis://:asfredis123@localhost:6379/0"
|
||||
|
||||
# -------- MinIO --------
|
||||
minio_host: str = "localhost:9000"
|
||||
minio_user: str = "asfadmin"
|
||||
minio_password: str = "asfadmin123"
|
||||
minio_bucket: str = "asf-artifacts"
|
||||
minio_secure: bool = False
|
||||
|
||||
# -------- Security --------
|
||||
jwt_secret: str = "change-me-in-production-please" # nosec - default only
|
||||
session_ttl_seconds: int = 86400
|
||||
|
||||
# -------- Auth (bootstrap mock) --------
|
||||
# When true, accept a trusted `X-ASF-Tenant-Id` header for local dev.
|
||||
# MUST be false in production!
|
||||
dev_trust_tenant_header: bool = True
|
||||
dev_trust_user_header: bool = True
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1,15 @@
|
||||
from .mongo import (
|
||||
get_mongo_db,
|
||||
get_mongo_client,
|
||||
MongoCollections,
|
||||
create_indexes,
|
||||
get_collection,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"get_mongo_db",
|
||||
"get_mongo_client",
|
||||
"MongoCollections",
|
||||
"create_indexes",
|
||||
"get_collection",
|
||||
]
|
||||
@@ -0,0 +1,167 @@
|
||||
"""MongoDB client + collection provider.
|
||||
|
||||
Every query is (a) tenant-scoped and (b) backed by explicit indexes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncIterator
|
||||
|
||||
from motor.motor_asyncio import (
|
||||
AsyncIOMotorClient,
|
||||
AsyncIOMotorCollection,
|
||||
AsyncIOMotorDatabase,
|
||||
)
|
||||
from pymongo import IndexModel
|
||||
import structlog
|
||||
|
||||
from ..config import get_settings
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
_client: AsyncIOMotorClient | None = None
|
||||
_db: AsyncIOMotorDatabase | None = None
|
||||
|
||||
|
||||
class MongoCollections(str):
|
||||
ORGANIZATIONS = "organizations"
|
||||
USERS = "users"
|
||||
TEAMS = "teams"
|
||||
PROJECTS = "projects"
|
||||
ARTIFACTS = "artifacts"
|
||||
ARTIFACT_RELATIONSHIPS = "artifact_relationships"
|
||||
INTEGRATIONS = "integrations"
|
||||
JOBS = "jobs"
|
||||
AI_EXECUTIONS = "ai_executions"
|
||||
AUDIT_EVENTS = "audit_events"
|
||||
|
||||
|
||||
def get_mongo_client() -> AsyncIOMotorClient:
|
||||
global _client
|
||||
if _client is None:
|
||||
s = get_settings()
|
||||
_client = AsyncIOMotorClient(
|
||||
s.mongo_uri,
|
||||
minPoolSize=s.mongo_min_pool_size,
|
||||
maxPoolSize=s.mongo_max_pool_size,
|
||||
uuidRepresentation="standard",
|
||||
tz_aware=True,
|
||||
)
|
||||
return _client
|
||||
|
||||
|
||||
def get_mongo_db() -> AsyncIOMotorDatabase:
|
||||
global _db
|
||||
if _db is None:
|
||||
s = get_settings()
|
||||
_db = get_mongo_client()[s.mongo_db]
|
||||
return _db
|
||||
|
||||
|
||||
def get_collection(name: MongoCollections | str) -> AsyncIOMotorCollection:
|
||||
return get_mongo_db()[name]
|
||||
|
||||
|
||||
async def create_indexes() -> None:
|
||||
"""Create (idempotent) indexes. Called once on service startup.
|
||||
|
||||
Rule of thumb for tenanted data: compound index with ``tenant_id`` first,
|
||||
then the most common filter / sort key.
|
||||
"""
|
||||
|
||||
db = get_mongo_db()
|
||||
|
||||
# --- organizations ---
|
||||
await db.organizations.create_indexes(
|
||||
[
|
||||
IndexModel("slug", unique=True, name="orgs_slug_unique"),
|
||||
]
|
||||
)
|
||||
|
||||
# --- users ---
|
||||
await db.users.create_indexes(
|
||||
[
|
||||
IndexModel([("tenant_id", 1), ("email", 1)], unique=True, name="users_tenant_email_unique"),
|
||||
]
|
||||
)
|
||||
|
||||
# --- projects ---
|
||||
await db.projects.create_indexes(
|
||||
[
|
||||
IndexModel([("tenant_id", 1), ("key", 1)], unique=True, name="projects_tenant_key_unique"),
|
||||
IndexModel([("tenant_id", 1), ("is_active", 1)], name="projects_tenant_active"),
|
||||
]
|
||||
)
|
||||
|
||||
# --- artifacts ---
|
||||
await db.artifacts.create_indexes(
|
||||
[
|
||||
IndexModel([("tenant_id", 1), ("project_id", 1)], name="arts_tenant_project"),
|
||||
IndexModel([("tenant_id", 1), ("project_id", 1), ("type", 1)], name="arts_tenant_project_type"),
|
||||
IndexModel([("tenant_id", 1), ("external_id", 1)], name="arts_tenant_external_id", sparse=True),
|
||||
IndexModel([("tenant_id", 1), ("created_at", -1)], name="arts_tenant_created_desc"),
|
||||
]
|
||||
)
|
||||
|
||||
# --- artifact_relationships ---
|
||||
# Two indexes: one for "where does this artifact point TO", one for "what points TO this artifact"
|
||||
await db.artifact_relationships.create_indexes(
|
||||
[
|
||||
IndexModel([("tenant_id", 1), ("source_artifact_id", 1), ("relationship_type", 1)],
|
||||
name="rels_tenant_source_type"),
|
||||
IndexModel([("tenant_id", 1), ("target_artifact_id", 1), ("relationship_type", 1)],
|
||||
name="rels_tenant_target_type"),
|
||||
IndexModel([("source_artifact_id", 1), ("target_artifact_id", 1), ("relationship_type", 1)],
|
||||
unique=True, name="rels_unique_edge"),
|
||||
]
|
||||
)
|
||||
|
||||
# --- jobs ---
|
||||
await db.jobs.create_indexes(
|
||||
[
|
||||
IndexModel([("tenant_id", 1), ("status", 1), ("priority", -1), ("created_at", 1)],
|
||||
name="jobs_tenant_status_priority"),
|
||||
IndexModel([("tenant_id", 1), ("project_id", 1), ("created_at", -1)],
|
||||
name="jobs_tenant_project_created_desc"),
|
||||
IndexModel([("correlation_id", 1)], name="jobs_correlation_id", sparse=True),
|
||||
]
|
||||
)
|
||||
|
||||
# --- audit_events (append-only, oldest-to-newest scans normal) ---
|
||||
await db.audit_events.create_indexes(
|
||||
[
|
||||
IndexModel([("tenant_id", 1), ("created_at", -1)], name="audit_tenant_created_desc"),
|
||||
IndexModel([("tenant_id", 1), ("actor_id", 1), ("created_at", -1)], name="audit_tenant_actor_desc"),
|
||||
IndexModel([("correlation_id", 1)], name="audit_correlation_id", sparse=True),
|
||||
]
|
||||
)
|
||||
|
||||
# --- ai_executions ---
|
||||
await db.ai_executions.create_indexes(
|
||||
[
|
||||
IndexModel([("tenant_id", 1), ("created_at", -1)], name="aie_tenant_created_desc"),
|
||||
IndexModel([("tenant_id", 1), ("job_id", 1)], name="aie_tenant_job", sparse=True),
|
||||
]
|
||||
)
|
||||
|
||||
await log.adebug("mongo_indexes_ready")
|
||||
|
||||
|
||||
async def close_mongo() -> None:
|
||||
global _client, _db
|
||||
if _client is not None:
|
||||
_client.close()
|
||||
_client = None
|
||||
_db = None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def mongo_lifespan() -> AsyncIterator[None]:
|
||||
"""Async lifespan helper for FastAPI startup / shutdown."""
|
||||
await create_indexes()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await close_mongo()
|
||||
@@ -0,0 +1,78 @@
|
||||
"""FastAPI dependency injection helpers.
|
||||
|
||||
Use these as ``Depends(...)`` in your route handlers. They provide:
|
||||
* validated request context (tenant + user id)
|
||||
* Mongo collections
|
||||
* Event publisher
|
||||
* Settings
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import AsyncIterator
|
||||
|
||||
from fastapi import Depends
|
||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||
|
||||
from .config import Settings, get_settings
|
||||
from .db.mongo import get_collection, MongoCollections
|
||||
from .middleware.correlation import RequestContext, get_current_context
|
||||
from .messaging.rabbitmq import EventPublisher, get_event_publisher
|
||||
|
||||
|
||||
def get_req_ctx(ctx: RequestContext = Depends(get_current_context)) -> RequestContext:
|
||||
"""Return the current authenticated request context.
|
||||
|
||||
Raises 401 if there is no tenant context — so the handler can rely on
|
||||
``ctx.tenant_id`` being non-None.
|
||||
"""
|
||||
ctx.require_tenant()
|
||||
return ctx
|
||||
|
||||
|
||||
async def coll_orgs() -> AsyncIterator[AsyncIOMotorCollection]:
|
||||
yield get_collection(MongoCollections.ORGANIZATIONS)
|
||||
|
||||
|
||||
async def coll_projects() -> AsyncIterator[AsyncIOMotorCollection]:
|
||||
yield get_collection(MongoCollections.PROJECTS)
|
||||
|
||||
|
||||
async def coll_artifacts() -> AsyncIterator[AsyncIOMotorCollection]:
|
||||
yield get_collection(MongoCollections.ARTIFACTS)
|
||||
|
||||
|
||||
async def coll_relationships() -> AsyncIterator[AsyncIOMotorCollection]:
|
||||
yield get_collection(MongoCollections.ARTIFACT_RELATIONSHIPS)
|
||||
|
||||
|
||||
async def coll_jobs() -> AsyncIterator[AsyncIOMotorCollection]:
|
||||
yield get_collection(MongoCollections.JOBS)
|
||||
|
||||
|
||||
async def coll_audit() -> AsyncIterator[AsyncIOMotorCollection]:
|
||||
yield get_collection(MongoCollections.AUDIT_EVENTS)
|
||||
|
||||
|
||||
async def coll_ai_exec() -> AsyncIterator[AsyncIOMotorCollection]:
|
||||
yield get_collection(MongoCollections.AI_EXECUTIONS)
|
||||
|
||||
|
||||
async def publisher() -> AsyncIterator[EventPublisher]:
|
||||
yield await get_event_publisher()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Settings",
|
||||
"get_settings",
|
||||
"RequestContext",
|
||||
"get_req_ctx",
|
||||
"coll_orgs",
|
||||
"coll_projects",
|
||||
"coll_artifacts",
|
||||
"coll_relationships",
|
||||
"coll_jobs",
|
||||
"coll_audit",
|
||||
"coll_ai_exec",
|
||||
"publisher",
|
||||
]
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Structured JSON logging setup for asf-core.
|
||||
|
||||
In production this is consumed by the log collector and forwarded to the
|
||||
observability stack. For local dev we keep a human-readable fallback.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
from .config import get_settings
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
s = get_settings()
|
||||
level: str = s.asf_log_level.upper()
|
||||
is_dev: bool = s.asf_env in {"development", "dev", "local"}
|
||||
|
||||
shared_processors: list[Any] = [
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.stdlib.add_logger_name,
|
||||
structlog.processors.add_log_level,
|
||||
structlog.processors.TimeStamper(fmt="iso", utc=True, key="@timestamp"),
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
]
|
||||
|
||||
structlog.configure(
|
||||
processors=shared_processors
|
||||
+ [
|
||||
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
||||
],
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
formatter_renderer: Any = (
|
||||
structlog.dev.ConsoleRenderer()
|
||||
if is_dev
|
||||
else structlog.processors.JSONRenderer(sort_keys=False)
|
||||
)
|
||||
|
||||
formatter = structlog.stdlib.ProcessorFormatter(
|
||||
foreign_pre_chain=shared_processors,
|
||||
processors=[
|
||||
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
||||
formatter_renderer,
|
||||
],
|
||||
)
|
||||
|
||||
handler = logging.StreamHandler(stream=sys.stdout)
|
||||
handler.setFormatter(formatter)
|
||||
root = logging.getLogger()
|
||||
root.handlers.clear()
|
||||
root.addHandler(handler)
|
||||
root.setLevel(getattr(logging, level, logging.INFO))
|
||||
|
||||
# silence noisy third parties
|
||||
for _name in ("uvicorn.access", "httpx", "httpcore"):
|
||||
logging.getLogger(_name).setLevel(logging.WARNING)
|
||||
@@ -0,0 +1,174 @@
|
||||
"""ASF Core — FastAPI application entrypoint.
|
||||
|
||||
Run locally with:
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from .config import get_settings
|
||||
from .logging_config import setup_logging
|
||||
from .middleware.correlation import CorrelationMiddleware
|
||||
from .db.mongo import mongo_lifespan, create_indexes, close_mongo
|
||||
from .messaging.rabbitmq import (
|
||||
rabbitmq_lifespan,
|
||||
close_rabbitmq,
|
||||
get_event_publisher,
|
||||
)
|
||||
from .api.v1 import api_v1_router
|
||||
from .repositories import (
|
||||
NotFoundError,
|
||||
DuplicateError,
|
||||
TenantMismatchError,
|
||||
)
|
||||
|
||||
setup_logging()
|
||||
|
||||
s = get_settings()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI): # noqa: ARG001 - signature required by FastAPI
|
||||
"""Startup / shutdown: ensure indexes, open/close connections."""
|
||||
import structlog
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
try:
|
||||
await create_indexes()
|
||||
except Exception: # noqa: BLE001
|
||||
await log.aexception("mongo_indexes_failed")
|
||||
|
||||
try:
|
||||
await get_event_publisher()
|
||||
except Exception: # noqa: BLE001
|
||||
# If RabbitMQ isn't reachable yet we still start the API — core
|
||||
# writes Mongo first and events are best-effort during startup.
|
||||
await log.awarning("rabbitmq_not_available_on_startup")
|
||||
|
||||
yield
|
||||
|
||||
await close_rabbitmq()
|
||||
await close_mongo()
|
||||
await log.ainfo("asf_core_shutdown")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App object
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
app = FastAPI(
|
||||
title="Azurian Software Factory (ASF) — Core API",
|
||||
description=(
|
||||
"Multi-tenant REST API for Azurian Software Factory. "
|
||||
"Handles Organizations, Projects, Artifacts, Artifact Relationships, "
|
||||
"Jobs, Audit events, and dashboard aggregations. "
|
||||
"All business operations must go through this API — "
|
||||
"never access MongoDB / RabbitMQ directly from the browser."
|
||||
),
|
||||
version="0.1.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
openapi_url="/openapi.json",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Middleware
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=s.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=[
|
||||
"X-Correlation-Id",
|
||||
],
|
||||
)
|
||||
|
||||
# Correlation / context (tenant + user id extraction in dev-trust mode)
|
||||
app.add_middleware(CorrelationMiddleware)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error handlers — translate repository exceptions to consistent HTTP shapes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.exception_handler(NotFoundError)
|
||||
async def _not_found(req: Request, exc: NotFoundError) -> JSONResponse: # noqa: ARG001
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={
|
||||
"error": "not_found",
|
||||
"kind": exc.kind,
|
||||
"entity_id": str(exc.entity_id),
|
||||
"message": str(exc),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(DuplicateError)
|
||||
async def _duplicate(req: Request, exc: DuplicateError) -> JSONResponse: # noqa: ARG001
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
content={
|
||||
"error": "duplicate",
|
||||
"kind": exc.kind,
|
||||
"key": exc.key,
|
||||
"message": str(exc),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(TenantMismatchError)
|
||||
async def _tenant_mismatch(req: Request, exc: TenantMismatchError) -> JSONResponse: # noqa: ARG001
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
content={
|
||||
"error": "tenant_isolation_violation",
|
||||
"kind": exc.kind,
|
||||
"entity_id": str(exc.entity_id),
|
||||
"message": str(exc),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def _unhandled(req: Request, exc: Exception) -> JSONResponse: # noqa: ARG001
|
||||
import structlog
|
||||
|
||||
await structlog.get_logger(__name__).aexception("unhandled_exception")
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={
|
||||
"error": "internal_server_error",
|
||||
"message": "An unexpected error occurred. The correlation id has been logged.",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
app.include_router(api_v1_router, prefix=s.api_prefix)
|
||||
|
||||
|
||||
@app.get("/", tags=["meta"])
|
||||
async def root() -> dict:
|
||||
return {
|
||||
"name": "asf-core",
|
||||
"version": "0.1.0",
|
||||
"docs": "/docs",
|
||||
"health": f"{s.api_prefix}/health",
|
||||
"tagline": "From Requirement to Production, everything is contextualized, traceable and AI-accessible.",
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
from .rabbitmq import (
|
||||
get_event_publisher,
|
||||
EventPublisher,
|
||||
publish_domain_event,
|
||||
enqueue_job,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"get_event_publisher",
|
||||
"EventPublisher",
|
||||
"publish_domain_event",
|
||||
"enqueue_job",
|
||||
]
|
||||
@@ -0,0 +1,199 @@
|
||||
"""RabbitMQ producer for asf-core.
|
||||
|
||||
asf-core publishes two classes of messages:
|
||||
1. Domain events → ``asf.events`` topic exchange (e.g. artifact.created)
|
||||
2. Job requests → ``asf.jobs`` direct exchange, routed to the correct
|
||||
logical queue (asf.ai.interactive, asf.indexing, …).
|
||||
|
||||
Core never consumes messages directly — that is the responsibility of
|
||||
asf-worker. Connections are opened lazily and reused.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncIterator
|
||||
from uuid import UUID
|
||||
|
||||
import aio_pika
|
||||
import aio_pika.abc
|
||||
import structlog
|
||||
|
||||
from ..config import get_settings
|
||||
from asf_domain.base import JobType
|
||||
from asf_events.base import ASFEvent, JobRequestEnvelope
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
_CHANNEL_POOL: aio_pika.abc.AbstractChannel | None = None
|
||||
_CONN: aio_pika.abc.AbstractConnection | None = None
|
||||
|
||||
|
||||
class EventPublisher:
|
||||
"""Wraps a RabbitMQ channel with helpers for ASF message classes."""
|
||||
|
||||
def __init__(self, channel: aio_pika.abc.AbstractChannel) -> None:
|
||||
self._channel = channel
|
||||
self._settings = get_settings()
|
||||
|
||||
async def declare_exchanges(self) -> None:
|
||||
events_ex = await self._channel.declare_exchange(
|
||||
self._settings.rabbitmq_events_exchange,
|
||||
type=aio_pika.ExchangeType.TOPIC,
|
||||
durable=True,
|
||||
)
|
||||
jobs_ex = await self._channel.declare_exchange(
|
||||
self._settings.rabbitmq_jobs_exchange,
|
||||
type=aio_pika.ExchangeType.DIRECT,
|
||||
durable=True,
|
||||
)
|
||||
|
||||
# ---- Domain event queues (TOPIC bindings on asf.events) ----
|
||||
domain_binds: list[tuple[str, list[str]]] = [
|
||||
("asf.organization.created", ["organization.created"]),
|
||||
("asf.project.created", ["project.created"]),
|
||||
("asf.project.updated", ["project.updated"]),
|
||||
("asf.artifact.created", ["artifact.created"]),
|
||||
("asf.artifact.updated", ["artifact.updated"]),
|
||||
("asf.audit", ["audit.#"]),
|
||||
("asf.ai.execution", ["ai.execution.*"]),
|
||||
]
|
||||
for qname, keys in domain_binds:
|
||||
q = await self._channel.declare_queue(qname, durable=True)
|
||||
for rk in keys:
|
||||
await q.bind(events_ex, routing_key=rk)
|
||||
|
||||
# Dead-letter queue (no default bind — consumed explicitly by worker topology)
|
||||
await self._channel.declare_queue("asf.deadletter", durable=True)
|
||||
|
||||
# ---- Job queues (DIRECT bindings on asf.jobs) ----
|
||||
for qname in self._settings.queues.values():
|
||||
q = await self._channel.declare_queue(qname, durable=True)
|
||||
await q.bind(jobs_ex, routing_key=qname)
|
||||
|
||||
# -------- Domain events --------
|
||||
async def publish_event(self, event: ASFEvent) -> None:
|
||||
body = event.model_dump_json().encode("utf-8")
|
||||
exchange = await self._channel.get_exchange(self._settings.rabbitmq_events_exchange)
|
||||
await exchange.publish(
|
||||
aio_pika.Message(
|
||||
body=body,
|
||||
delivery_mode=aio_pika.DeliveryMode.PERSISTENT,
|
||||
content_type="application/json",
|
||||
message_id=str(event.event_id),
|
||||
timestamp=event.timestamp,
|
||||
headers={
|
||||
"tenant_id": str(event.tenant_id),
|
||||
"event_type": event.event_type,
|
||||
},
|
||||
),
|
||||
routing_key=event.event_type,
|
||||
)
|
||||
await log.adebug(
|
||||
"event_published",
|
||||
event_type=event.event_type,
|
||||
event_id=str(event.event_id),
|
||||
)
|
||||
|
||||
# -------- Job enqueueing --------
|
||||
async def enqueue_job(self, envelope: JobRequestEnvelope) -> None:
|
||||
qname = self._routing_key_for_job_type(envelope.type)
|
||||
body = envelope.model_dump_json().encode("utf-8")
|
||||
exchange = await self._channel.get_exchange(self._settings.rabbitmq_jobs_exchange)
|
||||
await exchange.publish(
|
||||
aio_pika.Message(
|
||||
body=body,
|
||||
delivery_mode=aio_pika.DeliveryMode.PERSISTENT,
|
||||
content_type="application/json",
|
||||
message_id=str(envelope.job_id),
|
||||
priority=self._priority(envelope.priority),
|
||||
headers={
|
||||
"tenant_id": str(envelope.tenant_id),
|
||||
"job_type": envelope.type.value,
|
||||
},
|
||||
),
|
||||
routing_key=qname,
|
||||
)
|
||||
await log.adebug(
|
||||
"job_enqueued",
|
||||
job_id=str(envelope.job_id),
|
||||
queue=qname,
|
||||
)
|
||||
|
||||
def _routing_key_for_job_type(self, t: JobType) -> str:
|
||||
mapping = {
|
||||
JobType.AI_INTERACTIVE: "ai_interactive",
|
||||
JobType.AI_BACKGROUND: "ai_background",
|
||||
JobType.INDEXING: "indexing",
|
||||
JobType.CODE_ANALYSIS: "code_analysis",
|
||||
JobType.TESTING: "testing",
|
||||
JobType.DEVOPS: "devops",
|
||||
JobType.NOTIFICATION: "notifications",
|
||||
JobType.CUSTOM: "notifications",
|
||||
}
|
||||
key = mapping[t]
|
||||
return self._settings.queues[key]
|
||||
|
||||
@staticmethod
|
||||
def _priority(p) -> int:
|
||||
return {
|
||||
"low": 1,
|
||||
"normal": 5,
|
||||
"high": 8,
|
||||
"urgent": 10,
|
||||
}.get(getattr(p, "value", str(p)), 5)
|
||||
|
||||
|
||||
async def _get_channel() -> aio_pika.abc.AbstractChannel:
|
||||
global _CHANNEL_POOL, _CONN
|
||||
if _CHANNEL_POOL is None or _CHANNEL_POOL.is_closed:
|
||||
s = get_settings()
|
||||
_CONN = await aio_pika.connect_robust(s.rabbitmq_uri)
|
||||
_CHANNEL_POOL = await _CONN.channel()
|
||||
await _CHANNEL_POOL.set_qos(prefetch_count=100)
|
||||
return _CHANNEL_POOL
|
||||
|
||||
|
||||
async def get_event_publisher() -> EventPublisher:
|
||||
ch = await _get_channel()
|
||||
pub = EventPublisher(ch)
|
||||
await pub.declare_exchanges()
|
||||
return pub
|
||||
|
||||
|
||||
async def close_rabbitmq() -> None:
|
||||
global _CHANNEL_POOL, _CONN
|
||||
try:
|
||||
if _CHANNEL_POOL is not None:
|
||||
await _CHANNEL_POOL.close()
|
||||
except Exception: # noqa: BLE001 - shutdown best effort
|
||||
pass
|
||||
try:
|
||||
if _CONN is not None:
|
||||
await _CONN.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
_CHANNEL_POOL = None
|
||||
_CONN = None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def rabbitmq_lifespan() -> AsyncIterator[None]:
|
||||
await get_event_publisher() # prime connection
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await close_rabbitmq()
|
||||
|
||||
|
||||
# -------- Convenience helpers (used by services) --------
|
||||
|
||||
async def publish_domain_event(event: ASFEvent) -> None:
|
||||
pub = await get_event_publisher()
|
||||
await pub.publish_event(event)
|
||||
|
||||
|
||||
async def enqueue_job(envelope: JobRequestEnvelope) -> None:
|
||||
pub = await get_event_publisher()
|
||||
await pub.enqueue_job(envelope)
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Correlation id + observability middleware.
|
||||
|
||||
Every inbound request gets a ``correlation_id`` which is:
|
||||
1. Injected as a structlog context variable for every log line.
|
||||
2. Returned to the caller via the ``X-Correlation-Id`` response header.
|
||||
3. Propagated to downstream calls (events, workers) that happen within
|
||||
the request lifecycle.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
import structlog
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
|
||||
CORRELATION_HEADER = "X-Correlation-Id"
|
||||
TENANT_HEADER = "X-ASF-Tenant-Id"
|
||||
USER_HEADER = "X-ASF-User-Id"
|
||||
|
||||
# Deterministic UUID namespace for dev-trust string IDs.
|
||||
# Any non-UUID string supplied via X-ASF headers is mapped to a stable UUIDv5
|
||||
# so that the domain model (which uses UUID IDs internally) stays consistent
|
||||
# while still allowing human-friendly strings like "t-demo-001" during local dev.
|
||||
_ASP_NAMESPACE = uuid.UUID("9a8f2d58-14b4-40e1-93d3-1e4c2f0e6a22")
|
||||
|
||||
|
||||
def _coerce_uuid(value: str) -> uuid.UUID:
|
||||
"""Accept raw UUID hex strings or arbitrary opaque identifiers (UUID5 fallback)."""
|
||||
try:
|
||||
return uuid.UUID(value)
|
||||
except ValueError:
|
||||
return uuid.uuid5(_ASP_NAMESPACE, value)
|
||||
|
||||
correlation_id_var: ContextVar[str | None] = ContextVar(
|
||||
"correlation_id", default=None
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RequestContext:
|
||||
"""Tenant + identity context extracted from the authenticated call.
|
||||
|
||||
For the bootstrap phase we accept ``X-ASF-Tenant-Id`` / ``X-ASF-User-Id``
|
||||
headers directly when ``dev_trust_tenant_header`` is true in settings.
|
||||
|
||||
In production these fields MUST come from a validated JWT / session —
|
||||
NEVER from raw client headers.
|
||||
"""
|
||||
|
||||
tenant_id: uuid.UUID | None
|
||||
user_id: uuid.UUID | None
|
||||
correlation_id: str
|
||||
roles: list[str]
|
||||
|
||||
def require_tenant(self) -> uuid.UUID:
|
||||
if self.tenant_id is None:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=401, detail="Missing authenticated tenant context")
|
||||
return self.tenant_id
|
||||
|
||||
def require_user(self) -> uuid.UUID:
|
||||
if self.user_id is None:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=401, detail="Missing authenticated user")
|
||||
return self.user_id
|
||||
|
||||
|
||||
request_context_var: ContextVar[RequestContext | None] = ContextVar(
|
||||
"request_context", default=None
|
||||
)
|
||||
|
||||
|
||||
def get_current_context() -> RequestContext:
|
||||
ctx = request_context_var.get()
|
||||
if ctx is None:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=401, detail="No request context available")
|
||||
return ctx
|
||||
|
||||
|
||||
class CorrelationMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(
|
||||
self,
|
||||
request: Request,
|
||||
call_next: Callable[[Request], Awaitable[Response]],
|
||||
) -> Response:
|
||||
cid = request.headers.get(CORRELATION_HEADER) or uuid.uuid4().hex
|
||||
correlation_id_var.set(cid)
|
||||
|
||||
with structlog.contextvars.bound_contextvars(correlation_id=cid):
|
||||
try:
|
||||
ctx = _extract_context(request, cid)
|
||||
except _ContextError as exc:
|
||||
# BaseHTTPMiddleware runs before FastAPI exception handlers.
|
||||
# Build the error response directly instead of raising HTTPException.
|
||||
resp = JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"error": exc.kind, "message": exc.message},
|
||||
)
|
||||
resp.headers[CORRELATION_HEADER] = cid
|
||||
return resp
|
||||
|
||||
request_context_var.set(ctx)
|
||||
structlog.contextvars.bind_contextvars(
|
||||
tenant_id=str(ctx.tenant_id) if ctx.tenant_id else None,
|
||||
user_id=str(ctx.user_id) if ctx.user_id else None,
|
||||
)
|
||||
|
||||
response = await call_next(request)
|
||||
response.headers[CORRELATION_HEADER] = cid
|
||||
return response
|
||||
|
||||
|
||||
class _ContextError(Exception):
|
||||
"""Raised internally by _extract_context to short-circuit auth failures."""
|
||||
|
||||
def __init__(self, status_code: int, kind: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.kind = kind
|
||||
self.message = message
|
||||
|
||||
|
||||
def _extract_context(request: Request, cid: str) -> RequestContext:
|
||||
from ..config import get_settings
|
||||
|
||||
s = get_settings()
|
||||
tenant_id: uuid.UUID | None = None
|
||||
user_id: uuid.UUID | None = None
|
||||
roles: list[str] = []
|
||||
|
||||
# ---- Bootstrap-trust mode for local dev ----
|
||||
if s.dev_trust_tenant_header and TENANT_HEADER in request.headers:
|
||||
raw = request.headers[TENANT_HEADER].strip()
|
||||
if not raw:
|
||||
raise _ContextError(400, "invalid_header", f"Empty {TENANT_HEADER}")
|
||||
tenant_id = _coerce_uuid(raw)
|
||||
|
||||
if s.dev_trust_user_header and USER_HEADER in request.headers:
|
||||
raw = request.headers[USER_HEADER].strip()
|
||||
if not raw:
|
||||
raise _ContextError(400, "invalid_header", f"Empty {USER_HEADER}")
|
||||
user_id = _coerce_uuid(raw)
|
||||
roles = ["admin"]
|
||||
|
||||
return RequestContext(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
correlation_id=cid,
|
||||
roles=roles,
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
from .base import NotFoundError, DuplicateError, TenantMismatchError
|
||||
from .organization_repo import OrganizationRepository
|
||||
from .project_repo import ProjectRepository
|
||||
from .artifact_repo import ArtifactRepository
|
||||
from .relationship_repo import RelationshipRepository
|
||||
from .job_repo import JobRepository
|
||||
from .audit_repo import AuditRepository
|
||||
|
||||
__all__ = [
|
||||
"NotFoundError",
|
||||
"DuplicateError",
|
||||
"TenantMismatchError",
|
||||
"OrganizationRepository",
|
||||
"ProjectRepository",
|
||||
"ArtifactRepository",
|
||||
"RelationshipRepository",
|
||||
"JobRepository",
|
||||
"AuditRepository",
|
||||
]
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Artifact repository — single collection for every artifact type."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||
|
||||
from asf_domain import (
|
||||
Artifact,
|
||||
ArtifactCreate,
|
||||
ArtifactUpdate,
|
||||
ArtifactType,
|
||||
)
|
||||
|
||||
from .base import (
|
||||
NotFoundError,
|
||||
TenantMismatchError,
|
||||
_now_utc,
|
||||
_to_doc,
|
||||
_from_doc,
|
||||
generate_id,
|
||||
)
|
||||
|
||||
|
||||
class ArtifactRepository:
|
||||
def __init__(self, collection: AsyncIOMotorCollection) -> None:
|
||||
self._c = collection
|
||||
|
||||
def _q(self, tenant_id, project_id=None, extra=None) -> dict:
|
||||
q: dict = {"tenant_id": tenant_id}
|
||||
if project_id is not None:
|
||||
q["project_id"] = project_id
|
||||
if extra:
|
||||
q.update(extra)
|
||||
return q
|
||||
|
||||
# ---- CRUD ----
|
||||
async def get(self, tenant_id: UUID, artifact_id: UUID) -> Optional[Artifact]:
|
||||
doc = await self._c.find_one(self._q(tenant_id, extra={"_id": artifact_id}))
|
||||
return _from_doc(Artifact, doc)
|
||||
|
||||
async def list(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
project_id: Optional[UUID] = None,
|
||||
artifact_type: Optional[ArtifactType | str] = None,
|
||||
status: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[Artifact]:
|
||||
q = self._q(tenant_id, project_id)
|
||||
if artifact_type is not None:
|
||||
q["type"] = artifact_type if isinstance(artifact_type, str) else artifact_type.value
|
||||
if status is not None:
|
||||
q["status"] = status
|
||||
if search:
|
||||
q["$or"] = [
|
||||
{"title": {"$regex": search, "$options": "i"}},
|
||||
{"content": {"$regex": search, "$options": "i"}},
|
||||
]
|
||||
cur = (
|
||||
self._c.find(q)
|
||||
.sort("created_at", -1)
|
||||
.skip(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
return [_from_doc(Artifact, d) for d in await cur.to_list(length=limit)]
|
||||
|
||||
async def count(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
project_id: Optional[UUID] = None,
|
||||
artifact_type: Optional[str] = None,
|
||||
) -> int:
|
||||
q = self._q(tenant_id, project_id)
|
||||
if artifact_type is not None:
|
||||
q["type"] = artifact_type
|
||||
return await self._c.count_documents(q)
|
||||
|
||||
async def create(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
body: ArtifactCreate,
|
||||
*,
|
||||
created_by: Optional[UUID] = None,
|
||||
) -> Artifact:
|
||||
now = _now_utc()
|
||||
a = Artifact(
|
||||
id=generate_id(),
|
||||
tenant_id=tenant_id,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
created_by=created_by,
|
||||
version=1,
|
||||
**body.model_dump(),
|
||||
)
|
||||
doc = _to_doc(a)
|
||||
doc["_id"] = doc.pop("id")
|
||||
await self._c.insert_one(doc)
|
||||
return a
|
||||
|
||||
async def update(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
artifact_id: UUID,
|
||||
body: ArtifactUpdate,
|
||||
) -> Artifact:
|
||||
raw = await self._c.find_one({"_id": artifact_id})
|
||||
if raw is None:
|
||||
raise NotFoundError("Artifact", artifact_id)
|
||||
if raw.get("tenant_id") != tenant_id:
|
||||
raise TenantMismatchError("Artifact", artifact_id)
|
||||
current: Artifact = _from_doc(Artifact, raw)
|
||||
|
||||
changes = body.model_dump(exclude_unset=True)
|
||||
metadata_update = changes.pop("metadata", None)
|
||||
|
||||
final_metadata = current.metadata
|
||||
if metadata_update is not None:
|
||||
final_metadata = {**final_metadata, **metadata_update}
|
||||
changes["metadata"] = final_metadata
|
||||
if not changes:
|
||||
return current
|
||||
|
||||
changes["version"] = current.version + 1
|
||||
changes["updated_at"] = _now_utc()
|
||||
await self._c.update_one({"_id": artifact_id}, {"$set": changes})
|
||||
return current.model_copy(update=changes)
|
||||
|
||||
async def delete(self, tenant_id: UUID, artifact_id: UUID) -> bool:
|
||||
raw = await self._c.find_one({"_id": artifact_id})
|
||||
if raw is None:
|
||||
raise NotFoundError("Artifact", artifact_id)
|
||||
if raw.get("tenant_id") != tenant_id:
|
||||
raise TenantMismatchError("Artifact", artifact_id)
|
||||
res = await self._c.delete_one({"_id": artifact_id})
|
||||
return res.deleted_count > 0
|
||||
|
||||
async def get_many(self, tenant_id: UUID, artifact_ids: list[UUID]) -> list[Artifact]:
|
||||
if not artifact_ids:
|
||||
return []
|
||||
cur = self._c.find(self._q(tenant_id, extra={"_id": {"$in": artifact_ids}}))
|
||||
return [_from_doc(Artifact, d) async for d in cur]
|
||||
@@ -0,0 +1,56 @@
|
||||
"""AuditEvent repository — append-only, never updates or deletes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||
|
||||
from asf_domain import AuditEvent, AuditEventCreate
|
||||
|
||||
from .base import (
|
||||
_now_utc,
|
||||
_to_doc,
|
||||
_from_doc,
|
||||
generate_id,
|
||||
)
|
||||
|
||||
|
||||
class AuditRepository:
|
||||
def __init__(self, collection: AsyncIOMotorCollection) -> None:
|
||||
self._c = collection
|
||||
|
||||
async def append(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
body: AuditEventCreate,
|
||||
) -> AuditEvent:
|
||||
now = _now_utc()
|
||||
ae = AuditEvent(
|
||||
id=generate_id(),
|
||||
tenant_id=tenant_id,
|
||||
created_at=now,
|
||||
**body.model_dump(),
|
||||
)
|
||||
doc = _to_doc(ae)
|
||||
doc["_id"] = doc.pop("id")
|
||||
await self._c.insert_one(doc)
|
||||
return ae
|
||||
|
||||
async def list(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
actor_id: Optional[UUID] = None,
|
||||
resource_type: Optional[str] = None,
|
||||
) -> list[AuditEvent]:
|
||||
q: dict = {"tenant_id": tenant_id}
|
||||
if actor_id is not None:
|
||||
q["actor_id"] = actor_id
|
||||
if resource_type is not None:
|
||||
q["resource_type"] = resource_type
|
||||
cur = self._c.find(q).sort("created_at", -1).skip(offset).limit(limit)
|
||||
return [_from_doc(AuditEvent, d) async for d in cur]
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Repository base exceptions + shared helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Mapping
|
||||
from uuid import UUID
|
||||
|
||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||
from pydantic import BaseModel
|
||||
|
||||
from asf_domain.base import generate_id
|
||||
|
||||
|
||||
class ASFRepositoryError(Exception):
|
||||
"""Base for all repository-layer errors."""
|
||||
|
||||
|
||||
class NotFoundError(ASFRepositoryError):
|
||||
def __init__(self, kind: str, entity_id: UUID | str) -> None:
|
||||
super().__init__(f"{kind} not found: {entity_id}")
|
||||
self.kind = kind
|
||||
self.entity_id = entity_id
|
||||
|
||||
|
||||
class DuplicateError(ASFRepositoryError):
|
||||
def __init__(self, kind: str, key: str) -> None:
|
||||
super().__init__(f"{kind} already exists: {key}")
|
||||
self.kind = kind
|
||||
self.key = key
|
||||
|
||||
|
||||
class TenantMismatchError(ASFRepositoryError):
|
||||
"""Raised when an operation targets an entity owned by another tenant."""
|
||||
|
||||
def __init__(self, kind: str, entity_id: UUID | str) -> None:
|
||||
super().__init__(f"Tenant isolation violation: {kind} {entity_id}")
|
||||
|
||||
|
||||
def _now_utc() -> datetime:
|
||||
return datetime.now(tz=timezone.utc)
|
||||
|
||||
|
||||
def _to_doc(m: BaseModel) -> dict[str, Any]:
|
||||
return m.model_dump(mode="python", by_alias=False)
|
||||
|
||||
|
||||
def _from_doc(cls, doc: Mapping[str, Any]):
|
||||
"""Instantiate a Pydantic model from a Mongo document.
|
||||
|
||||
``_id`` → ``id`` rename handled explicitly. Motor already returns UUID
|
||||
objects for UUID fields when uuidRepresentation=standard is set.
|
||||
|
||||
Unknown document fields are filtered out (matches ``extra = forbid``
|
||||
used by most ASF domain models while still tolerating new audit-only
|
||||
Mongo columns like ``updated_at`` / ``metadata``).
|
||||
"""
|
||||
if doc is None:
|
||||
return None
|
||||
d = dict(doc)
|
||||
if "_id" in d and "id" not in d:
|
||||
d["id"] = d.pop("_id")
|
||||
allowed = set(cls.model_fields.keys())
|
||||
known = {k: v for k, v in d.items() if k in allowed}
|
||||
try:
|
||||
return cls.model_validate(known)
|
||||
except Exception:
|
||||
for k in list(known.keys()):
|
||||
f = cls.model_fields.get(k)
|
||||
if f is None:
|
||||
continue
|
||||
annotation = getattr(f, "annotation", None)
|
||||
# Wrap None for required fields with usable defaults — job usage, etc.
|
||||
if known[k] is None and annotation is not None:
|
||||
from typing import get_origin, get_args
|
||||
origin = get_origin(annotation)
|
||||
args = get_args(annotation)
|
||||
# Default dict for JobUsage
|
||||
name = ""
|
||||
if hasattr(annotation, "__name__"):
|
||||
name = annotation.__name__
|
||||
for arg in args:
|
||||
if hasattr(arg, "__name__"):
|
||||
name = arg.__name__
|
||||
break
|
||||
if name == "JobUsage":
|
||||
known[k] = {"extra": {}}
|
||||
return cls.model_validate(known)
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Job repository — persistent state of every async job."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||
|
||||
from asf_domain import (
|
||||
Job,
|
||||
JobCreate,
|
||||
JobUpdate,
|
||||
JobStatus,
|
||||
JobPriority,
|
||||
JobType,
|
||||
)
|
||||
|
||||
from .base import (
|
||||
NotFoundError,
|
||||
TenantMismatchError,
|
||||
_now_utc,
|
||||
_to_doc,
|
||||
_from_doc,
|
||||
generate_id,
|
||||
)
|
||||
|
||||
|
||||
class JobRepository:
|
||||
def __init__(self, collection: AsyncIOMotorCollection) -> None:
|
||||
self._c = collection
|
||||
|
||||
def _q(self, tenant_id, project_id=None, extra=None) -> dict:
|
||||
q: dict = {"tenant_id": tenant_id}
|
||||
if project_id is not None:
|
||||
q["project_id"] = project_id
|
||||
if extra:
|
||||
q.update(extra)
|
||||
return q
|
||||
|
||||
# ---- CRUD ----
|
||||
async def get(self, tenant_id: UUID, job_id: UUID) -> Optional[Job]:
|
||||
doc = await self._c.find_one(self._q(tenant_id, extra={"_id": job_id}))
|
||||
return _from_doc(Job, doc)
|
||||
|
||||
async def list(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
project_id: Optional[UUID] = None,
|
||||
status: Optional[JobStatus] = None,
|
||||
type: Optional[JobType] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[Job]:
|
||||
q = self._q(tenant_id, project_id)
|
||||
if status is not None:
|
||||
q["status"] = status.value if isinstance(status, JobStatus) else status
|
||||
if type is not None:
|
||||
q["type"] = type.value if isinstance(type, JobType) else type
|
||||
cur = self._c.find(q).sort("created_at", -1).skip(offset).limit(limit)
|
||||
return [_from_doc(Job, d) async for d in cur]
|
||||
|
||||
async def create(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
body: JobCreate,
|
||||
*,
|
||||
requested_by: Optional[UUID] = None,
|
||||
) -> Job:
|
||||
now = _now_utc()
|
||||
j = Job(
|
||||
id=generate_id(),
|
||||
tenant_id=tenant_id,
|
||||
requested_by=requested_by,
|
||||
status=JobStatus.QUEUED,
|
||||
created_at=now,
|
||||
usage={},
|
||||
**body.model_dump(exclude_defaults=False),
|
||||
)
|
||||
doc = _to_doc(j)
|
||||
doc["_id"] = doc.pop("id")
|
||||
await self._c.insert_one(doc)
|
||||
return j
|
||||
|
||||
async def update(
|
||||
self, tenant_id: UUID, job_id: UUID, body: JobUpdate
|
||||
) -> Job:
|
||||
raw = await self._c.find_one({"_id": job_id})
|
||||
if raw is None:
|
||||
raise NotFoundError("Job", job_id)
|
||||
if raw.get("tenant_id") != tenant_id:
|
||||
raise TenantMismatchError("Job", job_id)
|
||||
current: Job = _from_doc(Job, raw)
|
||||
changes = body.model_dump(exclude_unset=True)
|
||||
if not changes:
|
||||
return current
|
||||
changes["updated_at"] = _now_utc() if "updated_at" not in changes else changes["updated_at"]
|
||||
await self._c.update_one({"_id": job_id}, {"$set": changes})
|
||||
return current.model_copy(update=changes)
|
||||
|
||||
async def set_status(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
job_id: UUID,
|
||||
status: JobStatus,
|
||||
*,
|
||||
worker: Optional[str] = None,
|
||||
error: Optional[str] = None,
|
||||
) -> Job:
|
||||
updates: dict = {"status": status, "updated_at": _now_utc()}
|
||||
if status == JobStatus.RUNNING:
|
||||
updates["started_at"] = updates["updated_at"]
|
||||
if status in {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED}:
|
||||
updates["completed_at"] = updates["updated_at"]
|
||||
if worker is not None:
|
||||
updates["worker"] = worker
|
||||
if error is not None:
|
||||
updates["error"] = error
|
||||
raw = await self._c.find_one_and_update(
|
||||
{"_id": job_id, "tenant_id": tenant_id},
|
||||
{"$set": updates},
|
||||
return_document=True,
|
||||
)
|
||||
if raw is None:
|
||||
raise NotFoundError("Job", job_id)
|
||||
return _from_doc(Job, raw)
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Organization repository — tenant boundary record.
|
||||
|
||||
Note that Organization records are NOT themselves tenant-scoped (they ARE
|
||||
the tenant). Every other entity references tenant_id == organization.id.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional, Sequence
|
||||
from uuid import UUID
|
||||
|
||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||
from pymongo.errors import DuplicateKeyError
|
||||
|
||||
from asf_domain import (
|
||||
Organization,
|
||||
OrganizationCreate,
|
||||
OrganizationUpdate,
|
||||
)
|
||||
|
||||
from .base import (
|
||||
NotFoundError,
|
||||
DuplicateError,
|
||||
_now_utc,
|
||||
_to_doc,
|
||||
_from_doc,
|
||||
generate_id,
|
||||
)
|
||||
|
||||
|
||||
class OrganizationRepository:
|
||||
def __init__(self, collection: AsyncIOMotorCollection) -> None:
|
||||
self._c = collection
|
||||
|
||||
async def get(self, org_id: UUID) -> Optional[Organization]:
|
||||
doc = await self._c.find_one({"_id": org_id})
|
||||
return _from_doc(Organization, doc)
|
||||
|
||||
async def get_by_slug(self, slug: str) -> Optional[Organization]:
|
||||
doc = await self._c.find_one({"slug": slug})
|
||||
return _from_doc(Organization, doc)
|
||||
|
||||
async def list(self, *, limit: int = 100, offset: int = 0) -> list[Organization]:
|
||||
cur = self._c.find({}).sort("created_at", -1).skip(offset).limit(limit)
|
||||
return [_from_doc(Organization, d) for d in await cur.to_list(length=limit)]
|
||||
|
||||
async def create(self, body: OrganizationCreate, *, override_id: Optional[UUID] = None) -> Organization:
|
||||
now = _now_utc()
|
||||
org = Organization(
|
||||
id=override_id or generate_id(),
|
||||
**body.model_dump(),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
doc = _to_doc(org)
|
||||
doc["_id"] = doc.pop("id")
|
||||
try:
|
||||
await self._c.insert_one(doc)
|
||||
except DuplicateKeyError as exc:
|
||||
raise DuplicateError("Organization", org.slug) from exc
|
||||
return org
|
||||
|
||||
async def update(self, org_id: UUID, body: OrganizationUpdate) -> Organization:
|
||||
current = await self.get(org_id)
|
||||
if current is None:
|
||||
raise NotFoundError("Organization", org_id)
|
||||
changes = body.model_dump(exclude_unset=True)
|
||||
if not changes:
|
||||
return current
|
||||
changes["updated_at"] = _now_utc()
|
||||
await self._c.update_one({"_id": org_id}, {"$set": changes})
|
||||
updated = current.model_copy(update=changes)
|
||||
return updated
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Project repository — always tenant-scoped."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||
from pymongo.errors import DuplicateKeyError
|
||||
|
||||
from asf_domain import Project, ProjectCreate, ProjectUpdate
|
||||
|
||||
from .base import (
|
||||
NotFoundError,
|
||||
DuplicateError,
|
||||
TenantMismatchError,
|
||||
_now_utc,
|
||||
_to_doc,
|
||||
_from_doc,
|
||||
generate_id,
|
||||
)
|
||||
|
||||
|
||||
class ProjectRepository:
|
||||
def __init__(self, collection: AsyncIOMotorCollection) -> None:
|
||||
self._c = collection
|
||||
|
||||
# ---- internal helpers ----
|
||||
def _q(self, tenant_id: UUID, extra: Optional[dict] = None) -> dict:
|
||||
q: dict = {"tenant_id": tenant_id}
|
||||
if extra:
|
||||
q.update(extra)
|
||||
return q
|
||||
|
||||
# ---- CRUD ----
|
||||
async def get(self, tenant_id: UUID, project_id: UUID) -> Optional[Project]:
|
||||
doc = await self._c.find_one(self._q(tenant_id, {"_id": project_id}))
|
||||
return _from_doc(Project, doc)
|
||||
|
||||
async def get_by_key(self, tenant_id: UUID, key: str) -> Optional[Project]:
|
||||
doc = await self._c.find_one(self._q(tenant_id, {"key": key}))
|
||||
return _from_doc(Project, doc)
|
||||
|
||||
async def list(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
only_active: bool = True,
|
||||
) -> list[Project]:
|
||||
q = self._q(tenant_id)
|
||||
if only_active:
|
||||
q["is_active"] = True
|
||||
cur = self._c.find(q).sort("created_at", -1).skip(offset).limit(limit)
|
||||
return [_from_doc(Project, d) for d in await cur.to_list(length=limit)]
|
||||
|
||||
async def create(self, tenant_id: UUID, body: ProjectCreate) -> Project:
|
||||
now = _now_utc()
|
||||
p = Project(
|
||||
id=generate_id(),
|
||||
tenant_id=tenant_id,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
**body.model_dump(),
|
||||
)
|
||||
doc = _to_doc(p)
|
||||
doc["_id"] = doc.pop("id")
|
||||
try:
|
||||
await self._c.insert_one(doc)
|
||||
except DuplicateKeyError as exc:
|
||||
raise DuplicateError("Project", f"{tenant_id}/{p.key}") from exc
|
||||
return p
|
||||
|
||||
async def update(
|
||||
self, tenant_id: UUID, project_id: UUID, body: ProjectUpdate
|
||||
) -> Project:
|
||||
current = await self._c.find_one({"_id": project_id})
|
||||
if current is None:
|
||||
raise NotFoundError("Project", project_id)
|
||||
if current.get("tenant_id") != tenant_id:
|
||||
raise TenantMismatchError("Project", project_id)
|
||||
changes = body.model_dump(exclude_unset=True)
|
||||
if not changes:
|
||||
return _from_doc(Project, current)
|
||||
changes["updated_at"] = _now_utc()
|
||||
await self._c.update_one({"_id": project_id}, {"$set": changes})
|
||||
return _from_doc(Project, {**current, **changes})
|
||||
@@ -0,0 +1,134 @@
|
||||
"""ArtifactRelationship repository — explicit directed edges.
|
||||
|
||||
Indexes support:
|
||||
* find all outgoing edges from an artifact
|
||||
* find all incoming edges to an artifact
|
||||
* ensure uniqueness of a (source, target, type) triple
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||
from pymongo.errors import DuplicateKeyError
|
||||
|
||||
from asf_domain import ArtifactRelationship, ArtifactRelationshipCreate, RelationshipType
|
||||
|
||||
from .base import (
|
||||
NotFoundError,
|
||||
DuplicateError,
|
||||
TenantMismatchError,
|
||||
_now_utc,
|
||||
_to_doc,
|
||||
_from_doc,
|
||||
generate_id,
|
||||
)
|
||||
|
||||
|
||||
class RelationshipRepository:
|
||||
def __init__(
|
||||
self,
|
||||
collection: AsyncIOMotorCollection,
|
||||
artifacts_collection: AsyncIOMotorCollection,
|
||||
) -> None:
|
||||
self._c = collection
|
||||
self._arts = artifacts_collection
|
||||
|
||||
# ---- helpers ----
|
||||
async def _assert_tenant_owns(
|
||||
self, tenant_id: UUID, artifact_id: UUID
|
||||
) -> tuple[bool, UUID | None]:
|
||||
d = await self._arts.find_one(
|
||||
{"_id": artifact_id, "tenant_id": tenant_id},
|
||||
{"project_id": 1},
|
||||
)
|
||||
if d is None:
|
||||
return False, None
|
||||
return True, d.get("project_id")
|
||||
|
||||
# ---- CRUD ----
|
||||
async def create(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
body: ArtifactRelationshipCreate,
|
||||
) -> ArtifactRelationship:
|
||||
ok_src, src_prj = await self._assert_tenant_owns(tenant_id, body.source_artifact_id)
|
||||
ok_tgt, tgt_prj = await self._assert_tenant_owns(tenant_id, body.target_artifact_id)
|
||||
if not ok_src:
|
||||
raise TenantMismatchError("Artifact", body.source_artifact_id)
|
||||
if not ok_tgt:
|
||||
raise TenantMismatchError("Artifact", body.target_artifact_id)
|
||||
|
||||
now = _now_utc()
|
||||
r = ArtifactRelationship(
|
||||
id=generate_id(),
|
||||
tenant_id=tenant_id,
|
||||
source_project_id=src_prj,
|
||||
target_project_id=tgt_prj,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
**body.model_dump(),
|
||||
)
|
||||
doc = _to_doc(r)
|
||||
doc["_id"] = doc.pop("id")
|
||||
try:
|
||||
await self._c.insert_one(doc)
|
||||
except DuplicateKeyError as exc:
|
||||
raise DuplicateError(
|
||||
"ArtifactRelationship",
|
||||
f"{body.source_artifact_id}-{body.relationship_type}-{body.target_artifact_id}",
|
||||
) from exc
|
||||
return r
|
||||
|
||||
async def delete(self, tenant_id: UUID, relationship_id: UUID) -> bool:
|
||||
raw = await self._c.find_one({"_id": relationship_id})
|
||||
if raw is None:
|
||||
raise NotFoundError("ArtifactRelationship", relationship_id)
|
||||
if raw.get("tenant_id") != tenant_id:
|
||||
raise TenantMismatchError("ArtifactRelationship", relationship_id)
|
||||
res = await self._c.delete_one({"_id": relationship_id})
|
||||
return res.deleted_count > 0
|
||||
|
||||
# ---- traversal queries ----
|
||||
async def outgoing(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
artifact_id: UUID,
|
||||
*,
|
||||
relationship_type: Optional[RelationshipType] = None,
|
||||
limit: int = 100,
|
||||
) -> list[ArtifactRelationship]:
|
||||
q: dict = {"tenant_id": tenant_id, "source_artifact_id": artifact_id}
|
||||
if relationship_type is not None:
|
||||
q["relationship_type"] = relationship_type
|
||||
cur = self._c.find(q).sort("created_at", -1).limit(limit)
|
||||
return [_from_doc(ArtifactRelationship, d) async for d in cur]
|
||||
|
||||
async def incoming(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
artifact_id: UUID,
|
||||
*,
|
||||
relationship_type: Optional[RelationshipType] = None,
|
||||
limit: int = 100,
|
||||
) -> list[ArtifactRelationship]:
|
||||
q: dict = {"tenant_id": tenant_id, "target_artifact_id": artifact_id}
|
||||
if relationship_type is not None:
|
||||
q["relationship_type"] = relationship_type
|
||||
cur = self._c.find(q).sort("created_at", -1).limit(limit)
|
||||
return [_from_doc(ArtifactRelationship, d) async for d in cur]
|
||||
|
||||
async def list_for_artifact(
|
||||
self, tenant_id: UUID, artifact_id: UUID, *, limit: int = 200
|
||||
) -> list[ArtifactRelationship]:
|
||||
q = {
|
||||
"tenant_id": tenant_id,
|
||||
"$or": [
|
||||
{"source_artifact_id": artifact_id},
|
||||
{"target_artifact_id": artifact_id},
|
||||
],
|
||||
}
|
||||
cur = self._c.find(q).sort("created_at", -1).limit(limit)
|
||||
return [_from_doc(ArtifactRelationship, d) async for d in cur]
|
||||
@@ -0,0 +1,17 @@
|
||||
from .organization_service import OrganizationService
|
||||
from .project_service import ProjectService
|
||||
from .artifact_service import ArtifactService
|
||||
from .relationship_service import RelationshipService
|
||||
from .job_service import JobService
|
||||
from .audit_service import AuditService
|
||||
from .overview_service import OverviewService
|
||||
|
||||
__all__ = [
|
||||
"OrganizationService",
|
||||
"ProjectService",
|
||||
"ArtifactService",
|
||||
"RelationshipService",
|
||||
"JobService",
|
||||
"AuditService",
|
||||
"OverviewService",
|
||||
]
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Artifact service: CRUD with event publishing and audit.
|
||||
|
||||
The ArtifactRepository does NOT emit events. This service does — because
|
||||
event emission is a business / application-layer concern, not a pure
|
||||
persistence concern.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
import structlog
|
||||
|
||||
from asf_domain import (
|
||||
Artifact,
|
||||
ArtifactCreate,
|
||||
ArtifactUpdate,
|
||||
ArtifactType,
|
||||
AuditAction,
|
||||
AuditEventCreate,
|
||||
)
|
||||
from asf_events.artifact_events import (
|
||||
ArtifactCreated,
|
||||
ArtifactUpdated,
|
||||
ArtifactDeleted,
|
||||
)
|
||||
|
||||
from ..repositories import (
|
||||
ArtifactRepository,
|
||||
ProjectRepository,
|
||||
AuditRepository,
|
||||
NotFoundError,
|
||||
)
|
||||
from ..messaging import publish_domain_event
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class ArtifactService:
|
||||
def __init__(
|
||||
self,
|
||||
artifacts: ArtifactRepository,
|
||||
projects: ProjectRepository,
|
||||
audit: AuditRepository,
|
||||
*,
|
||||
tenant_id: UUID,
|
||||
actor_id: Optional[UUID] = None,
|
||||
) -> None:
|
||||
self._tenant = tenant_id
|
||||
self._artifacts = artifacts
|
||||
self._projects = projects
|
||||
self._audit = audit
|
||||
self._actor = actor_id
|
||||
|
||||
async def _project_exists(self, project_id: UUID) -> None:
|
||||
if await self._projects.get(self._tenant, project_id) is None:
|
||||
raise NotFoundError("Project", project_id)
|
||||
|
||||
async def get(self, artifact_id: UUID) -> Optional[Artifact]:
|
||||
return await self._artifacts.get(self._tenant, artifact_id)
|
||||
|
||||
async def list(
|
||||
self,
|
||||
*,
|
||||
project_id: Optional[UUID] = None,
|
||||
artifact_type: Optional[ArtifactType | str] = None,
|
||||
status: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[Artifact]:
|
||||
return await self._artifacts.list(
|
||||
self._tenant,
|
||||
project_id=project_id,
|
||||
artifact_type=artifact_type,
|
||||
status=status,
|
||||
search=search,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
async def count(
|
||||
self,
|
||||
*,
|
||||
project_id: Optional[UUID] = None,
|
||||
artifact_type: Optional[str] = None,
|
||||
) -> int:
|
||||
return await self._artifacts.count(
|
||||
self._tenant, project_id=project_id, artifact_type=artifact_type
|
||||
)
|
||||
|
||||
async def get_many(self, ids: list[UUID]) -> list[Artifact]:
|
||||
return await self._artifacts.get_many(self._tenant, ids)
|
||||
|
||||
async def create(self, body: ArtifactCreate) -> Artifact:
|
||||
await self._project_exists(body.project_id)
|
||||
a = await self._artifacts.create(
|
||||
self._tenant, body, created_by=self._actor
|
||||
)
|
||||
try:
|
||||
await publish_domain_event(
|
||||
ArtifactCreated(
|
||||
tenant_id=self._tenant,
|
||||
project_id=a.project_id,
|
||||
artifact_id=a.id,
|
||||
artifact_type=a.type.value
|
||||
if isinstance(a.type, ArtifactType)
|
||||
else str(a.type),
|
||||
payload={
|
||||
"title": a.title,
|
||||
"source": a.source,
|
||||
"version": a.version,
|
||||
"external_id": a.external_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
await log.aexception("event_publish_failed", event_name="artifact.created")
|
||||
|
||||
await self._audit.append(
|
||||
self._tenant,
|
||||
AuditEventCreate(
|
||||
action=AuditAction.CREATED,
|
||||
resource_type="artifact",
|
||||
resource_id=a.id,
|
||||
project_id=a.project_id,
|
||||
actor_id=self._actor,
|
||||
metadata={
|
||||
"type": a.type,
|
||||
"title": a.title,
|
||||
"version": a.version,
|
||||
},
|
||||
),
|
||||
)
|
||||
return a
|
||||
|
||||
async def update(self, artifact_id: UUID, body: ArtifactUpdate) -> Artifact:
|
||||
prev = await self._artifacts.get(self._tenant, artifact_id)
|
||||
if prev is None:
|
||||
raise NotFoundError("Artifact", artifact_id)
|
||||
updated = await self._artifacts.update(self._tenant, artifact_id, body)
|
||||
changed = sorted(body.model_dump(exclude_unset=True).keys())
|
||||
if changed:
|
||||
try:
|
||||
await publish_domain_event(
|
||||
ArtifactUpdated(
|
||||
tenant_id=self._tenant,
|
||||
project_id=updated.project_id,
|
||||
artifact_id=updated.id,
|
||||
artifact_type=updated.type
|
||||
if isinstance(updated.type, str)
|
||||
else updated.type.value,
|
||||
changed_fields=changed,
|
||||
previous_version=prev.version,
|
||||
payload={"version": updated.version},
|
||||
)
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
await self._audit.append(
|
||||
self._tenant,
|
||||
AuditEventCreate(
|
||||
action=AuditAction.UPDATED,
|
||||
resource_type="artifact",
|
||||
resource_id=updated.id,
|
||||
project_id=updated.project_id,
|
||||
actor_id=self._actor,
|
||||
metadata={"changed_fields": changed, "version": updated.version},
|
||||
),
|
||||
)
|
||||
return updated
|
||||
|
||||
async def delete(self, artifact_id: UUID) -> bool:
|
||||
a = await self._artifacts.get(self._tenant, artifact_id)
|
||||
if a is None:
|
||||
raise NotFoundError("Artifact", artifact_id)
|
||||
ok = await self._artifacts.delete(self._tenant, artifact_id)
|
||||
if ok:
|
||||
try:
|
||||
await publish_domain_event(
|
||||
ArtifactDeleted(
|
||||
tenant_id=self._tenant,
|
||||
project_id=a.project_id,
|
||||
artifact_id=a.id,
|
||||
artifact_type=a.type if isinstance(a.type, str) else a.type.value,
|
||||
)
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
await self._audit.append(
|
||||
self._tenant,
|
||||
AuditEventCreate(
|
||||
action=AuditAction.DELETED,
|
||||
resource_type="artifact",
|
||||
resource_id=artifact_id,
|
||||
project_id=a.project_id,
|
||||
actor_id=self._actor,
|
||||
metadata={"type": a.type, "title": a.title},
|
||||
),
|
||||
)
|
||||
return ok
|
||||
@@ -0,0 +1,39 @@
|
||||
"""AuditService: thin wrapper for AuditRepository queries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from asf_domain import AuditEvent, AuditEventCreate
|
||||
from ..repositories import AuditRepository
|
||||
|
||||
|
||||
class AuditService:
|
||||
def __init__(
|
||||
self,
|
||||
audit: AuditRepository,
|
||||
*,
|
||||
tenant_id: UUID,
|
||||
) -> None:
|
||||
self._tenant = tenant_id
|
||||
self._audit = audit
|
||||
|
||||
async def append(self, body: AuditEventCreate) -> AuditEvent:
|
||||
return await self._audit.append(self._tenant, body)
|
||||
|
||||
async def list(
|
||||
self,
|
||||
*,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
actor_id: Optional[UUID] = None,
|
||||
resource_type: Optional[str] = None,
|
||||
) -> list[AuditEvent]:
|
||||
return await self._audit.list(
|
||||
self._tenant,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
actor_id=actor_id,
|
||||
resource_type=resource_type,
|
||||
)
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Job service: lifecycle + RabbitMQ enqueueing + audit."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
import structlog
|
||||
|
||||
from asf_domain import (
|
||||
Job,
|
||||
JobCreate,
|
||||
JobUpdate,
|
||||
JobStatus,
|
||||
JobType,
|
||||
AuditAction,
|
||||
AuditEventCreate,
|
||||
)
|
||||
from asf_events.base import JobRequestEnvelope
|
||||
from asf_events.job_events import JobQueued, JobStarted, JobCompleted, JobFailed
|
||||
|
||||
from ..repositories import (
|
||||
JobRepository,
|
||||
ProjectRepository,
|
||||
AuditRepository,
|
||||
NotFoundError,
|
||||
)
|
||||
from ..messaging import publish_domain_event, enqueue_job as _enqueue_job
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class JobService:
|
||||
def __init__(
|
||||
self,
|
||||
jobs: JobRepository,
|
||||
projects: ProjectRepository,
|
||||
audit: AuditRepository,
|
||||
*,
|
||||
tenant_id: UUID,
|
||||
actor_id: Optional[UUID] = None,
|
||||
) -> None:
|
||||
self._tenant = tenant_id
|
||||
self._jobs = jobs
|
||||
self._projects = projects
|
||||
self._audit = audit
|
||||
self._actor = actor_id
|
||||
|
||||
async def get(self, job_id: UUID) -> Optional[Job]:
|
||||
return await self._jobs.get(self._tenant, job_id)
|
||||
|
||||
async def list(
|
||||
self,
|
||||
*,
|
||||
project_id: Optional[UUID] = None,
|
||||
status: Optional[JobStatus] = None,
|
||||
type: Optional[JobType] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[Job]:
|
||||
return await self._jobs.list(
|
||||
self._tenant,
|
||||
project_id=project_id,
|
||||
status=status,
|
||||
type=type,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
async def submit(self, body: JobCreate) -> Job:
|
||||
"""Create a persistent Job record and enqueue the work item on RabbitMQ."""
|
||||
if body.project_id is not None:
|
||||
if await self._projects.get(self._tenant, body.project_id) is None:
|
||||
raise NotFoundError("Project", body.project_id)
|
||||
|
||||
job = await self._jobs.create(self._tenant, body, requested_by=self._actor)
|
||||
|
||||
envelope = JobRequestEnvelope(
|
||||
job_id=job.id,
|
||||
tenant_id=self._tenant,
|
||||
project_id=job.project_id,
|
||||
type=job.type,
|
||||
priority=body.priority,
|
||||
correlation_id=body.correlation_id or str(job.id),
|
||||
payload=body.payload,
|
||||
)
|
||||
try:
|
||||
await _enqueue_job(envelope)
|
||||
queued_ok = True
|
||||
except Exception: # noqa: BLE001
|
||||
# Leave the job record as QUEUED — the worker retry loop / scheduler
|
||||
# will pick it up. The record in Mongo is the source of truth.
|
||||
queued_ok = False
|
||||
await log.aexception("rabbitmq_enqueue_failed", job_id=str(job.id))
|
||||
|
||||
try:
|
||||
await publish_domain_event(
|
||||
JobQueued(
|
||||
tenant_id=self._tenant,
|
||||
project_id=job.project_id,
|
||||
correlation_id=envelope.correlation_id,
|
||||
job_id=job.id,
|
||||
job_type=envelope.type.value
|
||||
if isinstance(envelope.type, JobType)
|
||||
else str(envelope.type),
|
||||
queue=envelope.type.name if isinstance(envelope.type, JobType) else str(envelope.type),
|
||||
payload={"queued_via_rabbit": queued_ok},
|
||||
)
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
await self._audit.append(
|
||||
self._tenant,
|
||||
AuditEventCreate(
|
||||
action=AuditAction.CREATED,
|
||||
resource_type="job",
|
||||
resource_id=job.id,
|
||||
project_id=job.project_id,
|
||||
actor_id=self._actor,
|
||||
correlation_id=envelope.correlation_id,
|
||||
metadata={"type": job.type, "priority": job.priority},
|
||||
),
|
||||
)
|
||||
return job
|
||||
|
||||
async def update(self, job_id: UUID, body: JobUpdate) -> Job:
|
||||
updated = await self._jobs.update(self._tenant, job_id, body)
|
||||
# Emit notable lifecycle transitions
|
||||
if body.status == JobStatus.RUNNING:
|
||||
try:
|
||||
await publish_domain_event(
|
||||
JobStarted(
|
||||
tenant_id=self._tenant,
|
||||
project_id=updated.project_id,
|
||||
job_id=job_id,
|
||||
worker=body.worker,
|
||||
payload={"started_at": str(body.started_at or updated.started_at)},
|
||||
)
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if body.status == JobStatus.COMPLETED:
|
||||
try:
|
||||
await publish_domain_event(
|
||||
JobCompleted(
|
||||
tenant_id=self._tenant,
|
||||
project_id=updated.project_id,
|
||||
job_id=job_id,
|
||||
payload={k: v for k, v in (body.usage.model_dump() if body.usage else {}).items()},
|
||||
)
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if body.status == JobStatus.FAILED:
|
||||
try:
|
||||
await publish_domain_event(
|
||||
JobFailed(
|
||||
tenant_id=self._tenant,
|
||||
project_id=updated.project_id,
|
||||
job_id=job_id,
|
||||
error=body.error or updated.error or "unknown",
|
||||
payload=body.error_detail or {},
|
||||
)
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return updated
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Organization service: business rules, event publishing, audit emission."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
import structlog
|
||||
|
||||
from asf_domain import (
|
||||
Organization,
|
||||
OrganizationCreate,
|
||||
OrganizationUpdate,
|
||||
AuditAction,
|
||||
AuditEventCreate,
|
||||
)
|
||||
from asf_events.identity_events import OrganizationCreated
|
||||
|
||||
from ..repositories import (
|
||||
OrganizationRepository,
|
||||
AuditRepository,
|
||||
NotFoundError,
|
||||
DuplicateError,
|
||||
)
|
||||
from ..messaging import publish_domain_event
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class OrganizationService:
|
||||
def __init__(
|
||||
self,
|
||||
orgs: OrganizationRepository,
|
||||
audit: AuditRepository,
|
||||
*,
|
||||
actor_id: Optional[UUID] = None,
|
||||
preferred_org_id: Optional[UUID] = None,
|
||||
) -> None:
|
||||
self._orgs = orgs
|
||||
self._audit = audit
|
||||
self._actor = actor_id
|
||||
self._preferred_org_id = preferred_org_id
|
||||
|
||||
async def get(self, org_id: UUID) -> Optional[Organization]:
|
||||
return await self._orgs.get(org_id)
|
||||
|
||||
async def get_by_slug(self, slug: str) -> Optional[Organization]:
|
||||
return await self._orgs.get_by_slug(slug)
|
||||
|
||||
async def list(self, *, limit=100, offset=0) -> list[Organization]:
|
||||
return await self._orgs.list(limit=limit, offset=offset)
|
||||
|
||||
async def create(self, body: OrganizationCreate) -> Organization:
|
||||
org = await self._orgs.create(body, override_id=self._preferred_org_id)
|
||||
await log.ainfo("organization_created", org_id=str(org.id), slug=org.slug)
|
||||
|
||||
# publish event
|
||||
try:
|
||||
await publish_domain_event(
|
||||
OrganizationCreated(
|
||||
tenant_id=org.id,
|
||||
organization_id=org.id,
|
||||
slug=org.slug,
|
||||
name=org.name,
|
||||
payload={"plan": org.plan},
|
||||
)
|
||||
)
|
||||
except Exception: # noqa: BLE001 - event publish failure MUST NOT roll back writes
|
||||
await log.aexception("event_publish_failed", event_name="organization.created")
|
||||
|
||||
await self._audit.append(
|
||||
org.id,
|
||||
AuditEventCreate(
|
||||
action=AuditAction.CREATED,
|
||||
resource_type="organization",
|
||||
resource_id=org.id,
|
||||
actor_id=self._actor,
|
||||
actor_type="user" if self._actor else "system",
|
||||
metadata={"name": org.name, "slug": org.slug},
|
||||
),
|
||||
)
|
||||
return org
|
||||
|
||||
async def update(self, org_id: UUID, body: OrganizationUpdate) -> Organization:
|
||||
updated = await self._orgs.update(org_id, body)
|
||||
await self._audit.append(
|
||||
org_id,
|
||||
AuditEventCreate(
|
||||
action=AuditAction.UPDATED,
|
||||
resource_type="organization",
|
||||
resource_id=updated.id,
|
||||
actor_id=self._actor,
|
||||
metadata=body.model_dump(exclude_unset=True),
|
||||
),
|
||||
)
|
||||
return updated
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Overview: dashboard aggregates for the Overview page.
|
||||
|
||||
The frontend Overview page shows a bunch of health / metric cards. These
|
||||
are projections of the data we have; for anything we don't yet compute
|
||||
end-to-end we return a ``mock`` flag so the UI can clearly label demo
|
||||
data without misleading anyone.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from asf_domain import ArtifactType, JobStatus
|
||||
from ..repositories import (
|
||||
ArtifactRepository,
|
||||
JobRepository,
|
||||
ProjectRepository,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProjectCard:
|
||||
key: str
|
||||
name: str
|
||||
is_mock: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class OverviewStats:
|
||||
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] # which fields are synthetic/demo
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class OverviewService:
|
||||
def __init__(
|
||||
self,
|
||||
artifacts: ArtifactRepository,
|
||||
projects: ProjectRepository,
|
||||
jobs: JobRepository,
|
||||
*,
|
||||
tenant_id: UUID,
|
||||
) -> None:
|
||||
self._tenant = tenant_id
|
||||
self._arts = artifacts
|
||||
self._projects = projects
|
||||
self._jobs = jobs
|
||||
|
||||
async def overview(self) -> OverviewStats:
|
||||
t = self._tenant
|
||||
total_projects = await self._projects.list(t, limit=1000)
|
||||
featured = [
|
||||
{"key": p.key, "name": p.name}
|
||||
for p in total_projects[:5]
|
||||
]
|
||||
n_projects = len(total_projects)
|
||||
|
||||
# --- real counts from MongoDB ---
|
||||
n_req = await self._arts.count(t, artifact_type=ArtifactType.REQUIREMENT.value)
|
||||
n_task = await self._arts.count(t, artifact_type=ArtifactType.TASK.value)
|
||||
n_test = await self._arts.count(t, artifact_type=ArtifactType.TEST.value)
|
||||
n_depl = await self._arts.count(t, artifact_type=ArtifactType.DEPLOYMENT.value)
|
||||
n_commits = await self._arts.count(t, artifact_type=ArtifactType.COMMIT.value)
|
||||
|
||||
jobs_running = len(await self._jobs.list(t, status=JobStatus.RUNNING, limit=1000))
|
||||
jobs_queued = len(await self._jobs.list(t, status=JobStatus.QUEUED, limit=1000))
|
||||
jobs_failed_recent = len(await self._jobs.list(t, status=JobStatus.FAILED, limit=100))
|
||||
|
||||
# --- synthetic / demo metrics (clearly flagged as mock) ---
|
||||
mock_fields: list[str] = [
|
||||
"implementation_coverage_pct",
|
||||
"test_coverage_pct",
|
||||
"traceability_score_pct",
|
||||
"project_health_pct",
|
||||
"security_findings",
|
||||
"failed_builds",
|
||||
]
|
||||
implementation_coverage_pct = (
|
||||
round((n_task / n_req) * 100, 1) if n_req else 78.0
|
||||
)
|
||||
test_coverage_pct = round((n_test / n_task) * 100, 1) if n_task else 62.5
|
||||
traceability_score_pct = min(
|
||||
99.0,
|
||||
40.0 + 0.4 * n_req + 0.2 * n_test + 0.1 * n_commits,
|
||||
)
|
||||
project_health_pct = max(
|
||||
30.0,
|
||||
95.0 - 3.0 * jobs_failed_recent - 0.5 * jobs_queued,
|
||||
)
|
||||
security_findings = 3 # demo
|
||||
failed_builds = 2 # demo
|
||||
active_ai_jobs = jobs_running + jobs_queued
|
||||
|
||||
return OverviewStats(
|
||||
tenant_id=str(t),
|
||||
total_projects=n_projects,
|
||||
total_requirements=n_req,
|
||||
total_tasks=n_task,
|
||||
total_tests=n_test,
|
||||
total_deployments=n_depl,
|
||||
total_commits=n_commits,
|
||||
jobs_running=jobs_running,
|
||||
jobs_queued=jobs_queued,
|
||||
jobs_failed_24h=jobs_failed_recent,
|
||||
implementation_coverage_pct=implementation_coverage_pct,
|
||||
test_coverage_pct=test_coverage_pct,
|
||||
traceability_score_pct=round(traceability_score_pct, 1),
|
||||
project_health_pct=round(project_health_pct, 1),
|
||||
security_findings=security_findings,
|
||||
failed_builds=failed_builds,
|
||||
active_ai_jobs=active_ai_jobs,
|
||||
featured_projects=featured,
|
||||
mock_fields=mock_fields,
|
||||
)
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Project service: CRUD + validation + event publishing + audit."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
import structlog
|
||||
|
||||
from asf_domain import (
|
||||
Project,
|
||||
ProjectCreate,
|
||||
ProjectUpdate,
|
||||
AuditAction,
|
||||
AuditEventCreate,
|
||||
)
|
||||
from asf_events.identity_events import ProjectCreated, ProjectUpdated
|
||||
|
||||
from ..repositories import (
|
||||
ProjectRepository,
|
||||
OrganizationRepository,
|
||||
AuditRepository,
|
||||
NotFoundError,
|
||||
)
|
||||
from ..messaging import publish_domain_event
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class ProjectService:
|
||||
def __init__(
|
||||
self,
|
||||
projects: ProjectRepository,
|
||||
orgs: OrganizationRepository,
|
||||
audit: AuditRepository,
|
||||
*,
|
||||
tenant_id: UUID,
|
||||
actor_id: Optional[UUID] = None,
|
||||
) -> None:
|
||||
self._tenant = tenant_id
|
||||
self._projects = projects
|
||||
self._orgs = orgs
|
||||
self._audit = audit
|
||||
self._actor = actor_id
|
||||
|
||||
async def _ensure_tenant_exists(self) -> None:
|
||||
if await self._orgs.get(self._tenant) is None:
|
||||
raise NotFoundError("Organization", self._tenant)
|
||||
|
||||
async def get(self, project_id: UUID) -> Optional[Project]:
|
||||
return await self._projects.get(self._tenant, project_id)
|
||||
|
||||
async def list(self, *, limit=100, offset=0, only_active=True) -> list[Project]:
|
||||
return await self._projects.list(
|
||||
self._tenant, limit=limit, offset=offset, only_active=only_active
|
||||
)
|
||||
|
||||
async def create(self, body: ProjectCreate) -> Project:
|
||||
await self._ensure_tenant_exists()
|
||||
p = await self._projects.create(self._tenant, body)
|
||||
try:
|
||||
await publish_domain_event(
|
||||
ProjectCreated(
|
||||
tenant_id=self._tenant,
|
||||
project_id=p.id,
|
||||
organization_id=self._tenant,
|
||||
project_key=p.key,
|
||||
name=p.name,
|
||||
payload={"default_branch": p.default_branch},
|
||||
)
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
await log.aexception("event_publish_failed", event_name="project.created")
|
||||
|
||||
await self._audit.append(
|
||||
self._tenant,
|
||||
AuditEventCreate(
|
||||
action=AuditAction.CREATED,
|
||||
resource_type="project",
|
||||
resource_id=p.id,
|
||||
project_id=p.id,
|
||||
actor_id=self._actor,
|
||||
metadata={"key": p.key, "name": p.name},
|
||||
),
|
||||
)
|
||||
return p
|
||||
|
||||
async def update(self, project_id: UUID, body: ProjectUpdate) -> Project:
|
||||
changes = body.model_dump(exclude_unset=True)
|
||||
updated = await self._projects.update(self._tenant, project_id, body)
|
||||
if changes:
|
||||
try:
|
||||
await publish_domain_event(
|
||||
ProjectUpdated(
|
||||
tenant_id=self._tenant,
|
||||
project_id=project_id,
|
||||
changed_fields=list(changes.keys()),
|
||||
payload=changes,
|
||||
)
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
await self._audit.append(
|
||||
self._tenant,
|
||||
AuditEventCreate(
|
||||
action=AuditAction.UPDATED,
|
||||
resource_type="project",
|
||||
resource_id=project_id,
|
||||
project_id=project_id,
|
||||
actor_id=self._actor,
|
||||
metadata=changes,
|
||||
),
|
||||
)
|
||||
return updated
|
||||
@@ -0,0 +1,133 @@
|
||||
"""ArtifactRelationship service: CRUD + events + audit."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from asf_domain import (
|
||||
ArtifactRelationship,
|
||||
ArtifactRelationshipCreate,
|
||||
RelationshipType,
|
||||
AuditAction,
|
||||
AuditEventCreate,
|
||||
)
|
||||
from asf_events.artifact_events import (
|
||||
ArtifactRelationshipCreated,
|
||||
ArtifactRelationshipDeleted,
|
||||
)
|
||||
|
||||
from ..repositories import (
|
||||
RelationshipRepository,
|
||||
AuditRepository,
|
||||
)
|
||||
from ..messaging import publish_domain_event
|
||||
|
||||
|
||||
class RelationshipService:
|
||||
def __init__(
|
||||
self,
|
||||
relationships: RelationshipRepository,
|
||||
audit: AuditRepository,
|
||||
*,
|
||||
tenant_id: UUID,
|
||||
actor_id: Optional[UUID] = None,
|
||||
) -> None:
|
||||
self._tenant = tenant_id
|
||||
self._rels = relationships
|
||||
self._audit = audit
|
||||
self._actor = actor_id
|
||||
|
||||
async def create(self, body: ArtifactRelationshipCreate) -> ArtifactRelationship:
|
||||
r = await self._rels.create(self._tenant, body)
|
||||
try:
|
||||
await publish_domain_event(
|
||||
ArtifactRelationshipCreated(
|
||||
tenant_id=self._tenant,
|
||||
relationship_id=r.id,
|
||||
source_artifact_id=r.source_artifact_id,
|
||||
target_artifact_id=r.target_artifact_id,
|
||||
relationship_type=r.relationship_type
|
||||
if isinstance(r.relationship_type, str)
|
||||
else r.relationship_type.value,
|
||||
source_project_id=r.source_project_id,
|
||||
target_project_id=r.target_project_id,
|
||||
)
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
await self._audit.append(
|
||||
self._tenant,
|
||||
AuditEventCreate(
|
||||
action=AuditAction.CREATED,
|
||||
resource_type="artifact_relationship",
|
||||
resource_id=r.id,
|
||||
actor_id=self._actor,
|
||||
metadata={
|
||||
"source": str(r.source_artifact_id),
|
||||
"type": r.relationship_type,
|
||||
"target": str(r.target_artifact_id),
|
||||
},
|
||||
),
|
||||
)
|
||||
return r
|
||||
|
||||
async def delete(self, relationship_id: UUID) -> bool:
|
||||
# We need source & target ids for the event; do a lookup.
|
||||
raw = None
|
||||
# reuse the raw document by querying collection directly
|
||||
r = await self._rels.outgoing(self._tenant, relationship_id) or await self._rels.incoming(
|
||||
self._tenant, relationship_id
|
||||
)
|
||||
ok = await self._rels.delete(self._tenant, relationship_id)
|
||||
if ok:
|
||||
if r:
|
||||
try:
|
||||
first = r[0]
|
||||
await publish_domain_event(
|
||||
ArtifactRelationshipDeleted(
|
||||
tenant_id=self._tenant,
|
||||
relationship_id=relationship_id,
|
||||
source_artifact_id=first.source_artifact_id,
|
||||
target_artifact_id=first.target_artifact_id,
|
||||
)
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
await self._audit.append(
|
||||
self._tenant,
|
||||
AuditEventCreate(
|
||||
action=AuditAction.DELETED,
|
||||
resource_type="artifact_relationship",
|
||||
resource_id=relationship_id,
|
||||
actor_id=self._actor,
|
||||
),
|
||||
)
|
||||
return ok
|
||||
|
||||
async def outgoing(
|
||||
self,
|
||||
artifact_id: UUID,
|
||||
*,
|
||||
relationship_type: Optional[RelationshipType] = None,
|
||||
limit: int = 100,
|
||||
) -> list[ArtifactRelationship]:
|
||||
return await self._rels.outgoing(
|
||||
self._tenant, artifact_id, relationship_type=relationship_type, limit=limit
|
||||
)
|
||||
|
||||
async def incoming(
|
||||
self,
|
||||
artifact_id: UUID,
|
||||
*,
|
||||
relationship_type: Optional[RelationshipType] = None,
|
||||
limit: int = 100,
|
||||
) -> list[ArtifactRelationship]:
|
||||
return await self._rels.incoming(
|
||||
self._tenant, artifact_id, relationship_type=relationship_type, limit=limit
|
||||
)
|
||||
|
||||
async def for_artifact(
|
||||
self, artifact_id: UUID, *, limit: int = 200
|
||||
) -> list[ArtifactRelationship]:
|
||||
return await self._rels.list_for_artifact(self._tenant, artifact_id, limit=limit)
|
||||
@@ -0,0 +1,34 @@
|
||||
# --- Runtime ---
|
||||
asf-domain==0.1.0
|
||||
asf-events==0.1.0
|
||||
asf-connectors==0.1.0
|
||||
|
||||
# --- Web framework ---
|
||||
fastapi>=0.115,<1
|
||||
uvicorn[standard]>=0.30,<1
|
||||
pydantic>=2.7,<3
|
||||
pydantic-settings>=2.2,<3
|
||||
|
||||
# --- Database / async driver ---
|
||||
motor>=3.5,<4
|
||||
|
||||
# --- Messaging ---
|
||||
aio-pika>=9.4,<10
|
||||
|
||||
# --- Cache ---
|
||||
redis>=5.0,<6
|
||||
|
||||
# --- Object storage ---
|
||||
minio>=7.2,<8
|
||||
|
||||
# --- Observability / utilities ---
|
||||
structlog>=24.4,<25
|
||||
python-dotenv>=1.0,<2
|
||||
python-ulid>=2.7,<3
|
||||
httpx>=0.27,<1
|
||||
|
||||
# --- Dev / Test ---
|
||||
pytest>=8
|
||||
pytest-asyncio>=0.23
|
||||
pytest-cov>=5
|
||||
httpx>=0.27
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Pytest fixtures shared across asf-core tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from asgi_lifespan import LifespanManager
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from app.main import app
|
||||
from app.middleware.correlation import (
|
||||
request_context_var,
|
||||
RequestContext,
|
||||
)
|
||||
|
||||
|
||||
TENANT_ID = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||
USER_ID = uuid.UUID("00000000-0000-0000-0000-000000000002")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tenant_context():
|
||||
"""Bind a dummy authenticated tenant+user context for the duration of a test."""
|
||||
|
||||
ctx = RequestContext(
|
||||
tenant_id=TENANT_ID,
|
||||
user_id=USER_ID,
|
||||
correlation_id="test-corr",
|
||||
roles=["admin"],
|
||||
)
|
||||
token = request_context_var.set(ctx)
|
||||
yield ctx
|
||||
request_context_var.reset(token)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def asf_client(tenant_context): # noqa: ARG001 - side effect
|
||||
"""Async httpx client pointed at the FastAPI app.
|
||||
|
||||
For unit tests in bootstrap mode we skip external infra by NOT starting
|
||||
the full lifespan; we instead instantiate routes with the tenant
|
||||
context bound so requests succeed without Mongo/Rabbit reachable.
|
||||
"""
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with LifespanManager(app, startup_timeout=10):
|
||||
async with AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://testserver",
|
||||
headers={
|
||||
"X-ASF-Tenant-Id": str(TENANT_ID),
|
||||
"X-ASF-User-Id": str(USER_ID),
|
||||
},
|
||||
) as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def utc_now():
|
||||
return datetime(2026, 9, 7, 12, 0, 0, tzinfo=timezone.utc)
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Unit tests for domain services using stubbed repositories (no DB needed)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from asf_domain import (
|
||||
OrganizationCreate,
|
||||
ProjectCreate,
|
||||
ArtifactCreate,
|
||||
ArtifactType,
|
||||
ArtifactRelationshipCreate,
|
||||
RelationshipType,
|
||||
JobCreate,
|
||||
JobType,
|
||||
JobStatus,
|
||||
)
|
||||
|
||||
from app.services import (
|
||||
OrganizationService,
|
||||
ProjectService,
|
||||
ArtifactService,
|
||||
RelationshipService,
|
||||
JobService,
|
||||
)
|
||||
|
||||
|
||||
NOW = datetime(2026, 9, 7, 12, 0, 0, tzinfo=timezone.utc)
|
||||
TENANT = uuid4()
|
||||
USER = uuid4()
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
class _StubAudit:
|
||||
def __init__(self) -> None:
|
||||
self.events: list[Any] = []
|
||||
|
||||
async def append(self, tenant_id: UUID, body: Any):
|
||||
self.events.append(body)
|
||||
return body # stub
|
||||
|
||||
|
||||
# ---------- Organizations ----------
|
||||
|
||||
async def test_org_service_create_returns_org():
|
||||
org_repo = AsyncMock()
|
||||
from asf_domain import Organization
|
||||
|
||||
created = Organization(
|
||||
id=uuid4(),
|
||||
name="Contoso",
|
||||
slug="contoso",
|
||||
plan="team",
|
||||
is_active=True,
|
||||
created_at=NOW,
|
||||
updated_at=NOW,
|
||||
)
|
||||
org_repo.create = AsyncMock(return_value=created)
|
||||
svc = OrganizationService(org_repo, _StubAudit(), actor_id=USER)
|
||||
|
||||
result = await svc.create(OrganizationCreate(name="Contoso", slug="contoso", plan="team"))
|
||||
assert result.slug == "contoso"
|
||||
org_repo.create.assert_awaited_once()
|
||||
|
||||
|
||||
# ---------- Projects ----------
|
||||
|
||||
async def test_project_service_create_checks_org_exists():
|
||||
project_repo = AsyncMock()
|
||||
org_repo = AsyncMock()
|
||||
org_repo.get = AsyncMock(return_value=None)
|
||||
|
||||
svc = ProjectService(project_repo, org_repo, _StubAudit(), tenant_id=TENANT, actor_id=USER)
|
||||
from app.repositories import NotFoundError
|
||||
|
||||
with pytest.raises(NotFoundError):
|
||||
await svc.create(ProjectCreate(name="Demo", key="DEMO"))
|
||||
|
||||
|
||||
# ---------- Artifacts ----------
|
||||
|
||||
async def test_artifact_service_create_checks_project():
|
||||
artifact_repo = AsyncMock()
|
||||
project_repo = AsyncMock()
|
||||
project_repo.get = AsyncMock(return_value=None)
|
||||
svc = ArtifactService(
|
||||
artifact_repo, project_repo, _StubAudit(), tenant_id=TENANT, actor_id=USER
|
||||
)
|
||||
from app.repositories import NotFoundError
|
||||
|
||||
with pytest.raises(NotFoundError):
|
||||
await svc.create(
|
||||
ArtifactCreate(
|
||||
project_id=uuid4(),
|
||||
type=ArtifactType.REQUIREMENT,
|
||||
title="Need MFA",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ---------- Jobs ----------
|
||||
|
||||
async def test_job_service_submit_creates_record():
|
||||
job_repo = AsyncMock()
|
||||
project_repo = AsyncMock()
|
||||
from asf_domain import Job
|
||||
|
||||
project_repo.get = AsyncMock(return_value=object()) # truthy
|
||||
new_job = Job(
|
||||
id=uuid4(),
|
||||
tenant_id=TENANT,
|
||||
project_id=uuid4(),
|
||||
type=JobType.AI_INTERACTIVE,
|
||||
status=JobStatus.QUEUED,
|
||||
payload={"question": "hi"},
|
||||
created_at=NOW,
|
||||
usage={},
|
||||
)
|
||||
job_repo.create = AsyncMock(return_value=new_job)
|
||||
|
||||
svc = JobService(job_repo, project_repo, _StubAudit(), tenant_id=TENANT, actor_id=USER)
|
||||
result = await svc.submit(
|
||||
JobCreate(
|
||||
type=JobType.AI_INTERACTIVE,
|
||||
payload={"question": "hi"},
|
||||
project_id=uuid4(),
|
||||
)
|
||||
)
|
||||
assert result.status == JobStatus.QUEUED
|
||||
job_repo.create.assert_awaited_once()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Smoke tests for health endpoints — do NOT require live Mongo."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
async def test_root_endpoint_returns_metadata(asf_client):
|
||||
resp = await asf_client.get("/")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["name"] == "asf-core"
|
||||
assert "docs" in body
|
||||
assert body["health"] == "/api/v1/health"
|
||||
|
||||
|
||||
async def test_health_liveness_ok(asf_client):
|
||||
resp = await asf_client.get("/api/v1/health")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["service"] == "asf-core"
|
||||
assert "uptime_seconds" in body
|
||||
assert body["environment"] in {"development", "test", "production"}
|
||||
@@ -0,0 +1,16 @@
|
||||
"""asf-worker — stateless RabbitMQ consumer ("gli addetti alle poste").
|
||||
|
||||
Each subscribed queue maps to a handler. Handlers look up the persistent
|
||||
Mongo ``Job`` record, update its status to running → completed|failed and
|
||||
optionally publish domain events.
|
||||
|
||||
Workers are horizontally scalable. All persistent state lives in MongoDB,
|
||||
never in process memory.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
from .config import WorkerSettings, get_settings
|
||||
from .worker import run_worker, build_consumer, JobHandlerFn
|
||||
|
||||
__all__ = ["WorkerSettings", "get_settings", "run_worker", "build_consumer", "JobHandlerFn"]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Allow running with `python -m app.worker`."""
|
||||
from .worker import run_worker
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_worker()
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class WorkerSettings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env", case_sensitive=False, extra="ignore"
|
||||
)
|
||||
asf_env: str = "development"
|
||||
asf_log_level: str = "INFO"
|
||||
rabbitmq_uri: str = "amqp://azurian:StrongPassword123!@172.19.40.201:5672/asf"
|
||||
mongo_uri: str = "mongodb://172.19.40.102:27017,172.19.40.103:27017/asf?replicaSet=rs0"
|
||||
mongo_db: str = "asf"
|
||||
ai_gateway_provider: str = "mock"
|
||||
|
||||
# Queue → routing key bindings consumed by this worker.
|
||||
# A single worker can subscribe to a subset; in production you'd run
|
||||
# separate worker deployments per queue pool.
|
||||
queues: list[str] = [
|
||||
"asf.ai.interactive",
|
||||
"asf.ai.background",
|
||||
"asf.indexing",
|
||||
"asf.code_analysis",
|
||||
"asf.testing",
|
||||
"asf.devops",
|
||||
"asf.notifications",
|
||||
]
|
||||
|
||||
# If true, AI jobs invoke the AI Gateway and write results back.
|
||||
enable_ai_handlers: bool = True
|
||||
|
||||
worker_id: str = "asf-worker-1"
|
||||
prefetch_count: int = 4
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> WorkerSettings:
|
||||
return WorkerSettings()
|
||||
@@ -0,0 +1,317 @@
|
||||
"""Job handler dispatch + default handlers (AI + logging).
|
||||
|
||||
Handlers share the following signature::
|
||||
|
||||
async fn(
|
||||
envelope: JobRequestEnvelope,
|
||||
*,
|
||||
deps: WorkerDependencies,
|
||||
) -> HandlerResult
|
||||
|
||||
WorkerDependencies wraps Mongo collections, the AI Gateway, and logging.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Awaitable, Callable, Protocol
|
||||
from uuid import UUID
|
||||
|
||||
import structlog
|
||||
|
||||
from motor.motor_asyncio import AsyncIOMotorCollection, AsyncIOMotorDatabase
|
||||
from asf_events.base import JobRequestEnvelope
|
||||
from asf_domain import JobStatus, JobUpdate, JobUsage
|
||||
|
||||
from app.config import WorkerSettings # type: ignore[attr-defined]
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
JobHandlerFn = Callable[["JobRequestEnvelope", "WorkerDependencies"], Awaitable["HandlerResult"]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class HandlerResult:
|
||||
status: JobStatus
|
||||
result: dict[str, Any] = field(default_factory=dict)
|
||||
error: str | None = None
|
||||
error_detail: dict[str, Any] | None = None
|
||||
usage: JobUsage | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkerDependencies:
|
||||
db: AsyncIOMotorDatabase
|
||||
jobs: AsyncIOMotorCollection
|
||||
ai_executions: AsyncIOMotorCollection
|
||||
artifacts: AsyncIOMotorCollection
|
||||
settings: WorkerSettings
|
||||
ai_gateway: Any | None = None # will be set if enable_ai_handlers=True
|
||||
worker_id: str = "asf-worker"
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Default handlers
|
||||
# ==========================================================================
|
||||
|
||||
|
||||
async def _mark_job_done(
|
||||
deps: WorkerDependencies,
|
||||
job_id: UUID,
|
||||
result: HandlerResult,
|
||||
) -> None:
|
||||
"""Persist the final handler outcome back to MongoDB jobs collection.
|
||||
|
||||
This is the ONLY place worker-state leaves process memory and hits the
|
||||
durable source of truth (MongoDB).
|
||||
"""
|
||||
updates: dict[str, Any] = {
|
||||
"status": result.status.value
|
||||
if isinstance(result.status, JobStatus)
|
||||
else str(result.status),
|
||||
"completed_at": __import__("datetime").datetime.now(
|
||||
__import__("datetime").timezone.utc
|
||||
),
|
||||
"updated_at": __import__("datetime").datetime.now(
|
||||
__import__("datetime").timezone.utc
|
||||
),
|
||||
"worker": deps.worker_id,
|
||||
}
|
||||
if result.result:
|
||||
updates["result"] = result.result
|
||||
if result.error:
|
||||
updates["error"] = result.error
|
||||
if result.error_detail:
|
||||
updates["error_detail"] = result.error_detail
|
||||
if result.usage is not None:
|
||||
updates["usage"] = (
|
||||
result.usage.model_dump()
|
||||
if hasattr(result.usage, "model_dump")
|
||||
else result.usage
|
||||
)
|
||||
await deps.jobs.update_one({"_id": job_id}, {"$set": updates})
|
||||
|
||||
|
||||
async def noop_handler(
|
||||
env: JobRequestEnvelope, deps: WorkerDependencies
|
||||
) -> HandlerResult:
|
||||
"""Catch-all handler: acknowledges the job and leaves a note.
|
||||
|
||||
Useful when a queue is not yet staffed with a real handler — ensures we
|
||||
never leave work stuck "running" with no outcome.
|
||||
"""
|
||||
log.warn(
|
||||
"no_handler_registered",
|
||||
job_id=str(env.job_id),
|
||||
queue=env.type,
|
||||
payload_keys=list(env.payload.keys()),
|
||||
)
|
||||
return HandlerResult(
|
||||
status=JobStatus.FAILED,
|
||||
error=f"No worker handler registered for job_type={env.type!r}",
|
||||
error_detail={"hint": "register a handler in app.worker.HANDLERS_BY_TYPE"},
|
||||
)
|
||||
|
||||
|
||||
async def echo_handler(
|
||||
env: JobRequestEnvelope, deps: WorkerDependencies
|
||||
) -> HandlerResult:
|
||||
"""Simple echo / dev handler: stores the payload back as result."""
|
||||
return HandlerResult(
|
||||
status=JobStatus.COMPLETED,
|
||||
result={"echo": env.payload, "handled_by": deps.worker_id},
|
||||
usage=JobUsage(cpu_seconds=0.001),
|
||||
)
|
||||
|
||||
|
||||
async def ai_handler(
|
||||
env: JobRequestEnvelope, deps: WorkerDependencies
|
||||
) -> HandlerResult:
|
||||
"""AI-interactive + AI-background handler.
|
||||
|
||||
Dispatches to the ASF AI Gateway (MockProvider in bootstrap) for:
|
||||
- "ask_asf" kind (chat completion)
|
||||
- "analyze_code"
|
||||
- "generate_tests"
|
||||
- "decompose_requirement"
|
||||
|
||||
In the bootstrap phase we safely fall back to echo if no gateway is
|
||||
attached (e.g. test runs with AI disabled).
|
||||
"""
|
||||
|
||||
kind = env.payload.get("kind", "unknown")
|
||||
log.info(
|
||||
"ai_handler_start",
|
||||
job_id=str(env.job_id),
|
||||
kind=kind,
|
||||
provider=deps.settings.ai_gateway_provider,
|
||||
)
|
||||
|
||||
if deps.ai_gateway is None:
|
||||
return HandlerResult(
|
||||
status=JobStatus.COMPLETED,
|
||||
result={
|
||||
"note": (
|
||||
"ai_gateway not attached to this worker. "
|
||||
"Install services/asf-ai and wire it in worker."
|
||||
),
|
||||
"payload": env.payload,
|
||||
},
|
||||
)
|
||||
|
||||
from asf_ai import ( # type: ignore[import-not-found]
|
||||
AIGateway,
|
||||
ChatRequest,
|
||||
ChatMessage,
|
||||
TraceContext,
|
||||
CodeAnalysisRequest,
|
||||
TestGenerationRequest,
|
||||
RequirementDecomposeRequest,
|
||||
)
|
||||
|
||||
gw: AIGateway = deps.ai_gateway
|
||||
trace = TraceContext(
|
||||
tenant_id=env.tenant_id,
|
||||
project_id=env.project_id,
|
||||
job_id=env.job_id,
|
||||
correlation_id=env.correlation_id,
|
||||
)
|
||||
|
||||
try:
|
||||
if kind == "ask_asf":
|
||||
question = env.payload.get("question", "")
|
||||
req = ChatRequest(
|
||||
trace=trace,
|
||||
messages=[ChatMessage(role="user", content=question)],
|
||||
context_summary=env.payload.get("context_summary"),
|
||||
)
|
||||
resp = await gw.chat(req)
|
||||
return HandlerResult(
|
||||
status=JobStatus.COMPLETED,
|
||||
result={
|
||||
"kind": "ask_asf",
|
||||
"question": question,
|
||||
"answer": resp.reply,
|
||||
"references": resp.references,
|
||||
},
|
||||
usage=JobUsage(
|
||||
input_tokens=resp.input_tokens,
|
||||
output_tokens=resp.output_tokens,
|
||||
estimated_cost=resp.estimated_cost,
|
||||
),
|
||||
)
|
||||
|
||||
if kind == "analyze_code":
|
||||
req = CodeAnalysisRequest(
|
||||
trace=trace,
|
||||
code_snippet=env.payload.get("code_snippet", ""),
|
||||
language=env.payload.get("language"),
|
||||
file_path=env.payload.get("file_path"),
|
||||
)
|
||||
resp = await gw.analyze_code(req)
|
||||
return HandlerResult(
|
||||
status=JobStatus.COMPLETED,
|
||||
result={
|
||||
"kind": "analyze_code",
|
||||
"summary": resp.summary,
|
||||
"findings": [f.model_dump(mode="json") for f in resp.findings],
|
||||
},
|
||||
usage=JobUsage(
|
||||
input_tokens=resp.input_tokens,
|
||||
output_tokens=resp.output_tokens,
|
||||
estimated_cost=resp.estimated_cost,
|
||||
),
|
||||
)
|
||||
|
||||
if kind == "generate_tests":
|
||||
req = TestGenerationRequest(
|
||||
trace=trace,
|
||||
code_snippet=env.payload.get("code_snippet", ""),
|
||||
language=env.payload.get("language", "python"),
|
||||
test_framework=env.payload.get("framework", "pytest"),
|
||||
goal=env.payload.get("goal", "Generate unit tests"),
|
||||
)
|
||||
resp = await gw.generate_tests(req)
|
||||
return HandlerResult(
|
||||
status=JobStatus.COMPLETED,
|
||||
result={
|
||||
"kind": "generate_tests",
|
||||
"test_cases": resp.test_cases,
|
||||
"fixtures_needed": resp.fixtures_needed,
|
||||
"coverage_notes": resp.coverage_notes,
|
||||
},
|
||||
usage=JobUsage(
|
||||
input_tokens=resp.input_tokens,
|
||||
output_tokens=resp.output_tokens,
|
||||
estimated_cost=resp.estimated_cost,
|
||||
),
|
||||
)
|
||||
|
||||
if kind == "decompose_requirement":
|
||||
req = RequirementDecomposeRequest(
|
||||
trace=trace,
|
||||
requirement_text=env.payload.get("requirement_text", ""),
|
||||
context=env.payload.get("context"),
|
||||
max_subtasks=int(env.payload.get("max_subtasks", 20)),
|
||||
)
|
||||
resp = await gw.decompose_requirement(req)
|
||||
return HandlerResult(
|
||||
status=JobStatus.COMPLETED,
|
||||
result={
|
||||
"kind": "decompose_requirement",
|
||||
"subtasks": [s.model_dump(mode="json") for s in resp.subtasks],
|
||||
"architecture_notes": resp.architecture_notes,
|
||||
"open_questions": resp.open_questions,
|
||||
},
|
||||
usage=JobUsage(
|
||||
input_tokens=resp.input_tokens,
|
||||
output_tokens=resp.output_tokens,
|
||||
estimated_cost=resp.estimated_cost,
|
||||
),
|
||||
)
|
||||
|
||||
return await echo_handler(env, deps)
|
||||
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.exception("ai_handler_failed", exc=str(exc))
|
||||
return HandlerResult(
|
||||
status=JobStatus.FAILED,
|
||||
error=f"AI handler raised: {type(exc).__name__}: {exc}",
|
||||
error_detail={"payload": env.payload},
|
||||
)
|
||||
|
||||
|
||||
async def indexing_handler(
|
||||
env: JobRequestEnvelope, deps: WorkerDependencies
|
||||
) -> HandlerResult:
|
||||
"""Stub handler for repository / doc indexing jobs."""
|
||||
log.info(
|
||||
"indexing_handler_stub",
|
||||
job_id=str(env.job_id),
|
||||
payload=env.payload,
|
||||
)
|
||||
return HandlerResult(
|
||||
status=JobStatus.COMPLETED,
|
||||
result={"stub": True, "indexed_items": 0, "source": env.payload.get("source")},
|
||||
usage=JobUsage(cpu_seconds=0.005),
|
||||
)
|
||||
|
||||
|
||||
async def notification_handler(
|
||||
env: JobRequestEnvelope, deps: WorkerDependencies
|
||||
) -> HandlerResult:
|
||||
"""Stub handler for email / webhook / in-app notifications."""
|
||||
log.info(
|
||||
"notification_stub",
|
||||
job_id=str(env.job_id),
|
||||
payload_keys=list(env.payload.keys()),
|
||||
)
|
||||
return HandlerResult(
|
||||
status=JobStatus.COMPLETED,
|
||||
result={"note": "notifications are a stub in bootstrap"},
|
||||
)
|
||||
|
||||
|
||||
# Final status persistence step, exported for reuse in the main worker loop.
|
||||
mark_job_done = _mark_job_done
|
||||
@@ -0,0 +1,22 @@
|
||||
"""ASF Worker job handlers — pluggable async handlers called from the generic consumer loop.
|
||||
|
||||
New capabilities are registered in ``REGISTRY`` below: mapping of ``job_type`` string
|
||||
to a ``JobHandlerFn``. The worker will route incoming messages by matching
|
||||
``JobRequestEnvelope.job_type`` against keys in ``REGISTRY``.
|
||||
|
||||
Currently stubbed; individual handler modules are added incrementally as features
|
||||
are plugged in (see bootstrap prompt §16).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from asf_events.base import JobRequestEnvelope
|
||||
|
||||
from ..handlers import HandlerResult, WorkerDependencies
|
||||
from ..worker import JobHandlerFn
|
||||
|
||||
REGISTRY: dict[str, JobHandlerFn] = {
|
||||
# "ai.analyze_code": run_analyze_code,
|
||||
# "ai.generate_tests": run_generate_tests,
|
||||
# "ai.decompose_requirements": run_decompose_requirements,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"""AI-related job handlers stubbed out for smoke import.
|
||||
|
||||
Actual execution routes through asf-ai.AIGateway once it is wired. The handler
|
||||
below produces a stable HandlerResult.ok(...) carrying a placeholder payload so
|
||||
that consumer-loop smoke tests do not need a live LLM.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from asf_events.base import JobRequestEnvelope
|
||||
|
||||
from ..handlers import HandlerResult
|
||||
from ..worker import WorkerDependencies
|
||||
|
||||
|
||||
async def run_ai_job(req: JobRequestEnvelope, deps: WorkerDependencies) -> HandlerResult:
|
||||
del deps
|
||||
payload = req.payload or {}
|
||||
handler = payload.get("handler") if isinstance(payload, dict) else None
|
||||
return HandlerResult.ok(job_id=req.job_id, output={"handler": handler or "ai.stub", "status": "ok"})
|
||||
@@ -0,0 +1,237 @@
|
||||
"""RabbitMQ consumer loop — main entry point for the asf-worker process.
|
||||
|
||||
Run::
|
||||
python -m app.worker
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import signal
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
import aio_pika
|
||||
import aio_pika.abc
|
||||
import structlog
|
||||
|
||||
from motor.motor_asyncio import AsyncIOMotorClient
|
||||
|
||||
from .config import WorkerSettings, get_settings
|
||||
from .handlers import (
|
||||
WorkerDependencies,
|
||||
noop_handler,
|
||||
echo_handler,
|
||||
ai_handler,
|
||||
indexing_handler,
|
||||
notification_handler,
|
||||
mark_job_done,
|
||||
HandlerResult,
|
||||
)
|
||||
from asf_events.base import JobRequestEnvelope
|
||||
from asf_domain import JobStatus, JobUpdate
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
JobHandlerFn = Callable[[JobRequestEnvelope, WorkerDependencies], Awaitable[HandlerResult]]
|
||||
"""Type alias for a queue handler: (envelope, deps) → coroutine returning HandlerResult."""
|
||||
|
||||
|
||||
# Queue name → handler function
|
||||
HANDLERS_BY_QUEUE: dict[str, Callable] = {
|
||||
"asf.ai.interactive": ai_handler,
|
||||
"asf.ai.background": ai_handler,
|
||||
"asf.indexing": indexing_handler,
|
||||
"asf.code_analysis": ai_handler, # use analyze_code via AI gateway
|
||||
"asf.testing": ai_handler, # use generate_tests via AI gateway
|
||||
"asf.devops": echo_handler, # stub
|
||||
"asf.notifications": notification_handler,
|
||||
}
|
||||
|
||||
|
||||
async def _claim_running(deps: WorkerDependencies, envelope: JobRequestEnvelope) -> None:
|
||||
"""Atomically mark the job as running + claim worker ownership.
|
||||
|
||||
Guards against duplicate-delivery races from RabbitMQ (at-least-once).
|
||||
If the job is already in a terminal state we bail out early.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
res = await deps.jobs.update_one(
|
||||
{
|
||||
"_id": envelope.job_id,
|
||||
"tenant_id": envelope.tenant_id,
|
||||
"status": {"$in": [JobStatus.QUEUED.value]},
|
||||
},
|
||||
{
|
||||
"$set": {
|
||||
"status": JobStatus.RUNNING.value,
|
||||
"started_at": now,
|
||||
"updated_at": now,
|
||||
"worker": deps.worker_id,
|
||||
},
|
||||
"$inc": {"retries": 1},
|
||||
},
|
||||
)
|
||||
if res.matched_count == 0:
|
||||
|
||||
log.warn(
|
||||
"job_already_claimed_or_terminal",
|
||||
job_id=str(envelope.job_id),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Consumer:
|
||||
settings: WorkerSettings
|
||||
connection: aio_pika.abc.AbstractConnection | None = None
|
||||
channel: aio_pika.abc.AbstractChannel | None = None
|
||||
mongo_client: AsyncIOMotorClient | None = None
|
||||
deps: WorkerDependencies | None = None
|
||||
stop_event: asyncio.Event | None = None
|
||||
|
||||
async def setup(self) -> None:
|
||||
self.stop_event = asyncio.Event()
|
||||
|
||||
# ---- Mongo ----
|
||||
self.mongo_client = AsyncIOMotorClient(
|
||||
self.settings.mongo_uri,
|
||||
uuidRepresentation="standard",
|
||||
tz_aware=True,
|
||||
)
|
||||
db = self.mongo_client[self.settings.mongo_db]
|
||||
self.deps = WorkerDependencies(
|
||||
db=db,
|
||||
jobs=db["jobs"],
|
||||
ai_executions=db["ai_executions"],
|
||||
artifacts=db["artifacts"],
|
||||
settings=self.settings,
|
||||
worker_id=self.settings.worker_id,
|
||||
)
|
||||
|
||||
# ---- Optional AI gateway wiring ----
|
||||
if self.settings.enable_ai_handlers:
|
||||
try:
|
||||
from asf_ai.app.gateway import AIGateway, AIGatewayConfig # type: ignore[import-not-found]
|
||||
self.deps.ai_gateway = AIGateway(config=AIGatewayConfig(
|
||||
default_provider=self.settings.ai_gateway_provider,
|
||||
))
|
||||
log.info(
|
||||
"ai_gateway_attached", provider=self.settings.ai_gateway_provider
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warn("ai_gateway_unavailable", exc=str(exc))
|
||||
|
||||
# ---- RabbitMQ ----
|
||||
self.connection = await aio_pika.connect_robust(self.settings.rabbitmq_uri)
|
||||
self.channel = await self.connection.channel()
|
||||
await self.channel.set_qos(prefetch_count=self.settings.prefetch_count)
|
||||
|
||||
jobs_exchange = await self.channel.declare_exchange(
|
||||
"asf.jobs", type=aio_pika.ExchangeType.DIRECT, durable=True
|
||||
)
|
||||
await self.channel.declare_exchange(
|
||||
"asf.events", type=aio_pika.ExchangeType.TOPIC, durable=True
|
||||
)
|
||||
|
||||
for qname in self.settings.queues:
|
||||
q = await self.channel.declare_queue(qname, durable=True)
|
||||
await q.bind(jobs_exchange, routing_key=qname)
|
||||
await q.consume(self._make_consumer_callback(qname))
|
||||
log.info("subscribed", queue=qname)
|
||||
|
||||
def _make_consumer_callback(self, qname: str):
|
||||
handler = HANDLERS_BY_QUEUE.get(qname, noop_handler)
|
||||
|
||||
async def _cb(message: aio_pika.abc.AbstractIncomingMessage) -> None:
|
||||
async with message.process(requeue=True, reject_on_redelivered=False):
|
||||
assert self.deps is not None
|
||||
try:
|
||||
envelope = JobRequestEnvelope.model_validate_json(message.body)
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("bad_message_body", queue=qname)
|
||||
return # ack & drop poison
|
||||
|
||||
# 1. claim ownership in Mongo
|
||||
await _claim_running(self.deps, envelope)
|
||||
# 2. run the handler
|
||||
try:
|
||||
result: HandlerResult = await handler(envelope, self.deps)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.exception("handler_unhandled_exception", queue=qname)
|
||||
result = HandlerResult(
|
||||
status=JobStatus.FAILED,
|
||||
error=f"{type(exc).__name__}: {exc}",
|
||||
)
|
||||
# 3. persist final state
|
||||
try:
|
||||
await mark_job_done(self.deps, envelope.job_id, result)
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("job_persistence_failed", job_id=str(envelope.job_id))
|
||||
|
||||
return _cb
|
||||
|
||||
async def run_forever(self) -> None:
|
||||
assert self.stop_event is not None
|
||||
log.info(
|
||||
"asf_worker_started",
|
||||
queues=self.settings.queues,
|
||||
worker_id=self.settings.worker_id,
|
||||
)
|
||||
await self.stop_event.wait()
|
||||
|
||||
async def close(self) -> None:
|
||||
log.info("asf_worker_shutting_down")
|
||||
if self.channel is not None:
|
||||
try:
|
||||
await self.channel.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if self.connection is not None:
|
||||
try:
|
||||
await self.connection.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if self.mongo_client is not None:
|
||||
self.mongo_client.close()
|
||||
|
||||
|
||||
def build_consumer(settings: WorkerSettings | None = None) -> Consumer:
|
||||
return Consumer(settings=settings or get_settings())
|
||||
|
||||
|
||||
async def run_worker_async() -> None:
|
||||
settings = get_settings()
|
||||
consumer = build_consumer(settings)
|
||||
await consumer.setup()
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _graceful() -> None:
|
||||
if consumer.stop_event is not None:
|
||||
consumer.stop_event.set()
|
||||
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
try:
|
||||
loop.add_signal_handler(sig, _graceful)
|
||||
except NotImplementedError:
|
||||
# Windows
|
||||
pass
|
||||
|
||||
try:
|
||||
await consumer.run_forever()
|
||||
finally:
|
||||
await consumer.close()
|
||||
|
||||
|
||||
def run_worker() -> None:
|
||||
"""Synchronous entry point (e.g. python -m app.worker)."""
|
||||
try:
|
||||
asyncio.run(run_worker_async())
|
||||
except KeyboardInterrupt:
|
||||
print("Interrupted. Goodbye.", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_worker()
|
||||
@@ -0,0 +1,4 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
@@ -0,0 +1,17 @@
|
||||
asf-domain==0.1.0
|
||||
asf-events==0.1.0
|
||||
asf-connectors==0.1.0
|
||||
|
||||
# Core API + context + ai are imported by worker handlers for end-to-end flow
|
||||
# (optional — install from local tree in dev)
|
||||
|
||||
pydantic>=2.7
|
||||
pydantic-settings>=2.2
|
||||
aio-pika>=9.4
|
||||
structlog>=24.4
|
||||
python-dotenv>=1.0
|
||||
motor>=3.5
|
||||
httpx>=0.27
|
||||
|
||||
pytest>=8
|
||||
pytest-asyncio>=0.23
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Shared pytest fixtures for asf-worker tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
WORKER_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(WORKER_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(WORKER_ROOT))
|
||||
|
||||
TENANT_ID = UUID("00000000-0000-0000-0000-000000000099")
|
||||
PROJECT_ID = UUID("00000000-0000-0000-0000-000000000088")
|
||||
CORRELATION_ID = "test-correlation-worker"
|
||||
|
||||
|
||||
class FakeJobsCollection:
|
||||
"""In-memory AsyncIOMotorCollection stand-in for claim/persistence tests.
|
||||
|
||||
Mirrors the tiny subset of the Motor API that `_claim_running` and
|
||||
`_mark_job_done` exercise: ``update_one(filter, ops)`` with
|
||||
``matched_count`` on the returned UpdateResult.
|
||||
"""
|
||||
|
||||
@dataclass
|
||||
class UpdateResult:
|
||||
matched_count: int = 0
|
||||
modified_count: int = 0
|
||||
upserted_id: object = None
|
||||
|
||||
def __init__(self, docs: dict[UUID, dict] | None = None) -> None:
|
||||
self._docs: dict[UUID, dict] = {k: dict(v) for k, v in (docs or {}).items()}
|
||||
self.last_update_filter: object = None
|
||||
self.last_update_ops: object = None
|
||||
|
||||
async def update_one(self, filt: dict, ops: dict) -> UpdateResult:
|
||||
self.last_update_filter = filt
|
||||
self.last_update_ops = ops
|
||||
jid = filt.get("_id")
|
||||
existing = self._docs.get(jid) if jid is not None else None
|
||||
if existing is None:
|
||||
return FakeJobsCollection.UpdateResult(matched_count=0)
|
||||
|
||||
# Apply the whole filter object like MongoDB does (simple top-level equality match).
|
||||
for k, v in filt.items():
|
||||
if k == "status" and isinstance(v, dict) and "$in" in v:
|
||||
if existing.get(k) not in v["$in"]:
|
||||
return FakeJobsCollection.UpdateResult(matched_count=0)
|
||||
elif k == "_id":
|
||||
if existing.get(k) != v:
|
||||
return FakeJobsCollection.UpdateResult(matched_count=0)
|
||||
else:
|
||||
if existing.get(k) != v:
|
||||
return FakeJobsCollection.UpdateResult(matched_count=0)
|
||||
|
||||
if "$set" in ops:
|
||||
for k, v in ops["$set"].items():
|
||||
existing[k] = v
|
||||
if "$inc" in ops:
|
||||
for k, v in ops["$inc"].items():
|
||||
existing[k] = (existing.get(k) or 0) + v
|
||||
return FakeJobsCollection.UpdateResult(matched_count=1, modified_count=1)
|
||||
|
||||
|
||||
class FakeDb:
|
||||
def __init__(self, jobs: FakeJobsCollection | None = None) -> None:
|
||||
self._jobs = jobs or FakeJobsCollection()
|
||||
|
||||
def __getitem__(self, col: str):
|
||||
if col == "jobs":
|
||||
return self._jobs
|
||||
raise KeyError(col)
|
||||
|
||||
|
||||
class FakeSettings:
|
||||
mongo_uri = "mongodb://fake"
|
||||
mongo_db = "fake_asf"
|
||||
rabbitmq_uri = "amqp://fake"
|
||||
queues: list[str] = []
|
||||
prefetch_count: int = 1
|
||||
worker_id: str = "test-worker"
|
||||
enable_ai_handlers: bool = False
|
||||
ai_gateway_provider: str = "mock"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_settings():
|
||||
return FakeSettings()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_jobs_collection():
|
||||
return FakeJobsCollection()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_db(fake_jobs_collection):
|
||||
return FakeDb(jobs=fake_jobs_collection)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def worker_deps(fake_settings, fake_db, fake_jobs_collection):
|
||||
from app.handlers import WorkerDependencies
|
||||
return WorkerDependencies(
|
||||
db=fake_db,
|
||||
jobs=fake_jobs_collection,
|
||||
ai_executions=fake_db["jobs"], # fine - not exercised
|
||||
artifacts=fake_db["jobs"],
|
||||
settings=fake_settings,
|
||||
worker_id=fake_settings.worker_id,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_envelope():
|
||||
from asf_events.base import JobRequestEnvelope
|
||||
from asf_domain import JobType
|
||||
return JobRequestEnvelope(
|
||||
job_id=uuid4(),
|
||||
tenant_id=TENANT_ID,
|
||||
project_id=PROJECT_ID,
|
||||
correlation_id=CORRELATION_ID,
|
||||
type=JobType.DEVOPS.value,
|
||||
payload={"hello": "world", "sequence": 7},
|
||||
priority="normal",
|
||||
)
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Tests for worker handlers + claim/persistence logic.
|
||||
|
||||
All tests run in-memory using FakeJobsCollection — no Mongo/Rabbit required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from asf_domain import JobStatus, JobUsage
|
||||
|
||||
|
||||
class TestEchoHandler:
|
||||
@pytest.mark.asyncio
|
||||
async def test_echo_handler_returns_payload_as_result(self, worker_deps, sample_envelope):
|
||||
from app.handlers import echo_handler
|
||||
res = await echo_handler(sample_envelope, worker_deps)
|
||||
assert res.status == JobStatus.COMPLETED
|
||||
assert res.result["echo"] == sample_envelope.payload
|
||||
assert res.result["handled_by"] == worker_deps.worker_id
|
||||
assert isinstance(res.usage, JobUsage)
|
||||
assert res.usage.cpu_seconds > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_noop_handler_marks_failed_when_queue_unregistered(self, worker_deps, sample_envelope):
|
||||
from app.handlers import noop_handler
|
||||
res = await noop_handler(sample_envelope, worker_deps)
|
||||
assert res.status == JobStatus.FAILED
|
||||
assert "No worker handler registered" in res.error
|
||||
assert res.error_detail is not None and "hint" in res.error_detail
|
||||
|
||||
|
||||
class TestMarkJobDonePersistence:
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_job_done_writes_status_result_usage(self, worker_deps, sample_envelope, fake_jobs_collection):
|
||||
from app.handlers import mark_job_done, HandlerResult
|
||||
fake_jobs_collection._docs[sample_envelope.job_id] = {
|
||||
"_id": sample_envelope.job_id,
|
||||
"status": JobStatus.RUNNING.value,
|
||||
}
|
||||
result = HandlerResult(
|
||||
status=JobStatus.COMPLETED,
|
||||
result={"echo": {"ok": True}},
|
||||
usage=JobUsage(cpu_seconds=0.042, input_tokens=100, output_tokens=200),
|
||||
)
|
||||
await mark_job_done(worker_deps, sample_envelope.job_id, result)
|
||||
doc = fake_jobs_collection._docs[sample_envelope.job_id]
|
||||
assert doc["status"] == JobStatus.COMPLETED.value
|
||||
assert doc["result"] == result.result
|
||||
assert doc["worker"] == worker_deps.worker_id
|
||||
assert doc["usage"]["cpu_seconds"] == 0.042
|
||||
assert doc["usage"]["input_tokens"] == 100
|
||||
assert doc["completed_at"] is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_job_done_records_error_when_failed(self, worker_deps, sample_envelope, fake_jobs_collection):
|
||||
from app.handlers import mark_job_done, HandlerResult
|
||||
fake_jobs_collection._docs[sample_envelope.job_id] = {"_id": sample_envelope.job_id}
|
||||
await mark_job_done(
|
||||
worker_deps,
|
||||
sample_envelope.job_id,
|
||||
HandlerResult(
|
||||
status=JobStatus.FAILED,
|
||||
error="BOOM",
|
||||
error_detail={"stage": "analyze"},
|
||||
),
|
||||
)
|
||||
doc = fake_jobs_collection._docs[sample_envelope.job_id]
|
||||
assert doc["status"] == JobStatus.FAILED.value
|
||||
assert doc["error"] == "BOOM"
|
||||
assert doc["error_detail"] == {"stage": "analyze"}
|
||||
# result / usage keys are ABSENT when empty
|
||||
assert "result" not in doc
|
||||
|
||||
|
||||
class TestClaimRunningAtomic:
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_sets_running_and_inc_retries(self, worker_deps, sample_envelope, fake_jobs_collection):
|
||||
from app.worker import _claim_running
|
||||
fake_jobs_collection._docs[sample_envelope.job_id] = {
|
||||
"_id": sample_envelope.job_id,
|
||||
"tenant_id": sample_envelope.tenant_id,
|
||||
"status": JobStatus.QUEUED.value,
|
||||
"retries": 0,
|
||||
}
|
||||
await _claim_running(worker_deps, sample_envelope)
|
||||
doc = fake_jobs_collection._docs[sample_envelope.job_id]
|
||||
assert doc["status"] == JobStatus.RUNNING.value
|
||||
assert doc["worker"] == worker_deps.worker_id
|
||||
assert doc["retries"] == 1
|
||||
assert doc["started_at"] is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_bails_if_job_already_terminal(self, worker_deps, sample_envelope, fake_jobs_collection):
|
||||
from app.worker import _claim_running
|
||||
fake_jobs_collection._docs[sample_envelope.job_id] = {
|
||||
"_id": sample_envelope.job_id,
|
||||
"tenant_id": sample_envelope.tenant_id,
|
||||
"status": JobStatus.COMPLETED.value, # not QUEUED
|
||||
"worker": "some-other-worker",
|
||||
}
|
||||
await _claim_running(worker_deps, sample_envelope)
|
||||
doc = fake_jobs_collection._docs[sample_envelope.job_id]
|
||||
# still terminal + still original worker -> no overwrites
|
||||
assert doc["status"] == JobStatus.COMPLETED.value
|
||||
assert doc["worker"] == "some-other-worker"
|
||||
assert "retries" not in doc or doc["retries"] == 0
|
||||
|
||||
|
||||
class TestAIMockedGatewayHandler:
|
||||
"""Ensure ai_handler works safely with no real AI gateway attached (fallback path)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ai_handler_without_gateway_returns_payload_note(self, worker_deps, sample_envelope):
|
||||
from asf_domain import JobType
|
||||
sample_envelope.type = JobType.AI_INTERACTIVE.value
|
||||
sample_envelope.payload = {"kind": "ask_asf", "question": "Summarize REQ-103"}
|
||||
worker_deps.ai_gateway = None
|
||||
from app.handlers import ai_handler
|
||||
res = await ai_handler(sample_envelope, worker_deps)
|
||||
assert res.status == JobStatus.COMPLETED
|
||||
assert "ai_gateway not attached" in res.result["note"]
|
||||
assert res.result["payload"]["kind"] == "ask_asf"
|
||||
|
||||
|
||||
class TestHandlersQueueContract:
|
||||
def test_expected_queues_have_registered_handlers(self):
|
||||
from app.worker import HANDLERS_BY_QUEUE
|
||||
expected = {
|
||||
"asf.ai.interactive",
|
||||
"asf.ai.background",
|
||||
"asf.indexing",
|
||||
"asf.code_analysis",
|
||||
"asf.testing",
|
||||
"asf.devops",
|
||||
"asf.notifications",
|
||||
}
|
||||
assert set(HANDLERS_BY_QUEUE.keys()) == expected
|
||||
Reference in New Issue
Block a user