"""Persistent company employee registry helpers. Employees are runtime/company assets, while organization configs describe the role graph and policies. This module keeps employee records outside org yaml and normalizes template-backed employees to a canonical template id. """ from __future__ import annotations import json import re from pathlib import Path from typing import Any import yaml from opc.core.config import EmployeeConfig, validate_organization_id EMPLOYEE_REGISTRY_SCHEMA_VERSION = 1 EMPLOYEE_REGISTRY_KIND = "company_employee" _COUNT_KEYS = { "successes", "partial_successes", "failures", "reflection_count", } def _slugify(value: str) -> str: slug = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip().lower()).strip("-") return slug or "employee" def employee_registry_dir(opc_home: Path, organization_id: Any) -> Path: org_id = validate_organization_id(organization_id) return Path(opc_home) / "company_state" / org_id / "employees" def employee_registry_path(opc_home: Path, organization_id: Any, employee_id: str) -> Path: filename = f"{_slugify(employee_id)}.yaml" return employee_registry_dir(opc_home, organization_id) / filename def is_placeholder_employee(employee: EmployeeConfig | dict[str, Any]) -> bool: metadata = dict(employee.metadata if isinstance(employee, EmployeeConfig) else employee.get("metadata") or {}) return bool(metadata.get("is_default_employee") or metadata.get("is_fallback_employee")) def _employee_from_payload(raw: Any) -> EmployeeConfig | None: if isinstance(raw, EmployeeConfig): return raw.model_copy(deep=True) if not isinstance(raw, dict): return None payload = raw.get("employee") if isinstance(raw.get("employee"), dict) else raw try: return EmployeeConfig.model_validate(payload) except Exception: return None def _append_unique(items: list[Any], additions: list[Any]) -> list[Any]: result = list(items or []) for item in list(additions or []): if item not in result: result.append(item) return result def _merge_metadata(base: dict[str, Any], incoming: dict[str, Any]) -> dict[str, Any]: merged = dict(base or {}) for key, value in dict(incoming or {}).items(): if key == "legacy_employee_ids": merged[key] = _append_unique(list(merged.get(key, []) or []), list(value or [])) elif key in {"home_role_ids", "staffed_role_ids"}: merged[key] = _append_unique(list(merged.get(key, []) or []), list(value or [])) elif key not in merged or _is_empty_value(merged.get(key)): merged[key] = value elif isinstance(merged.get(key), dict) and isinstance(value, dict): nested = dict(merged[key]) nested.update(value) merged[key] = nested return merged def _canonicalize_employee(employee: EmployeeConfig) -> tuple[EmployeeConfig, dict[str, str]]: if is_placeholder_employee(employee): return employee.model_copy(deep=True), {} old_id = str(employee.employee_id or "").strip() template_id = str(employee.template_id or "").strip() canonical_id = template_id or old_id metadata = dict(employee.metadata or {}) aliases: dict[str, str] = {} legacy_ids = [str(item).strip() for item in list(metadata.get("legacy_employee_ids", []) or []) if str(item).strip()] if old_id and old_id != canonical_id and old_id not in legacy_ids: legacy_ids.append(old_id) if legacy_ids: metadata["legacy_employee_ids"] = legacy_ids for legacy_id in legacy_ids: aliases[legacy_id] = canonical_id role_id = str(employee.role_id or "").strip() if role_id: metadata.setdefault("home_role_id", role_id) metadata["home_role_ids"] = _append_unique(list(metadata.get("home_role_ids", []) or []), [role_id]) metadata["staffed_role_ids"] = _append_unique(list(metadata.get("staffed_role_ids", []) or []), [role_id]) if template_id: metadata.setdefault("canonical_employee_id", canonical_id) return employee.model_copy(update={"employee_id": canonical_id, "metadata": metadata}), aliases def _merge_employee(base: EmployeeConfig, incoming: EmployeeConfig) -> EmployeeConfig: merged = base.model_dump() other = incoming.model_dump() for field in ("domains", "tags", "prompt_refs", "skill_refs"): merged[field] = _append_unique(list(merged.get(field, []) or []), list(other.get(field, []) or [])) for field in ("name", "template_id", "description", "category", "preferred_external_agent", "seniority", "status"): if not merged.get(field) and other.get(field): merged[field] = other[field] elif field == "description" and len(str(other.get(field, ""))) > len(str(merged.get(field, ""))): merged[field] = other[field] if not merged.get("role_id") and other.get("role_id"): merged["role_id"] = other["role_id"] merged["metadata"] = _merge_metadata(dict(merged.get("metadata", {}) or {}), dict(other.get("metadata", {}) or {})) return EmployeeConfig.model_validate(merged) def normalize_employee_records(employees: list[Any]) -> tuple[list[EmployeeConfig], dict[str, str]]: real_by_id: dict[str, EmployeeConfig] = {} placeholders: list[EmployeeConfig] = [] aliases: dict[str, str] = {} for raw in list(employees or []): employee = _employee_from_payload(raw) if employee is None: continue if is_placeholder_employee(employee): placeholders.append(employee) continue canonical, employee_aliases = _canonicalize_employee(employee) aliases.update(employee_aliases) existing = real_by_id.get(canonical.employee_id) real_by_id[canonical.employee_id] = _merge_employee(existing, canonical) if existing else canonical real = sorted(real_by_id.values(), key=lambda item: (item.category, item.name.lower(), item.employee_id)) return [*real, *placeholders], aliases def load_employee_registry(opc_home: Path, organization_id: Any) -> list[EmployeeConfig]: directory = employee_registry_dir(opc_home, organization_id) if not directory.is_dir(): return [] employees: list[EmployeeConfig] = [] for path in sorted(directory.glob("*.yaml")): try: data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} except Exception: continue employee = _employee_from_payload(data) if employee is not None: employees.append(employee) return employees def load_company_employees( opc_home: Path, organization_id: Any, legacy_employees: list[Any], ) -> list[EmployeeConfig]: registry_employees = load_employee_registry(opc_home, organization_id) employees, aliases = normalize_employee_records([*registry_employees, *list(legacy_employees or [])]) migrate_evolution_employee_ids(opc_home, aliases) return employees def write_employee_registry( opc_home: Path, organization_id: Any, employees: list[Any], ) -> tuple[list[EmployeeConfig], dict[str, str]]: normalized, aliases = normalize_employee_records(employees) real_employees = [employee for employee in normalized if not is_placeholder_employee(employee)] directory = employee_registry_dir(opc_home, organization_id) directory.mkdir(parents=True, exist_ok=True) expected_paths: set[Path] = set() for employee in real_employees: path = employee_registry_path(opc_home, organization_id, employee.employee_id) expected_paths.add(path) payload = { "schema_version": EMPLOYEE_REGISTRY_SCHEMA_VERSION, "kind": EMPLOYEE_REGISTRY_KIND, "organization_id": validate_organization_id(organization_id), "employee": employee.model_dump(), } _atomic_write_text( path, yaml.dump(payload, default_flow_style=False, sort_keys=False, allow_unicode=True), ) for path in directory.glob("*.yaml"): if path not in expected_paths: try: path.unlink() except OSError: pass migrate_evolution_employee_ids(opc_home, aliases) return normalized, aliases def migrate_evolution_employee_ids(opc_home: Path, aliases: dict[str, str]) -> None: canonical_aliases = { str(old).strip(): str(new).strip() for old, new in dict(aliases or {}).items() if str(old).strip() and str(new).strip() and str(old).strip() != str(new).strip() } if not canonical_aliases: return paths: list[Path] = [Path(opc_home) / "evolution" / "employees.json"] projects_dir = Path(opc_home) / "projects" if projects_dir.is_dir(): paths.extend(sorted(projects_dir.glob("*/employee_evolution.json"))) for path in paths: if not path.exists(): continue try: profile = json.loads(path.read_text(encoding="utf-8")) except Exception: continue if not isinstance(profile, dict): continue employees = profile.get("employees") if not isinstance(employees, dict): continue changed = False for legacy_id, canonical_id in canonical_aliases.items(): if legacy_id not in employees: continue legacy_record = employees.pop(legacy_id) current = employees.get(canonical_id) employees[canonical_id] = _merge_evolution_records(current, legacy_record) changed = True if changed: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(profile, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") def _merge_evolution_records(base: Any, incoming: Any) -> Any: if isinstance(base, dict) and isinstance(incoming, dict): merged = dict(base) for key, value in incoming.items(): if key in _COUNT_KEYS and isinstance(value, (int, float)): merged[key] = int(merged.get(key, 0) or 0) + int(value) elif isinstance(value, list): merged[key] = _append_unique(list(merged.get(key, []) or []), value) elif isinstance(value, dict): merged[key] = _merge_evolution_records(merged.get(key, {}), value) elif key not in merged or _is_empty_value(merged.get(key)): merged[key] = value return merged if isinstance(base, list) and isinstance(incoming, list): return _append_unique(base, incoming) return base if not _is_empty_value(base) else incoming def _is_empty_value(value: Any) -> bool: return value is None or value == "" or value == [] or value == {} def _atomic_write_text(path: Path, content: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_suffix(path.suffix + ".tmp") tmp.write_text(content, encoding="utf-8") tmp.replace(path)