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