365 lines
11 KiB
Python
365 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import uuid
|
|
from dataclasses import asdict, dataclass, field
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Protocol
|
|
|
|
import yaml
|
|
|
|
from .errors import InfospaceError
|
|
from .lifecycle import load_infospace
|
|
from .models import Infospace, KnowledgeArtifact
|
|
|
|
ENGINE_DIR = Path("output/engine")
|
|
ASSET_STORE = ENGINE_DIR / "assets.yaml"
|
|
SYNC_RUN_DIR = ENGINE_DIR / "sync-runs"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EngineCapabilityContract:
|
|
integration_posture: str
|
|
file_backed: list[str]
|
|
engine_backed: list[str]
|
|
explicit_non_goals: list[str]
|
|
review_required: list[str]
|
|
assumptions: dict[str, str]
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return asdict(self)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AssetRecord:
|
|
asset_id: str
|
|
artifact_id: str
|
|
infospace_slug: str
|
|
kind: str
|
|
title: str
|
|
path: str
|
|
digest: str
|
|
size_bytes: int
|
|
version: str
|
|
provenance: dict[str, Any] = field(default_factory=dict)
|
|
relationships: list[dict[str, Any]] = field(default_factory=list)
|
|
metadata: dict[str, Any] = field(default_factory=dict)
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: dict[str, Any]) -> "AssetRecord":
|
|
return cls(
|
|
asset_id=str(data["asset_id"]),
|
|
artifact_id=str(data["artifact_id"]),
|
|
infospace_slug=str(data["infospace_slug"]),
|
|
kind=str(data["kind"]),
|
|
title=str(data.get("title") or ""),
|
|
path=str(data["path"]),
|
|
digest=str(data["digest"]),
|
|
size_bytes=int(data.get("size_bytes") or 0),
|
|
version=str(data.get("version") or data["digest"][:12]),
|
|
provenance=dict(data.get("provenance") or {}),
|
|
relationships=list(data.get("relationships") or []),
|
|
metadata=dict(data.get("metadata") or {}),
|
|
)
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return asdict(self)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AssetSyncAction:
|
|
action: str
|
|
asset_id: str
|
|
artifact_id: str
|
|
path: str
|
|
digest: str
|
|
previous_digest: str = ""
|
|
workflow_id: str = ""
|
|
reason: str = ""
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {
|
|
key: value
|
|
for key, value in asdict(self).items()
|
|
if value not in ("", None)
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AssetSyncPlan:
|
|
infospace_slug: str
|
|
repository: str
|
|
dry_run: bool
|
|
status: str
|
|
actions: list[AssetSyncAction]
|
|
capabilities: EngineCapabilityContract
|
|
run_record_path: str = ""
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
data = {
|
|
"infospace_slug": self.infospace_slug,
|
|
"repository": self.repository,
|
|
"dry_run": self.dry_run,
|
|
"status": self.status,
|
|
"actions": [action.to_dict() for action in self.actions],
|
|
"contract": self.capabilities.to_dict(),
|
|
"run_record_path": self.run_record_path,
|
|
}
|
|
return {key: value for key, value in data.items() if value not in ("", [])}
|
|
|
|
|
|
class AssetRepository(Protocol):
|
|
repository_id: str
|
|
|
|
def get_asset(self, asset_id: str) -> AssetRecord | None:
|
|
"""Return an asset by durable ID if present."""
|
|
|
|
def list_assets(self) -> list[AssetRecord]:
|
|
"""Return all assets known to the repository."""
|
|
|
|
def upsert_asset(self, asset: AssetRecord) -> None:
|
|
"""Create or update a durable asset record."""
|
|
|
|
|
|
class LocalAssetRepository:
|
|
"""File-backed adapter used until a kontextual-engine client exists."""
|
|
|
|
def __init__(self, path: str | Path) -> None:
|
|
self.path = Path(path)
|
|
self.repository_id = f"local-file:{self.path}"
|
|
|
|
def get_asset(self, asset_id: str) -> AssetRecord | None:
|
|
return self._asset_map().get(asset_id)
|
|
|
|
def list_assets(self) -> list[AssetRecord]:
|
|
return list(self._asset_map().values())
|
|
|
|
def upsert_asset(self, asset: AssetRecord) -> None:
|
|
assets = self._asset_map()
|
|
assets[asset.asset_id] = asset
|
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
self.path.write_text(
|
|
yaml.safe_dump(
|
|
{
|
|
"assets": [
|
|
item.to_dict()
|
|
for item in sorted(
|
|
assets.values(),
|
|
key=lambda record: record.asset_id,
|
|
)
|
|
]
|
|
},
|
|
sort_keys=False,
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
def _asset_map(self) -> dict[str, AssetRecord]:
|
|
if not self.path.is_file():
|
|
return {}
|
|
data = yaml.safe_load(self.path.read_text(encoding="utf-8")) or {}
|
|
if not isinstance(data, dict):
|
|
return {}
|
|
return {
|
|
item.asset_id: item
|
|
for item in [
|
|
AssetRecord.from_dict(raw)
|
|
for raw in data.get("assets", [])
|
|
if isinstance(raw, dict)
|
|
]
|
|
}
|
|
|
|
|
|
def engine_capability_contract() -> EngineCapabilityContract:
|
|
return EngineCapabilityContract(
|
|
integration_posture="optional",
|
|
file_backed=[
|
|
"artifact_manifest",
|
|
"infospace_config",
|
|
"markdown_artifacts",
|
|
"evaluation_history",
|
|
"workflow_run_records",
|
|
"dry_run_sync_plans",
|
|
],
|
|
engine_backed=[
|
|
"asset_identity",
|
|
"asset_persistence",
|
|
"provenance_graph",
|
|
"relationship_index",
|
|
"retrieval",
|
|
"workflow_runs",
|
|
"permissions",
|
|
"audit",
|
|
],
|
|
explicit_non_goals=[
|
|
"markdown_syntax_rules",
|
|
"infospace_domain_methodology",
|
|
"repository_identity",
|
|
"provider_specific_llm_calls",
|
|
],
|
|
review_required=[
|
|
"apply_sync",
|
|
"engine_client_configuration",
|
|
"permission_changes",
|
|
],
|
|
assumptions={
|
|
"source": "kontextual-engine/INTENT.md",
|
|
"engine_role": "durable knowledge operations engine",
|
|
"bench_role": "application workspace with file-backed default behavior",
|
|
},
|
|
)
|
|
|
|
|
|
def plan_asset_sync(
|
|
root: str | Path,
|
|
repository: AssetRepository | None = None,
|
|
) -> AssetSyncPlan:
|
|
infospace = load_infospace(root)
|
|
repo = repository or LocalAssetRepository(infospace.root / ASSET_STORE)
|
|
actions = [
|
|
_sync_action(record, repo.get_asset(record.asset_id))
|
|
for record in _asset_records(infospace)
|
|
]
|
|
return AssetSyncPlan(
|
|
infospace_slug=infospace.config.slug,
|
|
repository=repo.repository_id,
|
|
dry_run=True,
|
|
status="planned",
|
|
actions=actions,
|
|
capabilities=engine_capability_contract(),
|
|
)
|
|
|
|
|
|
def sync_assets(
|
|
root: str | Path,
|
|
*,
|
|
repository: AssetRepository | None = None,
|
|
dry_run: bool = True,
|
|
) -> AssetSyncPlan:
|
|
infospace = load_infospace(root)
|
|
repo = repository or LocalAssetRepository(infospace.root / ASSET_STORE)
|
|
plan = plan_asset_sync(infospace.root, repo)
|
|
if dry_run:
|
|
return plan
|
|
|
|
records = {record.asset_id: record for record in _asset_records(infospace)}
|
|
for action in plan.actions:
|
|
if action.action in {"create", "update"}:
|
|
repo.upsert_asset(records[action.asset_id])
|
|
|
|
applied = AssetSyncPlan(
|
|
infospace_slug=plan.infospace_slug,
|
|
repository=plan.repository,
|
|
dry_run=False,
|
|
status="completed",
|
|
actions=plan.actions,
|
|
capabilities=plan.capabilities,
|
|
)
|
|
run_record_path = _write_sync_run(infospace.root, applied)
|
|
return AssetSyncPlan(
|
|
infospace_slug=applied.infospace_slug,
|
|
repository=applied.repository,
|
|
dry_run=applied.dry_run,
|
|
status=applied.status,
|
|
actions=applied.actions,
|
|
capabilities=applied.capabilities,
|
|
run_record_path=run_record_path,
|
|
)
|
|
|
|
|
|
def asset_id_for(infospace_slug: str, artifact_id: str) -> str:
|
|
return f"infospace:{infospace_slug}:artifact:{artifact_id}"
|
|
|
|
|
|
def _asset_records(infospace: Infospace) -> list[AssetRecord]:
|
|
return [
|
|
_asset_record_for(infospace, artifact)
|
|
for artifact in infospace.artifacts
|
|
]
|
|
|
|
|
|
def _asset_record_for(
|
|
infospace: Infospace,
|
|
artifact: KnowledgeArtifact,
|
|
) -> AssetRecord:
|
|
artifact_path = infospace.root / artifact.path
|
|
if not artifact_path.is_file():
|
|
raise InfospaceError(
|
|
"missing_artifact_file",
|
|
f"Manifest artifact file does not exist: {artifact.path}",
|
|
{"artifact_id": artifact.id, "path": artifact.path},
|
|
)
|
|
data = artifact_path.read_bytes()
|
|
digest = hashlib.sha256(data).hexdigest()
|
|
workflow_id = str(artifact.provenance.get("workflow_id") or "")
|
|
return AssetRecord(
|
|
asset_id=asset_id_for(infospace.config.slug, artifact.id),
|
|
artifact_id=artifact.id,
|
|
infospace_slug=infospace.config.slug,
|
|
kind=artifact.kind,
|
|
title=artifact.title,
|
|
path=artifact.path,
|
|
digest=digest,
|
|
size_bytes=len(data),
|
|
version=digest[:12],
|
|
provenance={
|
|
**artifact.provenance,
|
|
"artifact_id": artifact.id,
|
|
"artifact_path": artifact.path,
|
|
"infospace_slug": infospace.config.slug,
|
|
},
|
|
relationships=artifact.relationships,
|
|
metadata={
|
|
"workflow_id": workflow_id,
|
|
"source_path": artifact.provenance.get("source_path", ""),
|
|
},
|
|
)
|
|
|
|
|
|
def _sync_action(
|
|
desired: AssetRecord,
|
|
existing: AssetRecord | None,
|
|
) -> AssetSyncAction:
|
|
workflow_id = str(desired.provenance.get("workflow_id") or "")
|
|
if existing is None:
|
|
return AssetSyncAction(
|
|
action="create",
|
|
asset_id=desired.asset_id,
|
|
artifact_id=desired.artifact_id,
|
|
path=desired.path,
|
|
digest=desired.digest,
|
|
workflow_id=workflow_id,
|
|
reason="asset_not_present",
|
|
)
|
|
if existing.digest != desired.digest:
|
|
return AssetSyncAction(
|
|
action="update",
|
|
asset_id=desired.asset_id,
|
|
artifact_id=desired.artifact_id,
|
|
path=desired.path,
|
|
digest=desired.digest,
|
|
previous_digest=existing.digest,
|
|
workflow_id=workflow_id,
|
|
reason="digest_changed",
|
|
)
|
|
return AssetSyncAction(
|
|
action="unchanged",
|
|
asset_id=desired.asset_id,
|
|
artifact_id=desired.artifact_id,
|
|
path=desired.path,
|
|
digest=desired.digest,
|
|
previous_digest=existing.digest,
|
|
workflow_id=workflow_id,
|
|
reason="digest_matches",
|
|
)
|
|
|
|
|
|
def _write_sync_run(root: Path, plan: AssetSyncPlan) -> str:
|
|
run_id = uuid.uuid4().hex[:12]
|
|
path = root / SYNC_RUN_DIR / f"{run_id}.yaml"
|
|
payload = plan.to_dict()
|
|
payload["recorded_at"] = datetime.now(timezone.utc).isoformat()
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(yaml.safe_dump(payload, sort_keys=False), encoding="utf-8")
|
|
return str(path)
|