Files
OpenOPC/opc/core/org_config.py
T
2026-07-01 17:56:31 +08:00

270 lines
9.0 KiB
Python

"""Dedicated storage helpers for user-defined org architectures."""
from __future__ import annotations
from pathlib import Path
from typing import Any
import yaml
from opc.core.config import (
COMPANY_ORG_KIND,
COMPANY_ORG_SCHEMA_VERSION,
DEFAULT_ORGANIZATION_ID,
OPCConfig,
_atomic_write_text,
_company_org_payload_to_org_mapping,
_read_yaml_file,
build_company_org_payload_from_config,
slugify_organization_name,
validate_organization_id,
)
ORG_INDEX_FILENAME = "org_index.yaml"
ORG_CONFIGS_DIRNAME = "company_orgs"
ORG_CONFIG_KIND = COMPANY_ORG_KIND
ORG_CONFIG_SCHEMA_VERSION = COMPANY_ORG_SCHEMA_VERSION
RESERVED_ORG_CONFIG_IDS = frozenset({DEFAULT_ORGANIZATION_ID})
class RunnableOrgConfigError(ValueError):
"""Raised when a saved custom org cannot be safely activated or run."""
def org_index_path(config_dir: Path) -> Path:
return Path(config_dir) / ORG_INDEX_FILENAME
def org_configs_dir(config_dir: Path) -> Path:
return Path(config_dir) / ORG_CONFIGS_DIRNAME
def is_reserved_org_config_id(value: Any) -> bool:
try:
org_id = validate_organization_id(value)
except ValueError:
return False
return org_id in RESERVED_ORG_CONFIG_IDS
def validate_saved_org_id(value: Any) -> str:
org_id = validate_organization_id(value)
if org_id in RESERVED_ORG_CONFIG_IDS:
raise ValueError(f"Reserved organization_id for built-in company profile: {org_id!r}")
return org_id
def org_config_filename(organization_id: Any) -> str:
return f"org_{validate_saved_org_id(organization_id)}_config.yaml"
def organization_id_from_org_config_filename(path: Path) -> str | None:
name = Path(path).name
prefix = "org_"
suffix = "_config.yaml"
if not name.startswith(prefix) or not name.endswith(suffix):
return None
candidate = name[len(prefix):-len(suffix)]
try:
return validate_saved_org_id(candidate)
except ValueError:
return None
def org_config_path(config_dir: Path, organization_id: Any) -> Path:
return org_configs_dir(config_dir) / org_config_filename(organization_id)
def org_config_relative_path(organization_id: Any) -> str:
return f"{ORG_CONFIGS_DIRNAME}/{org_config_filename(organization_id)}"
def read_org_index(config_dir: Path) -> str | None:
path = org_index_path(config_dir)
if not path.exists():
return None
data = _read_yaml_file(path)
active_id = data.get("active_organization_id")
if not active_id:
return None
org_id = validate_organization_id(active_id)
return None if org_id in RESERVED_ORG_CONFIG_IDS else org_id
def write_org_index(config_dir: Path, organization_id: Any) -> None:
org_id = validate_saved_org_id(organization_id)
_atomic_write_text(
org_index_path(config_dir),
yaml.dump(
{
"schema_version": 1,
"active_organization_id": org_id,
},
default_flow_style=False,
sort_keys=False,
allow_unicode=True,
),
)
def list_org_config_paths(config_dir: Path) -> list[Path]:
org_dir = org_configs_dir(config_dir)
if not org_dir.is_dir():
return []
return sorted(path for path in org_dir.glob("org_*_config.yaml") if organization_id_from_org_config_filename(path))
def allocate_org_config_id(config_dir: Path, organization_name: Any, *, preferred_id: Any = "") -> str:
base = str(preferred_id or "").strip()
try:
candidate = validate_saved_org_id(base) if base else ""
except ValueError:
candidate = ""
if not candidate:
candidate = slugify_organization_name(organization_name)
existing = {
org_id
for path in list_org_config_paths(config_dir)
for org_id in [organization_id_from_org_config_filename(path)]
if org_id
}
existing.update(RESERVED_ORG_CONFIG_IDS)
if candidate not in existing:
return candidate
suffix = 2
while True:
tail = f"_{suffix}"
stem = candidate[: max(1, 64 - len(tail))].rstrip("_-") or "org"
next_id = f"{stem}{tail}"
if next_id not in existing:
return next_id
suffix += 1
def validate_org_config_payload(path: Path, data: dict[str, Any]) -> dict[str, Any]:
schema_version = int(data.get("schema_version", 1) or 1)
if schema_version > ORG_CONFIG_SCHEMA_VERSION:
raise ValueError(
f"{path.name} schema_version {schema_version} is not supported by this version of OpenOPC"
)
kind = str(data.get("kind", "") or "").strip()
if schema_version >= ORG_CONFIG_SCHEMA_VERSION and kind and kind != ORG_CONFIG_KIND:
raise ValueError(f"Unsupported org architecture kind in {path.name}: {kind}")
data["schema_version"] = schema_version
return data
def build_org_config_payload_from_config(
config: OPCConfig,
*,
organization_id: str | None = None,
organization_name: str | None = None,
) -> dict[str, Any]:
payload = build_company_org_payload_from_config(
config,
organization_id=organization_id,
organization_name=organization_name,
force_profile="custom",
)
org_id = validate_saved_org_id(payload.get("organization_id"))
payload["metadata"] = {
**dict(payload.get("metadata", {}) or {}),
"source": "org_mode",
"organization_config_file": org_config_relative_path(org_id),
}
return payload
def apply_org_config_payload_to_config(
base_config: OPCConfig,
data: dict[str, Any],
*,
source_path: Path | None = None,
) -> OPCConfig:
merged = base_config.model_dump()
org_mapping = _company_org_payload_to_org_mapping(data, source_path=source_path)
org_mapping["organization_id"] = validate_saved_org_id(org_mapping.get("organization_id"))
org_mapping["company_profile"] = "custom"
if org_mapping.get("organization_id"):
org_mapping["organization_config_file"] = org_config_relative_path(org_mapping["organization_id"])
merged["org"] = org_mapping
config = OPCConfig.model_validate(merged)
config.org.talent_templates = []
if source_path is not None:
try:
from opc.core.employee_registry import load_company_employees
config_dir = Path(source_path).parent.parent
config.org.employees = load_company_employees(
config_dir.parent,
config.org.organization_id,
list(config.org.employees),
)
except Exception:
pass
return config
def validate_runnable_org_config(config: OPCConfig, *, organization_id: Any = "") -> None:
"""Reject custom orgs that would silently fall back to corporate builtin roles.
Corporate can still fall back to builtin roles for legacy configs.
Saved custom orgs must carry explicit roles before activation or execution.
"""
org = config.org
profile = str(getattr(org, "company_profile", "") or "").strip().lower()
if profile != "custom":
return
org_id = validate_saved_org_id(organization_id or getattr(org, "organization_id", ""))
roles = [
role
for role in list(getattr(org, "roles", []) or [])
if str(getattr(role, "id", "") or "").strip()
]
if roles:
return
raise RunnableOrgConfigError(
f"Custom organization `{org_id}` has no roles. "
"Refusing to activate or run it with corporate fallback roles."
)
def write_org_config_payload(config_dir: Path, organization_id: Any, payload: dict[str, Any]) -> Path:
org_id = validate_saved_org_id(organization_id)
path = org_config_path(config_dir, org_id)
payload = dict(payload)
raw_employees = list(payload.get("employees", []) or [])
if raw_employees:
from opc.core.employee_registry import load_employee_registry, write_employee_registry
opc_home = Path(config_dir).parent
existing = load_employee_registry(opc_home, org_id)
write_employee_registry(opc_home, org_id, [*existing, *raw_employees])
payload["employees"] = []
payload["talent_templates"] = []
payload["organization_id"] = org_id
payload.setdefault("schema_version", ORG_CONFIG_SCHEMA_VERSION)
payload.setdefault("kind", ORG_CONFIG_KIND)
payload["metadata"] = {
**dict(payload.get("metadata", {}) or {}),
"organization_config_file": org_config_relative_path(org_id),
}
_atomic_write_text(
path,
yaml.dump(payload, default_flow_style=False, sort_keys=False, allow_unicode=True),
)
return path
def load_org_config_payload(config_dir: Path, organization_id: Any | None = None) -> tuple[dict[str, Any], Path]:
config_dir = Path(config_dir)
org_id = validate_saved_org_id(organization_id) if organization_id else read_org_index(config_dir)
if not org_id:
raise FileNotFoundError("No active org architecture is selected.")
path = org_config_path(config_dir, org_id)
if not path.exists():
raise FileNotFoundError(f"Org architecture config does not exist: {path}")
return validate_org_config_payload(path, _read_yaml_file(path)), path