Initial commit
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
"""Load, install, and uninstall OPC Market packages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import yaml
|
||||
|
||||
from .package_format import (
|
||||
ConflictReport,
|
||||
InstalledPackageInfo,
|
||||
OPCPackage,
|
||||
OPCPackageManifest,
|
||||
)
|
||||
from .sandbox_checker import SandboxChecker
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from opc.core.config import OPCConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_FRONTMATTER_RE = re.compile(r"^---\s*\n.*?\n---\s*\n?", re.DOTALL)
|
||||
|
||||
|
||||
class PackageLoader:
|
||||
"""Loads .opcpkg packages from disk and installs/uninstalls them."""
|
||||
|
||||
def __init__(self, config: OPCConfig, opc_home: Path) -> None:
|
||||
self.config = config
|
||||
self.opc_home = opc_home
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Load
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def load_from_path(self, pkg_path: Path) -> OPCPackage:
|
||||
"""Parse a .opcpkg directory into an OPCPackage."""
|
||||
pkg_path = pkg_path.expanduser().resolve()
|
||||
if not pkg_path.is_dir():
|
||||
raise FileNotFoundError(f"Package directory not found: {pkg_path}")
|
||||
|
||||
# manifest.yaml
|
||||
manifest_path = pkg_path / "manifest.yaml"
|
||||
if not manifest_path.exists():
|
||||
raise FileNotFoundError(f"Missing manifest.yaml in {pkg_path}")
|
||||
with open(manifest_path, encoding="utf-8") as f:
|
||||
manifest_data = yaml.safe_load(f) or {}
|
||||
manifest = OPCPackageManifest.model_validate(manifest_data)
|
||||
|
||||
# org_config.yaml
|
||||
org_path = pkg_path / "org_config.yaml"
|
||||
org_data: dict[str, Any] = {}
|
||||
if org_path.exists():
|
||||
with open(org_path, encoding="utf-8") as f:
|
||||
org_data = yaml.safe_load(f) or {}
|
||||
|
||||
# prompts/
|
||||
prompt_contents: dict[str, str] = {}
|
||||
prompts_dir = pkg_path / "prompts"
|
||||
if prompts_dir.is_dir():
|
||||
for p in sorted(prompts_dir.rglob("*.md")):
|
||||
try:
|
||||
prompt_contents[p.name] = p.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
logger.debug("Failed to read prompt %s", p)
|
||||
|
||||
# README.md
|
||||
readme = ""
|
||||
readme_path = pkg_path / "README.md"
|
||||
if readme_path.exists():
|
||||
readme = readme_path.read_text(encoding="utf-8")
|
||||
|
||||
return OPCPackage(
|
||||
manifest=manifest,
|
||||
roles=org_data.get("roles", []),
|
||||
work_item_templates=org_data.get("work_item_templates", []),
|
||||
runtime_policy=org_data.get("runtime_policy"),
|
||||
talent_templates=org_data.get("talent_templates", []),
|
||||
employees=org_data.get("employees", []),
|
||||
prompt_contents=prompt_contents,
|
||||
readme=readme,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Conflict Detection
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def detect_conflicts(self, package: OPCPackage) -> ConflictReport:
|
||||
"""Check for ID collisions between package and current org."""
|
||||
report = ConflictReport()
|
||||
existing_role_ids = {r.id for r in self.config.org.roles}
|
||||
existing_template_ids = self._current_talent_template_ids()
|
||||
|
||||
for role in package.roles:
|
||||
rid = role.get("id", "")
|
||||
if rid and rid in existing_role_ids:
|
||||
report.role_conflicts.append(rid)
|
||||
|
||||
for tmpl in package.talent_templates:
|
||||
tid = tmpl.get("id", "")
|
||||
if tid and tid in existing_template_ids:
|
||||
report.template_conflicts.append(tid)
|
||||
|
||||
return report
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Install
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def install(
|
||||
self,
|
||||
package: OPCPackage,
|
||||
strategy: str = "namespace",
|
||||
) -> InstalledPackageInfo:
|
||||
"""Install a package into the current org config.
|
||||
|
||||
Args:
|
||||
package: Parsed package to install.
|
||||
strategy: Conflict resolution — "namespace" (prefix IDs) or "overwrite".
|
||||
|
||||
Returns:
|
||||
InstalledPackageInfo to be appended to config.org.installed_packages.
|
||||
Caller must call config.save() to persist.
|
||||
"""
|
||||
pkg_id = package.manifest.id
|
||||
prefix = f"{pkg_id}:" if strategy == "namespace" else ""
|
||||
|
||||
# 1. Write prompt files
|
||||
self._write_prompts(pkg_id, package.prompt_contents)
|
||||
|
||||
# 2. Prepare roles with optional namespace prefix
|
||||
role_ids: list[str] = []
|
||||
for role_data in package.roles:
|
||||
role_data = dict(role_data) # shallow copy
|
||||
original_id = role_data.get("id", "")
|
||||
new_id = f"{prefix}{original_id}" if original_id else original_id
|
||||
role_data["id"] = new_id
|
||||
# Rewrite internal references
|
||||
if prefix:
|
||||
if role_data.get("reports_to") and role_data["reports_to"] != "owner":
|
||||
role_data["reports_to"] = f"{prefix}{role_data['reports_to']}"
|
||||
if role_data.get("can_spawn"):
|
||||
role_data["can_spawn"] = [f"{prefix}{s}" for s in role_data["can_spawn"]]
|
||||
# Rewrite prompt_refs to market directory
|
||||
if role_data.get("prompt_refs"):
|
||||
role_data["prompt_refs"] = [
|
||||
f"prompts/market/{pkg_id}/{Path(ref).name}"
|
||||
for ref in role_data["prompt_refs"]
|
||||
]
|
||||
from opc.core.config import RoleConfig
|
||||
self.config.org.roles.append(RoleConfig.model_validate(role_data))
|
||||
role_ids.append(new_id)
|
||||
|
||||
# 3. Prepare talent templates
|
||||
template_ids: list[str] = []
|
||||
for tmpl_data in package.talent_templates:
|
||||
tmpl_data = dict(tmpl_data)
|
||||
original_id = tmpl_data.get("id", "")
|
||||
new_id = f"{prefix}{original_id}" if original_id else original_id
|
||||
tmpl_data["id"] = new_id
|
||||
if tmpl_data.get("prompt_ref"):
|
||||
tmpl_data["prompt_ref"] = (
|
||||
f"prompts/market/{pkg_id}/{Path(tmpl_data['prompt_ref']).name}"
|
||||
)
|
||||
self._write_talent_template_prompt(pkg_id, tmpl_data, package.prompt_contents)
|
||||
template_ids.append(new_id)
|
||||
|
||||
# 4. Prepare employees
|
||||
for emp_data in package.employees:
|
||||
emp_data = dict(emp_data)
|
||||
if prefix:
|
||||
if emp_data.get("role_id"):
|
||||
emp_data["role_id"] = f"{prefix}{emp_data['role_id']}"
|
||||
if emp_data.get("template_id"):
|
||||
emp_data["template_id"] = f"{prefix}{emp_data['template_id']}"
|
||||
if emp_data.get("employee_id"):
|
||||
emp_data["employee_id"] = f"{prefix}{emp_data['employee_id']}"
|
||||
if emp_data.get("prompt_refs"):
|
||||
emp_data["prompt_refs"] = [
|
||||
f"prompts/market/{pkg_id}/{Path(ref).name}"
|
||||
for ref in emp_data["prompt_refs"]
|
||||
]
|
||||
from opc.core.config import EmployeeConfig
|
||||
self.config.org.employees.append(
|
||||
EmployeeConfig.model_validate(emp_data)
|
||||
)
|
||||
|
||||
# 5. Apply runtime policy if present in package
|
||||
if package.runtime_policy:
|
||||
from opc.core.config import RuntimePolicyConfig
|
||||
try:
|
||||
self.config.org.runtime_policies["custom"] = (
|
||||
RuntimePolicyConfig.model_validate(package.runtime_policy)
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to apply runtime_policy from package %s", pkg_id)
|
||||
|
||||
# 6. Build installation record
|
||||
info = InstalledPackageInfo(
|
||||
package_id=pkg_id,
|
||||
name=package.manifest.name,
|
||||
version=package.manifest.version,
|
||||
installed_at=datetime.now(timezone.utc).isoformat(),
|
||||
source_path=str(package.manifest.id),
|
||||
role_ids=role_ids,
|
||||
template_ids=template_ids,
|
||||
)
|
||||
self.config.org.installed_packages.append(info)
|
||||
|
||||
logger.info(
|
||||
"Installed package %s: %d roles, %d templates",
|
||||
pkg_id, len(role_ids), len(template_ids),
|
||||
)
|
||||
return info
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Uninstall
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def uninstall(self, package_id: str) -> bool:
|
||||
"""Remove an installed package from the org config.
|
||||
|
||||
Caller must call config.save() to persist.
|
||||
"""
|
||||
# Find the installed package record
|
||||
installed = None
|
||||
for pkg in self.config.org.installed_packages:
|
||||
pid = pkg.package_id if isinstance(pkg, InstalledPackageInfo) else pkg.get("package_id", "")
|
||||
if pid == package_id:
|
||||
installed = pkg
|
||||
break
|
||||
if installed is None:
|
||||
logger.warning("Package %s not found in installed_packages", package_id)
|
||||
return False
|
||||
|
||||
if isinstance(installed, InstalledPackageInfo):
|
||||
role_ids = set(installed.role_ids)
|
||||
template_ids = set(installed.template_ids)
|
||||
else:
|
||||
role_ids = set(installed.get("role_ids", []))
|
||||
template_ids = set(installed.get("template_ids", []))
|
||||
|
||||
# Remove roles
|
||||
self.config.org.roles = [
|
||||
r for r in self.config.org.roles if r.id not in role_ids
|
||||
]
|
||||
# Remove materialized package talent templates.
|
||||
for template_id in template_ids:
|
||||
try:
|
||||
self._talent_prompt_path(template_id).unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
# Remove employees belonging to removed roles
|
||||
self.config.org.employees = [
|
||||
e for e in self.config.org.employees
|
||||
if e.role_id not in role_ids
|
||||
]
|
||||
# Remove installed package record
|
||||
self.config.org.installed_packages = [
|
||||
p for p in self.config.org.installed_packages
|
||||
if (p.package_id if isinstance(p, InstalledPackageInfo) else p.get("package_id", "")) != package_id
|
||||
]
|
||||
|
||||
# Remove prompt files
|
||||
prompts_dir = self.opc_home / "prompts" / "market" / package_id
|
||||
if prompts_dir.exists():
|
||||
shutil.rmtree(prompts_dir, ignore_errors=True)
|
||||
|
||||
logger.info("Uninstalled package %s", package_id)
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _write_prompts(self, package_id: str, prompt_contents: dict[str, str]) -> None:
|
||||
"""Write prompt files to {opc_home}/prompts/market/{package_id}/."""
|
||||
if not prompt_contents:
|
||||
return
|
||||
target_dir = self.opc_home / "prompts" / "market" / package_id
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
for filename, content in prompt_contents.items():
|
||||
with open(target_dir / filename, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
def _current_talent_template_ids(self) -> set[str]:
|
||||
try:
|
||||
from opc.layer2_organization.talent_market import TalentMarket
|
||||
|
||||
return {template.id for template in TalentMarket(self.opc_home, self.config).list_available_templates()}
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
def _write_talent_template_prompt(
|
||||
self,
|
||||
package_id: str,
|
||||
template_data: dict[str, Any],
|
||||
prompt_contents: dict[str, str],
|
||||
) -> None:
|
||||
template_id = str(template_data.get("id") or "").strip()
|
||||
if not template_id:
|
||||
return
|
||||
prompt_ref = str(template_data.get("prompt_ref") or "").strip()
|
||||
source_name = Path(prompt_ref).name if prompt_ref else ""
|
||||
content = prompt_contents.get(source_name, "") if source_name else ""
|
||||
body = _FRONTMATTER_RE.sub("", content, count=1).strip()
|
||||
name = str(template_data.get("name") or template_id).strip() or template_id
|
||||
description = str(template_data.get("description") or "").strip()
|
||||
if not body:
|
||||
body = f"# {name}\n"
|
||||
if description:
|
||||
body += f"\n{description}\n"
|
||||
frontmatter = {
|
||||
"id": template_id,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"category": str(template_data.get("category") or "general").strip() or "general",
|
||||
"source_package": package_id,
|
||||
}
|
||||
for key in ("domains", "tags", "preferred_external_agent"):
|
||||
value = template_data.get(key)
|
||||
if value not in (None, "", [], {}):
|
||||
frontmatter[key] = value
|
||||
path = self._talent_prompt_path(template_id)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
"---\n"
|
||||
+ yaml.dump(frontmatter, default_flow_style=False, sort_keys=False, allow_unicode=True)
|
||||
+ "---\n\n"
|
||||
+ body.rstrip()
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def _talent_prompt_path(self, template_id: str) -> Path:
|
||||
filename = re.sub(r"[^A-Za-z0-9._:-]+", "-", str(template_id or "").strip()).strip("-") or "template"
|
||||
return self.opc_home / "prompts" / "talent" / f"{filename}.md"
|
||||
Reference in New Issue
Block a user