generated from coulomb/repo-seed
Structured OperationFailure, BatchItemResult, and BatchOperationResult envelopes
This commit is contained in:
@@ -56,10 +56,13 @@ from .core import (
|
||||
from .errors import (
|
||||
AdapterUnavailableError,
|
||||
AuthorizationError,
|
||||
BatchItemResult,
|
||||
BatchOperationResult,
|
||||
Diagnostic,
|
||||
DuplicateResourceError,
|
||||
KontextualError,
|
||||
NotFoundError,
|
||||
OperationFailure,
|
||||
ValidationError,
|
||||
)
|
||||
from .ingestion import IngestionRequest, IngestionResult, IngestionService
|
||||
@@ -110,6 +113,8 @@ __all__ = [
|
||||
"AuditEvent",
|
||||
"AuditOutcome",
|
||||
"AuthorizationError",
|
||||
"BatchItemResult",
|
||||
"BatchOperationResult",
|
||||
"Classification",
|
||||
"ConnectorCapability",
|
||||
"Collection",
|
||||
@@ -148,6 +153,7 @@ __all__ = [
|
||||
"MetadataValueType",
|
||||
"NormalizedDocument",
|
||||
"NotFoundError",
|
||||
"OperationFailure",
|
||||
"OperationRun",
|
||||
"OperationStage",
|
||||
"OperationContext",
|
||||
|
||||
@@ -388,6 +388,11 @@ class SQLiteAssetRegistryRepository:
|
||||
),
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
if _is_foreign_key_error(exc):
|
||||
raise ValidationError(
|
||||
"Version references an unknown asset",
|
||||
details={"asset_id": version.asset_id, "version_id": version.version_id},
|
||||
) from exc
|
||||
raise ValidationError(
|
||||
"Version sequence already exists for asset",
|
||||
details={"asset_id": version.asset_id, "sequence": version.sequence},
|
||||
@@ -404,29 +409,37 @@ class SQLiteAssetRegistryRepository:
|
||||
return [AssetVersion.from_dict(_loads(row["payload"])) for row in rows]
|
||||
|
||||
def save_audit_event(self, event: AuditEvent) -> AuditEvent:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
insert into audit_events (id, target, actor_id, correlation_id, outcome, occurred_at, payload)
|
||||
values (?, ?, ?, ?, ?, ?, ?)
|
||||
on conflict(id) do update set
|
||||
target=excluded.target,
|
||||
actor_id=excluded.actor_id,
|
||||
correlation_id=excluded.correlation_id,
|
||||
outcome=excluded.outcome,
|
||||
occurred_at=excluded.occurred_at,
|
||||
payload=excluded.payload
|
||||
""",
|
||||
(
|
||||
event.event_id,
|
||||
event.target,
|
||||
event.actor_id,
|
||||
event.correlation_id,
|
||||
event.outcome.value,
|
||||
event.occurred_at,
|
||||
_json(event.to_dict()),
|
||||
),
|
||||
)
|
||||
try:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
insert into audit_events (id, target, actor_id, correlation_id, outcome, occurred_at, payload)
|
||||
values (?, ?, ?, ?, ?, ?, ?)
|
||||
on conflict(id) do update set
|
||||
target=excluded.target,
|
||||
actor_id=excluded.actor_id,
|
||||
correlation_id=excluded.correlation_id,
|
||||
outcome=excluded.outcome,
|
||||
occurred_at=excluded.occurred_at,
|
||||
payload=excluded.payload
|
||||
""",
|
||||
(
|
||||
event.event_id,
|
||||
event.target,
|
||||
event.actor_id,
|
||||
event.correlation_id,
|
||||
event.outcome.value,
|
||||
event.occurred_at,
|
||||
_json(event.to_dict()),
|
||||
),
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
if _is_foreign_key_error(exc):
|
||||
raise ValidationError(
|
||||
"Audit event references an unknown actor",
|
||||
details={"actor_id": event.actor_id, "event_id": event.event_id},
|
||||
) from exc
|
||||
raise
|
||||
return event
|
||||
|
||||
def get_audit_event(self, event_id: str) -> AuditEvent:
|
||||
@@ -482,28 +495,36 @@ class SQLiteAssetRegistryRepository:
|
||||
return IdempotencyRecord.from_dict(_loads(row["payload"]))
|
||||
|
||||
def save_ingestion_job(self, job: IngestionJob) -> IngestionJob:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
insert into ingestion_jobs (id, status, actor_id, correlation_id, created_at, updated_at, payload)
|
||||
values (?, ?, ?, ?, ?, ?, ?)
|
||||
on conflict(id) do update set
|
||||
status=excluded.status,
|
||||
actor_id=excluded.actor_id,
|
||||
correlation_id=excluded.correlation_id,
|
||||
updated_at=excluded.updated_at,
|
||||
payload=excluded.payload
|
||||
""",
|
||||
(
|
||||
job.job_id,
|
||||
job.status.value,
|
||||
job.actor_id,
|
||||
job.correlation_id,
|
||||
job.created_at,
|
||||
job.updated_at,
|
||||
_json(job.to_dict()),
|
||||
),
|
||||
)
|
||||
try:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
insert into ingestion_jobs (id, status, actor_id, correlation_id, created_at, updated_at, payload)
|
||||
values (?, ?, ?, ?, ?, ?, ?)
|
||||
on conflict(id) do update set
|
||||
status=excluded.status,
|
||||
actor_id=excluded.actor_id,
|
||||
correlation_id=excluded.correlation_id,
|
||||
updated_at=excluded.updated_at,
|
||||
payload=excluded.payload
|
||||
""",
|
||||
(
|
||||
job.job_id,
|
||||
job.status.value,
|
||||
job.actor_id,
|
||||
job.correlation_id,
|
||||
job.created_at,
|
||||
job.updated_at,
|
||||
_json(job.to_dict()),
|
||||
),
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
if _is_foreign_key_error(exc):
|
||||
raise ValidationError(
|
||||
"Ingestion job references an unknown actor",
|
||||
details={"actor_id": job.actor_id, "job_id": job.job_id},
|
||||
) from exc
|
||||
raise
|
||||
return job
|
||||
|
||||
def get_ingestion_job(self, job_id: str) -> IngestionJob:
|
||||
@@ -613,7 +634,8 @@ class SQLiteAssetRegistryRepository:
|
||||
correlation_id text not null,
|
||||
created_at text not null,
|
||||
updated_at text not null,
|
||||
payload text not null
|
||||
payload text not null,
|
||||
foreign key(actor_id) references actors(id)
|
||||
);
|
||||
create index if not exists idx_assets_lifecycle on assets(lifecycle);
|
||||
create index if not exists idx_representations_asset on representations(asset_id);
|
||||
@@ -666,6 +688,10 @@ def _json(value: dict[str, Any]) -> str:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def _is_foreign_key_error(exc: sqlite3.IntegrityError) -> bool:
|
||||
return "FOREIGN KEY" in str(exc).upper()
|
||||
|
||||
|
||||
def _loads(value: str) -> dict[str, Any]:
|
||||
return json.loads(value)
|
||||
|
||||
|
||||
@@ -24,6 +24,130 @@ class Diagnostic:
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OperationFailure:
|
||||
"""Structured operation failure suitable for API and batch envelopes."""
|
||||
|
||||
code: str
|
||||
message: str
|
||||
operation: str
|
||||
correlation_id: str
|
||||
details: dict[str, Any] = field(default_factory=dict)
|
||||
remediation: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data: dict[str, Any] = {
|
||||
"code": self.code,
|
||||
"message": self.message,
|
||||
"operation": self.operation,
|
||||
"correlation_id": self.correlation_id,
|
||||
"details": dict(self.details),
|
||||
}
|
||||
if self.remediation:
|
||||
data["remediation"] = self.remediation
|
||||
return data
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BatchItemResult:
|
||||
"""One item result inside a batch operation envelope."""
|
||||
|
||||
item_id: str
|
||||
operation: str
|
||||
success: bool
|
||||
result_ref: dict[str, Any] = field(default_factory=dict)
|
||||
error: OperationFailure | None = None
|
||||
|
||||
@classmethod
|
||||
def succeeded(
|
||||
cls,
|
||||
*,
|
||||
item_id: str,
|
||||
operation: str,
|
||||
result_ref: dict[str, Any] | None = None,
|
||||
) -> "BatchItemResult":
|
||||
return cls(
|
||||
item_id=item_id,
|
||||
operation=operation,
|
||||
success=True,
|
||||
result_ref=dict(result_ref or {}),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def failed(
|
||||
cls,
|
||||
*,
|
||||
item_id: str,
|
||||
operation: str,
|
||||
error: OperationFailure,
|
||||
) -> "BatchItemResult":
|
||||
return cls(item_id=item_id, operation=operation, success=False, error=error)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data: dict[str, Any] = {
|
||||
"item_id": self.item_id,
|
||||
"operation": self.operation,
|
||||
"success": self.success,
|
||||
}
|
||||
if self.result_ref:
|
||||
data["result_ref"] = dict(self.result_ref)
|
||||
if self.error:
|
||||
data["error"] = self.error.to_dict()
|
||||
return data
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BatchOperationResult:
|
||||
"""Compact result envelope for batch operations with partial failures."""
|
||||
|
||||
operation: str
|
||||
correlation_id: str
|
||||
items: tuple[BatchItemResult, ...] = ()
|
||||
audit_event_id: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "items", tuple(self.items))
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
return len(self.items)
|
||||
|
||||
@property
|
||||
def succeeded(self) -> int:
|
||||
return sum(1 for item in self.items if item.success)
|
||||
|
||||
@property
|
||||
def failed(self) -> int:
|
||||
return self.total - self.succeeded
|
||||
|
||||
@property
|
||||
def partial(self) -> bool:
|
||||
return self.succeeded > 0 and self.failed > 0
|
||||
|
||||
@property
|
||||
def outcome(self) -> str:
|
||||
if self.partial:
|
||||
return "partial"
|
||||
if self.failed:
|
||||
return "failed"
|
||||
return "success"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data: dict[str, Any] = {
|
||||
"operation": self.operation,
|
||||
"correlation_id": self.correlation_id,
|
||||
"outcome": self.outcome,
|
||||
"total": self.total,
|
||||
"succeeded": self.succeeded,
|
||||
"failed": self.failed,
|
||||
"partial": self.partial,
|
||||
"items": [item.to_dict() for item in self.items],
|
||||
}
|
||||
if self.audit_event_id:
|
||||
data["audit_event_id"] = self.audit_event_id
|
||||
return data
|
||||
|
||||
|
||||
class KontextualError(Exception):
|
||||
"""Base class for explicit engine failures."""
|
||||
|
||||
@@ -41,6 +165,22 @@ class KontextualError(Exception):
|
||||
details=dict(self.details),
|
||||
)
|
||||
|
||||
def to_operation_failure(
|
||||
self,
|
||||
*,
|
||||
operation: str,
|
||||
correlation_id: str,
|
||||
remediation: str | None = None,
|
||||
) -> OperationFailure:
|
||||
return OperationFailure(
|
||||
code=str(self.details.get("code") or self.code),
|
||||
message=str(self),
|
||||
operation=operation,
|
||||
correlation_id=correlation_id,
|
||||
details=dict(self.details),
|
||||
remediation=remediation,
|
||||
)
|
||||
|
||||
|
||||
class NotFoundError(KontextualError):
|
||||
code = "kontextual.not_found"
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
"""Application services for the engine."""
|
||||
|
||||
from .asset_service import AssetChangeResult, AssetRegistryService, RelationshipChangeResult
|
||||
from .asset_service import (
|
||||
AssetChangeResult,
|
||||
AssetRegistryService,
|
||||
RelationshipChangeResult,
|
||||
)
|
||||
from .ingestion_service import AssetIngestionResult, AssetIngestionService
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -27,7 +27,13 @@ from kontextual_engine.core import (
|
||||
SourceReference,
|
||||
VersionChangeType,
|
||||
)
|
||||
from kontextual_engine.errors import AuthorizationError, ValidationError
|
||||
from kontextual_engine.errors import (
|
||||
AuthorizationError,
|
||||
BatchItemResult,
|
||||
BatchOperationResult,
|
||||
KontextualError,
|
||||
ValidationError,
|
||||
)
|
||||
from kontextual_engine.ports import AllowAllPolicyGateway, AssetRegistryRepository, PolicyGateway
|
||||
|
||||
|
||||
@@ -158,6 +164,108 @@ class AssetRegistryService:
|
||||
operation="asset.metadata.add",
|
||||
)
|
||||
decision = self._authorize(context, "asset.metadata.add", f"asset:{asset.id}")
|
||||
return self._save_metadata_record_change(
|
||||
asset,
|
||||
record,
|
||||
context,
|
||||
decision,
|
||||
operation="asset.metadata.add",
|
||||
)
|
||||
|
||||
def add_metadata_records_batch(
|
||||
self,
|
||||
asset_id: str,
|
||||
records: list[MetadataRecord] | tuple[MetadataRecord, ...],
|
||||
context: OperationContext,
|
||||
*,
|
||||
expected_current_version_id: str | None = None,
|
||||
) -> BatchOperationResult:
|
||||
operation = "asset.metadata.batch_add"
|
||||
asset = self.repository.get_asset(asset_id)
|
||||
self._assert_expected_current_version(
|
||||
asset,
|
||||
expected_current_version_id,
|
||||
operation=operation,
|
||||
)
|
||||
decision = self._authorize(
|
||||
context,
|
||||
operation,
|
||||
f"asset:{asset.id}",
|
||||
resource_metadata={"count": str(len(records))},
|
||||
)
|
||||
item_results: list[BatchItemResult] = []
|
||||
for record in records:
|
||||
item_operation = "asset.metadata.add"
|
||||
try:
|
||||
asset = self.repository.get_asset(asset.id)
|
||||
result = self._save_metadata_record_change(
|
||||
asset,
|
||||
record,
|
||||
context,
|
||||
decision,
|
||||
operation=item_operation,
|
||||
)
|
||||
except KontextualError as exc:
|
||||
item_results.append(
|
||||
BatchItemResult.failed(
|
||||
item_id=record.record_id,
|
||||
operation=item_operation,
|
||||
error=exc.to_operation_failure(
|
||||
operation=item_operation,
|
||||
correlation_id=context.correlation_id,
|
||||
remediation=_remediation_for_error(exc),
|
||||
),
|
||||
)
|
||||
)
|
||||
continue
|
||||
item_results.append(
|
||||
BatchItemResult.succeeded(
|
||||
item_id=record.record_id,
|
||||
operation=item_operation,
|
||||
result_ref={
|
||||
"asset_id": result.asset.id,
|
||||
"record_id": record.record_id,
|
||||
"version_id": result.version.version_id,
|
||||
"audit_event_id": result.audit_event.event_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
batch_result = BatchOperationResult(
|
||||
operation=operation,
|
||||
correlation_id=context.correlation_id,
|
||||
items=tuple(item_results),
|
||||
)
|
||||
audit_event = self._audit(
|
||||
operation,
|
||||
f"asset:{asset.id}",
|
||||
AuditOutcome(batch_result.outcome),
|
||||
context,
|
||||
decision,
|
||||
details={
|
||||
"total": batch_result.total,
|
||||
"succeeded": batch_result.succeeded,
|
||||
"failed": batch_result.failed,
|
||||
"failed_item_ids": [
|
||||
item.item_id for item in batch_result.items if not item.success
|
||||
],
|
||||
},
|
||||
)
|
||||
return BatchOperationResult(
|
||||
operation=batch_result.operation,
|
||||
correlation_id=batch_result.correlation_id,
|
||||
items=batch_result.items,
|
||||
audit_event_id=audit_event.event_id,
|
||||
)
|
||||
|
||||
def _save_metadata_record_change(
|
||||
self,
|
||||
asset: KnowledgeAsset,
|
||||
record: MetadataRecord,
|
||||
context: OperationContext,
|
||||
decision: PolicyDecision,
|
||||
*,
|
||||
operation: str,
|
||||
) -> AssetChangeResult:
|
||||
next_sequence = self._next_sequence(asset.id)
|
||||
self._validate_metadata_records(
|
||||
asset.classification,
|
||||
@@ -177,7 +285,7 @@ class AssetRegistryService:
|
||||
self.repository.save_asset(asset)
|
||||
self.repository.save_version(version)
|
||||
event = self._audit(
|
||||
"asset.metadata.add",
|
||||
operation,
|
||||
f"asset:{asset.id}",
|
||||
AuditOutcome.SUCCESS,
|
||||
context,
|
||||
@@ -686,7 +794,7 @@ class AssetRegistryService:
|
||||
context: OperationContext,
|
||||
policy_decision: PolicyDecision,
|
||||
*,
|
||||
details: dict[str, str] | None = None,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> AuditEvent:
|
||||
event = AuditEvent.from_context(
|
||||
operation,
|
||||
@@ -761,3 +869,15 @@ class AssetRegistryService:
|
||||
"Idempotency record references an unknown asset version",
|
||||
details={"asset_id": asset_id, "version_id": version_id},
|
||||
)
|
||||
|
||||
|
||||
def _remediation_for_error(error: KontextualError) -> str | None:
|
||||
if isinstance(error, ValidationError):
|
||||
if error.details.get("code") == "asset.version_conflict":
|
||||
return "Reload the asset, review the current version, and retry with the latest expected_current_version_id."
|
||||
if error.details.get("issues"):
|
||||
return "Correct the submitted fields so they satisfy the active metadata schema, then retry the failed item."
|
||||
return "Correct the submitted value and retry the failed item."
|
||||
if isinstance(error, AuthorizationError):
|
||||
return "Request policy approval or rerun with an actor that is authorized for this operation."
|
||||
return None
|
||||
|
||||
@@ -66,6 +66,7 @@ class AssetIngestionService:
|
||||
classification: Classification | None = None,
|
||||
idempotency_key: str | None = None,
|
||||
) -> AssetIngestionResult:
|
||||
self.repository.save_actor(context.actor)
|
||||
connector = self._connector("local_file")
|
||||
job = IngestionJob.create(
|
||||
input={"connector": connector.name, "source_uri": str(path), "mode": "file"},
|
||||
@@ -97,6 +98,7 @@ class AssetIngestionService:
|
||||
recursive: bool = True,
|
||||
classification: Classification | None = None,
|
||||
) -> IngestionJob:
|
||||
self.repository.save_actor(context.actor)
|
||||
connector = self._directory_connector("local_file")
|
||||
job = IngestionJob.create(
|
||||
input={
|
||||
|
||||
Reference in New Issue
Block a user