Assembly Management Rework - Stage 2 Complete

This commit is contained in:
2025-11-02 19:24:03 -07:00
parent a86f3117f1
commit 50f59d085f
8 changed files with 995 additions and 815 deletions

View File

@@ -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).")

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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()

View 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 {}