Files
2026-07-01 17:56:31 +08:00

998 lines
52 KiB
Python

"""Organization service.
The heavy organization mutation implementation still lives in the underlying
org engine/config models; this service exposes the shared entrypoint used by UI
and CLI surfaces.
"""
from __future__ import annotations
from typing import Any
from opc.core.models import normalize_role_runtime_status
from opc.core.org_config import (
RunnableOrgConfigError,
apply_org_config_payload_to_config,
allocate_org_config_id,
build_org_config_payload_from_config,
list_org_config_paths,
load_org_config_payload,
org_config_relative_path,
org_config_path,
validate_runnable_org_config,
validate_saved_org_id,
write_org_config_payload,
write_org_index,
)
from opc.layer2_organization.org_work_item_planner import build_custom_org_work_item_blueprint
from opc.layer2_organization.phase import kanban_column, should_hide_work_item_from_company_kanban
from opc.layer2_organization.work_item_identity import (
work_item_identity_payload,
work_item_projection_id_from_metadata,
work_item_turn_type_from_metadata,
)
from opc.layer4_tools.output_budget import clip_text
from opc.plugins.office_ui.org_architecture_snapshot import (
apply_org_architecture_snapshot,
build_org_architecture_snapshot,
dump_org_architecture_snapshot,
parse_org_architecture_snapshot,
)
from .context import OfficeServiceContext
from .models import ServiceError, ServiceEvent, ServiceResult
class OrgService:
def __init__(self, context: OfficeServiceContext) -> None:
self.context = context
def _ensure_custom_org_editable(self) -> None:
if not self.context.is_custom_org_editable():
raise ServiceError(
"org_read_only",
"Corporate organization is read-only. Select or create a saved custom org before editing.",
)
async def info(self, *, include_events: bool = False) -> ServiceResult:
"""Build the full Office UI org_info payload.
This mirrors the historical WS payload shape so the frontend, CLI, and
board can share one source of truth without protocol changes.
"""
engine = self.context.engine
result: dict[str, Any] = {
"roles": [],
"employees": [],
"company_profile": "",
"organization_id": "",
"organization_name": "",
"organization_config_file": "",
"final_decider_role_id": None,
"top_level_role_ids": [],
"channels": [],
"connectors": [],
"runtime_teams": [],
"runtime_seats": [],
"work_items": [],
"frontier": {},
"runtime_topology_preview": {},
"work_item_runtime_preview": {},
"project_run": {},
"project_dossier": {},
"seat_digests": [],
"revision_links": [],
"project_recovery": {},
"org_version": 0,
"runtime_topology_version": 0,
}
cfg_org = getattr(getattr(engine, "config", None), "org", None)
if cfg_org is not None:
result["organization_id"] = str(getattr(cfg_org, "organization_id", "") or "")
result["organization_name"] = str(getattr(cfg_org, "organization_name", "") or "")
result["organization_config_file"] = str(getattr(cfg_org, "organization_config_file", "") or "")
org = getattr(engine, "org_engine", None)
agents: list[Any] = []
if org:
try:
agents = list(org.list_agents())
builtin_ids: set[str] = set()
if self.context.mode_state.exec_mode not in {"org", "custom"}:
try:
from opc.layer2_organization.company_runtime_profiles import get_builtin_roles
for profile in ("corporate",):
builtin_ids.update(role.id for role in get_builtin_roles(profile))
except Exception:
pass
result["roles"] = [
{
"role_id": agent.role_id,
"name": agent.name,
"responsibility": agent.responsibility,
"status": agent.status.value if hasattr(agent.status, "value") else str(agent.status),
"reports_to": agent.reports_to,
"icon": getattr(agent, "icon", None),
"can_spawn": list(agent.can_spawn) if agent.can_spawn else [],
"tools": list(agent.tools) if agent.tools else [],
"is_builtin": agent.role_id in builtin_ids,
"execution_strategy": agent.runtime_policy.get("execution_strategy", "auto")
if isinstance(agent.runtime_policy, dict)
else "auto",
"preferred_external_agent": agent.preferred_external_agent,
"prompt_refs": list(agent.prompt_refs) if agent.prompt_refs else [],
}
for agent in agents
]
except Exception:
pass
try:
employees = list(org.list_employees())
effective_role_ids = {agent.role_id for agent in agents}
role_getter = getattr(org, "employee_role_ids", None)
filtered_employees = []
for employee in employees:
if callable(role_getter):
try:
role_ids = list(role_getter(employee))
except Exception:
role_ids = []
else:
role_ids = [str(getattr(employee, "role_id", "") or "").strip()]
if any(role_id in effective_role_ids for role_id in role_ids):
filtered_employees.append(employee)
employees = filtered_employees
emp_agent_map = {}
if self.context.agent_store is not None:
getter = getattr(self.context.agent_store, "get_employee_agent_map", None)
if callable(getter):
emp_agent_map = await getter()
emp_list = []
for employee in employees:
emp_meta = dict(getattr(employee, "metadata", {}) or {})
role_ids: list[str] = []
role_getter = getattr(org, "employee_role_ids", None)
if callable(role_getter):
try:
role_ids = list(role_getter(employee))
except Exception:
role_ids = []
if not role_ids:
for value in [
getattr(employee, "role_id", ""),
emp_meta.get("home_role_id"),
*list(emp_meta.get("home_role_ids", []) or []),
*list(emp_meta.get("staffed_role_ids", []) or []),
]:
role_id = str(value or "").strip()
if role_id and role_id not in role_ids:
role_ids.append(role_id)
emp_dict: dict[str, Any] = {
"employee_id": employee.employee_id,
"name": employee.name,
"role_id": employee.role_id,
"role_ids": role_ids,
"category": getattr(employee, "category", ""),
"domains": list(getattr(employee, "domains", [])),
"seniority": getattr(employee, "seniority", "junior"),
"status": getattr(employee, "status", "active"),
"tags": list(getattr(employee, "tags", [])),
"prompt_refs": list(getattr(employee, "prompt_refs", [])),
"skill_refs": list(getattr(employee, "skill_refs", [])),
"preferred_external_agent": getattr(employee, "preferred_external_agent", None),
"experience_score": 0.0,
"learned_skill_refs": [],
"is_default_employee": bool(emp_meta.get("is_default_employee", False)),
}
linked = emp_agent_map.get(employee.employee_id)
if linked:
emp_dict["linked_agent_id"] = linked
emp_list.append(emp_dict)
result["employees"] = emp_list
except Exception:
pass
try:
result["company_profile"] = org.get_company_profile()
result["final_decider_role_id"] = org.get_final_decider_role_id(strict=False)
result["top_level_role_ids"] = org.get_top_level_role_ids()
result["org_version"] = org.current_org_version()
result["runtime_topology_version"] = org.current_runtime_topology_version()
except Exception:
pass
try:
result["runtime_policy"] = org.get_runtime_policy(org.get_company_profile())
except Exception:
pass
try:
preview_topology = org.build_runtime_delegation_topology()
result["runtime_topology_preview"] = preview_topology
if org.get_company_profile() == "custom":
policy = org.get_runtime_policy("custom")
policy_payload = policy.model_dump() if hasattr(policy, "model_dump") else dict(policy or {})
result["work_item_runtime_preview"] = build_custom_org_work_item_blueprint(
org,
runtime_topology=preview_topology,
runtime_policy=policy_payload,
).to_dict()
except Exception:
pass
store = getattr(engine, "store", None)
if store is not None:
try:
if bool(getattr(store, "is_ready", False)):
project_id = getattr(engine, "project_id", None) or "default"
if hasattr(store, "list_open_delegation_runs"):
runs = await store.list_open_delegation_runs(project_id=project_id)
else:
runs = await store.list_delegation_runs(project_id=project_id, status="running")
if runs:
active_run = runs[0]
cells = await store.list_delegation_cells(active_run.run_id)
role_sessions = await store.list_delegation_role_sessions(active_run.run_id)
work_items = await store.list_delegation_work_items(active_run.run_id)
runtime_teams = await store.list_team_instances(run_id=active_run.run_id) if hasattr(store, "list_team_instances") else []
runtime_seats = await store.list_seat_states(run_id=active_run.run_id) if hasattr(store, "list_seat_states") else []
legacy_team_payload = [
{
"cell_id": cell.cell_id,
"manager_role_id": cell.manager_role_id,
"member_role_ids": list(cell.member_role_ids),
"status": cell.status,
"is_final_decider_cell": bool((cell.metadata or {}).get("is_final_decider_cell")),
}
for cell in cells
]
legacy_seat_payload = [
{
"role_session_id": session.role_session_id,
"role_id": session.role_id,
"employee_id": session.employee_id,
"focused_work_item_id": session.focused_work_item_id,
"background_work_item_ids": list(session.background_work_item_ids),
"pending_work_item_ids": list(getattr(session, "pending_work_item_ids", []) or []),
"queue_depth": len(list(getattr(session, "pending_work_item_ids", []) or [])),
"manager_role_ids": list(session.manager_role_ids),
"status": normalize_role_runtime_status(session.status, session.focused_work_item_id),
}
for session in role_sessions
]
column_counts = {"todo": 0, "in_progress": 0, "in_review": 0, "done": 0}
blocker_count = 0
rework_count = 0
for item in work_items:
metadata = dict(item.metadata or {})
if should_hide_work_item_from_company_kanban(metadata):
continue
column = kanban_column(item.phase)
if column in column_counts:
column_counts[column] += 1
if item.blocked_reason or item.phase.value in {
"waiting_for_peer",
"waiting_for_children",
"needs_attention",
"waiting_dependencies",
}:
blocker_count += 1
if str(metadata.get("rework_feedback", "") or "").strip():
rework_count += 1
result["frontier"] = {
"run_id": active_run.run_id,
"status": active_run.status,
"lifecycle_status": getattr(active_run, "lifecycle_status", ""),
"total_cells": len(cells),
"total_role_sessions": len(role_sessions),
"total_work_items": sum(column_counts.values()),
"todo_count": column_counts["todo"],
"in_progress_count": column_counts["in_progress"],
"in_review_count": column_counts["in_review"],
"done_count": column_counts["done"],
"blocker_count": blocker_count,
"rework_count": rework_count,
"ready_count": column_counts["todo"],
"running_count": column_counts["in_progress"],
"blocked_count": blocker_count,
"waiting_count": column_counts["in_review"],
"failed_count": 0,
}
result["project_run"] = {
"run_id": active_run.run_id,
"project_id": active_run.project_id,
"session_id": active_run.session_id,
"status": active_run.status,
"lifecycle_status": getattr(active_run, "lifecycle_status", ""),
"company_profile": active_run.company_profile,
"execution_model": active_run.execution_model,
"current_revision": getattr(active_run, "current_revision", 1),
"latest_deliverable_summary": getattr(active_run, "latest_deliverable_summary", ""),
"recovery_pointer": dict(getattr(active_run, "recovery_pointer", {}) or {}),
}
dossier = dict(getattr(active_run, "project_dossier", {}) or {})
memory = getattr(engine, "memory", None)
if not dossier and memory is not None and hasattr(memory, "build_project_dossier"):
try:
dossier = await memory.build_project_dossier(
project_id=project_id,
run_id=active_run.run_id,
session_id=active_run.session_id,
)
except Exception:
dossier = {}
result["project_dossier"] = dossier
result["runtime_teams"] = [
{
"team_instance_id": team.team_instance_id,
"cell_id": team.team_id,
"team_id": team.team_id,
"manager_role_id": str((team.metadata or {}).get("lead_role_id", "") or ""),
"member_role_ids": list(team.role_ids),
"seat_ids": list(team.seat_ids),
"status": team.status,
"parent_team_id": str((team.metadata or {}).get("parent_team_id", "") or ""),
}
for team in runtime_teams
] if runtime_teams else legacy_team_payload
result["runtime_seats"] = [
{
"role_session_id": seat.role_runtime_session_id,
"role_id": seat.role_id,
"employee_id": seat.employee_id,
"team_id": seat.team_id,
"team_instance_id": seat.team_instance_id,
"seat_id": seat.seat_id,
"focused_work_item_id": seat.current_work_item_id,
"current_work_item_id": seat.current_work_item_id,
"manager_role_ids": list(seat.manager_role_ids),
"manager_seat_id": seat.manager_seat_id,
"status": normalize_role_runtime_status(seat.status, seat.current_work_item_id),
"resident_status": normalize_role_runtime_status(seat.resident_status or seat.status, seat.current_work_item_id),
"latest_notification": dict(getattr(seat, "latest_notification", {}) or {}),
"manager_digest": dict(getattr(seat, "manager_digest", {}) or {}),
}
for seat in runtime_seats
] if runtime_seats else legacy_seat_payload
result["work_items"] = [
{
"work_item_id": item.work_item_id,
"role_id": item.role_id,
"cell_id": item.cell_id,
"team_id": item.team_id,
"seat_id": item.seat_id,
"team_instance_id": item.team_instance_id,
"title": item.title,
"kind": item.kind,
"phase": item.phase.value,
"kanban_column": kanban_column(item.phase),
"batch_id": getattr(item, "batch_id", ""),
"batch_index": getattr(item, "batch_index", 0),
"deliverable_summary": clip_text(
getattr(item, "deliverable_summary", ""),
limit=1200,
marker="ui deliverable preview truncated",
).text,
"deliverable_summary_chars": len(str(getattr(item, "deliverable_summary", "") or "")),
"blocked_reason": getattr(item, "blocked_reason", ""),
"handoff_status": getattr(item, "handoff_status", ""),
"parent_work_item_id": item.parent_work_item_id,
**work_item_identity_payload(
projection_id=work_item_projection_id_from_metadata(
dict(item.metadata or {}),
fallback=str(item.projection_id or item.work_item_id or ""),
),
turn_type=work_item_turn_type_from_metadata(
dict(item.metadata or {}),
fallback=str(item.kind or ""),
),
),
"metadata": dict(item.metadata or {}),
"adaptive": dict((item.metadata or {}).get("adaptive", {}) or {}),
}
for item in work_items
]
result["seat_digests"] = [
{
"seat_id": seat.seat_id,
"team_id": seat.team_id,
"role_id": seat.role_id,
"employee_id": seat.employee_id,
"role_session_id": seat.role_runtime_session_id,
"resident_status": normalize_role_runtime_status(
seat.resident_status or seat.status,
seat.current_work_item_id,
),
"current_work_item": dict(getattr(seat, "current_work_item", {}) or {}),
"latest_notification": dict(getattr(seat, "latest_notification", {}) or {}),
"manager_digest": dict(getattr(seat, "manager_digest", {}) or {}),
}
for seat in runtime_seats
]
if hasattr(store, "get_session_links") and active_run.session_id:
links = await store.get_session_links(active_run.session_id, limit=50)
result["revision_links"] = [
{
"link_id": link.link_id,
"session_id": link.session_id,
"linked_session_id": link.linked_session_id,
"link_type": link.link_type,
"metadata": dict(link.metadata or {}),
"created_at": link.created_at.isoformat(),
}
for link in links
if str(link.link_type or "").strip() in {"continuation_of", "revision_of", "delivery_of"}
]
result["project_recovery"] = dict(getattr(active_run, "recovery_pointer", {}) or {})
except Exception:
pass
channel_mgr = getattr(engine, "channel_manager", None)
if channel_mgr:
try:
statuses = channel_mgr.get_all_statuses()
result["channels"] = [
{
"name": status.get("name", ""),
"enabled": status.get("enabled", False),
"running": status.get("running", False),
"configured": status.get("configured", False),
"available": status.get("available", False),
"ready": status.get("ready", False),
"last_error": status.get("last_error"),
"delivery_mode": status.get("delivery_mode", ""),
}
for status in statuses
]
except Exception:
pass
try:
result["installed_packages"] = [
package.model_dump() if hasattr(package, "model_dump") else package
for package in engine.config.org.installed_packages
]
except Exception:
result["installed_packages"] = []
result["runtime_teams"] = list(result.get("runtime_teams") or [])
result["runtime_seats"] = list(result.get("runtime_seats") or [])
result["work_items"] = list(result.get("work_items") or [])
result["frontier"] = dict(result.get("frontier", {}) or {})
events = self.info_events(result) if include_events else []
return ServiceResult(result, events)
def info_events(self, payload: dict[str, Any]) -> list[ServiceEvent]:
events = [ServiceEvent("org_info", payload)]
if payload.get("project_run"):
events.append(ServiceEvent("project_run_updated", payload["project_run"]))
if payload.get("seat_digests"):
events.append(ServiceEvent("seat_digest_updated", {
"run_id": dict(payload.get("project_run", {}) or {}).get("run_id"),
"seat_digests": payload["seat_digests"],
}))
if payload.get("work_items"):
events.append(ServiceEvent("work_item_batch_updated", {
"run_id": dict(payload.get("project_run", {}) or {}).get("run_id"),
"work_items": payload["work_items"],
"frontier": payload.get("frontier", {}),
}))
if payload.get("project_recovery"):
events.append(ServiceEvent("project_recovery_updated", payload["project_recovery"]))
if payload.get("revision_links"):
events.append(ServiceEvent("project_revision_created", {
"run_id": dict(payload.get("project_run", {}) or {}).get("run_id"),
"revision_links": payload["revision_links"],
}))
return events
async def export_config(self) -> ServiceResult:
from opc.core.config import build_company_org_payload_from_config
snapshot = build_org_architecture_snapshot(self.context.engine.config)
try:
config_payload = build_org_config_payload_from_config(self.context.engine.config)
except ValueError:
profile = str(getattr(self.context.engine.config.org, "company_profile", "") or "corporate").strip()
config_payload = build_company_org_payload_from_config(
self.context.engine.config,
force_profile=profile or "corporate",
)
return ServiceResult({
"config": config_payload,
"yaml": dump_org_architecture_snapshot(snapshot),
})
async def import_config(self, payload: dict[str, Any] | str, *, dry_run: bool = False) -> ServiceResult:
try:
if isinstance(payload, str):
snapshot = parse_org_architecture_snapshot(payload)
validated = apply_org_architecture_snapshot(self.context.engine.config, snapshot)
try:
validate_saved_org_id(getattr(validated.org, "organization_id", ""))
except ValueError:
if "organization_id" in snapshot:
raise
config_dir = self.context.opc_home / "config"
organization_name = str(
getattr(validated.org, "organization_name", "")
or getattr(validated.org, "company_name", "")
or "org"
).strip()
organization_id = allocate_org_config_id(config_dir, organization_name)
validated.org.organization_id = organization_id
validated.org.organization_name = organization_name
validated.org.organization_config_file = org_config_relative_path(organization_id)
else:
validated = apply_org_config_payload_to_config(self.context.engine.config, payload)
except Exception as exc:
raise ServiceError("org_config_invalid", str(exc), {"validation_errors": [str(exc)]}) from exc
before_roles = {str(getattr(role, "id", getattr(role, "role_id", "")) or "") for role in self.context.engine.config.org.roles}
after_roles = {str(getattr(role, "id", getattr(role, "role_id", "")) or "") for role in validated.org.roles}
preview = {
"roles_added": len(after_roles - before_roles),
"roles_removed": len(before_roles - after_roles),
"employees_changed": abs(len(validated.org.employees) - len(self.context.engine.config.org.employees)),
}
if dry_run:
return ServiceResult({"ok": True, "dry_run": True, "preview": preview})
self._ensure_custom_org_editable()
async with self.context.config_lock:
self.context.rebind_config(validated)
await self._persist_and_reload()
info = await self.info(include_events=True)
return ServiceResult(
{"ok": True, "dry_run": False, "preview": preview},
info.events,
)
async def saved_list(self) -> ServiceResult:
import yaml
items: list[dict[str, Any]] = []
for path in list_org_config_paths(self.context.opc_home / "config"):
try:
parsed = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(parsed, dict):
continue
base_config = getattr(self.context.engine, "config", None)
if base_config is None:
from opc.core.config import OPCConfig
base_config = OPCConfig()
validated = apply_org_config_payload_to_config(
base_config,
parsed,
source_path=path,
)
org_id = str(parsed.get("organization_id") or path.stem.removeprefix("org_").removesuffix("_config"))
org_name = str(parsed.get("organization_name") or (parsed.get("company") or {}).get("name") or org_id)
items.append({
"name": org_id,
"organization_id": org_id,
"organization_name": org_name,
"filename": path.name,
"saved_at": path.stat().st_mtime,
"roles_count": len(validated.org.roles),
"employees_count": len(validated.org.employees),
})
except Exception:
continue
active_name = ""
if self.context.get_active_saved_org_name is not None:
active_name = await self.context.get_active_saved_org_name()
return ServiceResult({"orgs": items, "active_name": active_name or None})
async def saved_load(self, name: str) -> ServiceResult:
try:
organization_id = validate_saved_org_id(name)
payload, path = load_org_config_payload(self.context.opc_home / "config", organization_id)
validated = apply_org_config_payload_to_config(
self.context.engine.config,
payload,
source_path=path,
)
validate_runnable_org_config(validated, organization_id=organization_id)
except FileNotFoundError:
raise ServiceError("saved_org_not_found", "Saved organization not found", {"name": name})
except RunnableOrgConfigError as exc:
raise ServiceError("saved_org_not_runnable", str(exc), {"name": name}) from exc
except ValueError as exc:
raise ServiceError("saved_org_reserved", str(exc), {"name": name}) from exc
async with self.context.config_lock:
self.context.rebind_config(validated)
await self._persist_and_reload()
if self.context.set_active_saved_org_name is not None:
await self.context.set_active_saved_org_name(organization_id)
return ServiceResult({"ok": True, "name": organization_id, "config": payload})
async def saved_save_as(self, name: str, *, overwrite: bool = False) -> ServiceResult:
from opc.core.config import slugify_organization_name
self._ensure_custom_org_editable()
organization_name = str(name or "").strip()
if not organization_name:
raise ServiceError("organization_name_required", "organization name required")
config_dir = self.context.opc_home / "config"
preferred_id = slugify_organization_name(organization_name)
organization_id = preferred_id if overwrite else allocate_org_config_id(config_dir, organization_name, preferred_id=preferred_id)
try:
organization_id = validate_saved_org_id(organization_id)
except ValueError as exc:
raise ServiceError("saved_org_reserved", str(exc), {"name": name}) from exc
cfg = self.context.engine.config
async with self.context.config_lock:
cfg.org.organization_id = organization_id
cfg.org.organization_name = organization_name
cfg.org.organization_config_file = org_config_relative_path(organization_id)
cfg.org.company_name = organization_name
cfg.org.company_profile = "custom"
snapshot = build_org_architecture_snapshot(cfg, force_profile="custom")
snapshot["organization_id"] = organization_id
snapshot["organization_name"] = organization_name
snapshot.setdefault("company", {})["name"] = organization_name
path = write_org_config_payload(config_dir, organization_id, snapshot)
write_org_index(config_dir, organization_id)
await self._persist_and_reload()
if self.context.set_active_saved_org_name is not None:
await self.context.set_active_saved_org_name(organization_id)
return ServiceResult({
"ok": True,
"name": organization_id,
"organization_id": organization_id,
"organization_name": organization_name,
"filename": path.name,
"path": str(path),
})
async def saved_create(self, *, organization_name: str, members: list[dict[str, Any]]) -> ServiceResult:
from opc.core.config import EmployeeConfig, OPCConfig, RoleConfig, slugify_organization_name
display_name = str(organization_name or "").strip()
if not display_name:
raise ServiceError("organization_name_required", "organization name required")
if not isinstance(members, list):
raise ServiceError("org_members_required", "members must be a list")
normalized_members: list[dict[str, Any]] = []
for index, item in enumerate(members):
if not isinstance(item, dict):
continue
name = str(item.get("name") or "").strip()
if not name:
continue
normalized_members.append({
"source_index": index,
"name": name,
"responsibility": str(item.get("responsibility") or "").strip(),
"prompt": str(item.get("prompt") or "").strip(),
"reports_to_index": item.get("reports_to_index"),
})
if len(normalized_members) < 2:
raise ServiceError("org_members_required", "organization requires at least two members")
config_dir = self.context.opc_home / "config"
organization_id = allocate_org_config_id(config_dir, display_name)
role_ids: list[str] = []
used_role_ids: set[str] = set()
for idx, member in enumerate(normalized_members):
base = slugify_organization_name(member["name"], fallback=f"member_{idx + 1}")
role_id = base
suffix = 2
while role_id in used_role_ids:
tail = f"_{suffix}"
role_id = f"{base[: max(1, 64 - len(tail))].rstrip('_-') or 'member'}{tail}"
suffix += 1
used_role_ids.add(role_id)
role_ids.append(role_id)
roles: list[RoleConfig] = []
employees: list[EmployeeConfig] = []
for idx, member in enumerate(normalized_members):
raw_parent = member.get("reports_to_index")
if raw_parent in (None, "", [], {}):
reports_to = "owner" if idx == 0 else role_ids[0]
else:
try:
parent_index = int(raw_parent)
except (TypeError, ValueError) as exc:
raise ServiceError("invalid_org_member_hierarchy", "invalid reports_to_index", {"index": idx}) from exc
if parent_index < 0 or parent_index >= idx:
raise ServiceError("invalid_org_member_hierarchy", "reports_to_index must point to an earlier member", {
"index": idx,
"reports_to_index": parent_index,
})
reports_to = role_ids[parent_index]
role_id = role_ids[idx]
responsibility = member["responsibility"] or f"Owns {member['name']} responsibilities."
roles.append(RoleConfig(
id=role_id,
name=member["name"],
responsibility=responsibility,
reports_to=reports_to,
prompt_refs=[member["prompt"]] if member["prompt"] else [],
))
employees.append(EmployeeConfig(
employee_id=f"{role_id}-default-employee",
template_id="system_default_employee",
name=f"{member['name']} Default Employee",
role_id=role_id,
description=f"Default employee for the {member['name']} role.",
category="general",
metadata={
"is_default_employee": True,
"auto_created_for_role": role_id,
"employee_origin": "system_default",
"persist_to_org": True,
},
))
cfg = self.context.engine.config.model_copy(deep=True)
cfg.org.organization_id = organization_id
cfg.org.organization_name = display_name
cfg.org.organization_config_file = org_config_relative_path(organization_id)
cfg.org.company_name = display_name
cfg.org.company_profile = "custom"
cfg.org.company_profiles = ["corporate", "custom"]
cfg.org.execution_model = "actor_runtime"
cfg.org.final_decider_role_id = role_ids[0]
cfg.org.roles = roles
cfg.org.employees = employees
cfg.org.escalation_rules = []
cfg.org.runtime_policies = {}
cfg.org.talent_templates = []
cfg.org.teams = []
cfg.org.team_runtime = OPCConfig().org.team_runtime
cfg.org.installed_packages = []
validate_runnable_org_config(cfg, organization_id=organization_id)
async with self.context.config_lock:
payload = build_org_config_payload_from_config(
cfg,
organization_id=organization_id,
organization_name=display_name,
)
path = write_org_config_payload(config_dir, organization_id, payload)
write_org_index(config_dir, organization_id)
self.context.rebind_config(cfg)
org = getattr(self.context.engine, "org_engine", None)
if org and hasattr(org, "reload_from_config"):
org.reload_from_config()
if self.context.set_active_saved_org_name is not None:
await self.context.set_active_saved_org_name(organization_id)
return ServiceResult({
"ok": True,
"name": organization_id,
"organization_id": organization_id,
"organization_name": display_name,
"filename": path.name,
"path": str(path),
"roles_count": len(roles),
"employees_count": len(employees),
})
async def saved_delete(self, name: str) -> ServiceResult:
try:
organization_id = validate_saved_org_id(str(name or ""))
except ValueError as exc:
raise ServiceError("saved_org_reserved", str(exc), {"name": name}) from exc
active = ""
if self.context.get_active_saved_org_name is not None:
active = await self.context.get_active_saved_org_name()
if active == organization_id:
raise ServiceError("cannot_delete_active", "cannot_delete_active", {"name": name})
path = org_config_path(self.context.opc_home / "config", organization_id)
if path.exists():
path.unlink()
return ServiceResult({"ok": True, "name": organization_id, "organization_id": organization_id, "filename": path.name})
async def add_role(self, role_payload: dict[str, Any]) -> ServiceResult:
from opc.core.config import RoleConfig
self._ensure_custom_org_editable()
role_id = str(role_payload.get("role_id") or role_payload.get("id") or "").strip()
if not role_id:
raise ServiceError("missing_role_id", "role_id required")
cfg = self.context.engine.config.org
if any(self._role_id(role) == role_id for role in cfg.roles):
raise ServiceError("role_exists", "Role already exists", {"role_id": role_id})
role = RoleConfig(
id=role_id,
name=str(role_payload.get("name") or role_id),
responsibility=str(role_payload.get("responsibility") or role_payload.get("description") or ""),
reports_to=str(role_payload.get("reports_to") or "owner"),
icon=(str(role_payload.get("icon") or "").strip() or None),
tools=list(role_payload.get("tools", []) or []),
)
async with self.context.config_lock:
cfg.roles.append(role)
await self._persist_and_reload()
info = await self.info(include_events=True)
events = list(info.events)
if self.context.mode_state.exec_mode in {"org", "custom"}:
agents = await self._ensure_custom_role_agents()
events.append(ServiceEvent("ack", {"ok": True, "action": "agents_synced", "agents": agents}))
if self.context.broadcast_snapshot is not None:
await self.context.broadcast_snapshot()
return ServiceResult({"role": self._model_payload(role)}, events)
async def bulk_add_roles(self, roles: list[dict[str, Any]]) -> ServiceResult:
self._ensure_custom_org_editable()
added: list[str] = []
for role in roles:
role_id = str(role.get("role_id") or role.get("id") or "").strip()
if not role_id:
continue
if any(self._role_id(existing) == role_id for existing in self.context.engine.config.org.roles):
continue
result = await self.add_role(role)
added.append(str(result.payload.get("role", {}).get("id") or role_id))
if not added:
raise ServiceError("no_roles_added", "No valid roles to add")
return ServiceResult({"role_ids": added, "count": len(added)})
async def update_role(self, role_id: str, updates: dict[str, Any]) -> ServiceResult:
self._ensure_custom_org_editable()
role_id = str(role_id or "").strip()
if not role_id:
raise ServiceError("missing_role_id", "role_id required")
cfg = self.context.engine.config.org
target = next((role for role in cfg.roles if self._role_id(role) == role_id), None)
if target is None:
raise ServiceError("role_not_found", "Role not found", {"role_id": role_id})
if str(updates.get("reports_to", "") or "").strip() == role_id:
raise ServiceError("role_cycle", "Role cannot report to itself", {"role_id": role_id})
for key in ("name", "responsibility", "reports_to", "icon", "preferred_external_agent"):
if key in updates:
setattr(target, key, (str(updates[key]).strip() or None) if key in {"icon", "preferred_external_agent"} else str(updates[key]).strip())
if "reports_to" in updates:
new_reports_to = str(updates.get("reports_to") or "").strip()
if new_reports_to and new_reports_to != "owner":
role_map = {self._role_id(role): str(getattr(role, "reports_to", "") or "") for role in cfg.roles}
role_map[role_id] = new_reports_to
visited: set[str] = set()
cursor = new_reports_to
while cursor and cursor != "owner":
if cursor in visited:
raise ServiceError("role_cycle", "This would create a circular hierarchy", {"role_id": role_id})
visited.add(cursor)
cursor = role_map.get(cursor, "")
for key in ("can_spawn", "tools", "prompt_refs", "skill_refs", "capabilities"):
if key in updates:
value = updates.get(key) or []
if isinstance(value, str):
value = [item.strip() for item in value.split(",") if item.strip()]
else:
value = [str(item).strip() for item in list(value) if str(item).strip()]
setattr(target, key, list(value))
if "execution_strategy" in updates and hasattr(target, "runtime_policy"):
strategy = str(updates.get("execution_strategy") or "auto").strip()
if strategy:
target.runtime_policy.execution_strategy = strategy
async with self.context.config_lock:
await self._persist_and_reload()
info = await self.info(include_events=True)
return ServiceResult({"role": self._model_payload(target), "action": "role_updated", "role_id": role_id}, info.events)
async def delete_role(self, role_id: str) -> ServiceResult:
self._ensure_custom_org_editable()
cfg = self.context.engine.config.org
before = len(cfg.roles)
cfg.roles = [role for role in cfg.roles if self._role_id(role) != role_id]
if len(cfg.roles) == before:
raise ServiceError("role_not_found", "Role not found", {"role_id": role_id})
cfg.employees = [employee for employee in cfg.employees if getattr(employee, "role_id", "") != role_id]
for role in cfg.roles:
role.can_spawn = [item for item in list(getattr(role, "can_spawn", []) or []) if item != role_id]
if getattr(role, "reports_to", "") == role_id:
role.reports_to = "owner"
async with self.context.config_lock:
await self._persist_and_reload()
agents = await self.context.agent_store.get_all()
for agent in agents:
if agent.get("opc_role_id") == role_id:
await self.context.agent_store.remove_agent(agent["agent_id"])
if self.context.mode_state.exec_mode in {"org", "custom"}:
await self.context.agent_store.sync_custom_shadow()
info = await self.info(include_events=True)
return ServiceResult({"role_id": role_id, "action": "role_deleted"}, info.events)
async def update_runtime_policy(self, policy: dict[str, Any], *, profile: str = "custom") -> ServiceResult:
from opc.core.config import RuntimePolicyConfig
self._ensure_custom_org_editable()
current = self.context.engine.config.org.runtime_policies.get(profile)
base = current.model_dump() if hasattr(current, "model_dump") else {}
merged = self._deep_merge(base, dict(policy or {}))
self.context.engine.config.org.runtime_policies[profile] = RuntimePolicyConfig.model_validate(merged)
async with self.context.config_lock:
await self._persist_and_reload()
info = await self.info(include_events=True)
return ServiceResult(
{"profile": profile, "policy": self.context.engine.config.org.runtime_policies[profile].model_dump(), "action": "runtime_policy_updated"},
info.events,
)
async def update_org_strategy(self, *, final_decider_role_id: str | None = None) -> ServiceResult:
self._ensure_custom_org_editable()
value = str(final_decider_role_id or "").strip() or None
previous = self.context.engine.config.org.final_decider_role_id
self.context.engine.config.org.final_decider_role_id = value
org = getattr(self.context.engine, "org_engine", None)
if org and hasattr(org, "reload_from_config"):
org.reload_from_config()
validate = getattr(org, "validate_company_runtime_setup", None)
if callable(validate):
setup_error = validate()
if setup_error:
self.context.engine.config.org.final_decider_role_id = previous
org.reload_from_config()
raise ServiceError("invalid_org_strategy", str(setup_error))
async with self.context.config_lock:
await self._persist_and_reload()
info = await self.info(include_events=True)
return ServiceResult({"final_decider_role_id": value, "action": "org_strategy_updated"}, info.events)
async def reset_architecture(self) -> ServiceResult:
self._ensure_custom_org_editable()
async with self.context.config_lock:
self.context.engine.config.org.roles = []
self.context.engine.config.org.employees = []
self.context.engine.config.org.installed_packages = []
self.context.engine.config.org.runtime_policies.pop("custom", None)
await self._persist_and_reload()
try:
for agent in await self.context.agent_store.get_all():
await self.context.agent_store.remove_agent(agent["agent_id"])
await self.context.agent_store.sync_custom_shadow()
except Exception:
pass
info = await self.info(include_events=True)
return ServiceResult({"ok": True, "action": "architecture_reset"}, info.events)
async def _persist_and_reload(self) -> None:
if self.context.persist_runtime_config is not None:
self.context.persist_runtime_config()
else:
self.context.engine.config.save()
self.context.rebind_config(self.context.engine.config)
org = getattr(self.context.engine, "org_engine", None)
if org and hasattr(org, "reload_from_config"):
org.reload_from_config()
async def _ensure_custom_role_agents(self) -> list[dict[str, Any]]:
if self.context.ensure_custom_role_agents is not None:
return await self.context.ensure_custom_role_agents()
if self.context.agent_store is None:
return []
org = getattr(self.context.engine, "org_engine", None)
if self.context.mode_state.exec_mode in {"org", "custom"} and org is not None:
ensure = getattr(self.context.agent_store, "ensure_custom_role_agents", None)
if callable(ensure):
agents = await ensure(org)
if self.context.sync_role_map is not None:
await self.context.sync_role_map()
return agents
getter = getattr(self.context.agent_store, "get_all", None)
return await getter() if callable(getter) else []
@staticmethod
def _role_id(role: Any) -> str:
return str(getattr(role, "id", getattr(role, "role_id", "")) or "")
@staticmethod
def _deep_merge(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
result = dict(base)
for key, value in patch.items():
if isinstance(value, dict) and isinstance(result.get(key), dict):
result[key] = OrgService._deep_merge(result[key], value)
else:
result[key] = value
return result
@staticmethod
def _model_payload(value: Any) -> dict[str, Any]:
if hasattr(value, "model_dump"):
return value.model_dump()
if hasattr(value, "__dict__"):
return dict(value.__dict__)
return dict(value or {})