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
@@ -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]]: ...
+20
View File
@@ -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*"]
+22
View File
@@ -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 = ...
+154
View File
@@ -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 = ...
+62
View File
@@ -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 = ...
+184
View File
@@ -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"
+15
View File
@@ -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."""
+157
View File
@@ -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 = ...
+106
View File
@@ -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.")
+28
View File
@@ -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
+65
View File
@@ -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] = {}
+19
View File
@@ -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*"]