mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-16 04:05:48 -07:00
Assembly Management Rework - Stage 2 Complete
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import threading
|
||||
from pathlib import Path
|
||||
@@ -17,6 +18,7 @@ from typing import Dict, List, Mapping, Optional
|
||||
from .databases import AssemblyDatabaseManager
|
||||
from .models import AssemblyDomain, AssemblyRecord, CachedAssembly
|
||||
from .payloads import PayloadManager
|
||||
from .sync import sync_official_domain
|
||||
|
||||
|
||||
class AssemblyCache:
|
||||
@@ -90,12 +92,29 @@ class AssemblyCache:
|
||||
for record in records:
|
||||
self._payload_manager.ensure_runtime_copy(record.payload)
|
||||
entry = CachedAssembly(domain=domain, record=record, is_dirty=False, last_persisted=record.updated_at)
|
||||
self._store[record.assembly_id] = entry
|
||||
self._domain_index[domain][record.assembly_id] = entry
|
||||
self._store[record.assembly_guid] = entry
|
||||
self._domain_index[domain][record.assembly_guid] = entry
|
||||
|
||||
def get_entry(self, assembly_guid: str) -> Optional[CachedAssembly]:
|
||||
"""Return a defensive copy of the cached assembly."""
|
||||
|
||||
def get(self, assembly_id: str) -> Optional[AssemblyRecord]:
|
||||
with self._lock:
|
||||
entry = self._store.get(assembly_id)
|
||||
entry = self._store.get(assembly_guid)
|
||||
if entry is None:
|
||||
return None
|
||||
return self._clone_entry(entry)
|
||||
|
||||
def list_entries(self, *, domain: Optional[AssemblyDomain] = None) -> List[CachedAssembly]:
|
||||
"""Return defensive copies of cached assemblies, optionally filtered by domain."""
|
||||
|
||||
with self._lock:
|
||||
if domain is None:
|
||||
return [self._clone_entry(entry) for entry in self._store.values()]
|
||||
return [self._clone_entry(entry) for entry in self._domain_index[domain].values()]
|
||||
|
||||
def get(self, assembly_guid: str) -> Optional[AssemblyRecord]:
|
||||
with self._lock:
|
||||
entry = self._store.get(assembly_guid)
|
||||
if not entry:
|
||||
return None
|
||||
return entry.record
|
||||
@@ -108,37 +127,37 @@ class AssemblyCache:
|
||||
|
||||
def stage_upsert(self, domain: AssemblyDomain, record: AssemblyRecord) -> None:
|
||||
with self._lock:
|
||||
entry = self._store.get(record.assembly_id)
|
||||
entry = self._store.get(record.assembly_guid)
|
||||
if entry is None:
|
||||
entry = CachedAssembly(domain=domain, record=record, is_dirty=True)
|
||||
entry.mark_dirty()
|
||||
self._store[record.assembly_id] = entry
|
||||
self._domain_index[domain][record.assembly_id] = entry
|
||||
self._store[record.assembly_guid] = entry
|
||||
self._domain_index[domain][record.assembly_guid] = entry
|
||||
else:
|
||||
entry.domain = domain
|
||||
entry.record = record
|
||||
entry.mark_dirty()
|
||||
self._pending_deletes.pop(record.assembly_id, None)
|
||||
self._dirty[record.assembly_id] = entry
|
||||
self._pending_deletes.pop(record.assembly_guid, None)
|
||||
self._dirty[record.assembly_guid] = entry
|
||||
self._flush_event.set()
|
||||
|
||||
def stage_delete(self, assembly_id: str) -> None:
|
||||
def stage_delete(self, assembly_guid: str) -> None:
|
||||
with self._lock:
|
||||
entry = self._store.get(assembly_id)
|
||||
entry = self._store.get(assembly_guid)
|
||||
if not entry:
|
||||
return
|
||||
entry.is_dirty = True
|
||||
self._dirty.pop(assembly_id, None)
|
||||
self._pending_deletes[assembly_id] = entry
|
||||
self._dirty.pop(assembly_guid, None)
|
||||
self._pending_deletes[assembly_guid] = entry
|
||||
self._flush_event.set()
|
||||
|
||||
def describe(self) -> List[Dict[str, str]]:
|
||||
with self._lock:
|
||||
snapshot = []
|
||||
for assembly_id, entry in self._store.items():
|
||||
for assembly_guid, entry in self._store.items():
|
||||
snapshot.append(
|
||||
{
|
||||
"assembly_id": assembly_id,
|
||||
"assembly_guid": assembly_guid,
|
||||
"domain": entry.domain.value,
|
||||
"is_dirty": "true" if entry.is_dirty else "false",
|
||||
"dirty_since": entry.dirty_since.isoformat() if entry.dirty_since else "",
|
||||
@@ -158,6 +177,23 @@ class AssemblyCache:
|
||||
if flush:
|
||||
self._flush_dirty_entries()
|
||||
|
||||
def read_payload_bytes(self, assembly_guid: str) -> bytes:
|
||||
"""Return the payload bytes for the specified assembly."""
|
||||
|
||||
with self._lock:
|
||||
entry = self._store.get(assembly_guid)
|
||||
if not entry:
|
||||
raise KeyError(f"Assembly '{assembly_guid}' not found in cache")
|
||||
return self._payload_manager.read_payload_bytes(entry.record.payload)
|
||||
|
||||
@property
|
||||
def payload_manager(self) -> PayloadManager:
|
||||
return self._payload_manager
|
||||
|
||||
@property
|
||||
def database_manager(self) -> AssemblyDatabaseManager:
|
||||
return self._db_manager
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
@@ -186,17 +222,17 @@ class AssemblyCache:
|
||||
self._db_manager.delete_record(entry.domain, entry)
|
||||
self._payload_manager.delete_payload(entry.record.payload)
|
||||
with self._lock:
|
||||
self._store.pop(entry.record.assembly_id, None)
|
||||
self._domain_index[entry.domain].pop(entry.record.assembly_id, None)
|
||||
self._store.pop(entry.record.assembly_guid, None)
|
||||
self._domain_index[entry.domain].pop(entry.record.assembly_guid, None)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Failed to delete assembly %s in domain %s: %s",
|
||||
entry.record.assembly_id,
|
||||
entry.record.assembly_guid,
|
||||
entry.domain.value,
|
||||
exc,
|
||||
)
|
||||
with self._lock:
|
||||
self._pending_deletes[entry.record.assembly_id] = entry
|
||||
self._pending_deletes[entry.record.assembly_guid] = entry
|
||||
return
|
||||
|
||||
for entry in dirty_items:
|
||||
@@ -207,14 +243,24 @@ class AssemblyCache:
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Failed to flush assembly %s in domain %s: %s",
|
||||
entry.record.assembly_id,
|
||||
entry.record.assembly_guid,
|
||||
entry.domain.value,
|
||||
exc,
|
||||
)
|
||||
with self._lock:
|
||||
self._dirty[entry.record.assembly_id] = entry
|
||||
self._dirty[entry.record.assembly_guid] = entry
|
||||
break
|
||||
|
||||
def _clone_entry(self, entry: CachedAssembly) -> CachedAssembly:
|
||||
record_copy = copy.deepcopy(entry.record)
|
||||
return CachedAssembly(
|
||||
domain=entry.domain,
|
||||
record=record_copy,
|
||||
is_dirty=entry.is_dirty,
|
||||
last_persisted=entry.last_persisted,
|
||||
dirty_since=entry.dirty_since,
|
||||
)
|
||||
|
||||
|
||||
def initialise_assembly_runtime(
|
||||
*,
|
||||
@@ -232,6 +278,10 @@ def initialise_assembly_runtime(
|
||||
db_manager.initialise()
|
||||
|
||||
payload_manager = PayloadManager(staging_root=payload_staging, runtime_root=payload_runtime, logger=logger)
|
||||
try:
|
||||
sync_official_domain(db_manager, payload_manager, staging_root, logger=logger)
|
||||
except Exception: # pragma: no cover - best effort during bootstrap
|
||||
(logger or logging.getLogger(__name__)).exception("Official assembly sync failed during startup.")
|
||||
flush_interval = _resolve_flush_interval(config)
|
||||
|
||||
return AssemblyCache.initialise(
|
||||
@@ -275,4 +325,3 @@ def _discover_staging_root() -> Path:
|
||||
if data_dir.is_dir():
|
||||
return data_dir.resolve()
|
||||
raise RuntimeError("Could not locate staging assemblies directory (expected Data/Engine/Assemblies).")
|
||||
|
||||
|
||||
@@ -22,34 +22,27 @@ from .models import AssemblyDomain, AssemblyRecord, CachedAssembly, PayloadDescr
|
||||
|
||||
|
||||
_SCHEMA_STATEMENTS: Iterable[str] = (
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS payloads (
|
||||
payload_guid TEXT PRIMARY KEY,
|
||||
payload_type TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
file_extension TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
checksum TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS assemblies (
|
||||
assembly_id TEXT PRIMARY KEY,
|
||||
assembly_guid TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
summary TEXT,
|
||||
category TEXT,
|
||||
assembly_kind TEXT NOT NULL,
|
||||
assembly_type TEXT,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
payload_guid TEXT NOT NULL,
|
||||
metadata_json TEXT,
|
||||
tags_json TEXT,
|
||||
checksum TEXT,
|
||||
payload_type TEXT NOT NULL,
|
||||
payload_file_name TEXT NOT NULL,
|
||||
payload_file_extension TEXT NOT NULL,
|
||||
payload_size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
payload_checksum TEXT,
|
||||
payload_created_at TEXT NOT NULL,
|
||||
payload_updated_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(payload_guid) REFERENCES payloads(payload_guid) ON DELETE CASCADE
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
""",
|
||||
"CREATE INDEX IF NOT EXISTS idx_assemblies_kind ON assemblies(assembly_kind)",
|
||||
@@ -87,6 +80,14 @@ class AssemblyDatabaseManager:
|
||||
runtime = (self._runtime_root / domain.database_name).resolve()
|
||||
self._paths[domain] = AssemblyDatabasePaths(staging=staging, runtime=runtime)
|
||||
|
||||
@property
|
||||
def staging_root(self) -> Path:
|
||||
return self._staging_root
|
||||
|
||||
@property
|
||||
def runtime_root(self) -> Path:
|
||||
return self._runtime_root
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
@@ -102,6 +103,18 @@ class AssemblyDatabaseManager:
|
||||
conn.close()
|
||||
self._mirror_database(domain)
|
||||
|
||||
def reset_domain(self, domain: AssemblyDomain) -> None:
|
||||
"""Remove all assemblies and payload metadata for the specified domain."""
|
||||
|
||||
conn = self._open_connection(domain)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("DELETE FROM assemblies")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
self._mirror_database(domain)
|
||||
|
||||
def load_all(self, domain: AssemblyDomain) -> List[AssemblyRecord]:
|
||||
"""Load all assembly records for the given domain."""
|
||||
|
||||
@@ -111,29 +124,26 @@ class AssemblyDatabaseManager:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
a.assembly_id AS assembly_id,
|
||||
a.display_name AS display_name,
|
||||
a.summary AS summary,
|
||||
a.category AS category,
|
||||
a.assembly_kind AS assembly_kind,
|
||||
a.assembly_type AS assembly_type,
|
||||
a.version AS version,
|
||||
a.payload_guid AS payload_guid,
|
||||
a.metadata_json AS metadata_json,
|
||||
a.tags_json AS tags_json,
|
||||
a.checksum AS assembly_checksum,
|
||||
a.created_at AS assembly_created_at,
|
||||
a.updated_at AS assembly_updated_at,
|
||||
p.payload_guid AS payload_guid,
|
||||
p.payload_type AS payload_type,
|
||||
p.file_name AS payload_file_name,
|
||||
p.file_extension AS payload_file_extension,
|
||||
p.size_bytes AS payload_size_bytes,
|
||||
p.checksum AS payload_checksum,
|
||||
p.created_at AS payload_created_at,
|
||||
p.updated_at AS payload_updated_at
|
||||
FROM assemblies AS a
|
||||
JOIN payloads AS p ON p.payload_guid = a.payload_guid
|
||||
assembly_guid,
|
||||
display_name,
|
||||
summary,
|
||||
category,
|
||||
assembly_kind,
|
||||
assembly_type,
|
||||
version,
|
||||
metadata_json,
|
||||
tags_json,
|
||||
checksum AS assembly_checksum,
|
||||
payload_type,
|
||||
payload_file_name,
|
||||
payload_file_extension,
|
||||
payload_size_bytes,
|
||||
payload_checksum,
|
||||
payload_created_at,
|
||||
payload_updated_at,
|
||||
created_at AS assembly_created_at,
|
||||
updated_at AS assembly_updated_at
|
||||
FROM assemblies
|
||||
"""
|
||||
)
|
||||
records: List[AssemblyRecord] = []
|
||||
@@ -144,7 +154,7 @@ class AssemblyDatabaseManager:
|
||||
except Exception:
|
||||
payload_type = PayloadType.UNKNOWN
|
||||
payload = PayloadDescriptor(
|
||||
guid=row["payload_guid"],
|
||||
assembly_guid=row["assembly_guid"],
|
||||
payload_type=payload_type,
|
||||
file_name=row["payload_file_name"],
|
||||
file_extension=row["payload_file_extension"],
|
||||
@@ -164,7 +174,7 @@ class AssemblyDatabaseManager:
|
||||
except Exception:
|
||||
tags = {}
|
||||
record = AssemblyRecord(
|
||||
assembly_id=row["assembly_id"],
|
||||
assembly_guid=row["assembly_guid"],
|
||||
display_name=row["display_name"],
|
||||
summary=row["summary"],
|
||||
category=row["category"],
|
||||
@@ -191,20 +201,62 @@ class AssemblyDatabaseManager:
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
payload = record.payload
|
||||
metadata_json = json.dumps(record.metadata or {})
|
||||
tags_json = json.dumps(record.tags or {})
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO payloads (payload_guid, payload_type, file_name, file_extension, size_bytes, checksum, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(payload_guid) DO UPDATE SET
|
||||
payload_type = excluded.payload_type,
|
||||
file_name = excluded.file_name,
|
||||
file_extension = excluded.file_extension,
|
||||
size_bytes = excluded.size_bytes,
|
||||
INSERT INTO assemblies (
|
||||
assembly_guid,
|
||||
display_name,
|
||||
summary,
|
||||
category,
|
||||
assembly_kind,
|
||||
assembly_type,
|
||||
version,
|
||||
metadata_json,
|
||||
tags_json,
|
||||
checksum,
|
||||
payload_type,
|
||||
payload_file_name,
|
||||
payload_file_extension,
|
||||
payload_size_bytes,
|
||||
payload_checksum,
|
||||
payload_created_at,
|
||||
payload_updated_at,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(assembly_guid) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
summary = excluded.summary,
|
||||
category = excluded.category,
|
||||
assembly_kind = excluded.assembly_kind,
|
||||
assembly_type = excluded.assembly_type,
|
||||
version = excluded.version,
|
||||
metadata_json = excluded.metadata_json,
|
||||
tags_json = excluded.tags_json,
|
||||
checksum = excluded.checksum,
|
||||
payload_type = excluded.payload_type,
|
||||
payload_file_name = excluded.payload_file_name,
|
||||
payload_file_extension = excluded.payload_file_extension,
|
||||
payload_size_bytes = excluded.payload_size_bytes,
|
||||
payload_checksum = excluded.payload_checksum,
|
||||
payload_created_at = excluded.payload_created_at,
|
||||
payload_updated_at = excluded.payload_updated_at,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
payload.guid,
|
||||
record.assembly_guid,
|
||||
record.display_name,
|
||||
record.summary,
|
||||
record.category,
|
||||
record.assembly_kind,
|
||||
record.assembly_type,
|
||||
record.version,
|
||||
metadata_json,
|
||||
tags_json,
|
||||
record.checksum,
|
||||
payload.payload_type.value,
|
||||
payload.file_name,
|
||||
payload.file_extension,
|
||||
@@ -212,53 +264,6 @@ class AssemblyDatabaseManager:
|
||||
payload.checksum,
|
||||
payload.created_at.isoformat(),
|
||||
payload.updated_at.isoformat(),
|
||||
),
|
||||
)
|
||||
metadata_json = json.dumps(record.metadata or {})
|
||||
tags_json = json.dumps(record.tags or {})
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO assemblies (
|
||||
assembly_id,
|
||||
display_name,
|
||||
summary,
|
||||
category,
|
||||
assembly_kind,
|
||||
assembly_type,
|
||||
version,
|
||||
payload_guid,
|
||||
metadata_json,
|
||||
tags_json,
|
||||
checksum,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(assembly_id) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
summary = excluded.summary,
|
||||
category = excluded.category,
|
||||
assembly_kind = excluded.assembly_kind,
|
||||
assembly_type = excluded.assembly_type,
|
||||
version = excluded.version,
|
||||
payload_guid = excluded.payload_guid,
|
||||
metadata_json = excluded.metadata_json,
|
||||
tags_json = excluded.tags_json,
|
||||
checksum = excluded.checksum,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
record.assembly_id,
|
||||
record.display_name,
|
||||
record.summary,
|
||||
record.category,
|
||||
record.assembly_kind,
|
||||
record.assembly_type,
|
||||
record.version,
|
||||
payload.guid,
|
||||
metadata_json,
|
||||
tags_json,
|
||||
record.checksum,
|
||||
record.created_at.isoformat(),
|
||||
record.updated_at.isoformat(),
|
||||
),
|
||||
@@ -275,7 +280,7 @@ class AssemblyDatabaseManager:
|
||||
conn = self._open_connection(domain)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("DELETE FROM assemblies WHERE assembly_id = ?", (record.assembly_id,))
|
||||
cur.execute("DELETE FROM assemblies WHERE assembly_guid = ?", (record.assembly_guid,))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -326,4 +331,3 @@ class AssemblyDatabaseManager:
|
||||
runtime_candidate,
|
||||
exc,
|
||||
)
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ class PayloadType(str, Enum):
|
||||
class PayloadDescriptor:
|
||||
"""Represents on-disk payload material referenced by an assembly."""
|
||||
|
||||
guid: str
|
||||
assembly_guid: str
|
||||
payload_type: PayloadType
|
||||
file_name: str
|
||||
file_extension: str
|
||||
@@ -56,7 +56,7 @@ class PayloadDescriptor:
|
||||
|
||||
def as_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"guid": self.guid,
|
||||
"assembly_guid": self.assembly_guid,
|
||||
"payload_type": self.payload_type.value,
|
||||
"file_name": self.file_name,
|
||||
"file_extension": self.file_extension,
|
||||
@@ -66,12 +66,18 @@ class PayloadDescriptor:
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
@property
|
||||
def guid(self) -> str:
|
||||
"""Backwards-compatible accessor for legacy references."""
|
||||
|
||||
return self.assembly_guid
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AssemblyRecord:
|
||||
"""Represents an assembly row hydrated from persistence."""
|
||||
|
||||
assembly_id: str
|
||||
assembly_guid: str
|
||||
display_name: str
|
||||
summary: Optional[str]
|
||||
category: Optional[str]
|
||||
@@ -106,4 +112,3 @@ class CachedAssembly:
|
||||
self.is_dirty = False
|
||||
self.last_persisted = now
|
||||
self.dirty_since = None
|
||||
|
||||
|
||||
@@ -38,12 +38,12 @@ class PayloadManager:
|
||||
payload_type: PayloadType,
|
||||
content: Union[str, bytes],
|
||||
*,
|
||||
guid: Optional[str] = None,
|
||||
assembly_guid: Optional[str] = None,
|
||||
extension: Optional[str] = None,
|
||||
) -> PayloadDescriptor:
|
||||
"""Persist payload content and mirror it to the runtime directory."""
|
||||
|
||||
resolved_guid = self._normalise_guid(guid or uuid.uuid4().hex)
|
||||
resolved_guid = self._normalise_guid(assembly_guid or uuid.uuid4().hex)
|
||||
resolved_extension = self._normalise_extension(extension or self._default_extension(payload_type))
|
||||
now = _dt.datetime.utcnow()
|
||||
data = content.encode("utf-8") if isinstance(content, str) else bytes(content)
|
||||
@@ -67,7 +67,7 @@ class PayloadManager:
|
||||
self._logger.debug("Failed to mirror payload %s to runtime copy: %s", resolved_guid, exc)
|
||||
|
||||
descriptor = PayloadDescriptor(
|
||||
guid=resolved_guid,
|
||||
assembly_guid=resolved_guid,
|
||||
payload_type=payload_type,
|
||||
file_name=file_name,
|
||||
file_extension=resolved_extension,
|
||||
@@ -85,8 +85,8 @@ class PayloadManager:
|
||||
checksum = hashlib.sha256(data).hexdigest()
|
||||
now = _dt.datetime.utcnow()
|
||||
|
||||
staging_path = self._payload_dir(self._staging_root, descriptor.guid) / descriptor.file_name
|
||||
runtime_path = self._payload_dir(self._runtime_root, descriptor.guid) / descriptor.file_name
|
||||
staging_path = self._payload_dir(self._staging_root, descriptor.assembly_guid) / descriptor.file_name
|
||||
runtime_path = self._payload_dir(self._runtime_root, descriptor.assembly_guid) / descriptor.file_name
|
||||
staging_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
runtime_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -96,7 +96,7 @@ class PayloadManager:
|
||||
try:
|
||||
shutil.copy2(staging_path, runtime_path)
|
||||
except Exception as exc: # pragma: no cover - best effort mirror
|
||||
self._logger.debug("Failed to mirror payload %s during update: %s", descriptor.guid, exc)
|
||||
self._logger.debug("Failed to mirror payload %s during update: %s", descriptor.assembly_guid, exc)
|
||||
|
||||
descriptor.size_bytes = len(data)
|
||||
descriptor.checksum = checksum
|
||||
@@ -106,28 +106,28 @@ class PayloadManager:
|
||||
def read_payload_bytes(self, descriptor: PayloadDescriptor) -> bytes:
|
||||
"""Retrieve payload content from the staging copy."""
|
||||
|
||||
staging_path = self._payload_dir(self._staging_root, descriptor.guid) / descriptor.file_name
|
||||
staging_path = self._payload_dir(self._staging_root, descriptor.assembly_guid) / descriptor.file_name
|
||||
return staging_path.read_bytes()
|
||||
|
||||
def ensure_runtime_copy(self, descriptor: PayloadDescriptor) -> None:
|
||||
"""Ensure the runtime payload copy matches the staging content."""
|
||||
|
||||
staging_path = self._payload_dir(self._staging_root, descriptor.guid) / descriptor.file_name
|
||||
runtime_path = self._payload_dir(self._runtime_root, descriptor.guid) / descriptor.file_name
|
||||
staging_path = self._payload_dir(self._staging_root, descriptor.assembly_guid) / descriptor.file_name
|
||||
runtime_path = self._payload_dir(self._runtime_root, descriptor.assembly_guid) / descriptor.file_name
|
||||
if not staging_path.exists():
|
||||
self._logger.warning("Payload missing on disk; guid=%s path=%s", descriptor.guid, staging_path)
|
||||
self._logger.warning("Payload missing on disk; guid=%s path=%s", descriptor.assembly_guid, staging_path)
|
||||
return
|
||||
runtime_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
shutil.copy2(staging_path, runtime_path)
|
||||
except Exception as exc: # pragma: no cover - best effort mirror
|
||||
self._logger.debug("Failed to mirror payload %s via ensure_runtime_copy: %s", descriptor.guid, exc)
|
||||
self._logger.debug("Failed to mirror payload %s via ensure_runtime_copy: %s", descriptor.assembly_guid, exc)
|
||||
|
||||
def delete_payload(self, descriptor: PayloadDescriptor) -> None:
|
||||
"""Remove staging and runtime payload files."""
|
||||
|
||||
for root in (self._staging_root, self._runtime_root):
|
||||
dir_path = self._payload_dir(root, descriptor.guid)
|
||||
dir_path = self._payload_dir(root, descriptor.assembly_guid)
|
||||
file_path = dir_path / descriptor.file_name
|
||||
try:
|
||||
if file_path.exists():
|
||||
@@ -135,7 +135,9 @@ class PayloadManager:
|
||||
if dir_path.exists() and not any(dir_path.iterdir()):
|
||||
dir_path.rmdir()
|
||||
except Exception as exc: # pragma: no cover - best effort cleanup
|
||||
self._logger.debug("Failed to remove payload directory %s (%s): %s", descriptor.guid, root, exc)
|
||||
self._logger.debug(
|
||||
"Failed to remove payload directory %s (%s): %s", descriptor.assembly_guid, root, exc
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helper methods
|
||||
@@ -163,4 +165,3 @@ class PayloadManager:
|
||||
@staticmethod
|
||||
def _normalise_guid(guid: str) -> str:
|
||||
return guid.strip().lower()
|
||||
|
||||
|
||||
215
Data/Engine/assembly_management/sync.py
Normal file
215
Data/Engine/assembly_management/sync.py
Normal file
@@ -0,0 +1,215 @@
|
||||
# ======================================================
|
||||
# Data\Engine\assembly_management\sync.py
|
||||
# Description: Synchronises assembly databases from staged filesystem sources (official domain importer).
|
||||
#
|
||||
# API Endpoints (if applicable): None
|
||||
# ======================================================
|
||||
|
||||
"""Synchronisation helpers for assembly persistence domains."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Optional, Tuple
|
||||
|
||||
from .databases import AssemblyDatabaseManager
|
||||
from .models import AssemblyDomain, AssemblyRecord, CachedAssembly, PayloadType
|
||||
from .payloads import PayloadManager
|
||||
|
||||
|
||||
_SCRIPT_DIRS = {"scripts", "script"}
|
||||
_WORKFLOW_DIRS = {"workflows", "workflow"}
|
||||
_ANSIBLE_DIRS = {"ansible_playbooks", "ansible-playbooks", "ansible"}
|
||||
|
||||
|
||||
def sync_official_domain(
|
||||
db_manager: AssemblyDatabaseManager,
|
||||
payload_manager: PayloadManager,
|
||||
staging_root: Path,
|
||||
*,
|
||||
logger: Optional[logging.Logger] = None,
|
||||
) -> None:
|
||||
"""Repopulate the official domain database from staged JSON assemblies."""
|
||||
|
||||
log = logger or logging.getLogger(__name__)
|
||||
root = staging_root.resolve()
|
||||
if not root.is_dir():
|
||||
log.warning("Assembly staging root missing during official sync: %s", root)
|
||||
return
|
||||
|
||||
files = tuple(_iter_assembly_sources(root))
|
||||
if not files:
|
||||
log.info("No staged assemblies discovered for official sync; clearing domain.")
|
||||
db_manager.reset_domain(AssemblyDomain.OFFICIAL)
|
||||
return
|
||||
|
||||
db_manager.reset_domain(AssemblyDomain.OFFICIAL)
|
||||
|
||||
imported = 0
|
||||
skipped = 0
|
||||
|
||||
for rel_path, file_path in files:
|
||||
record = _record_from_file(rel_path, file_path, payload_manager, log)
|
||||
if record is None:
|
||||
skipped += 1
|
||||
continue
|
||||
entry = CachedAssembly(
|
||||
domain=AssemblyDomain.OFFICIAL,
|
||||
record=record,
|
||||
is_dirty=False,
|
||||
last_persisted=record.updated_at,
|
||||
)
|
||||
try:
|
||||
db_manager.upsert_record(AssemblyDomain.OFFICIAL, entry)
|
||||
imported += 1
|
||||
except Exception: # pragma: no cover - defensive logging
|
||||
skipped += 1
|
||||
log.exception("Failed to import assembly %s during official sync.", rel_path)
|
||||
|
||||
log.info(
|
||||
"Official assembly sync complete: imported=%s skipped=%s source_root=%s",
|
||||
imported,
|
||||
skipped,
|
||||
root,
|
||||
)
|
||||
|
||||
|
||||
def _iter_assembly_sources(root: Path) -> Iterable[Tuple[str, Path]]:
|
||||
for path in root.rglob("*.json"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
rel_path = path.relative_to(root).as_posix()
|
||||
yield rel_path, path
|
||||
|
||||
|
||||
def _record_from_file(
|
||||
rel_path: str,
|
||||
file_path: Path,
|
||||
payload_manager: PayloadManager,
|
||||
logger: logging.Logger,
|
||||
) -> Optional[AssemblyRecord]:
|
||||
try:
|
||||
text = file_path.read_text(encoding="utf-8")
|
||||
except Exception as exc:
|
||||
logger.warning("Unable to read assembly source %s: %s", file_path, exc)
|
||||
return None
|
||||
|
||||
try:
|
||||
document = json.loads(text)
|
||||
except Exception as exc:
|
||||
logger.warning("Invalid JSON for assembly %s: %s", file_path, exc)
|
||||
return None
|
||||
|
||||
kind = _infer_kind(rel_path)
|
||||
if kind == "unknown":
|
||||
logger.debug("Skipping non-assembly file %s", rel_path)
|
||||
return None
|
||||
|
||||
payload_type = _payload_type_for_kind(kind)
|
||||
guid = hashlib.sha1(rel_path.encode("utf-8")).hexdigest()
|
||||
descriptor = payload_manager.store_payload(payload_type, text, assembly_guid=guid, extension=".json")
|
||||
|
||||
file_stat = file_path.stat()
|
||||
timestamp = _dt.datetime.utcfromtimestamp(file_stat.st_mtime).replace(microsecond=0)
|
||||
descriptor.created_at = timestamp
|
||||
descriptor.updated_at = timestamp
|
||||
|
||||
assembly_id = _assembly_id_from_path(rel_path)
|
||||
document_metadata = _metadata_from_document(kind, document, rel_path)
|
||||
tags = _coerce_dict(document.get("tags"))
|
||||
|
||||
record = AssemblyRecord(
|
||||
assembly_id=assembly_id,
|
||||
display_name=document_metadata.get("display_name") or assembly_id.rsplit("/", 1)[-1],
|
||||
summary=document_metadata.get("summary"),
|
||||
category=document_metadata.get("category"),
|
||||
assembly_kind=kind,
|
||||
assembly_type=document_metadata.get("assembly_type"),
|
||||
version=_coerce_int(document.get("version"), default=1),
|
||||
payload=descriptor,
|
||||
metadata=document_metadata,
|
||||
tags=tags,
|
||||
checksum=hashlib.sha256(text.encode("utf-8")).hexdigest(),
|
||||
created_at=timestamp,
|
||||
updated_at=timestamp,
|
||||
)
|
||||
return record
|
||||
|
||||
|
||||
def _infer_kind(rel_path: str) -> str:
|
||||
first = rel_path.split("/", 1)[0].strip().lower()
|
||||
if first in _SCRIPT_DIRS:
|
||||
return "script"
|
||||
if first in _WORKFLOW_DIRS:
|
||||
return "workflow"
|
||||
if first in _ANSIBLE_DIRS:
|
||||
return "ansible"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _payload_type_for_kind(kind: str) -> PayloadType:
|
||||
if kind == "workflow":
|
||||
return PayloadType.WORKFLOW
|
||||
if kind == "script":
|
||||
return PayloadType.SCRIPT
|
||||
if kind == "ansible":
|
||||
return PayloadType.BINARY
|
||||
return PayloadType.UNKNOWN
|
||||
|
||||
|
||||
def _assembly_id_from_path(rel_path: str) -> str:
|
||||
normalised = rel_path.replace("\\", "/")
|
||||
if normalised.lower().endswith(".json"):
|
||||
normalised = normalised[:-5]
|
||||
return normalised
|
||||
|
||||
|
||||
def _metadata_from_document(kind: str, document: dict, rel_path: str) -> dict:
|
||||
metadata = {
|
||||
"source_path": rel_path,
|
||||
"display_name": None,
|
||||
"summary": None,
|
||||
"category": None,
|
||||
"assembly_type": None,
|
||||
}
|
||||
|
||||
if kind == "workflow":
|
||||
metadata["display_name"] = document.get("tab_name") or document.get("name")
|
||||
metadata["summary"] = document.get("description")
|
||||
metadata["category"] = "workflow"
|
||||
metadata["assembly_type"] = "workflow"
|
||||
elif kind == "script":
|
||||
metadata["display_name"] = document.get("name") or document.get("display_name")
|
||||
metadata["summary"] = document.get("description")
|
||||
metadata["category"] = (document.get("category") or "script").lower()
|
||||
metadata["assembly_type"] = (document.get("type") or "powershell").lower()
|
||||
elif kind == "ansible":
|
||||
metadata["display_name"] = document.get("name") or document.get("display_name") or rel_path.rsplit("/", 1)[-1]
|
||||
metadata["summary"] = document.get("description")
|
||||
metadata["category"] = "ansible"
|
||||
metadata["assembly_type"] = "ansible"
|
||||
|
||||
metadata.update(
|
||||
{
|
||||
"sites": document.get("sites"),
|
||||
"variables": document.get("variables"),
|
||||
"files": document.get("files"),
|
||||
}
|
||||
)
|
||||
metadata["display_name"] = metadata.get("display_name") or rel_path.rsplit("/", 1)[-1]
|
||||
return metadata
|
||||
|
||||
|
||||
def _coerce_int(value, *, default: int = 0) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _coerce_dict(value) -> dict:
|
||||
return value if isinstance(value, dict) else {}
|
||||
Reference in New Issue
Block a user