first
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
"""asf-connectors: Protocol-based interfaces for every external system ASF
|
||||
can integrate with.
|
||||
|
||||
Concrete implementations (GitHub adapter, Jira adapter, ...) are provided
|
||||
separately, often in their own installable extras. Business code MUST
|
||||
depend only on the Protocols defined here. This keeps ASF Core decoupled
|
||||
from any single provider.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
from asf_connectors.git import GitConnector, PullRequestInfo, CommitInfo
|
||||
from asf_connectors.issues import IssueTrackerConnector, IssueInfo
|
||||
from asf_connectors.ci import CICDConnector, BuildInfo, DeploymentInfo
|
||||
from asf_connectors.observability import ObservabilityConnector
|
||||
|
||||
__all__ = [
|
||||
"GitConnector",
|
||||
"PullRequestInfo",
|
||||
"CommitInfo",
|
||||
"IssueTrackerConnector",
|
||||
"IssueInfo",
|
||||
"CICDConnector",
|
||||
"BuildInfo",
|
||||
"DeploymentInfo",
|
||||
"ObservabilityConnector",
|
||||
]
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Abstract CI/CD connector interface."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Protocol, Optional, Sequence
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BuildInfo:
|
||||
build_id: str
|
||||
pipeline_name: str
|
||||
status: str # success | failed | running | cancelled
|
||||
started_at: Optional[datetime] = None
|
||||
finished_at: Optional[datetime] = None
|
||||
triggered_by: Optional[str] = None
|
||||
branch: Optional[str] = None
|
||||
commit_sha: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeploymentInfo:
|
||||
deployment_id: str
|
||||
environment: str # dev | staging | prod | ...
|
||||
status: str # success | failed | in_progress
|
||||
version_ref: Optional[str] = None
|
||||
started_at: Optional[datetime] = None
|
||||
finished_at: Optional[datetime] = None
|
||||
target: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
|
||||
|
||||
class CICDConnector(Protocol):
|
||||
"""Jenkins / GitLab CI / GitHub Actions / ArgoCD / ..."""
|
||||
|
||||
async def get_build(self, pipeline: str, build_id: str) -> Optional[BuildInfo]: ...
|
||||
async def list_builds(self, pipeline: str, limit: int = 50) -> Sequence[BuildInfo]: ...
|
||||
|
||||
async def get_deployment(self, deployment_id: str) -> Optional[DeploymentInfo]: ...
|
||||
async def list_deployments(self, environment: str, limit: int = 50) -> Sequence[DeploymentInfo]: ...
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Abstract Git (repository / source-control) connector interface."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Protocol, AsyncIterator, Optional, Sequence
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommitInfo:
|
||||
sha: str
|
||||
message: str
|
||||
author_email: str
|
||||
author_name: str
|
||||
authored_at: datetime
|
||||
branch: Optional[str] = None
|
||||
files_changed: Sequence[str] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PullRequestInfo:
|
||||
number: int
|
||||
title: str
|
||||
state: str # open | closed | merged
|
||||
branch: str
|
||||
target_branch: str
|
||||
author: str
|
||||
created_at: datetime
|
||||
merged_at: Optional[datetime] = None
|
||||
description: Optional[str] = None
|
||||
commit_shas: Sequence[str] = ()
|
||||
labels: Sequence[str] = ()
|
||||
|
||||
|
||||
class GitConnector(Protocol):
|
||||
"""Any Git-hosting provider: GitHub, GitLab, Bitbucket, local Gitea..."""
|
||||
|
||||
async def list_commits(
|
||||
self, repo_ref: str, *, branch: Optional[str] = None, since: Optional[datetime] = None
|
||||
) -> AsyncIterator[CommitInfo]: ...
|
||||
|
||||
async def get_commit(self, repo_ref: str, sha: str) -> Optional[CommitInfo]: ...
|
||||
|
||||
async def list_pull_requests(
|
||||
self, repo_ref: str, *, state: str = "open"
|
||||
) -> AsyncIterator[PullRequestInfo]: ...
|
||||
|
||||
async def get_pull_request(self, repo_ref: str, number: int) -> Optional[PullRequestInfo]: ...
|
||||
|
||||
async def index_repository(self, repo_ref: str) -> None:
|
||||
"""Trigger a full sync (artifacts + relationships) for a repo.
|
||||
|
||||
Implementation must emit events; it must NOT mutate Core data
|
||||
directly.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Abstract issue / requirement tracker connector (Jira, Linear, etc.)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Protocol, AsyncIterator, Optional, Any, Sequence
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IssueInfo:
|
||||
key: str
|
||||
title: str
|
||||
type: str # epic, story, bug, task, ...
|
||||
status: str
|
||||
description: Optional[str] = None
|
||||
assignee: Optional[str] = None
|
||||
labels: Sequence[str] = ()
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
parent_key: Optional[str] = None
|
||||
metadata: dict[str, Any] = None # type: ignore[assignment]
|
||||
|
||||
|
||||
class IssueTrackerConnector(Protocol):
|
||||
"""Provider interface for Jira / GitHub Issues / YouTrack / ..."""
|
||||
|
||||
async def search_issues(
|
||||
self, query: str, *, project_ref: Optional[str] = None, limit: int = 100
|
||||
) -> AsyncIterator[IssueInfo]: ...
|
||||
|
||||
async def get_issue(self, issue_key: str) -> Optional[IssueInfo]: ...
|
||||
|
||||
async def sync_project(self, project_ref: str) -> None: ...
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Abstract observability connector (Grafana / Prometheus / Datadog / ...)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Protocol, Optional, Any
|
||||
|
||||
|
||||
class ObservabilityConnector(Protocol):
|
||||
async def query_metric(
|
||||
self,
|
||||
query: str,
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
step: Optional[str] = None,
|
||||
) -> list[dict[str, Any]]: ...
|
||||
|
||||
async def get_incident(self, incident_id: str) -> Optional[dict[str, Any]]: ...
|
||||
|
||||
async def list_active_incidents(self, limit: int = 50) -> list[dict[str, Any]]: ...
|
||||
@@ -0,0 +1,20 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "asf-connectors"
|
||||
version = "0.1.0"
|
||||
description = "ASF external-system connector interfaces (Protocol-based, no concrete adapters yet)."
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"asf-domain>=0.1.0",
|
||||
"asf-events>=0.1.0",
|
||||
"pydantic>=2.7,<3",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["asf_connectors*"]
|
||||
@@ -0,0 +1,22 @@
|
||||
# asf-domain — Shared Pydantic Domain Models
|
||||
|
||||
> Package condiviso: *tutti* i servizi Python importano i modelli da qui.
|
||||
> Non copiare-incollare modelli: se cambi un modello qui, cambia per tutti.
|
||||
|
||||
Install (local dev):
|
||||
|
||||
```bash
|
||||
cd packages/asf-domain
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
Provides:
|
||||
|
||||
- `asf_domain.base` — UUID ULID factory, tenant-aware base model, enums.
|
||||
- `asf_domain.identity` — Organization, User, Team, Project.
|
||||
- `asf_domain.artifact` — Artifact, ArtifactRelationship (the core "Everything is an Artifact" model).
|
||||
- `asf_domain.job` — Job lifecycle & usage tracking.
|
||||
- `asf_domain.audit` — AuditEvent.
|
||||
- `asf_domain.integration` — Integration.
|
||||
- `asf_domain.ai_execution` — AIExecution record.
|
||||
- `asf_domain.common` — Typed aliases, shared types.
|
||||
@@ -0,0 +1,86 @@
|
||||
"""asf-domain: shared Pydantic models for Azurian Software Factory.
|
||||
|
||||
Every Python service in ASF imports its domain models from this package.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
from asf_domain.base import (
|
||||
ASFBaseModel,
|
||||
TenantScopedModel,
|
||||
generate_id,
|
||||
UTCDatetime,
|
||||
ArtifactType,
|
||||
RelationshipType,
|
||||
JobStatus,
|
||||
JobPriority,
|
||||
JobType,
|
||||
AuditAction,
|
||||
)
|
||||
from asf_domain.identity import (
|
||||
Organization,
|
||||
OrganizationCreate,
|
||||
OrganizationUpdate,
|
||||
Project,
|
||||
ProjectCreate,
|
||||
ProjectUpdate,
|
||||
Team,
|
||||
User,
|
||||
)
|
||||
from asf_domain.artifact import (
|
||||
Artifact,
|
||||
ArtifactCreate,
|
||||
ArtifactUpdate,
|
||||
ArtifactRelationship,
|
||||
ArtifactRelationshipCreate,
|
||||
RelationshipDirection,
|
||||
)
|
||||
from asf_domain.job import (
|
||||
Job,
|
||||
JobCreate,
|
||||
JobUpdate,
|
||||
JobUsage,
|
||||
)
|
||||
from asf_domain.ai_execution import (
|
||||
AIExecution,
|
||||
AIExecutionCreate,
|
||||
)
|
||||
from asf_domain.integration import Integration, IntegrationCreate
|
||||
from asf_domain.audit import AuditEvent, AuditEventCreate
|
||||
|
||||
__all__ = [
|
||||
"ASFBaseModel",
|
||||
"TenantScopedModel",
|
||||
"generate_id",
|
||||
"UTCDatetime",
|
||||
"ArtifactType",
|
||||
"RelationshipType",
|
||||
"JobStatus",
|
||||
"JobPriority",
|
||||
"JobType",
|
||||
"AuditAction",
|
||||
"Organization",
|
||||
"OrganizationCreate",
|
||||
"OrganizationUpdate",
|
||||
"Project",
|
||||
"ProjectCreate",
|
||||
"ProjectUpdate",
|
||||
"Team",
|
||||
"User",
|
||||
"Artifact",
|
||||
"ArtifactCreate",
|
||||
"ArtifactUpdate",
|
||||
"ArtifactRelationship",
|
||||
"ArtifactRelationshipCreate",
|
||||
"RelationshipDirection",
|
||||
"Job",
|
||||
"JobCreate",
|
||||
"JobUpdate",
|
||||
"JobUsage",
|
||||
"AIExecution",
|
||||
"AIExecutionCreate",
|
||||
"Integration",
|
||||
"IntegrationCreate",
|
||||
"AuditEvent",
|
||||
"AuditEventCreate",
|
||||
]
|
||||
@@ -0,0 +1,68 @@
|
||||
"""AIExecution — immutable record of a single inference call.
|
||||
|
||||
Stored after every real (or mock) AI invocation so that:
|
||||
- usage/cost is attributable to a tenant + project + job
|
||||
- inputs/outputs can be audited / replayed
|
||||
- the AI Gateway can train / evaluate routing policies later
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional, Dict
|
||||
from pydantic import Field
|
||||
|
||||
from asf_domain.base import (
|
||||
ASFBaseModel,
|
||||
TenantScopedModel,
|
||||
ASFFieldID,
|
||||
UTCDatetime,
|
||||
generate_id,
|
||||
)
|
||||
|
||||
|
||||
class AIExecutionBase(ASFBaseModel):
|
||||
capability: str = Field(
|
||||
...,
|
||||
max_length=128,
|
||||
description="ASF capability requested (e.g. chat, analyze_code, generate_tests).",
|
||||
)
|
||||
provider: str = Field(
|
||||
..., max_length=64, description="Adapter used (e.g. mock, openai, nvidia, local)."
|
||||
)
|
||||
model: str = Field(
|
||||
..., max_length=128, description="Concrete model identifier reported by the adapter."
|
||||
)
|
||||
success: bool = True
|
||||
|
||||
|
||||
class AIExecutionCreate(AIExecutionBase):
|
||||
job_id: Optional[ASFFieldID] = None
|
||||
project_id: Optional[ASFFieldID] = None
|
||||
input_ref: Optional[str] = Field(
|
||||
None, max_length=512,
|
||||
description="MinIO ref or short fingerprint of the input payload (never raw secrets).",
|
||||
)
|
||||
output_ref: Optional[str] = Field(
|
||||
None, max_length=512, description="MinIO ref / fingerprint of the response."
|
||||
)
|
||||
input_tokens: Optional[int] = Field(None, ge=0)
|
||||
output_tokens: Optional[int] = Field(None, ge=0)
|
||||
duration_ms: Optional[int] = Field(None, ge=0)
|
||||
estimated_cost: Optional[float] = Field(None, ge=0)
|
||||
error_message: Optional[str] = Field(None, max_length=2000)
|
||||
metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AIExecution(TenantScopedModel, AIExecutionBase):
|
||||
id: ASFFieldID = Field(default_factory=generate_id)
|
||||
job_id: Optional[ASFFieldID] = None
|
||||
project_id: Optional[ASFFieldID] = None
|
||||
input_ref: Optional[str] = None
|
||||
output_ref: Optional[str] = None
|
||||
input_tokens: Optional[int] = None
|
||||
output_tokens: Optional[int] = None
|
||||
duration_ms: Optional[int] = None
|
||||
estimated_cost: Optional[float] = None
|
||||
error_message: Optional[str] = None
|
||||
metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: UTCDatetime = ...
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Artifact — the heart of the "Everything is an Artifact" principle.
|
||||
|
||||
One single generic model captures every lifecycle entity (requirement,
|
||||
task, commit, test, deployment, incident, ...). Artifact subtypes are
|
||||
expressed via the ``type`` enum / string and stored in the same MongoDB
|
||||
``artifacts`` collection.
|
||||
|
||||
Relationships between artifacts live in ``artifact_relationships``
|
||||
collection and are modelled as explicit directed edges — this keeps the
|
||||
option open to project the same data onto a graph database later without
|
||||
rewriting domain logic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional, List, Dict
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from asf_domain.base import (
|
||||
ASFBaseModel,
|
||||
TenantScopedModel,
|
||||
ASFFieldID,
|
||||
UTCDatetime,
|
||||
ArtifactType,
|
||||
RelationshipType,
|
||||
generate_id,
|
||||
)
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Artifact itself
|
||||
# ======================================================================
|
||||
|
||||
|
||||
class ArtifactBase(ASFBaseModel):
|
||||
type: ArtifactType = Field(
|
||||
..., description="Kind of artifact (requirement | task | commit | …)"
|
||||
)
|
||||
external_id: Optional[str] = Field(
|
||||
None,
|
||||
max_length=255,
|
||||
description="Optional identifier in an external system "
|
||||
"(e.g. GitHub PR id, Jira issue key)",
|
||||
)
|
||||
title: str = Field(..., min_length=1, max_length=500)
|
||||
content: Optional[str] = Field(
|
||||
None,
|
||||
description="Primary human readable body (markdown, description, diff, log body, …). "
|
||||
"Large content should live in MinIO; store a reference here or in metadata.",
|
||||
)
|
||||
source: Optional[str] = Field(
|
||||
None,
|
||||
max_length=255,
|
||||
description="Origin system: jira, github, manual, ai, …",
|
||||
)
|
||||
status: Optional[str] = Field(
|
||||
None, max_length=80, description="Status (e.g. open, done, failed, released)."
|
||||
)
|
||||
|
||||
|
||||
class ArtifactCreate(ArtifactBase):
|
||||
"""Create payload. ``tenant_id`` and ``project_id`` are set server-side."""
|
||||
|
||||
project_id: ASFFieldID = Field(
|
||||
..., description="Owning project (validated against tenant context server side)."
|
||||
)
|
||||
metadata: Dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Arbitrary extensible fields. Use this for artifact subtype "
|
||||
"specific attributes (e.g. commit.sha, test.outcome, deployment.env).",
|
||||
)
|
||||
|
||||
|
||||
class ArtifactUpdate(ASFBaseModel):
|
||||
title: Optional[str] = Field(None, min_length=1, max_length=500)
|
||||
content: Optional[str] = None
|
||||
type: Optional[ArtifactType] = None
|
||||
external_id: Optional[str] = None
|
||||
source: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
metadata: Optional[Dict[str, Any]] = Field(
|
||||
None,
|
||||
description="When provided, performs a shallow merge with existing metadata.",
|
||||
)
|
||||
|
||||
|
||||
class Artifact(TenantScopedModel, ArtifactBase):
|
||||
"""Stored Artifact record.
|
||||
|
||||
``relationships`` is a denormalised, always-stale list kept for fast
|
||||
reads in the UI. The authoritative source of truth for edges is the
|
||||
``artifact_relationships`` collection.
|
||||
"""
|
||||
|
||||
id: ASFFieldID = Field(default_factory=generate_id)
|
||||
project_id: ASFFieldID = ...
|
||||
version: int = Field(
|
||||
1, ge=1, description="Monotonic version bumped on every update."
|
||||
)
|
||||
metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: UTCDatetime = ...
|
||||
updated_at: UTCDatetime = ...
|
||||
created_by: Optional[ASFFieldID] = Field(
|
||||
None, description="User id that created this artifact (null = system / import)."
|
||||
)
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# ArtifactRelationship — the explicit edge
|
||||
# ======================================================================
|
||||
|
||||
|
||||
class RelationshipDirection(str, Enum):
|
||||
FORWARD = "forward"
|
||||
REVERSE = "reverse"
|
||||
|
||||
|
||||
class ArtifactRelationshipBase(ASFBaseModel):
|
||||
relationship_type: RelationshipType = Field(
|
||||
...,
|
||||
description="Semantic predicate: ``source <relationship_type> target``.",
|
||||
)
|
||||
source_artifact_id: ASFFieldID = Field(
|
||||
..., description="Source artifact id (subject)."
|
||||
)
|
||||
target_artifact_id: ASFFieldID = Field(
|
||||
..., description="Target artifact id (object)."
|
||||
)
|
||||
metadata: Dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Free-form metadata on the edge (confidence, evidence ref, …).",
|
||||
)
|
||||
|
||||
|
||||
class ArtifactRelationshipCreate(ArtifactRelationshipBase):
|
||||
"""Create payload. tenant_id / project_ids are validated server side."""
|
||||
pass
|
||||
|
||||
|
||||
class ArtifactRelationship(TenantScopedModel, ArtifactRelationshipBase):
|
||||
"""Stored edge between two artifacts.
|
||||
|
||||
Invariant: both source and target MUST belong to the same tenant.
|
||||
They MAY live in different projects inside that tenant when an
|
||||
explicit cross-project reference is intentional.
|
||||
"""
|
||||
|
||||
id: ASFFieldID = Field(default_factory=generate_id)
|
||||
source_project_id: ASFFieldID = ...
|
||||
target_project_id: ASFFieldID = ...
|
||||
created_at: UTCDatetime = ...
|
||||
updated_at: UTCDatetime = ...
|
||||
@@ -0,0 +1,62 @@
|
||||
"""AuditEvent — append-only security & business audit trail.
|
||||
|
||||
Audit events are never mutated or deleted. They record WHO did WHAT to
|
||||
WHICH entity WHEN and from WHERE.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional, Dict
|
||||
from pydantic import Field
|
||||
|
||||
from asf_domain.base import (
|
||||
ASFBaseModel,
|
||||
TenantScopedModel,
|
||||
ASFFieldID,
|
||||
UTCDatetime,
|
||||
AuditAction,
|
||||
generate_id,
|
||||
)
|
||||
|
||||
|
||||
class AuditEventBase(ASFBaseModel):
|
||||
action: AuditAction = Field(..., description="What happened.")
|
||||
resource_type: str = Field(
|
||||
...,
|
||||
max_length=80,
|
||||
description="Affected entity kind (organization, project, artifact, job, api_key, …).",
|
||||
)
|
||||
resource_id: Optional[ASFFieldID] = Field(None, description="Affected entity id if applicable.")
|
||||
actor_id: Optional[ASFFieldID] = Field(
|
||||
None, description="User id that caused the event, or null for system actions."
|
||||
)
|
||||
actor_type: str = Field(
|
||||
"user",
|
||||
max_length=40,
|
||||
description="user | system | api_key | service — kind of actor that caused the event.",
|
||||
)
|
||||
success: bool = True
|
||||
reason: Optional[str] = Field(
|
||||
None, max_length=500, description="For failed events, why the action was rejected."
|
||||
)
|
||||
|
||||
|
||||
class AuditEventCreate(AuditEventBase):
|
||||
project_id: Optional[ASFFieldID] = None
|
||||
ip_address: Optional[str] = Field(None, max_length=64)
|
||||
user_agent: Optional[str] = Field(None, max_length=512)
|
||||
correlation_id: Optional[str] = Field(None, max_length=128)
|
||||
metadata: Dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Structured detail of the change. NEVER log secrets here.",
|
||||
)
|
||||
|
||||
|
||||
class AuditEvent(TenantScopedModel, AuditEventBase):
|
||||
id: ASFFieldID = Field(default_factory=generate_id)
|
||||
project_id: Optional[ASFFieldID] = None
|
||||
ip_address: Optional[str] = None
|
||||
user_agent: Optional[str] = None
|
||||
correlation_id: Optional[str] = None
|
||||
metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: UTCDatetime = ...
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Base types and enums for all ASF domain models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Annotated
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
field_validator,
|
||||
PlainSerializer,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Identifiers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ASFFieldID = Annotated[
|
||||
UUID,
|
||||
Field(
|
||||
description="ASF unique identifier (UUID v4)",
|
||||
examples=["3fa85f64-5717-4562-b3fc-2c963f66afa6"],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def generate_id() -> UUID:
|
||||
"""Return a new UUID v4 identifier for an ASF entity."""
|
||||
return uuid4()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Datetime helpers — force UTC on the wire
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _utc_serialize(dt: datetime) -> str:
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
UTCDatetime = Annotated[
|
||||
datetime,
|
||||
PlainSerializer(_utc_serialize, return_type=str, when_used="json"),
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Base model configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ASFBaseModel(BaseModel):
|
||||
"""Base Pydantic model used everywhere in ASF."""
|
||||
|
||||
model_config = ConfigDict(
|
||||
extra="forbid",
|
||||
populate_by_name=True,
|
||||
use_enum_values=False,
|
||||
from_attributes=True,
|
||||
str_strip_whitespace=True,
|
||||
json_encoders={datetime: _utc_serialize},
|
||||
)
|
||||
|
||||
|
||||
class TenantScopedModel(ASFBaseModel):
|
||||
"""Any record that belongs to a tenant (Organization)."""
|
||||
|
||||
tenant_id: ASFFieldID = Field(
|
||||
description="Owning tenant (Organization) identifier. "
|
||||
"Always validated against the authenticated context — never "
|
||||
"trust the value supplied by an untrusted client."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Artifact type universe (extensible, open)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ArtifactType(str, enum.Enum):
|
||||
"""Canonical set of Artifact subtypes in the initial bootstrap.
|
||||
|
||||
This list is intentionally extensible: adding a new kind of lifecycle
|
||||
entity is a new ArtifactType value, NOT a new MongoDB collection.
|
||||
"""
|
||||
|
||||
REQUIREMENT = "requirement"
|
||||
EPIC = "epic"
|
||||
STORY = "story"
|
||||
TASK = "task"
|
||||
ARCHITECTURE_DECISION = "architecture_decision"
|
||||
DOCUMENT = "document"
|
||||
SOURCE_FILE = "source_file"
|
||||
COMMIT = "commit"
|
||||
PULL_REQUEST = "pull_request"
|
||||
BUILD = "build"
|
||||
TEST = "test"
|
||||
SECURITY_FINDING = "security_finding"
|
||||
DEPLOYMENT = "deployment"
|
||||
RELEASE = "release"
|
||||
RUNTIME_EVENT = "runtime_event"
|
||||
INCIDENT = "incident"
|
||||
AI_ANALYSIS = "ai_analysis"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Relationship types between artifacts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RelationshipType(str, enum.Enum):
|
||||
"""Canonical relationship predicates between two Artifacts.
|
||||
|
||||
Semantics: ``source <predicate> target``, e.g.
|
||||
``REQ-1 implements TASK-7`` means TASK-7 implements REQ-1.
|
||||
For the inverse use IMPLEMENTED_BY.
|
||||
"""
|
||||
|
||||
IMPLEMENTS = "implements"
|
||||
IMPLEMENTED_BY = "implemented_by"
|
||||
VERIFIES = "verifies"
|
||||
VERIFIED_BY = "verified_by"
|
||||
GENERATED_FROM = "generated_from"
|
||||
DOCUMENTED_BY = "documented_by"
|
||||
DEPENDS_ON = "depends_on"
|
||||
AFFECTS = "affects"
|
||||
DEPLOYED_AS = "deployed_as"
|
||||
CAUSED_BY = "caused_by"
|
||||
SUPERSEDES = "supersedes"
|
||||
RELATED_TO = "related_to"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Job lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class JobStatus(str, enum.Enum):
|
||||
QUEUED = "queued"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class JobPriority(str, enum.Enum):
|
||||
LOW = "low"
|
||||
NORMAL = "normal"
|
||||
HIGH = "high"
|
||||
URGENT = "urgent"
|
||||
|
||||
|
||||
class JobType(str, enum.Enum):
|
||||
AI_INTERACTIVE = "ai_interactive"
|
||||
AI_BACKGROUND = "ai_background"
|
||||
INDEXING = "indexing"
|
||||
CODE_ANALYSIS = "code_analysis"
|
||||
TESTING = "testing"
|
||||
DEVOPS = "devops"
|
||||
NOTIFICATION = "notification"
|
||||
CUSTOM = "custom"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Audit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AuditAction(str, enum.Enum):
|
||||
CREATED = "created"
|
||||
UPDATED = "updated"
|
||||
DELETED = "deleted"
|
||||
ACCESSED = "accessed"
|
||||
LOGGED_IN = "logged_in"
|
||||
LOGGED_OUT = "logged_out"
|
||||
PERMISSION_CHANGED = "permission_changed"
|
||||
API_KEY_CREATED = "api_key_created"
|
||||
API_KEY_REVOKED = "api_key_revoked"
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Small shared type aliases & tiny helpers.
|
||||
|
||||
Put things here when they are used by multiple domain modules but don't
|
||||
have a home of their own.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TypeAlias, Any, Dict, Awaitable, Callable
|
||||
|
||||
JSONDict: TypeAlias = Dict[str, Any]
|
||||
"""Any loosely-typed JSON object (metadata, unstructured payloads, …)."""
|
||||
|
||||
AsyncHandler: TypeAlias = Callable[..., Awaitable[Any]]
|
||||
"""Generic async callable type alias."""
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Identity domain: Organization, User, Team, Project.
|
||||
|
||||
SaaS tenancy:
|
||||
Organization == tenant boundary
|
||||
└── Teams
|
||||
└── Projects
|
||||
└── Artifacts + Relationships + Jobs
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, List
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from asf_domain.base import (
|
||||
ASFBaseModel,
|
||||
TenantScopedModel,
|
||||
ASFFieldID,
|
||||
UTCDatetime,
|
||||
generate_id,
|
||||
)
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Organization (tenant)
|
||||
# ======================================================================
|
||||
|
||||
|
||||
class OrganizationBase(ASFBaseModel):
|
||||
name: str = Field(
|
||||
...,
|
||||
min_length=2,
|
||||
max_length=120,
|
||||
description="Human readable organization / tenant name",
|
||||
examples=["Azurian Labs", "Contoso Retail"],
|
||||
)
|
||||
slug: str = Field(
|
||||
...,
|
||||
min_length=3,
|
||||
max_length=60,
|
||||
pattern=r"^[a-z0-9][a-z0-9\-]{1,58}[a-z0-9]$",
|
||||
description="URL-safe unique identifier for the tenant",
|
||||
examples=["azurian-labs"],
|
||||
)
|
||||
plan: str = Field(
|
||||
"free",
|
||||
description="Subscription tier placeholder",
|
||||
examples=["free", "team", "enterprise"],
|
||||
)
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class OrganizationCreate(OrganizationBase):
|
||||
"""Payload used to create a new Organization (tenant)."""
|
||||
pass
|
||||
|
||||
|
||||
class OrganizationUpdate(ASFBaseModel):
|
||||
name: Optional[str] = Field(None, min_length=2, max_length=120)
|
||||
slug: Optional[str] = Field(None, min_length=3, max_length=60)
|
||||
plan: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class Organization(OrganizationBase):
|
||||
"""Stored Organization record — also the tenant boundary record."""
|
||||
|
||||
id: ASFFieldID = Field(default_factory=generate_id)
|
||||
created_at: UTCDatetime = Field(
|
||||
..., description="Creation timestamp (UTC, written on insert)"
|
||||
)
|
||||
updated_at: UTCDatetime = Field(
|
||||
..., description="Last update timestamp (UTC, written on every write)"
|
||||
)
|
||||
metadata: dict = Field(
|
||||
default_factory=dict,
|
||||
description="Tenant-level extensible metadata (billing id, features, …)",
|
||||
)
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# User (bootstrap skeleton; real auth lives outside domain)
|
||||
# ======================================================================
|
||||
|
||||
|
||||
class User(TenantScopedModel):
|
||||
"""Minimal User record used for ownership / audit references."""
|
||||
|
||||
id: ASFFieldID = Field(default_factory=generate_id)
|
||||
email: str = Field(..., max_length=255)
|
||||
display_name: str = Field(..., max_length=120)
|
||||
roles: List[str] = Field(default_factory=lambda: ["member"])
|
||||
is_active: bool = True
|
||||
created_at: UTCDatetime = ...
|
||||
updated_at: UTCDatetime = ...
|
||||
|
||||
@field_validator("email")
|
||||
@classmethod
|
||||
def _lower_email(cls, v: str) -> str:
|
||||
return v.strip().lower()
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Team
|
||||
# ======================================================================
|
||||
|
||||
|
||||
class Team(TenantScopedModel):
|
||||
id: ASFFieldID = Field(default_factory=generate_id)
|
||||
name: str = Field(..., min_length=2, max_length=80)
|
||||
description: Optional[str] = Field(None, max_length=500)
|
||||
member_ids: List[ASFFieldID] = Field(default_factory=list)
|
||||
created_at: UTCDatetime = ...
|
||||
updated_at: UTCDatetime = ...
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Project
|
||||
# ======================================================================
|
||||
|
||||
|
||||
class ProjectBase(ASFBaseModel):
|
||||
name: str = Field(..., min_length=2, max_length=120)
|
||||
key: str = Field(
|
||||
...,
|
||||
min_length=2,
|
||||
max_length=20,
|
||||
pattern=r"^[A-Z][A-Z0-9]{1,19}$",
|
||||
description="Short project key (e.g. ASF, PROJ) used in human readable refs",
|
||||
examples=["ASF", "PRJ"],
|
||||
)
|
||||
description: Optional[str] = Field(None, max_length=1000)
|
||||
default_branch: str = Field("main", max_length=100)
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class ProjectCreate(ProjectBase):
|
||||
"""Create payload; tenant_id is injected server side from auth context."""
|
||||
pass
|
||||
|
||||
|
||||
class ProjectUpdate(ASFBaseModel):
|
||||
name: Optional[str] = Field(None, min_length=2, max_length=120)
|
||||
key: Optional[str] = Field(None, min_length=2, max_length=20)
|
||||
description: Optional[str] = Field(None, max_length=1000)
|
||||
default_branch: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class Project(TenantScopedModel, ProjectBase):
|
||||
"""Stored Project record. Lives inside a tenant."""
|
||||
|
||||
id: ASFFieldID = Field(default_factory=generate_id)
|
||||
team_ids: List[ASFFieldID] = Field(default_factory=list)
|
||||
created_at: UTCDatetime = ...
|
||||
updated_at: UTCDatetime = ...
|
||||
metadata: dict = Field(default_factory=dict)
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Integration — registration of a connector bound to a tenant.
|
||||
|
||||
The connector itself lives in ``asf-connectors``; this record stores:
|
||||
* which connector type is in use
|
||||
* tenant-scoped configuration references (secrets live elsewhere)
|
||||
* last health state
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional, Dict
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from asf_domain.base import (
|
||||
ASFBaseModel,
|
||||
TenantScopedModel,
|
||||
ASFFieldID,
|
||||
UTCDatetime,
|
||||
generate_id,
|
||||
)
|
||||
|
||||
|
||||
class ConnectorType(str, Enum):
|
||||
GITHUB = "github"
|
||||
GITLAB = "gitlab"
|
||||
BITBUCKET = "bitbucket"
|
||||
JIRA = "jira"
|
||||
CONFLUENCE = "confluence"
|
||||
NOTION = "notion"
|
||||
JENKINS = "jenkins"
|
||||
SONARQUBE = "sonarqube"
|
||||
GRAFANA = "grafana"
|
||||
GENERIC_WEBHOOK = "generic_webhook"
|
||||
CUSTOM = "custom"
|
||||
|
||||
|
||||
class IntegrationBase(ASFBaseModel):
|
||||
name: str = Field(..., min_length=2, max_length=80)
|
||||
connector: ConnectorType = ConnectorType.CUSTOM
|
||||
is_enabled: bool = True
|
||||
|
||||
|
||||
class IntegrationCreate(IntegrationBase):
|
||||
config_ref: Optional[str] = Field(
|
||||
None,
|
||||
max_length=512,
|
||||
description="Reference to a secure store (e.g. vault path) holding connector secrets. "
|
||||
"NEVER inline credentials here.",
|
||||
)
|
||||
metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class Integration(TenantScopedModel, IntegrationBase):
|
||||
id: ASFFieldID = Field(default_factory=generate_id)
|
||||
config_ref: Optional[str] = None
|
||||
metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||
last_health_at: Optional[UTCDatetime] = None
|
||||
last_health_status: Optional[str] = Field(None, max_length=40)
|
||||
created_at: UTCDatetime = ...
|
||||
updated_at: UTCDatetime = ...
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Job model — persistent asynchronous job state.
|
||||
|
||||
RabbitMQ delivers work messages. MongoDB is the source of truth for job
|
||||
lifecycle. Worker processes update Job records as they progress through
|
||||
queued → running → completed|failed|cancelled.
|
||||
|
||||
Do not use RabbitMQ queues as the permanent job database.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional, Dict
|
||||
from pydantic import Field
|
||||
|
||||
from asf_domain.base import (
|
||||
ASFBaseModel,
|
||||
TenantScopedModel,
|
||||
ASFFieldID,
|
||||
UTCDatetime,
|
||||
JobStatus,
|
||||
JobPriority,
|
||||
JobType,
|
||||
generate_id,
|
||||
)
|
||||
|
||||
|
||||
class JobUsage(ASFBaseModel):
|
||||
"""Optional resource / cost usage tracked for a job."""
|
||||
|
||||
input_tokens: Optional[int] = Field(None, ge=0)
|
||||
output_tokens: Optional[int] = Field(None, ge=0)
|
||||
gpu_seconds: Optional[float] = Field(None, ge=0)
|
||||
cpu_seconds: Optional[float] = Field(None, ge=0)
|
||||
estimated_cost: Optional[float] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
description="Estimated cost in tenant currency (EUR/USD). Approximate, used for metering only.",
|
||||
)
|
||||
extra: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class JobBase(ASFBaseModel):
|
||||
type: JobType = Field(
|
||||
...,
|
||||
description="Logical job class. Used for routing to the correct RabbitMQ queue.",
|
||||
)
|
||||
priority: JobPriority = JobPriority.NORMAL
|
||||
payload: Dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="The actual work payload (e.g. {artifact_id, prompt, model_preference}).",
|
||||
)
|
||||
correlation_id: Optional[str] = Field(
|
||||
None,
|
||||
max_length=128,
|
||||
description="Optional correlation id propagated across logs, events, child jobs.",
|
||||
)
|
||||
|
||||
|
||||
class JobCreate(JobBase):
|
||||
"""Create payload. tenant_id/project_id/requested_by are injected server-side."""
|
||||
|
||||
project_id: Optional[ASFFieldID] = None
|
||||
model: Optional[str] = Field(
|
||||
None,
|
||||
max_length=128,
|
||||
description="Preferred model / engine, only meaningful for AI job types. "
|
||||
"Ultimate routing decision belongs to the AI Gateway.",
|
||||
)
|
||||
|
||||
|
||||
class JobUpdate(ASFBaseModel):
|
||||
"""Updates issued by workers as the job progresses."""
|
||||
|
||||
status: Optional[JobStatus] = None
|
||||
worker: Optional[str] = Field(
|
||||
None, max_length=255, description="Identifier of the worker process claiming this job."
|
||||
)
|
||||
started_at: Optional[UTCDatetime] = None
|
||||
completed_at: Optional[UTCDatetime] = None
|
||||
result: Optional[Dict[str, Any]] = None
|
||||
error: Optional[str] = Field(None, max_length=2000)
|
||||
error_detail: Optional[Dict[str, Any]] = None
|
||||
usage: Optional[JobUsage] = None
|
||||
progress_percent: Optional[int] = Field(None, ge=0, le=100)
|
||||
|
||||
|
||||
class Job(TenantScopedModel, JobBase):
|
||||
"""Persistent job record — the authoritative source of truth."""
|
||||
|
||||
id: ASFFieldID = Field(default_factory=generate_id)
|
||||
project_id: Optional[ASFFieldID] = None
|
||||
status: JobStatus = JobStatus.QUEUED
|
||||
requested_by: Optional[ASFFieldID] = Field(
|
||||
None, description="User that requested the job, or null for system jobs."
|
||||
)
|
||||
worker: Optional[str] = None
|
||||
model: Optional[str] = None
|
||||
created_at: UTCDatetime = ...
|
||||
started_at: Optional[UTCDatetime] = None
|
||||
completed_at: Optional[UTCDatetime] = None
|
||||
result: Optional[Dict[str, Any]] = None
|
||||
error: Optional[str] = None
|
||||
error_detail: Optional[Dict[str, Any]] = None
|
||||
usage: JobUsage = Field(default_factory=JobUsage)
|
||||
progress_percent: Optional[int] = Field(None, ge=0, le=100)
|
||||
retries: int = Field(0, ge=0, description="How many times this job has been retried.")
|
||||
@@ -0,0 +1,28 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "asf-domain"
|
||||
version = "0.1.0"
|
||||
description = "ASF shared domain models (Pydantic v2)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
authors = [{ name = "Azurian Software Factory Team" }]
|
||||
license = { text = "Proprietary" }
|
||||
dependencies = [
|
||||
"pydantic>=2.7,<3",
|
||||
"pydantic-settings>=2.2,<3",
|
||||
"python-ulid>=2.7,<3",
|
||||
"typing_extensions>=4.11",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8",
|
||||
"pytest-asyncio>=0.23",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["asf_domain*"]
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Tests for core domain invariants around artifacts & relationships."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from asf_domain import (
|
||||
Artifact,
|
||||
ArtifactCreate,
|
||||
ArtifactRelationshipCreate,
|
||||
ArtifactRelationship,
|
||||
ArtifactType,
|
||||
RelationshipType,
|
||||
generate_id,
|
||||
)
|
||||
|
||||
|
||||
NOW = datetime(2026, 9, 7, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_generate_id_returns_unique_uuid4() -> None:
|
||||
a = generate_id()
|
||||
b = generate_id()
|
||||
assert a != b
|
||||
assert a.version == 4
|
||||
|
||||
|
||||
def test_artifact_create_minimal() -> None:
|
||||
body = ArtifactCreate(
|
||||
project_id=uuid4(),
|
||||
type=ArtifactType.REQUIREMENT,
|
||||
title="Login page must support MFA",
|
||||
)
|
||||
assert body.type == ArtifactType.REQUIREMENT
|
||||
assert body.metadata == {}
|
||||
|
||||
|
||||
def test_artifact_stored_round_trip() -> None:
|
||||
tenant_id = uuid4()
|
||||
project_id = uuid4()
|
||||
|
||||
a = Artifact(
|
||||
tenant_id=tenant_id,
|
||||
project_id=project_id,
|
||||
type=ArtifactType.TASK,
|
||||
title="Wire MFA provider",
|
||||
content="- [ ] choose TOTP lib\n- [ ] enroll flow",
|
||||
metadata={"estimated_points": 5, "labels": ["auth", "security"]},
|
||||
created_at=NOW,
|
||||
updated_at=NOW,
|
||||
)
|
||||
d = a.model_dump(mode="json")
|
||||
# ensure UTC serialization works
|
||||
assert d["created_at"].endswith("Z")
|
||||
assert d["metadata"]["labels"] == ["auth", "security"]
|
||||
|
||||
|
||||
def test_artifact_rejects_empty_title() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ArtifactCreate(project_id=uuid4(), type=ArtifactType.STORY, title="")
|
||||
|
||||
|
||||
def test_relationship_create() -> None:
|
||||
a = uuid4()
|
||||
b = uuid4()
|
||||
rel = ArtifactRelationshipCreate(
|
||||
relationship_type=RelationshipType.IMPLEMENTED_BY,
|
||||
source_artifact_id=a,
|
||||
target_artifact_id=b,
|
||||
)
|
||||
assert rel.source_artifact_id == a
|
||||
|
||||
|
||||
def test_relationship_stored_enforces_tenancy() -> None:
|
||||
tenant_id = uuid4()
|
||||
a, b = uuid4(), uuid4()
|
||||
pa, pb = uuid4(), uuid4()
|
||||
rel = ArtifactRelationship(
|
||||
tenant_id=tenant_id,
|
||||
source_project_id=pa,
|
||||
target_project_id=pb,
|
||||
relationship_type=RelationshipType.VERIFIED_BY,
|
||||
source_artifact_id=a,
|
||||
target_artifact_id=b,
|
||||
created_at=NOW,
|
||||
updated_at=NOW,
|
||||
)
|
||||
# a relationship carries both project ids for tenant isolation queries
|
||||
assert rel.tenant_id == tenant_id
|
||||
assert rel.source_project_id == pa
|
||||
assert rel.target_project_id == pb
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Tests for Organization / Project invariants."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from asf_domain import (
|
||||
Organization,
|
||||
OrganizationCreate,
|
||||
Project,
|
||||
ProjectCreate,
|
||||
)
|
||||
|
||||
|
||||
NOW = datetime(2026, 9, 7, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_organization_create_validates_slug() -> None:
|
||||
OrganizationCreate(name="Contoso", slug="contoso-retail")
|
||||
with pytest.raises(ValidationError):
|
||||
OrganizationCreate(name="Bad", slug="UPPERCASE no!")
|
||||
|
||||
|
||||
def test_project_create_validates_key_uppercase() -> None:
|
||||
ProjectCreate(name="ASF Core", key="ASF")
|
||||
with pytest.raises(ValidationError):
|
||||
ProjectCreate(name="Bad", key="lowercase")
|
||||
|
||||
|
||||
def test_project_scoped_under_tenant() -> None:
|
||||
tenant_id = uuid4()
|
||||
p = Project(
|
||||
tenant_id=tenant_id,
|
||||
name="ASF Web",
|
||||
key="WEB",
|
||||
created_at=NOW,
|
||||
updated_at=NOW,
|
||||
)
|
||||
assert p.tenant_id == tenant_id
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Tests for job state transitions & usage tracking."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from asf_domain import (
|
||||
Job,
|
||||
JobCreate,
|
||||
JobUpdate,
|
||||
JobStatus,
|
||||
JobPriority,
|
||||
JobType,
|
||||
JobUsage,
|
||||
)
|
||||
|
||||
|
||||
NOW = datetime(2026, 9, 7, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_job_create_defaults() -> None:
|
||||
jc = JobCreate(
|
||||
type=JobType.AI_INTERACTIVE,
|
||||
payload={"prompt": "summarize REQ-1", "artifact_id": str(uuid4())},
|
||||
)
|
||||
assert jc.priority == JobPriority.NORMAL
|
||||
assert jc.model is None
|
||||
|
||||
|
||||
def test_job_lifecycle_updates() -> None:
|
||||
tenant_id = uuid4()
|
||||
j = Job(
|
||||
tenant_id=tenant_id,
|
||||
type=JobType.AI_BACKGROUND,
|
||||
payload={"artifact_id": str(uuid4())},
|
||||
created_at=NOW,
|
||||
)
|
||||
assert j.status == JobStatus.QUEUED
|
||||
|
||||
# worker claims it
|
||||
update1 = JobUpdate(status=JobStatus.RUNNING, worker="worker-asf-ai-7", started_at=NOW)
|
||||
data = j.model_copy(update=update1.model_dump(exclude_unset=True))
|
||||
assert data.status == JobStatus.RUNNING
|
||||
assert data.worker == "worker-asf-ai-7"
|
||||
|
||||
# worker completes with usage
|
||||
usage = JobUsage(
|
||||
input_tokens=1500, output_tokens=400, gpu_seconds=2.3, estimated_cost=0.012
|
||||
)
|
||||
update2 = JobUpdate(status=JobStatus.COMPLETED, completed_at=NOW, usage=usage)
|
||||
final = data.model_copy(update=update2.model_dump(exclude_unset=True))
|
||||
assert final.status == JobStatus.COMPLETED
|
||||
final_usage = JobUsage.model_validate(final.usage)
|
||||
assert final_usage.output_tokens == 400
|
||||
assert final_usage.estimated_cost == pytest.approx(0.012)
|
||||
|
||||
|
||||
def test_job_update_validates_progress_bounds() -> None:
|
||||
from pydantic import ValidationError
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
JobUpdate(progress_percent=150)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""asf-events: canonical contracts for every message that travels on the
|
||||
ASF RabbitMQ backbone (both domain events and job-request envelopes).
|
||||
|
||||
Service code NEVER constructs a message dict inline: always use one of
|
||||
the models in this package. This keeps producers and consumers typed.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
from asf_events.base import ASFEvent, JobRequestEnvelope
|
||||
from asf_events.artifact_events import (
|
||||
ArtifactCreated,
|
||||
ArtifactUpdated,
|
||||
ArtifactDeleted,
|
||||
ArtifactRelationshipCreated,
|
||||
ArtifactRelationshipDeleted,
|
||||
)
|
||||
from asf_events.ai_events import (
|
||||
AIInferenceRequested,
|
||||
AIInferenceCompleted,
|
||||
AIInferenceFailed,
|
||||
)
|
||||
from asf_events.identity_events import (
|
||||
OrganizationCreated,
|
||||
ProjectCreated,
|
||||
ProjectUpdated,
|
||||
)
|
||||
from asf_events.job_events import (
|
||||
JobQueued,
|
||||
JobStarted,
|
||||
JobCompleted,
|
||||
JobFailed,
|
||||
JobCancelled,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ASFEvent",
|
||||
"JobRequestEnvelope",
|
||||
"ArtifactCreated",
|
||||
"ArtifactUpdated",
|
||||
"ArtifactDeleted",
|
||||
"ArtifactRelationshipCreated",
|
||||
"ArtifactRelationshipDeleted",
|
||||
"AIInferenceRequested",
|
||||
"AIInferenceCompleted",
|
||||
"AIInferenceFailed",
|
||||
"OrganizationCreated",
|
||||
"ProjectCreated",
|
||||
"ProjectUpdated",
|
||||
"JobQueued",
|
||||
"JobStarted",
|
||||
"JobCompleted",
|
||||
"JobFailed",
|
||||
"JobCancelled",
|
||||
]
|
||||
@@ -0,0 +1,41 @@
|
||||
"""AI-specific domain events (published by the AI Gateway / workers)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from asf_events.base import ASFEvent
|
||||
|
||||
|
||||
class AIInferenceRequested(ASFEvent):
|
||||
event_type: str = "ai.inference.requested"
|
||||
job_id: Optional[UUID] = None
|
||||
capability: str
|
||||
provider: str
|
||||
preferred_model: Optional[str] = None
|
||||
payload: Dict[str, Any]
|
||||
|
||||
|
||||
class AIInferenceCompleted(ASFEvent):
|
||||
event_type: str = "ai.inference.completed"
|
||||
job_id: Optional[UUID] = None
|
||||
execution_id: UUID
|
||||
capability: str
|
||||
provider: str
|
||||
model_used: str
|
||||
duration_ms: Optional[int] = None
|
||||
input_tokens: Optional[int] = None
|
||||
output_tokens: Optional[int] = None
|
||||
estimated_cost: Optional[float] = None
|
||||
payload: Dict[str, Any]
|
||||
|
||||
|
||||
class AIInferenceFailed(ASFEvent):
|
||||
event_type: str = "ai.inference.failed"
|
||||
job_id: Optional[UUID] = None
|
||||
execution_id: UUID
|
||||
capability: str
|
||||
provider: str
|
||||
error: str
|
||||
payload: Dict[str, Any] = {}
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Artifact + ArtifactRelationship domain events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from asf_events.base import ASFEvent
|
||||
|
||||
|
||||
class ArtifactCreated(ASFEvent):
|
||||
event_type: str = "artifact.created"
|
||||
artifact_id: UUID
|
||||
artifact_type: str
|
||||
project_id: UUID
|
||||
payload: Dict[str, Any]
|
||||
|
||||
|
||||
class ArtifactUpdated(ASFEvent):
|
||||
event_type: str = "artifact.updated"
|
||||
artifact_id: UUID
|
||||
artifact_type: str
|
||||
project_id: UUID
|
||||
changed_fields: list[str]
|
||||
previous_version: int
|
||||
payload: Dict[str, Any]
|
||||
|
||||
|
||||
class ArtifactDeleted(ASFEvent):
|
||||
event_type: str = "artifact.deleted"
|
||||
artifact_id: UUID
|
||||
artifact_type: str
|
||||
project_id: UUID
|
||||
|
||||
|
||||
class ArtifactRelationshipCreated(ASFEvent):
|
||||
event_type: str = "artifact.relationship.created"
|
||||
relationship_id: UUID
|
||||
source_artifact_id: UUID
|
||||
target_artifact_id: UUID
|
||||
relationship_type: str
|
||||
source_project_id: UUID
|
||||
target_project_id: UUID
|
||||
payload: Dict[str, Any] = {}
|
||||
|
||||
|
||||
class ArtifactRelationshipDeleted(ASFEvent):
|
||||
event_type: str = "artifact.relationship.deleted"
|
||||
relationship_id: UUID
|
||||
source_artifact_id: UUID
|
||||
target_artifact_id: UUID
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Base event envelope + job request envelope.
|
||||
|
||||
Every RabbitMQ message is either:
|
||||
1. An ``ASFEvent`` subclass → a fact that happened (domain event).
|
||||
2. A ``JobRequestEnvelope`` → a unit of work to be executed (job).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional, Dict
|
||||
from uuid import uuid4, UUID
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from asf_domain.base import ASFBaseModel, UTCDatetime, JobType, JobPriority
|
||||
|
||||
|
||||
class ASFEvent(ASFBaseModel):
|
||||
"""Abstract base for every domain event published on RabbitMQ.
|
||||
|
||||
Semantics: facts that happened, never commands.
|
||||
Naming pattern: <Noun>.<Ved> (artifact.created, deployment.completed).
|
||||
"""
|
||||
|
||||
event_id: UUID = Field(default_factory=uuid4)
|
||||
event_type: str = Field(
|
||||
...,
|
||||
max_length=128,
|
||||
description="Dot-delimited routing key e.g. 'artifact.created'.",
|
||||
)
|
||||
timestamp: UTCDatetime = Field(
|
||||
default_factory=lambda: datetime.now(tz=timezone.utc)
|
||||
)
|
||||
tenant_id: UUID = Field(..., description="Owning tenant.")
|
||||
project_id: Optional[UUID] = None
|
||||
correlation_id: Optional[str] = Field(None, max_length=128)
|
||||
causation_id: Optional[str] = Field(
|
||||
None,
|
||||
max_length=128,
|
||||
description="Id of the previous event / request that caused this one.",
|
||||
)
|
||||
payload: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class JobRequestEnvelope(ASFBaseModel):
|
||||
"""Envelope delivered to a RabbitMQ worker queue.
|
||||
|
||||
The worker performs the work, then updates the corresponding Mongo
|
||||
``Job`` record (its id is in this envelope). RabbitMQ is NOT the
|
||||
permanent job state store — MongoDB is.
|
||||
"""
|
||||
|
||||
job_id: UUID
|
||||
tenant_id: UUID
|
||||
project_id: Optional[UUID] = None
|
||||
type: JobType
|
||||
priority: JobPriority = JobPriority.NORMAL
|
||||
correlation_id: Optional[str] = None
|
||||
payload: Dict[str, Any] = Field(default_factory=dict)
|
||||
reply_to: Optional[str] = Field(
|
||||
None,
|
||||
max_length=255,
|
||||
description="Optional queue/routing-key for completion events.",
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Organization / Project domain events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
from uuid import UUID
|
||||
|
||||
from asf_events.base import ASFEvent
|
||||
|
||||
|
||||
class OrganizationCreated(ASFEvent):
|
||||
event_type: str = "organization.created"
|
||||
organization_id: UUID
|
||||
slug: str
|
||||
name: str
|
||||
payload: Dict[str, Any] = {}
|
||||
|
||||
|
||||
class ProjectCreated(ASFEvent):
|
||||
event_type: str = "project.created"
|
||||
project_id: UUID
|
||||
organization_id: UUID
|
||||
project_key: str
|
||||
name: str
|
||||
payload: Dict[str, Any] = {}
|
||||
|
||||
|
||||
class ProjectUpdated(ASFEvent):
|
||||
event_type: str = "project.updated"
|
||||
project_id: UUID
|
||||
changed_fields: list[str]
|
||||
payload: Dict[str, Any] = {}
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Job lifecycle domain events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from asf_events.base import ASFEvent
|
||||
|
||||
|
||||
class JobQueued(ASFEvent):
|
||||
event_type: str = "job.queued"
|
||||
job_id: UUID
|
||||
job_type: str
|
||||
queue: str
|
||||
payload: Dict[str, Any] = {}
|
||||
|
||||
|
||||
class JobStarted(ASFEvent):
|
||||
event_type: str = "job.started"
|
||||
job_id: UUID
|
||||
worker: Optional[str] = None
|
||||
payload: Dict[str, Any] = {}
|
||||
|
||||
|
||||
class JobCompleted(ASFEvent):
|
||||
event_type: str = "job.completed"
|
||||
job_id: UUID
|
||||
duration_ms: Optional[int] = None
|
||||
payload: Dict[str, Any] = {}
|
||||
|
||||
|
||||
class JobFailed(ASFEvent):
|
||||
event_type: str = "job.failed"
|
||||
job_id: UUID
|
||||
error: str
|
||||
payload: Dict[str, Any] = {}
|
||||
|
||||
|
||||
class JobCancelled(ASFEvent):
|
||||
event_type: str = "job.cancelled"
|
||||
job_id: UUID
|
||||
reason: Optional[str] = None
|
||||
payload: Dict[str, Any] = {}
|
||||
@@ -0,0 +1,19 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "asf-events"
|
||||
version = "0.1.0"
|
||||
description = "ASF event schemas (RabbitMQ payload contracts)"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"asf-domain>=0.1.0",
|
||||
"pydantic>=2.7,<3",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["asf_events*"]
|
||||
Reference in New Issue
Block a user