Implement addressable artifacts with content-based identity and change detection. Core Features: - Artifact model with SHA-256 content digests - ArtifactReference for cross-space addressing - IArtifactRepository interface for pluggable storage - SQLiteArtifactRepository implementation - ArtifactService for high-level operations - Content digest calculation utilities Database: - prompt_artifacts table with indexes - Support for artifact metadata and types - UNIQUE constraint on space_id+name Tests (41 passing): - 26 model tests (metadata, artifacts, references, digests) - 15 repository tests (CRUD, queries, constraints) Implements: - FR-1.1: Unique addressability by name and ID - FR-1.2: Content digest computation and storage - FR-1.3: Cross-space artifact references Files Created: - markitect/prompts/models.py - markitect/prompts/repositories/interfaces.py - markitect/prompts/repositories/sqlite.py - markitect/prompts/services/artifact_service.py - migrations/prompts/001_create_artifacts_table.sql - tests/unit/prompts/test_artifact_models.py - tests/unit/prompts/test_artifact_repository.py Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
240 lines
6.5 KiB
Python
240 lines
6.5 KiB
Python
"""
|
|
Artifact service for high-level artifact management operations.
|
|
|
|
This service provides business logic for creating, querying, and updating
|
|
artifacts with automatic content digest calculation and change tracking.
|
|
"""
|
|
|
|
from typing import List, Optional
|
|
from datetime import datetime
|
|
|
|
from markitect.prompts.models import (
|
|
Artifact,
|
|
ArtifactType,
|
|
ArtifactMetadata,
|
|
ArtifactReference,
|
|
calculate_content_digest,
|
|
)
|
|
from markitect.prompts.repositories.interfaces import (
|
|
IArtifactRepository,
|
|
ArtifactNotFoundError,
|
|
)
|
|
|
|
|
|
class ArtifactService:
|
|
"""
|
|
Service for artifact management operations.
|
|
|
|
Provides high-level business logic on top of artifact repository,
|
|
handling content digest calculation, change detection, and cross-space
|
|
artifact resolution.
|
|
"""
|
|
|
|
def __init__(self, repository: IArtifactRepository):
|
|
"""
|
|
Initialize service with repository.
|
|
|
|
Args:
|
|
repository: Artifact repository implementation
|
|
"""
|
|
self.repository = repository
|
|
|
|
def create_artifact(
|
|
self,
|
|
space_id: str,
|
|
name: str,
|
|
content: str,
|
|
artifact_type: ArtifactType = ArtifactType.CONTENT,
|
|
metadata: Optional[ArtifactMetadata] = None,
|
|
) -> Artifact:
|
|
"""
|
|
Create a new artifact with automatic digest calculation.
|
|
|
|
Args:
|
|
space_id: ID of containing space
|
|
name: Artifact name
|
|
content: Artifact content
|
|
artifact_type: Type classification
|
|
metadata: Optional metadata
|
|
|
|
Returns:
|
|
Created artifact
|
|
|
|
Raises:
|
|
DuplicateArtifactError: If artifact already exists
|
|
"""
|
|
artifact = Artifact.create(
|
|
space_id=space_id,
|
|
name=name,
|
|
content=content,
|
|
artifact_type=artifact_type,
|
|
metadata=metadata,
|
|
)
|
|
return self.repository.create(artifact)
|
|
|
|
def get_artifact(self, artifact_id: str) -> Artifact:
|
|
"""
|
|
Retrieve artifact by ID.
|
|
|
|
Args:
|
|
artifact_id: Artifact identifier
|
|
|
|
Returns:
|
|
Artifact instance
|
|
|
|
Raises:
|
|
ArtifactNotFoundError: If artifact doesn't exist
|
|
"""
|
|
artifact = self.repository.get_by_id(artifact_id)
|
|
if not artifact:
|
|
raise ArtifactNotFoundError(f"Artifact with ID '{artifact_id}' not found")
|
|
return artifact
|
|
|
|
def get_artifact_by_name(self, space_id: str, name: str) -> Artifact:
|
|
"""
|
|
Retrieve artifact by space and name.
|
|
|
|
Args:
|
|
space_id: Space identifier
|
|
name: Artifact name
|
|
|
|
Returns:
|
|
Artifact instance
|
|
|
|
Raises:
|
|
ArtifactNotFoundError: If artifact doesn't exist
|
|
"""
|
|
artifact = self.repository.get_by_name(space_id, name)
|
|
if not artifact:
|
|
raise ArtifactNotFoundError(
|
|
f"Artifact '{name}' not found in space '{space_id}'"
|
|
)
|
|
return artifact
|
|
|
|
def resolve_reference(
|
|
self,
|
|
reference: ArtifactReference,
|
|
search_spaces: List[str],
|
|
) -> Optional[Artifact]:
|
|
"""
|
|
Resolve an artifact reference across multiple spaces.
|
|
|
|
Implements FR-1.3: Cross-space artifact references
|
|
Searches spaces in order until artifact is found.
|
|
|
|
Args:
|
|
reference: Artifact reference to resolve
|
|
search_spaces: List of space IDs to search in order
|
|
|
|
Returns:
|
|
Resolved artifact, or None if not found
|
|
"""
|
|
# If reference specifies space, search only that space
|
|
if reference.space_id:
|
|
return self.repository.get_by_name(reference.space_id, reference.name)
|
|
|
|
# Search spaces in order
|
|
for space_id in search_spaces:
|
|
artifact = self.repository.get_by_name(space_id, reference.name)
|
|
if artifact:
|
|
# TODO: Handle version constraint when versioning is implemented
|
|
return artifact
|
|
|
|
return None
|
|
|
|
def update_artifact_content(
|
|
self,
|
|
artifact_id: str,
|
|
new_content: str,
|
|
) -> Artifact:
|
|
"""
|
|
Update artifact content with automatic digest recalculation.
|
|
|
|
Args:
|
|
artifact_id: Artifact to update
|
|
new_content: New content
|
|
|
|
Returns:
|
|
Updated artifact
|
|
|
|
Raises:
|
|
ArtifactNotFoundError: If artifact doesn't exist
|
|
"""
|
|
artifact = self.get_artifact(artifact_id)
|
|
artifact.update_content(new_content)
|
|
return self.repository.update(artifact)
|
|
|
|
def detect_change(self, artifact_id: str, current_content: str) -> bool:
|
|
"""
|
|
Detect if artifact content has changed.
|
|
|
|
Args:
|
|
artifact_id: Artifact to check
|
|
current_content: Current content to compare
|
|
|
|
Returns:
|
|
True if content has changed
|
|
|
|
Raises:
|
|
ArtifactNotFoundError: If artifact doesn't exist
|
|
"""
|
|
artifact = self.get_artifact(artifact_id)
|
|
current_digest = calculate_content_digest(current_content)
|
|
return artifact.has_changed(current_digest)
|
|
|
|
def list_artifacts(
|
|
self,
|
|
space_id: str,
|
|
artifact_type: Optional[ArtifactType] = None,
|
|
) -> List[Artifact]:
|
|
"""
|
|
List artifacts in a space.
|
|
|
|
Args:
|
|
space_id: Space identifier
|
|
artifact_type: Optional type filter
|
|
|
|
Returns:
|
|
List of artifacts
|
|
"""
|
|
return self.repository.list_by_space(space_id, artifact_type)
|
|
|
|
def find_by_digest(self, content_digest: str) -> List[Artifact]:
|
|
"""
|
|
Find all artifacts with matching content digest.
|
|
|
|
Useful for finding duplicate content across spaces.
|
|
|
|
Args:
|
|
content_digest: SHA-256 digest to match
|
|
|
|
Returns:
|
|
List of artifacts with matching digest
|
|
"""
|
|
return self.repository.get_by_digest(content_digest)
|
|
|
|
def delete_artifact(self, artifact_id: str) -> bool:
|
|
"""
|
|
Delete an artifact.
|
|
|
|
Args:
|
|
artifact_id: Artifact to delete
|
|
|
|
Returns:
|
|
True if deleted, False if not found
|
|
"""
|
|
return self.repository.delete(artifact_id)
|
|
|
|
def artifact_exists(self, space_id: str, name: str) -> bool:
|
|
"""
|
|
Check if artifact exists.
|
|
|
|
Args:
|
|
space_id: Space identifier
|
|
name: Artifact name
|
|
|
|
Returns:
|
|
True if artifact exists
|
|
"""
|
|
return self.repository.exists(space_id, name)
|