Initial commit

This commit is contained in:
LZH-YS1998
2026-07-01 17:56:31 +08:00
commit d78931979d
731 changed files with 311088 additions and 0 deletions
@@ -0,0 +1,51 @@
"""Shared Office services used by WebSocket UI, CLI, and CLI board."""
from __future__ import annotations
from .agent import AgentService
from .comms import CommsService
from .context import ModeState, OfficeServiceContext
from .kanban import KanbanService
from .market import MarketService
from .models import ServiceError, ServiceEvent, ServiceResult
from .org import OrgService
from .project import ProjectService
from .runtime import RuntimeService
from .session import SessionService
from .talent import TalentService
from .work_item import WorkItemService
class OfficeServices:
def __init__(self, context: OfficeServiceContext) -> None:
self.context = context
self.project = ProjectService(context)
self.session = SessionService(context)
self.kanban = KanbanService(context, self.session)
self.runtime = RuntimeService(context, self.session)
self.agent = AgentService(context)
self.org = OrgService(context)
self.talent = TalentService(context)
self.market = MarketService(context)
self.comms = CommsService(context)
self.work_item = WorkItemService(context)
__all__ = [
"AgentService",
"CommsService",
"KanbanService",
"MarketService",
"ModeState",
"OfficeServiceContext",
"OfficeServices",
"OrgService",
"ProjectService",
"RuntimeService",
"ServiceError",
"ServiceEvent",
"ServiceResult",
"SessionService",
"TalentService",
"WorkItemService",
]
+223
View File
@@ -0,0 +1,223 @@
"""Agent registry service."""
from __future__ import annotations
import time
import uuid
from pathlib import Path
from typing import Any
from .context import OfficeServiceContext
from .models import ServiceError, ServiceEvent, ServiceResult
class AgentService:
def __init__(self, context: OfficeServiceContext) -> None:
self.context = context
async def list(self) -> ServiceResult:
agents = await self.context.agent_store.get_all()
enriched: list[dict[str, Any]] = []
for agent in agents:
item = dict(agent)
agent_id = str(item.get("agent_id", "") or "")
tracker = self.context.event_adapter.get_tracker(agent_id) if agent_id else None
runtime_status = tracker.state.value if tracker else str(item.get("status", "idle") or "idle")
item["status"] = runtime_status
item["runtime_status"] = runtime_status
item["current_tool"] = tracker.current_tool if tracker else item.get("current_tool")
item["current_task_id"] = tracker.task_id if tracker else item.get("current_task_id")
enriched.append(item)
return ServiceResult({"agents": enriched})
async def create(self, *, name: str, role_id: str, office_id: str = "office-0", description: str = "", specialties: list[str] | None = None) -> ServiceResult:
if not name or not role_id:
raise ServiceError("missing_agent_fields", "name and role_id required")
agent = await self.context.agent_store.create_agent(
name=name,
opc_role_id=role_id,
office_id=office_id,
org_engine=getattr(self.context.engine, "org_engine", None),
description=description,
specialties=specialties or [],
)
if self.context.mode_state.exec_mode in {"org", "custom"}:
await self.context.agent_store.sync_custom_shadow()
return ServiceResult({"agent": agent}, [ServiceEvent("event", {"type": "agent_created", "agent_id": agent.get("agent_id"), "data": agent})])
async def create_from_template(self, *, template_id: str, role_id: str = "", office_id: str = "office-0") -> ServiceResult:
from opc.layer2_organization.talent_market import TalentMarket
template_id = str(template_id or "").strip()
if not template_id:
raise ServiceError("missing_template_id", "template_id required")
market = TalentMarket(self.context.opc_home, self.context.engine.config)
template = next((item for item in market.list_templates() if getattr(item, "id", "") == template_id), None)
if template is None:
template = next((item for item in market.scan_local_talent() if getattr(item, "id", "") == template_id), None)
if template is None:
raise ServiceError("template_not_found", "Template not found", {"template_id": template_id})
agent = await self.context.agent_store.create_agent(
name=str(getattr(template, "name", "") or template_id),
opc_role_id=str(role_id or template_id),
office_id=office_id,
org_engine=getattr(self.context.engine, "org_engine", None),
description=str(getattr(template, "description", "") or ""),
specialties=[*list(getattr(template, "domains", []) or []), *list(getattr(template, "tags", []) or [])],
)
if self.context.mode_state.exec_mode in {"org", "custom"}:
await self.context.agent_store.sync_custom_shadow()
return ServiceResult({"agent": agent}, [ServiceEvent("event", {"type": "agent_created", "agent_id": agent.get("agent_id"), "data": agent})])
async def import_employee(self, *, employee_id: str, office_id: str = "office-0") -> ServiceResult:
employee_id = str(employee_id or "").strip()
if not employee_id:
raise ServiceError("missing_employee_id", "employee_id required")
org = getattr(self.context.engine, "org_engine", None)
employee_obj = org.get_employee(employee_id) if org and hasattr(org, "get_employee") else None
if employee_obj is None:
employee_obj = next(
(item for item in getattr(self.context.engine.config.org, "employees", []) or [] if getattr(item, "employee_id", "") == employee_id),
None,
)
if employee_obj is None:
raise ServiceError("employee_not_found", "Employee not found", {"employee_id": employee_id})
employee = {
"employee_id": getattr(employee_obj, "employee_id", ""),
"name": getattr(employee_obj, "name", "") or employee_id,
"role_id": getattr(employee_obj, "role_id", ""),
"category": getattr(employee_obj, "category", ""),
"domains": list(getattr(employee_obj, "domains", []) or []),
"tags": list(getattr(employee_obj, "tags", []) or []),
}
agent = await self.context.agent_store.create_agent_from_employee(employee, office_id=office_id)
if self.context.mode_state.exec_mode in {"org", "custom"}:
await self.context.agent_store.sync_custom_shadow()
agents = await self.context.agent_store.get_all()
return ServiceResult(
{"agent": agent, "agents": agents, "imported_employee_id": employee_id},
[ServiceEvent("event", {"type": "agent_created", "agent_id": agent.get("agent_id"), "data": agent})],
)
async def delete(self, agent_id: str) -> ServiceResult:
removed = await self.context.agent_store.remove_agent(agent_id)
if not removed:
raise ServiceError("agent_not_found", "Agent not found", {"agent_id": agent_id})
role_id = removed.get("opc_role_id", agent_id)
await self._clean_orphaned_assignments(role_id)
employee_id = removed.get("employee_id")
if self.context.mode_state.exec_mode in {"org", "custom"} and employee_id:
org = getattr(self.context.engine, "org_engine", None)
if org is not None:
async with self.context.config_lock:
employee = org.get_employee(employee_id) if hasattr(org, "get_employee") else None
prompt_refs = list(getattr(employee, "prompt_refs", []) or []) if employee else []
remover = getattr(org, "remove_employee", None)
if callable(remover):
remover(employee_id)
ensure_default = getattr(org, "ensure_default_employee_for_role", None)
if callable(ensure_default) and role_id:
ensure_default(role_id, persist=False)
for ref in prompt_refs:
if str(ref).startswith("prompts/custom/"):
(Path(getattr(self.context.engine, "opc_home", self.context.opc_home)) / ref).unlink(missing_ok=True)
self._persist_config()
if self.context.mode_state.exec_mode in {"org", "custom"}:
if self.context.ensure_custom_role_agents is not None and removed.get("employee_id"):
await self.context.ensure_custom_role_agents()
await self.context.agent_store.sync_custom_shadow()
if self.context.sync_role_map is not None:
await self.context.sync_role_map()
events = [ServiceEvent("event", {
"event_id": str(uuid.uuid4()),
"type": "agent_removed",
"agent_id": agent_id,
"data": {},
"timestamp": time.time(),
})]
return ServiceResult({"agents": await self.context.agent_store.get_all(), "deleted": agent_id}, events)
async def move(self, *, agent_id: str, office_id: str, seat_zone: str | None = None, desk_id: str | None = None) -> ServiceResult:
agent = await self.context.agent_store.move_agent(agent_id, office_id, seat_zone, desk_id)
if not agent:
raise ServiceError("agent_not_found", "Agent not found", {"agent_id": agent_id})
return ServiceResult({"agent": agent})
async def detail(self, *, project_id: str, agent_id: str) -> ServiceResult:
agents = await self.context.agent_store.get_all()
agent = next((a for a in agents if a.get("agent_id") == agent_id), None)
if not agent:
raise ServiceError("agent_not_found", "Agent not found", {"agent_id": agent_id})
engine = await self.context.engine_for_project(project_id)
task_history: list[dict[str, str]] = []
role_id = agent.get("opc_role_id", agent_id)
if getattr(engine, "store", None):
tasks = await engine.store.get_tasks(project_id=project_id)
task_history = [
{
"task_id": task.id,
"title": task.title,
"status": task.status.value if hasattr(task.status, "value") else str(task.status),
}
for task in tasks
if getattr(task, "assigned_to", "") == role_id
]
tracker = self.context.event_adapter.get_tracker(agent_id) if self.context.event_adapter else None
detail: dict[str, Any] = {
"agent_id": agent_id,
"name": agent.get("name", ""),
"role_name": agent.get("opc_role_id", ""),
"office_id": agent.get("office_id", ""),
"status": tracker.state.value if tracker else str(agent.get("status", "idle") or "idle"),
"current_task_id": tracker.task_id if tracker else agent.get("current_task_id"),
"current_tool": tracker.current_tool if tracker else agent.get("current_tool"),
"task_history": task_history,
"inbox_count": 0,
**agent,
}
employee_id = agent.get("employee_id")
if employee_id:
detail["employee_id"] = employee_id
org = getattr(engine, "org_engine", None)
try:
employee = org.get_employee(employee_id) if org and hasattr(org, "get_employee") else None
if employee:
employee_info: dict[str, Any] = {
"domains": list(getattr(employee, "domains", []) or []),
"seniority": getattr(employee, "seniority", "junior"),
"tags": list(getattr(employee, "tags", []) or []),
"category": getattr(employee, "category", ""),
}
evolution = getattr(org, "employee_evolution", None) if org else None
if evolution:
try:
employee_info["experience_score"] = evolution.get_experience_score(
employee.employee_id,
role_id=employee.role_id,
domains=list(getattr(employee, "domains", []) or []),
)
except Exception:
pass
detail["employee_info"] = employee_info
except Exception:
pass
return ServiceResult({"detail": detail})
async def _clean_orphaned_assignments(self, role_id: str) -> None:
store = getattr(self.context.engine, "store", None)
if not store:
return
try:
tasks = await store.get_tasks(project_id=getattr(self.context.engine, "project_id", None) or "default")
for task in tasks:
if getattr(task, "assigned_to", "") == role_id:
task.assigned_to = ""
await store.save_task(task)
except Exception:
pass
def _persist_config(self) -> None:
if self.context.persist_runtime_config is not None:
self.context.persist_runtime_config()
else:
self.context.engine.config.save()
+329
View File
@@ -0,0 +1,329 @@
"""Comms state service."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from opc.layer2_organization import comms as file_comms
from .context import OfficeServiceContext
from .models import ServiceError, ServiceResult
class CommsService:
def __init__(self, context: OfficeServiceContext) -> None:
self.context = context
async def state(self, *, project_id: str, task_id: str = "", session_id: str = "") -> ServiceResult:
engine = await self.context.engine_for_project(project_id)
store = getattr(engine, "store", None)
request_project_id = self.context.normalize_project_id(project_id)
if not self.context.store_is_ready(store):
return ServiceResult({"available": False, "reason": "store_not_ready", "project_id": request_project_id})
task = None
task_id = str(task_id or "").strip()
if task_id:
try:
task = await store.get_task(task_id)
except Exception:
task = None
resolved_project_id = self.context.normalize_project_id(
(getattr(task, "project_id", None) if task is not None else None) or request_project_id
)
session_id_hint = str(session_id or "").strip()
if task is not None and not session_id_hint:
session_id_hint = (
str(getattr(task, "parent_session_id", "") or "").strip()
or str(getattr(task, "session_id", "") or "").strip()
)
if task is None or not self._task_has_comms_workspace(task):
try:
tasks = await store.get_tasks(project_id=resolved_project_id)
except Exception:
tasks = []
def _ts(candidate: Any) -> float:
created_at = getattr(candidate, "created_at", None)
if created_at is None:
return 0.0
timestamp = getattr(created_at, "timestamp", None)
if callable(timestamp):
try:
return float(timestamp())
except Exception:
return 0.0
try:
return float(created_at)
except (TypeError, ValueError):
return 0.0
ranked = sorted(
tasks,
key=lambda candidate: (
0
if session_id_hint
and (
getattr(candidate, "parent_session_id", "") == session_id_hint
or getattr(candidate, "session_id", "") == session_id_hint
)
else 1,
-_ts(candidate),
),
)
for candidate in ranked:
metadata = dict(getattr(candidate, "metadata", {}) or {})
if (
str(metadata.get("comms_workspace_root") or "").strip()
or str(metadata.get("target_output_dir") or "").strip()
):
task = candidate
break
if task is None:
return ServiceResult({
"available": False,
"reason": "no_task_with_workspace",
"project_id": resolved_project_id,
})
metadata = dict(getattr(task, "metadata", {}) or {})
workspace_root = (
str(metadata.get("comms_workspace_root") or "").strip()
or str(metadata.get("target_output_dir") or "").strip()
or str(metadata.get("setup_workspace_prepared") or "").strip()
)
if not workspace_root:
return ServiceResult({
"available": False,
"reason": "no_workspace_root",
"project_id": resolved_project_id,
})
resolved_session_id = (
str(getattr(task, "parent_session_id", "") or "").strip()
or str(getattr(task, "session_id", "") or "").strip()
or session_id_hint
or "default"
)
try:
layout = file_comms.resolve_layout(workspace_root, resolved_project_id, resolved_session_id)
except Exception as exc:
return ServiceResult({
"available": False,
"reason": f"layout_error: {exc}",
"project_id": resolved_project_id,
})
base_payload = {
"project_id": resolved_project_id,
"session_id": resolved_session_id,
"workspace_root": workspace_root,
"output_root": str(metadata.get("output_root") or metadata.get("target_output_dir") or "").strip(),
"comms_root": str(layout.root),
}
if not layout.root.is_dir():
return ServiceResult({
"available": True,
"empty": True,
**base_payload,
"projection_status": "empty",
"recent_failures": list((getattr(task, "context_snapshot", {}) or {}).get("comms_failures", []) or [])[-5:],
"roles": [],
"meetings": [],
})
projection_status = "unknown"
try:
communication = getattr(engine, "communication", None)
if communication and hasattr(communication, "rebuild_comms_projection"):
await communication.rebuild_comms_projection(task=task, layout=layout)
projection_status = "synced"
except Exception as exc:
projection_status = f"projection_error: {exc}"
roles_payload: list[dict[str, Any]] = []
try:
role_dirs = sorted(
[path for path in layout.inbox_root.iterdir() if path.is_dir()],
key=lambda path: path.name,
) if layout.inbox_root.is_dir() else []
except OSError:
role_dirs = []
for role_dir in role_dirs:
role_id = role_dir.name
try:
unread_headers = file_comms.list_unread(layout, role_id, limit=8)
except Exception:
unread_headers = []
try:
seen_count = sum(
1 for path in (role_dir / "seen").iterdir()
if path.is_file() and path.suffix == ".md"
) if (role_dir / "seen").is_dir() else 0
except OSError:
seen_count = 0
try:
outbox_count = sum(
1 for path in (role_dir / "outbox").iterdir()
if path.is_file() and path.suffix == ".md"
) if (role_dir / "outbox").is_dir() else 0
except OSError:
outbox_count = 0
recent_seen: list[dict[str, Any]] = []
recent_outbox: list[dict[str, Any]] = []
try:
recent_seen = [
self._header_payload(header, "seen")
for header in file_comms.list_role_messages(
layout,
role_id,
include_new=False,
include_seen=True,
include_outbox=False,
limit=12,
)
]
except Exception:
pass
try:
recent_outbox = [
self._header_payload(header, "sent")
for header in file_comms.list_role_messages(
layout,
role_id,
include_new=False,
include_seen=False,
include_outbox=True,
limit=12,
)
]
except Exception:
pass
roles_payload.append({
"role_id": role_id,
"unread_count": len(unread_headers),
"has_blocking": any(bool(getattr(header, "blocking", False)) for header in unread_headers),
"seen_count": seen_count,
"outbox_count": outbox_count,
"recent_unread": [self._header_payload(header, "new") for header in unread_headers],
"recent_seen": recent_seen,
"recent_outbox": recent_outbox,
})
meetings_payload: list[dict[str, Any]] = []
try:
for state in file_comms.list_active_meetings(layout):
meetings_payload.append({
"meeting_id": state.meeting_id,
"topic": state.topic,
"status": state.status,
"organizer": state.organizer,
"participants": list(state.participants),
"entry_count": state.entry_count,
"opened_at": state.opened_at,
"transcript_path": str(state.transcript_path),
})
if layout.meetings_root.is_dir():
for child in sorted(layout.meetings_root.iterdir())[-10:]:
if not child.is_dir():
continue
state = file_comms.read_meeting_state(layout, child.name)
if state is None or state.status != "closed":
continue
meetings_payload.append({
"meeting_id": state.meeting_id,
"topic": state.topic,
"status": state.status,
"organizer": state.organizer,
"participants": list(state.participants),
"entry_count": state.entry_count,
"opened_at": state.opened_at,
"closed_at": state.closed_at,
"decision": state.decision,
"transcript_path": str(state.transcript_path),
})
except Exception:
pass
recent_failures: list[dict[str, Any]] = []
try:
session_tasks = await store.get_tasks(project_id=resolved_project_id)
except Exception:
session_tasks = []
for candidate in session_tasks:
candidate_root = (
str(getattr(candidate, "parent_session_id", "") or "").strip()
or str(getattr(candidate, "session_id", "") or "").strip()
)
if candidate_root != resolved_session_id:
continue
for failure in list((getattr(candidate, "context_snapshot", {}) or {}).get("comms_failures", []) or [])[-3:]:
if isinstance(failure, dict):
recent_failures.append(dict(failure))
return ServiceResult({
"available": True,
**base_payload,
"projection_status": projection_status,
"recent_failures": recent_failures[-8:],
"roles": roles_payload,
"meetings": meetings_payload,
})
async def read(self, *, project_id: str, task_id: str = "", path: str) -> ServiceResult:
if not str(path or "").strip():
raise ServiceError("path_required", "path_required")
candidate = Path(path).resolve()
if ".opc-comms" not in candidate.parts:
raise ServiceError("path_outside_comms", "path_outside_comms", {"path": path})
if not candidate.is_file():
raise ServiceError("not_a_file", "not_a_file", {"path": path})
try:
header, body = file_comms.read_message(candidate)
except Exception as exc:
raise ServiceError("read_error", f"read_error: {exc}", {"path": path}) from exc
return ServiceResult({
"project_id": self.context.normalize_project_id(project_id),
"task_id": task_id,
"path": str(candidate),
"header": getattr(header, "raw_frontmatter", {}) if header else {},
"message": self._header_payload(header),
"body": body,
})
@staticmethod
def _header_payload(header: Any, bucket: str = "") -> dict[str, Any]:
if header is None:
return {}
payload = {
"path": str(getattr(header, "path", "")),
"message_id": getattr(header, "message_id", ""),
"from": getattr(header, "from_role", ""),
"to": getattr(header, "to_role", ""),
"from_role": getattr(header, "from_role", ""),
"to_role": getattr(header, "to_role", ""),
"subject": getattr(header, "subject", ""),
"sent_at": getattr(header, "sent_at", ""),
"blocking": bool(getattr(header, "blocking", False)),
"priority": getattr(header, "priority", "normal"),
"tags": list(getattr(header, "tags", []) or []),
}
if bucket:
payload["bucket"] = bucket
return payload
@staticmethod
def _task_has_comms_workspace(task: Any | None) -> bool:
if task is None:
return False
metadata = dict(getattr(task, "metadata", {}) or {})
return bool(
str(metadata.get("comms_workspace_root") or "").strip()
or str(metadata.get("target_output_dir") or "").strip()
or str(metadata.get("setup_workspace_prepared") or "").strip()
or str(metadata.get("comms_root") or "").strip()
)
+201
View File
@@ -0,0 +1,201 @@
"""Shared Office service context.
This module intentionally owns runtime wiring that was previously duplicated or
buried inside the WebSocket handler: project validation, project-engine
delegation, active mode defaults, and access to UI persistence stores.
"""
from __future__ import annotations
import asyncio
import inspect
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Awaitable, Callable, Optional
from loguru import logger
from opc.core.config import get_project_workplace
LoadOrgConfigHook = Callable[[Optional[str]], bool]
SetActiveOrgHook = Callable[[str], Awaitable[None]]
GetActiveOrgHook = Callable[[], Awaitable[str]]
PersistRuntimeConfigHook = Callable[[], None]
RebindEngineConfigHook = Callable[[Any], None]
AsyncNoArgHook = Callable[[], Awaitable[Any]]
CancelSessionTasksHook = Callable[[str], None]
CancelTaskTreeHook = Callable[..., Awaitable[list[str]]]
RuntimeControlHook = Callable[..., Awaitable[Any]]
@dataclass
class ModeState:
exec_mode: str = "task"
company_profile: str = "corporate"
task_preferred_agent: str = "native"
class OfficeServiceContext:
"""Dependency holder shared by Office UI, CLI, and CLI board services."""
def __init__(
self,
*,
engine: Any,
agent_store: Any,
chat_store: Any,
event_adapter: Any,
mode_state: ModeState | None = None,
) -> None:
self.root_engine = engine
self.active_engine = engine
self.agent_store = agent_store
self.chat_store = chat_store
self.event_adapter = event_adapter
self.mode_state = mode_state or ModeState()
self.active_project_id = self.normalize_project_id(getattr(engine, "project_id", None))
self.project_switch_lock = asyncio.Lock()
self.config_lock = asyncio.Lock()
self.background_tasks: set[asyncio.Task[Any]] = set()
self.task_bg_map: dict[str, set[asyncio.Task[Any]]] = {}
self.task_bg_context: dict[asyncio.Task[Any], dict[str, Any]] = {}
self.session_to_task: dict[str, str] = {}
self.active_runtime_children: dict[str, str] = {}
self.stop_requested_task_ids: set[str] = set()
self.task_locks: dict[str, asyncio.Lock] = {}
self.task_lock_holders: dict[str, asyncio.Task[Any]] = {}
self.load_active_org_config: LoadOrgConfigHook | None = None
self.set_active_saved_org_name: SetActiveOrgHook | None = None
self.get_active_saved_org_name: GetActiveOrgHook | None = None
self.on_engine_activated: Callable[[Any, str], None] | None = None
self.persist_runtime_config: PersistRuntimeConfigHook | None = None
self.rebind_engine_config: RebindEngineConfigHook | None = None
self.sync_role_map: AsyncNoArgHook | None = None
self.ensure_custom_role_agents: AsyncNoArgHook | None = None
self.broadcast_snapshot: AsyncNoArgHook | None = None
self.cancel_session_tasks: CancelSessionTasksHook | None = None
self.cancel_task_tree: CancelTaskTreeHook | None = None
self.runtime_stop_hook: RuntimeControlHook | None = None
self.runtime_continue_hook: RuntimeControlHook | None = None
@property
def engine(self) -> Any:
return self.active_engine
@property
def opc_home(self) -> Path:
return Path(getattr(self.root_engine, "opc_home", Path.cwd() / ".opc"))
@staticmethod
def normalize_project_id(project_id: Any) -> str:
return str(project_id or "default").strip() or "default"
@staticmethod
def is_safe_project_id(project_id: str) -> bool:
return bool(re.match(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$", project_id or ""))
@staticmethod
def store_is_ready(store: Any) -> bool:
if store is None:
return False
ready = getattr(store, "is_ready", True)
return bool(ready)
def active_engine_project_id(self) -> str:
return self.normalize_project_id(getattr(self.active_engine, "project_id", None) or self.active_project_id)
def rebind_config(self, config: Any) -> None:
if self.rebind_engine_config is not None:
self.rebind_engine_config(config)
return
self.engine.config = config
org_engine = getattr(self.engine, "org_engine", None)
if org_engine is not None:
org_engine.config = config
talent_market = getattr(self.engine, "talent_market", None)
if talent_market is not None:
talent_market.config = config
if hasattr(self.engine, "_runtime_config_signature"):
self.engine._runtime_config_signature = None
def is_custom_org_editable(self) -> bool:
mode = str(getattr(self.mode_state, "exec_mode", "") or "").strip().lower()
profile = str(getattr(self.mode_state, "company_profile", "") or "").strip().lower()
cfg_org = getattr(getattr(self.engine, "config", None), "org", None)
cfg_profile = str(getattr(cfg_org, "company_profile", "") or "").strip().lower()
org_id = str(getattr(cfg_org, "organization_id", "") or "").strip().lower()
return (
mode in {"org", "custom"}
and profile == "custom"
and cfg_profile == "custom"
and org_id != "corporate"
)
def project_dir(self, project_id: str) -> Path:
return self.opc_home / "projects" / self.normalize_project_id(project_id)
def project_workplace(self, project_id: str) -> Path:
hook = getattr(self, "project_workplace_hook", None)
if callable(hook):
return Path(hook(self.normalize_project_id(project_id)))
return get_project_workplace(self.normalize_project_id(project_id))
def list_project_entries(self) -> list[dict[str, str]]:
projects_dir = self.opc_home / "projects"
projects: list[dict[str, str]] = []
if projects_dir.is_dir():
for entry in sorted(projects_dir.iterdir()):
if entry.is_dir():
projects.append({"id": entry.name, "name": entry.name})
if not any(project["id"] == "default" for project in projects):
projects.insert(0, {"id": "default", "name": "default"})
return projects
def project_exists(self, project_id: str) -> bool:
normalized = self.normalize_project_id(project_id)
if normalized == "default":
return True
return self.project_dir(normalized).is_dir()
async def engine_for_project(self, project_id: str) -> Any:
normalized = self.normalize_project_id(project_id)
root = self.root_engine
current_root_project = self.normalize_project_id(getattr(root, "project_id", None))
if normalized == current_root_project:
engine = root
else:
delegate_getter = getattr(root, "_get_project_delegate", None)
if not callable(delegate_getter):
raise RuntimeError("Project switching requires OPCEngine project delegates.")
maybe_engine = delegate_getter(normalized)
engine = await maybe_engine if inspect.isawaitable(maybe_engine) else maybe_engine
wire = getattr(self, "wire_engine_callbacks", None)
if callable(wire):
try:
wire(engine)
except Exception:
logger.debug("Failed to wire service project engine callbacks", exc_info=True)
return engine
async def activate_project(self, project_id: str) -> Any:
engine = await self.engine_for_project(project_id)
self.active_engine = engine
self.active_project_id = self.normalize_project_id(getattr(engine, "project_id", None) or project_id)
if self.on_engine_activated is not None:
self.on_engine_activated(engine, self.active_project_id)
else:
ensure_attachment_store = getattr(engine, "_ensure_attachment_store", None)
if callable(ensure_attachment_store):
ensure_attachment_store()
return engine
def get_task_lock(self, task_id: str) -> asyncio.Lock:
prev_holder = self.task_lock_holders.get(task_id)
if prev_holder is not None and prev_holder.done():
self.task_locks.pop(task_id, None)
self.task_lock_holders.pop(task_id, None)
lock = self.task_locks.get(task_id)
if lock is None:
lock = asyncio.Lock()
self.task_locks[task_id] = lock
return lock
+83
View File
@@ -0,0 +1,83 @@
"""Factory for shared Office services."""
from __future__ import annotations
from types import TracebackType
from typing import Any, Awaitable, Callable
import aiosqlite
from opc.core.config import OPCConfig, get_opc_home
from opc.engine import OPCEngine
from opc.plugins.office_ui.agent_store import AgentStore
from opc.plugins.office_ui.chat_store import ChatStore
from opc.plugins.office_ui.event_adapter import EventAdapter
from . import OfficeServices
from .context import ModeState, OfficeServiceContext
class OfficeServiceFactory:
"""Async context manager that owns engine and UI-state persistence."""
def __init__(
self,
*,
config: OPCConfig | None = None,
project_id: str | None = None,
on_progress: Callable[..., Awaitable[None]] | None = None,
on_runtime_event: Callable[[Any], Awaitable[None]] | None = None,
on_escalation: Callable[..., Awaitable[str | None]] | None = None,
) -> None:
self.config = config
self.project_id = project_id
self.on_progress = on_progress
self.on_runtime_event = on_runtime_event
self.on_escalation = on_escalation
self.db: aiosqlite.Connection | None = None
self.engine: OPCEngine | None = None
self.services: OfficeServices | None = None
async def __aenter__(self) -> OfficeServices:
if self.config is None:
config_dir = get_opc_home() / "config"
self.config = OPCConfig.load(config_dir) if config_dir.exists() else OPCConfig()
self.engine = OPCEngine(
config=self.config,
project_id=self.project_id,
on_progress=self.on_progress,
on_runtime_event=self.on_runtime_event,
on_escalation=self.on_escalation,
)
self.db = await aiosqlite.connect(str(self.engine.opc_home / "ui_state.db"))
agent_store = AgentStore(self.db)
await agent_store.initialize()
chat_store = ChatStore(self.db)
await chat_store.initialize()
event_adapter = EventAdapter()
await self.engine.initialize()
mode_state = ModeState(
exec_mode=await agent_store.get_server_state("exec_mode", "task"),
company_profile=await agent_store.get_server_state("company_profile", "corporate"),
task_preferred_agent=await agent_store.get_server_state("task_preferred_agent", "native"),
)
context = OfficeServiceContext(
engine=self.engine,
agent_store=agent_store,
chat_store=chat_store,
event_adapter=event_adapter,
mode_state=mode_state,
)
self.services = OfficeServices(context)
return self.services
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
if self.engine is not None:
await self.engine.shutdown()
if self.db is not None:
await self.db.close()
+107
View File
@@ -0,0 +1,107 @@
"""Kanban task service shared by Office UI and CLI."""
from __future__ import annotations
import uuid
from typing import Any
from opc.core.models import TaskStatus
from opc.layer2_organization.work_item_transition import apply_task_status_transition
from opc.presentation.kanban import column_to_task_status
from .context import OfficeServiceContext
from .models import ServiceError, ServiceEvent, ServiceResult
from .session import SessionService
class KanbanService:
TERMINAL_STATUSES: set[str] = {"done", "failed", "cancelled"}
def __init__(self, context: OfficeServiceContext, session_service: SessionService) -> None:
self.context = context
self.session_service = session_service
def _reject_system_driven(self) -> None:
if self.context.mode_state.exec_mode in {"company", "org", "custom"}:
raise ServiceError("company_mode_kanban_is_system_driven", "company_mode_kanban_is_system_driven")
async def create_task(self, *, project_id: str, title: str, description: str = "", task_id: str | None = None, board_id: str | None = None, assignee_ids: list[str] | None = None) -> ServiceResult:
self._reject_system_driven()
return await self.session_service.create(
project_id=project_id,
title=title or "Untitled",
description=description,
task_id=task_id or str(uuid.uuid4()),
exec_mode=self.context.mode_state.exec_mode,
company_profile=self.context.mode_state.company_profile,
preferred_agent=self.context.mode_state.task_preferred_agent,
interface="office_ui",
board_id=board_id,
assignee_ids=assignee_ids or [],
)
async def update_task(self, *, project_id: str, task_id: str, updates: dict[str, Any]) -> ServiceResult:
self._reject_system_driven()
engine = await self.context.engine_for_project(project_id)
if task_id and getattr(engine, "store", None):
task = await engine.store.get_task(task_id)
if task:
if "title" in updates:
task.title = updates["title"]
if "description" in updates:
task.description = updates["description"]
if "tags" in updates:
task.tags = updates["tags"]
await engine.store.save_task(task)
payload = {"project_id": self.context.normalize_project_id(project_id), "task_id": task_id}
return ServiceResult(payload, [ServiceEvent("kanban_updated", payload)])
async def move_task(self, *, project_id: str, task_id: str, column_id: str) -> ServiceResult:
self._reject_system_driven()
engine = await self.context.engine_for_project(project_id)
if task_id and column_id and getattr(engine, "store", None):
task = await engine.store.get_task(task_id)
if task:
current = task.status.value if hasattr(task.status, "value") else str(task.status)
new_status = column_to_task_status(column_id)
if not new_status:
raise ServiceError("invalid_column", f"Invalid column/status: {column_id}")
target = new_status.value if hasattr(new_status, "value") else str(new_status)
if current in self.TERMINAL_STATUSES and target not in self.TERMINAL_STATUSES:
raise ServiceError("terminal_task", f"Cannot move {current} task back to {column_id}")
if current != target:
task.status = new_status
await engine.store.save_task(task)
payload = {"project_id": self.context.normalize_project_id(project_id), "task_id": task_id, "display_id": "", "column_name": column_id}
return ServiceResult(payload, [ServiceEvent("board_task_moved", payload)])
async def delete_task(self, *, project_id: str, task_id: str) -> ServiceResult:
self._reject_system_driven()
engine = await self.context.engine_for_project(project_id)
if task_id and getattr(engine, "store", None):
task = await engine.store.get_task(task_id)
if task:
await apply_task_status_transition(
engine.store,
task,
target_status_or_phase=TaskStatus.CANCELLED,
reason="kanban_delete_task",
release_claim=True,
)
payload = {"project_id": self.context.normalize_project_id(project_id), "task_id": task_id}
return ServiceResult(payload, [ServiceEvent("kanban_updated", payload)])
async def assign(self, *, project_id: str, task_id: str, agent_id: str) -> ServiceResult:
self._reject_system_driven()
engine = await self.context.engine_for_project(project_id)
if task_id and agent_id and getattr(engine, "store", None):
task = await engine.store.get_task(task_id)
if task:
agent = await self.context.agent_store._get_one(agent_id)
role_id = agent.get("opc_role_id", agent_id) if agent else agent_id
task.assigned_to = role_id
await engine.store.save_task(task)
return ServiceResult({"project_id": self.context.normalize_project_id(project_id), "task_id": task_id, "agent_id": agent_id})
async def status(self, *, project_id: str, task_id: str, status: str) -> ServiceResult:
return await self.move_task(project_id=project_id, task_id=task_id, column_id=status)
+262
View File
@@ -0,0 +1,262 @@
"""OPC Market service."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from .context import OfficeServiceContext
from .models import ServiceError, ServiceEvent, ServiceResult
class MarketService:
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 browse(self) -> ServiceResult:
from opc.market.architecture_registry import get_all_presets
return ServiceResult({"presets": [
item.to_display_card() if hasattr(item, "to_display_card") else self._preset_payload(item)
for item in get_all_presets()
]})
async def preview(self, preset_id: str) -> ServiceResult:
from opc.market.architecture_registry import get_preset
preset = get_preset(preset_id)
if not preset:
raise ServiceError("preset_not_found", "Preset not found", {"preset_id": preset_id})
return ServiceResult(preset.to_detail() if hasattr(preset, "to_detail") else {"preset": self._preset_payload(preset)})
async def apply_preset(self, *, preset_id: str, strategy: str = "overwrite") -> ServiceResult:
from opc.market.architecture_registry import apply_architecture_preset_to_config, get_preset
self._ensure_custom_org_editable()
preset = get_preset(preset_id)
if not preset:
raise ServiceError("preset_not_found", f"Preset '{preset_id}' not found", {"preset_id": preset_id})
if strategy not in {"namespace", "overwrite"}:
raise ServiceError("invalid_strategy", "Strategy must be namespace or overwrite")
async with self.context.config_lock:
info = apply_architecture_preset_to_config(self.context.engine.config, preset_id, strategy=strategy, clear_existing=True)
role_ids = list(info.role_ids)
work_item_template_ids = list(info.work_item_template_ids or info.template_ids)
employee_ids = self._ensure_default_employees(role_ids)
self._persist_config()
events = await self._reload_custom_agents_for_roles(role_ids)
events.extend(await self._org_events())
return ServiceResult({
"ok": True,
"action": "market_preset_applied",
"package_id": preset_id,
"name": getattr(preset, "name", preset_id),
"roles": len(role_ids),
"work_item_templates": len(work_item_template_ids),
"employees": len(employee_ids),
}, events)
async def list_installed(self) -> ServiceResult:
packages = []
for package in list(getattr(self.context.engine.config.org, "installed_packages", []) or []):
if hasattr(package, "model_dump"):
packages.append(package.model_dump())
elif isinstance(package, dict):
packages.append(package)
return ServiceResult({"packages": packages})
async def export(self, *, package_id: str, name: str, description: str = "", version: str = "1.0.0", output_dir: str = ".") -> ServiceResult:
from opc.market import PackageExporter
if not package_id or not name:
raise ServiceError("missing_package_fields", "package_id and name required")
exporter = PackageExporter(self.context.engine.config, self.context.opc_home)
package = exporter.export_current(package_id=package_id, name=name, description=description, version=version)
out_path = exporter.write_to_path(package, Path(output_dir or self.context.opc_home / "exports"))
return ServiceResult({
"ok": True,
"action": "market_exported",
"path": str(out_path),
"package_id": package_id,
"roles": len(package.roles),
"templates": len(package.talent_templates),
})
async def install(self, *, path: str, strategy: str = "namespace") -> ServiceResult:
from opc.market import PackageLoader, SandboxChecker
self._ensure_custom_org_editable()
if not path:
raise ServiceError("missing_path", "path required")
loader = PackageLoader(self.context.engine.config, self.context.opc_home)
package = loader.load_from_path(Path(path))
report = SandboxChecker().validate(package)
if not report.passed:
raise ServiceError("package_security_failed", "Security check failed", {
"sandbox_errors": list(report.errors),
"sandbox_warnings": list(report.warnings),
})
async with self.context.config_lock:
info = loader.install(package, strategy=strategy)
employee_ids = self._ensure_default_employees(list(info.role_ids))
self._persist_config()
events = await self._reload_custom_agents_for_roles(list(info.role_ids))
events.extend(await self._org_events())
return ServiceResult({
"ok": True,
"action": "market_installed",
"package_id": info.package_id,
"name": info.name,
"roles": len(info.role_ids),
"templates": len(info.template_ids),
"employees": len(employee_ids),
"warnings": list(report.warnings),
}, events)
async def uninstall(self, package_id: str) -> ServiceResult:
from opc.market import PackageLoader
from opc.market.package_format import InstalledPackageInfo
self._ensure_custom_org_editable()
if not package_id:
raise ServiceError("missing_package_id", "package_id required")
removed_role_ids: set[str] = set()
for package in self.context.engine.config.org.installed_packages:
pid = package.package_id if isinstance(package, InstalledPackageInfo) else package.get("package_id", "")
if pid == package_id:
removed_role_ids = set(package.role_ids if isinstance(package, InstalledPackageInfo) else package.get("role_ids", []))
break
async with self.context.config_lock:
success = PackageLoader(self.context.engine.config, self.context.opc_home).uninstall(package_id)
if success:
org = getattr(self.context.engine, "org_engine", None)
if org:
org.reload_from_config()
await self._clean_orphaned_assignments(removed_role_ids)
self._persist_config()
if not success:
raise ServiceError("package_not_found", f"Package '{package_id}' not found", {"package_id": package_id})
events: list[ServiceEvent] = []
if removed_role_ids and self.context.agent_store is not None:
try:
agents = await self.context.agent_store.get_all()
to_remove = [agent for agent in agents if agent.get("opc_role_id") in removed_role_ids]
for agent in to_remove:
await self.context.agent_store.remove_agent(agent["agent_id"])
if to_remove:
if self.context.sync_role_map is not None:
await self.context.sync_role_map()
sync = getattr(self.context.agent_store, "sync_custom_shadow", None)
if callable(sync):
await sync()
events.append(ServiceEvent("ack", {
"ok": True,
"action": "agents_spawned",
"agents": await self.context.agent_store.get_all(),
}))
except Exception:
pass
events.extend(await self._org_events())
return ServiceResult({"ok": True, "action": "market_uninstalled", "package_id": package_id}, events)
def _persist_config(self) -> None:
org = getattr(self.context.engine, "org_engine", None)
if org:
org.reload_from_config()
if self.context.persist_runtime_config is not None:
self.context.persist_runtime_config()
else:
self.context.engine.config.save()
def _ensure_default_employees(self, role_ids: list[str]) -> list[str]:
org = getattr(self.context.engine, "org_engine", None)
if not org:
return []
employee_ids: list[str] = []
for role_id in role_ids:
try:
employee = org.ensure_default_employee_for_role(role_id, persist=False)
if employee:
employee_ids.append(employee.employee_id)
except Exception:
pass
return employee_ids
async def _reload_custom_agents_for_roles(self, role_ids: list[str]) -> list[ServiceEvent]:
if self.context.mode_state.exec_mode not in {"org", "custom"}:
return []
org = getattr(self.context.engine, "org_engine", None)
store = self.context.agent_store
if org is None or store is None:
return []
try:
db = getattr(store, "_db", None)
if db is not None:
await db.execute("DELETE FROM agents")
await db.execute("DELETE FROM custom_agents_shadow")
await db.commit()
loader = getattr(store, "_load_from_role_infos", None)
if callable(loader):
installed = set(role_ids)
roles_info = [role for role in org.list_agents() if role.role_id in installed]
if roles_info:
await loader(roles_info, "custom")
sync = getattr(store, "sync_custom_shadow", None)
if callable(sync):
await sync()
if self.context.sync_role_map is not None:
await self.context.sync_role_map()
return [ServiceEvent("ack", {"ok": True, "action": "agents_spawned", "agents": await store.get_all()})]
except Exception:
return []
async def _clean_orphaned_assignments(self, removed_role_ids: set[str]) -> None:
if not removed_role_ids:
return
store = getattr(self.context.engine, "store", None)
if not store:
return
try:
tasks = await store.get_tasks(project_id=getattr(self.context.engine, "project_id", None) or "default")
for task in tasks:
if getattr(task, "assigned_to", "") in removed_role_ids:
task.assigned_to = ""
await store.save_task(task)
except Exception:
pass
async def _org_events(self) -> list[ServiceEvent]:
from .org import OrgService
return (await OrgService(self.context).info(include_events=True)).events
@staticmethod
def _preset_payload(preset: Any) -> dict[str, Any]:
return {
"id": getattr(preset, "id", ""),
"name": getattr(preset, "name", ""),
"description": getattr(preset, "description", ""),
"category": getattr(preset, "category", ""),
"roles": len(getattr(preset, "roles", []) or []),
"templates": len(getattr(preset, "work_item_templates", []) or []),
"collaboration_pattern": getattr(preset, "collaboration_pattern", ""),
}
@staticmethod
def _installed_payload(package: Any) -> dict[str, Any]:
if hasattr(package, "package_id"):
return {
"package_id": package.package_id,
"name": package.name,
"version": package.version,
"role_ids": list(package.role_ids),
"template_ids": list(package.template_ids),
}
return dict(package or {})
+35
View File
@@ -0,0 +1,35 @@
"""Shared service result types for Office UI, CLI, and CLI board surfaces."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass(frozen=True)
class ServiceEvent:
"""Outbound event to publish on UI transports or consume by CLI callers."""
type: str
payload: dict[str, Any] = field(default_factory=dict)
@dataclass
class ServiceResult:
"""Structured service response plus optional side-effect events."""
payload: dict[str, Any] = field(default_factory=dict)
events: list[ServiceEvent] = field(default_factory=list)
class ServiceError(Exception):
"""Expected business error from a shared service."""
def __init__(self, code: str, message: str, payload: dict[str, Any] | None = None) -> None:
super().__init__(message)
self.code = code
self.message = message
self.payload = dict(payload or {})
def to_payload(self) -> dict[str, Any]:
return {"error": self.message, "code": self.code, **self.payload}
+997
View File
@@ -0,0 +1,997 @@
"""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 {})
+358
View File
@@ -0,0 +1,358 @@
"""Project lifecycle service shared by Office UI and CLI."""
from __future__ import annotations
import asyncio
import shutil
import sqlite3
from pathlib import Path
from typing import Any
from loguru import logger
from opc.layer5_memory.markdown_memory import MarkdownMemoryStore
from opc.plugins.office_ui.snapshot_builder import build_collab_sync, build_project_index_sync, build_snapshot
from .context import OfficeServiceContext
from .models import ServiceEvent, ServiceError, ServiceResult
class ProjectService:
def __init__(self, context: OfficeServiceContext) -> None:
self.context = context
@staticmethod
def _quote_sql_identifier(name: str) -> str:
return '"' + str(name).replace('"', '""') + '"'
@classmethod
def _rewrite_project_id_in_sqlite(cls, db_path: Path, old_project_id: str, new_project_id: str) -> dict[str, int]:
if not db_path.exists():
return {}
counts: dict[str, int] = {}
conn = sqlite3.connect(str(db_path))
try:
rows = conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'").fetchall()
for (table_name,) in rows:
table = str(table_name or "")
if not table or table.startswith("sqlite_"):
continue
quoted = cls._quote_sql_identifier(table)
columns = conn.execute(f"PRAGMA table_info({quoted})").fetchall()
if not any(str(col[1]) == "project_id" for col in columns):
continue
cursor = conn.execute(
f"UPDATE {quoted} SET project_id = ? WHERE project_id = ?",
(new_project_id, old_project_id),
)
counts[table] = int(cursor.rowcount or 0)
conn.commit()
finally:
conn.close()
return counts
async def _close_project_engine_store(self, project_id: str) -> None:
root = self.context.root_engine
candidates: list[Any] = []
active = self.context.engine
if self.context.normalize_project_id(getattr(active, "project_id", None)) == project_id:
candidates.append(active)
if self.context.normalize_project_id(getattr(root, "project_id", None)) == project_id:
candidates.append(root)
delegates = getattr(root, "_project_engine_delegates", None)
if isinstance(delegates, dict):
delegate = delegates.pop(project_id, None)
if delegate is not None:
candidates.append(delegate)
seen: set[int] = set()
for engine in candidates:
marker = id(engine)
if marker in seen:
continue
seen.add(marker)
store = getattr(engine, "store", None)
close = getattr(store, "close", None)
if callable(close):
try:
maybe = close()
if asyncio.iscoroutine(maybe):
await maybe
except Exception:
logger.debug(f"Failed to close project store for {project_id}", exc_info=True)
async def list(self, *, active_project_id: str | None = None) -> ServiceResult:
active = active_project_id or self.context.active_engine_project_id()
return ServiceResult({
"projects": self.context.list_project_entries(),
"active_project_id": self.context.normalize_project_id(active),
})
async def create(self, project_id: str, *, active_project_id: str | None = None) -> ServiceResult:
project_id = str(project_id or "").strip()
if not project_id:
raise ServiceError("missing_project_id", "Missing project_id")
if not self.context.is_safe_project_id(project_id):
raise ServiceError("invalid_project_id", "Invalid project_id (use alphanumeric, hyphens, underscores)")
projects_dir = self.context.project_dir(project_id)
memory_store = MarkdownMemoryStore(Path(self.context.root_engine.opc_home))
memory_path = memory_store.memory_path(project_id)
workplace = self.context.project_workplace(project_id)
if projects_dir.exists() or memory_path.exists() or workplace.exists():
raise ServiceError("project_exists", f"Project '{project_id}' already exists")
projects_dir.mkdir(parents=True, exist_ok=False)
workplace.mkdir(parents=True, exist_ok=False)
memory_store.ensure_memory_file(project_id, f"# Project Memory ({project_id})")
active = active_project_id or self.context.active_engine_project_id()
return ServiceResult({
"action": "create_project",
"project_id": project_id,
"projects": self.context.list_project_entries(),
"active_project_id": self.context.normalize_project_id(active),
})
async def rename(self, old_project_id: str, new_project_id: str) -> ServiceResult:
old_id = str(old_project_id or "").strip()
new_id = str(new_project_id or "").strip()
if not old_id or not new_id:
raise ServiceError("missing_project_id", "Missing project_id")
if old_id == "default":
raise ServiceError("default_project", "Cannot rename the default project")
if new_id == "default":
raise ServiceError("invalid_project_id", "Cannot rename a project to 'default'")
if not self.context.is_safe_project_id(old_id) or not self.context.is_safe_project_id(new_id):
raise ServiceError("invalid_project_id", "Invalid project_id (use alphanumeric, hyphens, underscores)")
if old_id == new_id:
return ServiceResult({
"action": "rename_project",
"old_project_id": old_id,
"project_id": new_id,
"new_project_id": new_id,
"renamed": False,
"projects": self.context.list_project_entries(),
"active_project_id": self.context.active_engine_project_id(),
})
old_dir = self.context.project_dir(old_id)
new_dir = self.context.project_dir(new_id)
memory_store = MarkdownMemoryStore(Path(self.context.root_engine.opc_home))
old_memory = memory_store.memory_path(old_id)
new_memory = memory_store.memory_path(new_id)
old_workplace = self.context.project_workplace(old_id)
new_workplace = self.context.project_workplace(new_id)
old_exists = old_dir.is_dir() or old_memory.exists() or old_workplace.exists()
if not old_exists:
raise ServiceError("project_not_found", f"Project '{old_id}' does not exist", {"project_id": old_id})
if new_dir.exists() or new_memory.exists() or new_workplace.exists():
raise ServiceError("project_exists", f"Project '{new_id}' already exists", {"project_id": new_id})
chat_data_exists = getattr(self.context.chat_store, "project_data_exists", None)
if callable(chat_data_exists) and await chat_data_exists(new_id):
raise ServiceError("project_exists", f"Project '{new_id}' already has UI data", {"project_id": new_id})
was_active = self.context.active_engine_project_id() == old_id
if was_active:
for task in list(self.context.background_tasks):
task.cancel()
self.context.background_tasks.clear()
self.context.task_bg_map.clear()
self.context.task_bg_context.clear()
await self._close_project_engine_store(old_id)
if old_dir.is_dir():
new_dir.parent.mkdir(parents=True, exist_ok=True)
old_dir.rename(new_dir)
else:
new_dir.mkdir(parents=True, exist_ok=True)
if old_memory.exists():
new_memory.parent.mkdir(parents=True, exist_ok=True)
old_memory.rename(new_memory)
if old_workplace.exists():
new_workplace.parent.mkdir(parents=True, exist_ok=True)
old_workplace.rename(new_workplace)
db_counts = self._rewrite_project_id_in_sqlite(new_dir / "tasks.db", old_id, new_id)
chat_counts: dict[str, int] = {}
rename_chat = getattr(self.context.chat_store, "rename_project_data", None)
if callable(rename_chat):
try:
chat_counts = dict(await rename_chat(old_id, new_id) or {})
except ValueError as exc:
raise ServiceError("project_exists", str(exc), {"project_id": new_id}) from exc
active_id = self.context.active_engine_project_id()
events = [ServiceEvent("project_renamed", {"old_project_id": old_id, "project_id": new_id, "new_project_id": new_id})]
payload: dict[str, Any] = {
"action": "rename_project",
"old_project_id": old_id,
"project_id": new_id,
"new_project_id": new_id,
"renamed": True,
"projects": self.context.list_project_entries(),
"active_project_id": active_id,
"updated_task_tables": db_counts,
"updated_ui_rows": chat_counts,
}
if was_active:
engine = await self.context.activate_project(new_id)
await self.context.chat_store.ensure_activity_channel(project_id=new_id)
await self.context.chat_store.ensure_secretary_channel(project_id=new_id)
payload["active_project_id"] = new_id
payload["engine_project_id"] = getattr(engine, "project_id", new_id)
events.append(ServiceEvent("project_switched", {"project_id": new_id}))
snapshot = await build_snapshot(
self.context.engine,
self.context.agent_store,
self.context.chat_store,
self.context.event_adapter,
)
snapshot["exec_mode"] = self.context.mode_state.exec_mode
snapshot["company_profile"] = self.context.mode_state.company_profile
snapshot["task_preferred_agent"] = self.context.mode_state.task_preferred_agent
events.append(ServiceEvent("snapshot", snapshot))
collab = await build_collab_sync(
self.context.engine,
self.context.agent_store,
self.context.chat_store,
self.context.event_adapter,
exec_mode=self.context.mode_state.exec_mode,
)
events.append(ServiceEvent("collab_sync_push", collab))
return ServiceResult(payload, events)
async def delete(self, project_id: str) -> ServiceResult:
project_id = str(project_id or "").strip()
if not project_id or project_id == "default":
raise ServiceError("default_project", "Cannot delete the default project")
if not self.context.is_safe_project_id(project_id):
raise ServiceError("invalid_project_id", "Invalid project_id")
was_active = self.context.active_engine_project_id() == project_id
if was_active:
for task in list(self.context.background_tasks):
task.cancel()
self.context.background_tasks.clear()
self.context.task_bg_map.clear()
self.context.task_bg_context.clear()
deleted_channels = 0
delete_chat = getattr(self.context.chat_store, "delete_project_data", None)
if callable(delete_chat):
deleted_channels = int(await delete_chat(project_id) or 0)
logger.info(f"Deleted {deleted_channels} channels for project '{project_id}'")
projects_dir = self.context.project_dir(project_id)
if projects_dir.is_dir():
active_engine = self.context.engine
if was_active and getattr(active_engine, "store", None):
try:
await active_engine.store.close()
except Exception:
logger.debug("Failed to close active project store before delete", exc_info=True)
shutil.rmtree(str(projects_dir), ignore_errors=True)
workplace = self.context.project_workplace(project_id)
if workplace.is_dir():
shutil.rmtree(str(workplace), ignore_errors=True)
memory = getattr(self.context.engine, "memory", None)
if memory:
delete_fn = getattr(memory, "delete_project", None)
if callable(delete_fn):
try:
maybe = delete_fn(project_id)
if asyncio.iscoroutine(maybe):
await maybe
except Exception:
logger.debug(f"memory.delete_project failed for {project_id}", exc_info=True)
events = [ServiceEvent("project_deleted", {"project_id": project_id})]
payload: dict[str, Any] = {"project_id": project_id, "deleted_channels": deleted_channels}
if was_active:
self.context.project_dir("default").mkdir(parents=True, exist_ok=True)
await self.context.activate_project("default")
await self.context.chat_store.ensure_activity_channel(project_id="default")
await self.context.chat_store.ensure_secretary_channel(project_id="default")
payload["active_project_id"] = "default"
events.append(ServiceEvent("project_switched", {"project_id": "default"}))
snapshot = await build_snapshot(
self.context.engine,
self.context.agent_store,
self.context.chat_store,
self.context.event_adapter,
)
snapshot["exec_mode"] = self.context.mode_state.exec_mode
snapshot["company_profile"] = self.context.mode_state.company_profile
snapshot["task_preferred_agent"] = self.context.mode_state.task_preferred_agent
events.append(ServiceEvent("snapshot", snapshot))
collab = await build_collab_sync(
self.context.engine,
self.context.agent_store,
self.context.chat_store,
self.context.event_adapter,
exec_mode=self.context.mode_state.exec_mode,
)
events.append(ServiceEvent("collab_sync_push", collab))
return ServiceResult(payload, events)
async def switch(self, project_id: str, *, switch_seq: str = "", include_snapshot: bool = True) -> ServiceResult:
new_id = str(project_id or "").strip()
if not new_id:
raise ServiceError("missing_project_id", "Missing project_id")
if not self.context.is_safe_project_id(new_id):
raise ServiceError("invalid_project_id", "Invalid project_id")
async with self.context.project_switch_lock:
if new_id == "default":
self.context.project_dir(new_id).mkdir(parents=True, exist_ok=True)
self.context.project_workplace(new_id).mkdir(parents=True, exist_ok=True)
elif not self.context.project_dir(new_id).is_dir():
raise ServiceError("project_not_found", f"Project '{new_id}' does not exist", {"project_id": new_id, "switch_seq": switch_seq})
engine = await self.context.activate_project(new_id)
await self.context.chat_store.ensure_activity_channel(project_id=new_id)
await self.context.chat_store.ensure_secretary_channel(project_id=new_id)
events = [ServiceEvent("project_switched", {"project_id": new_id, "switch_seq": switch_seq})]
if include_snapshot:
index_payload = await self.project_index(new_id, switch_seq=switch_seq, include_snapshot=True)
for key in ("project_index", "snapshot"):
if key in index_payload.payload:
event_type = "project_index_push" if key == "project_index" else "snapshot"
events.append(ServiceEvent(event_type, index_payload.payload[key]))
return ServiceResult({"project_id": new_id, "switch_seq": switch_seq, "engine_project_id": getattr(engine, "project_id", new_id)}, events)
async def project_index(
self,
project_id: str,
*,
switch_seq: str = "",
view_generation: Any = None,
include_snapshot: bool = False,
) -> ServiceResult:
engine = await self.context.engine_for_project(project_id)
index_payload = await build_project_index_sync(
engine,
self.context.agent_store,
self.context.chat_store,
self.context.event_adapter,
exec_mode=self.context.mode_state.exec_mode,
)
index_payload["project_id"] = self.context.normalize_project_id(project_id)
index_payload["switch_seq"] = switch_seq
if view_generation is not None:
index_payload["view_generation"] = view_generation
payload: dict[str, Any] = {"project_index": index_payload}
if include_snapshot:
snapshot = await build_snapshot(
engine,
self.context.agent_store,
self.context.chat_store,
self.context.event_adapter,
)
snapshot["project_id"] = self.context.normalize_project_id(project_id)
snapshot["exec_mode"] = self.context.mode_state.exec_mode
snapshot["company_profile"] = self.context.mode_state.company_profile
snapshot["task_preferred_agent"] = self.context.mode_state.task_preferred_agent
snapshot["switch_seq"] = switch_seq
if view_generation is not None:
snapshot["view_generation"] = view_generation
payload["snapshot"] = snapshot
return ServiceResult(payload)
+247
View File
@@ -0,0 +1,247 @@
"""Runtime and global execution-mode service."""
from __future__ import annotations
from typing import Any
from opc.plugins.office_ui.snapshot_builder import build_collab_sync, build_snapshot
from .context import OfficeServiceContext
from .models import ServiceError, ServiceEvent, ServiceResult
from .session import SessionService
class RuntimeService:
def __init__(self, context: OfficeServiceContext, session_service: SessionService) -> None:
self.context = context
self.session_service = session_service
async def mode_show(self) -> ServiceResult:
active_org = ""
if self.context.mode_state.exec_mode == "org" and self.context.get_active_saved_org_name is not None:
active_org = await self.context.get_active_saved_org_name()
return ServiceResult({
"mode": self.context.mode_state.exec_mode,
"profile": self.context.mode_state.company_profile,
"org_id": active_org,
"preferred_agent": self.context.mode_state.task_preferred_agent,
})
async def status(self, *, project_id: str, limit: int = 50) -> ServiceResult:
engine = await self.context.engine_for_project(project_id)
store = getattr(engine, "store", None)
payload: dict[str, Any] = {
"project_id": project_id,
"mode": self.context.mode_state.exec_mode,
"profile": self.context.mode_state.company_profile,
"preferred_agent": self.context.mode_state.task_preferred_agent,
"active_tasks": [],
"runtime_sessions": [],
"external_sessions": [],
"checkpoints": [],
}
if not self.context.store_is_ready(store):
payload["available"] = False
payload["reason"] = "store_not_ready"
return ServiceResult(payload)
from opc.core.models import TaskStatus
terminal = {TaskStatus.DONE, TaskStatus.FAILED, TaskStatus.CANCELLED}
tasks = await store.get_tasks(project_id=project_id) if hasattr(store, "get_tasks") else []
payload["active_tasks"] = [
{
"task_id": getattr(task, "id", ""),
"title": getattr(task, "title", ""),
"status": getattr(getattr(task, "status", None), "value", getattr(task, "status", "")),
"session_id": getattr(task, "session_id", ""),
"assigned_to": getattr(task, "assigned_to", ""),
}
for task in tasks
if getattr(task, "status", None) not in terminal
][:limit]
if hasattr(store, "list_runtime_sessions"):
payload["runtime_sessions"] = await store.list_runtime_sessions(project_id=project_id, limit=limit)
if hasattr(store, "list_external_sessions"):
payload["external_sessions"] = await store.list_external_sessions(project_id=project_id, limit=limit)
if hasattr(store, "get_pending_checkpoints"):
payload["checkpoints"] = await store.get_pending_checkpoints(project_id=project_id)
payload["checkpoints"] = payload["checkpoints"][:limit]
return ServiceResult(payload)
async def mode_set(
self,
*,
mode: str,
profile: str = "corporate",
preferred_agent: str | None = None,
org_id: str | None = None,
sync_config: bool = True,
) -> ServiceResult:
new_mode = self.session_service.normalize_exec_mode(mode)
normalized_org_id = self.session_service.normalize_org_id(org_id)
if new_mode == "org":
profile = "custom"
if sync_config and normalized_org_id and self.context.load_active_org_config:
if not self.context.load_active_org_config(normalized_org_id):
raise ServiceError("org_not_found", "org_not_found", {"org_id": normalized_org_id})
if self.context.set_active_saved_org_name:
await self.context.set_active_saved_org_name(normalized_org_id)
else:
normalized_org_id = ""
profile = self.session_service.normalize_company_profile(profile)
if new_mode == "company" and profile == "custom":
profile = "corporate"
agent = self.session_service.normalize_preferred_agent(
preferred_agent if preferred_agent is not None else self.context.mode_state.task_preferred_agent,
default=self.context.mode_state.task_preferred_agent,
)
self.context.mode_state.exec_mode = new_mode
self.context.mode_state.company_profile = profile
self.context.mode_state.task_preferred_agent = agent
if self.context.agent_store:
await self.context.agent_store.set_server_state("exec_mode", new_mode)
await self.context.agent_store.set_server_state("company_profile", profile)
await self.context.agent_store.set_server_state("task_preferred_agent", agent)
if getattr(self.context.engine, "org_engine", None) and self.context.agent_store:
await self.context.agent_store.load_preset("custom" if new_mode == "org" else profile, self.context.engine.org_engine)
snapshot = await build_snapshot(
self.context.engine,
self.context.agent_store,
self.context.chat_store,
self.context.event_adapter,
)
snapshot["exec_mode"] = new_mode
snapshot["company_profile"] = profile
snapshot["task_preferred_agent"] = agent
collab = await build_collab_sync(
self.context.engine,
self.context.agent_store,
self.context.chat_store,
self.context.event_adapter,
exec_mode=new_mode,
)
payload = {"mode": new_mode, "profile": profile, "org_id": normalized_org_id, "preferred_agent": agent}
return ServiceResult(payload, [ServiceEvent("snapshot", snapshot), ServiceEvent("collab_sync_push", collab)])
async def run_task(self, *, project_id: str, task_id: str) -> ServiceResult:
engine = await self.context.engine_for_project(project_id)
task = await engine.store.get_task(task_id) if getattr(engine, "store", None) else None
if not task:
raise ServiceError("task_not_found", "task_not_found", {"task_id": task_id})
prompt = f"{getattr(task, 'title', '')}\n{getattr(task, 'description', '')}".strip()
return await self.session_service.send(
project_id=project_id,
task_id=task_id,
content=prompt,
)
async def checkpoints(self, *, project_id: str, limit: int = 50) -> ServiceResult:
engine = await self.context.engine_for_project(project_id)
store = getattr(engine, "store", None)
checkpoints = await store.get_pending_checkpoints(project_id=project_id) if store and hasattr(store, "get_pending_checkpoints") else []
return ServiceResult({"project_id": project_id, "checkpoints": checkpoints[-limit:]})
async def logs(self, *, project_id: str, task_id: str, limit: int = 100) -> ServiceResult:
engine = await self.context.engine_for_project(project_id)
store = getattr(engine, "store", None)
task = await store.get_task(task_id) if store else None
if not task:
raise ServiceError("task_not_found", "task_not_found", {"task_id": task_id})
metadata = dict(getattr(task, "metadata", {}) or {})
transcript = await store.get_session_transcript(task.session_id) if getattr(task, "session_id", None) else []
runtime_sessions = []
runtime_events: list[dict[str, Any]] = []
if hasattr(store, "list_runtime_sessions"):
runtime_sessions = await store.list_runtime_sessions(project_id=project_id, task_id=task_id, limit=limit)
if runtime_sessions and hasattr(store, "list_runtime_events"):
for session in runtime_sessions:
runtime_id = str(session.get("runtime_session_id", "") or "")
if runtime_id:
runtime_events.extend(await store.list_runtime_events(runtime_id, limit=limit))
enriched_events = [self._runtime_event_payload(event) for event in runtime_events[-limit:]]
return ServiceResult({
"project_id": project_id,
"task_id": task_id,
"target": {
"task_id": task_id,
"session_id": str(getattr(task, "session_id", "") or ""),
"title": str(getattr(task, "title", "") or ""),
"status": str(getattr(getattr(task, "status", None), "value", getattr(task, "status", "")) or ""),
"role_id": str(metadata.get("role_id") or getattr(task, "assigned_to", "") or ""),
"agent_id": str(metadata.get("agent_id") or metadata.get("preferred_agent") or ""),
"work_item_id": str(
metadata.get("work_item_id")
or metadata.get("linked_work_item_id")
or ""
),
},
"transcript": transcript[-limit:],
"runtime_sessions": runtime_sessions,
"runtime_events": enriched_events,
})
@staticmethod
def _runtime_event_payload(event: Any) -> dict[str, Any]:
if isinstance(event, dict):
payload = dict(event)
elif hasattr(event, "model_dump"):
payload = dict(event.model_dump())
else:
payload = dict(getattr(event, "__dict__", {}) or {})
event_type = str(payload.get("event_type") or payload.get("type") or "")
raw_payload = payload.get("payload")
if isinstance(raw_payload, dict):
tool_name = str(raw_payload.get("tool_name") or raw_payload.get("name") or "")
summary = str(raw_payload.get("summary") or raw_payload.get("result_summary") or raw_payload.get("text") or "")
else:
tool_name = ""
summary = ""
display_parts = [part for part in (event_type, tool_name, summary) if part]
payload["display_text"] = " | ".join(display_parts)
payload["event_type"] = event_type
return payload
async def recovery_scan(self, *, project_id: str) -> ServiceResult:
manager = await self._recovery_manager(project_id)
from opc.plugins.office_ui.recovery_manager import _serialize_status
status = await manager.get_recovery_status()
return ServiceResult(_serialize_status(status, project_id=project_id))
async def recovery_action(self, *, project_id: str, action: str, parent_task_id: str) -> ServiceResult:
manager = await self._recovery_manager(project_id)
normalized = str(action or "").strip().lower()
if normalized == "scan":
return await self.recovery_scan(project_id=project_id)
if not str(parent_task_id or "").strip():
raise ServiceError("parent_task_id_required", "parent_task_id required")
if normalized in {"resume", "retry"}:
payload = await manager.resume(parent_task_id)
elif normalized == "cancel":
payload = await manager.cancel(parent_task_id)
else:
raise ServiceError("unknown_recovery_action", f"unknown action: {action}", {"action": action})
payload = {**dict(payload), "project_id": project_id, "parent_task_id": parent_task_id, "action": normalized}
if not payload.get("ok", False):
raise ServiceError(str(payload.get("error") or "recovery_failed"), str(payload.get("error") or "recovery_failed"), payload)
return ServiceResult(payload)
async def _recovery_manager(self, project_id: str) -> Any:
engine = await self.context.engine_for_project(project_id)
async def _noop_broadcast(_event: dict[str, Any]) -> None:
return None
managers = getattr(self.context, "recovery_managers", None)
if managers is None:
managers = {}
setattr(self.context, "recovery_managers", managers)
key = self.context.normalize_project_id(project_id)
existing = managers.get(key)
if existing is not None and getattr(existing, "_engine", None) is engine:
return existing
from opc.plugins.office_ui.recovery_manager import RuntimeRecoveryManager
manager = RuntimeRecoveryManager(engine, _noop_broadcast)
managers[key] = manager
return manager
File diff suppressed because it is too large Load Diff
+264
View File
@@ -0,0 +1,264 @@
"""Talent market service."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from opc.layer2_organization.talent_market import TalentMarket
from .context import OfficeServiceContext
from .models import ServiceError, ServiceEvent, ServiceResult
class TalentService:
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.",
)
@property
def market(self) -> TalentMarket:
return TalentMarket(self.context.opc_home, self.context.engine.config)
async def list(self) -> ServiceResult:
market = self.market
templates = list(market.list_templates())
known = {getattr(item, "id", "") for item in templates}
try:
for item in market.scan_local_talent():
if getattr(item, "id", "") not in known:
templates.append(item)
known.add(getattr(item, "id", ""))
except Exception:
pass
payloads = [self._template_payload(item) for item in templates]
try:
from opc.market.talent_presets import get_all_talent_presets
for preset in get_all_talent_presets():
preset_id = str(preset.get("id", "") or "")
if preset_id and preset_id not in known:
payloads.append({
"template_id": preset_id,
"id": preset_id,
"name": preset.get("name", preset_id),
"description": preset.get("description", ""),
"category": preset.get("category", ""),
"domains": list(preset.get("domains", []) or []),
"tags": list(preset.get("tags", []) or []),
"preferred_external_agent": preset.get("preferred_external_agent"),
"source_repo": "builtin",
"emoji": preset.get("emoji", ""),
"color": preset.get("color", ""),
"vibe": preset.get("vibe", ""),
})
except Exception:
pass
payloads.sort(key=lambda item: (str(item.get("category", "")), str(item.get("name", "")).lower()))
return ServiceResult({"templates": payloads, "talent_dir": str(market.opc_home / "prompts" / "talent")})
async def employees(self) -> ServiceResult:
employees = self.market.list_employees()
return ServiceResult({"employees": [self._employee_payload(item) for item in employees]})
async def scan(self) -> ServiceResult:
templates = self.market.scan_local_talent()
return ServiceResult({"templates": [self._template_payload(item) for item in templates]})
async def import_repo(self, path: str) -> ServiceResult:
self._ensure_custom_org_editable()
repo_path = Path(path).expanduser().resolve()
if not repo_path.is_dir():
raise ServiceError("directory_not_found", f"directory not found: {path}", {"path": path})
imported = self.market.import_from_repo(repo_path)
self._persist_config()
return ServiceResult({"action": "talent_imported", "imported": [self._template_payload(item) for item in imported], "count": len(imported)})
async def import_selected(self, template_ids: list[str]) -> ServiceResult:
self._ensure_custom_org_editable()
if not template_ids:
raise ServiceError("missing_template_ids", "No templates selected")
imported = self.market.import_local_templates(template_ids)
self._persist_config()
return ServiceResult({"count": len(imported), "imported": [self._template_payload(item) for item in imported]})
async def _load_target_org_for_hire(self, organization_id: str | None = None) -> None:
target_org_id = str(organization_id or "").strip()
if not target_org_id and self.context.get_active_saved_org_name is not None:
try:
target_org_id = str(await self.context.get_active_saved_org_name() or "").strip()
except Exception:
target_org_id = ""
if not target_org_id:
return
cfg_org = getattr(getattr(self.context.engine, "config", None), "org", None)
current_org_id = str(getattr(cfg_org, "organization_id", "") or "").strip()
current_profile = str(getattr(cfg_org, "company_profile", "") or "").strip().lower()
if current_org_id == target_org_id and current_profile == "custom":
return
if self.context.load_active_org_config is None:
return
try:
loaded = self.context.load_active_org_config(target_org_id)
except Exception as exc:
raise ServiceError(
"saved_org_load_failed",
f"Failed to load organization '{target_org_id}' before hiring.",
{"organization_id": target_org_id},
) from exc
if not loaded:
raise ServiceError(
"saved_org_not_found",
f"Organization '{target_org_id}' is not available for hiring.",
{"organization_id": target_org_id},
)
async def hire(
self,
*,
template_id: str,
role_id: str,
employee_name: str | None = None,
employee_id: str | None = None,
organization_id: str | None = None,
) -> ServiceResult:
await self._load_target_org_for_hire(organization_id)
self._ensure_custom_org_editable()
if not template_id or not role_id:
raise ServiceError("missing_hire_fields", "template_id and role_id required")
role_exists = any(
str(getattr(role, "id", getattr(role, "role_id", "")) or "") == role_id
for role in getattr(self.context.engine.config.org, "roles", []) or []
)
org = getattr(self.context.engine, "org_engine", None)
if not role_exists and org and hasattr(org, "get_agent"):
role_exists = bool(org.get_agent(role_id))
if not role_exists:
raise ServiceError("role_not_found", f"Role '{role_id}' does not exist", {"role_id": role_id})
displaced_placeholder_ids = [
item.employee_id
for item in getattr(self.context.engine.config.org, "employees", []) or []
if item.role_id == role_id
and (
dict(getattr(item, "metadata", {}) or {}).get("is_default_employee")
or dict(getattr(item, "metadata", {}) or {}).get("is_fallback_employee")
)
]
try:
employee = self.market.hire_template(template_id, role_id, employee_name=employee_name, employee_id=employee_id)
self._persist_config()
except Exception as exc:
raise ServiceError("talent_hire_failed", str(exc), {"template_id": template_id, "role_id": role_id}) from exc
for displaced_id in displaced_placeholder_ids:
try:
remover = getattr(self.context.agent_store, "remove_agent", None)
if callable(remover):
await remover(f"emp-{displaced_id}")
except Exception:
pass
employee_payload = self._employee_payload(employee)
payload: dict[str, Any] = {
"ok": True,
"action": "talent_hired",
"employee_id": employee.employee_id,
"name": employee.name,
"role_id": employee.role_id,
"employee": employee_payload,
}
events: list[ServiceEvent] = []
if self.context.mode_state.exec_mode in {"org", "custom"}:
try:
agents: list[dict[str, Any]] = []
if self.context.ensure_custom_role_agents is not None:
agents = list(await self.context.ensure_custom_role_agents() or [])
else:
creator = getattr(self.context.agent_store, "create_agent_from_employee", None)
if callable(creator):
await creator(employee_payload)
if self.context.sync_role_map is not None:
await self.context.sync_role_map()
sync = getattr(self.context.agent_store, "sync_custom_shadow", None)
if callable(sync):
await sync()
if not agents:
getter = getattr(self.context.agent_store, "get_all", None)
agents = await getter() if callable(getter) else []
events.append(ServiceEvent("ack", {"ok": True, "action": "agent_spawned", "agents": agents}))
payload["deploy_ok"] = True
except Exception as exc:
payload["deploy_ok"] = False
payload["deploy_error"] = str(exc)
return ServiceResult(payload, events)
async def employee_detail(self, employee_id: str) -> ServiceResult:
employee = next((item for item in self.market.list_employees() if item.employee_id == employee_id), None)
if not employee:
raise ServiceError("employee_not_found", "Employee not found", {"employee_id": employee_id})
payload = self._employee_payload(employee)
org = getattr(self.context.engine, "org_engine", None)
evolution = getattr(org, "employee_evolution", None) if org else None
if evolution:
try:
payload["experience_score"] = evolution.get_experience_score(
employee.employee_id,
role_id=employee.role_id,
domains=list(getattr(employee, "domains", []) or []),
)
payload["learned_skill_refs"] = evolution.get_learned_skill_refs(employee.employee_id)
payload["delta_context"] = evolution.build_employee_delta_context(employee.employee_id)
payload["profile"] = evolution.get_employee_profile(employee.employee_id)
except Exception:
pass
return ServiceResult({"employee": payload})
async def import_employee_as_agent(self, *, employee_id: str, office_id: str = "office-0") -> ServiceResult:
from .agent import AgentService
return await AgentService(self.context).import_employee(employee_id=employee_id, office_id=office_id)
def _persist_config(self) -> None:
if self.context.persist_runtime_config is not None:
self.context.persist_runtime_config()
else:
self.context.engine.config.save()
@staticmethod
def _template_payload(template: Any) -> dict[str, Any]:
return {
"template_id": getattr(template, "id", ""),
"id": getattr(template, "id", ""),
"name": getattr(template, "name", ""),
"description": getattr(template, "description", ""),
"category": getattr(template, "category", ""),
"domains": list(getattr(template, "domains", []) or []),
"tags": list(getattr(template, "tags", []) or []),
"preferred_external_agent": getattr(template, "preferred_external_agent", None),
"source_repo": getattr(template, "source_repo", ""),
"emoji": getattr(template, "emoji", "") or "",
"color": getattr(template, "color", "") or "",
"vibe": getattr(template, "vibe", "") or "",
}
@staticmethod
def _employee_payload(employee: Any) -> dict[str, Any]:
return {
"employee_id": getattr(employee, "employee_id", ""),
"name": getattr(employee, "name", ""),
"role_id": getattr(employee, "role_id", ""),
"category": getattr(employee, "category", ""),
"domains": list(getattr(employee, "domains", []) or []),
"seniority": getattr(employee, "seniority", "junior"),
"status": getattr(employee, "status", "active"),
"tags": list(getattr(employee, "tags", []) or []),
"prompt_refs": list(getattr(employee, "prompt_refs", []) or []),
"skill_refs": list(getattr(employee, "skill_refs", []) or []),
"preferred_external_agent": getattr(employee, "preferred_external_agent", None),
}
+522
View File
@@ -0,0 +1,522 @@
"""Company work-item read service."""
from __future__ import annotations
from collections import Counter, defaultdict
from typing import Any
from opc.layer2_organization.phase import coerce_phase, kanban_column, should_hide_work_item_from_company_kanban
from opc.layer2_organization.work_item_links import task_by_linked_work_item_id
from .context import OfficeServiceContext
from .models import ServiceError, ServiceResult
class WorkItemService:
def __init__(self, context: OfficeServiceContext) -> None:
self.context = context
async def list(
self,
*,
project_id: str,
session_id: str | None = None,
role_id: str | None = None,
status: str | None = None,
limit: int = 100,
kanban_visible_only: bool = False,
) -> ServiceResult:
session_id = str(session_id or "").strip()
engine = await self.context.engine_for_project(project_id)
store = getattr(engine, "store", None)
if not self.context.store_is_ready(store):
raise ServiceError("store_not_ready", "store_not_ready", {"project_id": project_id})
runs = await self._runs(store, project_id, session_id=session_id or None)
tasks = await store.get_tasks(project_id=project_id) if hasattr(store, "get_tasks") else []
hydrate = getattr(store, "hydrate_task_work_item_links", None)
if callable(hydrate):
await hydrate(tasks)
linked_tasks = task_by_linked_work_item_id(tasks)
rows: list[dict[str, Any]] = []
for run in runs:
run_id = str(getattr(run, "run_id", "") or "")
if not run_id:
continue
for item in await store.list_delegation_work_items(run_id):
linked_task = linked_tasks.get(str(getattr(item, "work_item_id", "") or ""))
if session_id and not self._matches_session_scope(session_id, run=run, item=item, linked_task=linked_task):
continue
if kanban_visible_only and not self._is_company_kanban_visible(item):
continue
payload = self._work_item_payload(item, run=run, linked_task=linked_task)
if role_id and payload.get("role_id") != role_id:
continue
if status and payload.get("phase") != status and payload.get("kanban_column") != status:
continue
rows.append(payload)
rows.sort(key=lambda item: str(item.get("updated_at", "")), reverse=True)
return ServiceResult({
"project_id": project_id,
"session_id": session_id,
"work_items": rows[: max(1, int(limit or 100))],
})
async def show(self, *, project_id: str, work_item_id: str, limit: int = 100) -> ServiceResult:
engine = await self.context.engine_for_project(project_id)
store = getattr(engine, "store", None)
if not self.context.store_is_ready(store):
raise ServiceError("store_not_ready", "store_not_ready", {"project_id": project_id})
runs = await self._runs(store, project_id)
tasks = await store.get_tasks(project_id=project_id) if hasattr(store, "get_tasks") else []
hydrate = getattr(store, "hydrate_task_work_item_links", None)
if callable(hydrate):
await hydrate(tasks)
linked_tasks = task_by_linked_work_item_id(tasks)
target = None
target_run = None
for run in runs:
run_id = str(getattr(run, "run_id", "") or "")
for item in await store.list_delegation_work_items(run_id):
if str(getattr(item, "work_item_id", "") or "") == work_item_id:
target = item
target_run = run
break
if target is not None:
break
if target is None:
raise ServiceError("work_item_not_found", "Work item not found", {"work_item_id": work_item_id})
linked_task = linked_tasks.get(work_item_id)
logs = await self.logs(project_id=project_id, work_item_id=work_item_id, limit=limit)
return ServiceResult({
"project_id": project_id,
"work_item": self._work_item_payload(target, run=target_run, linked_task=linked_task),
"logs": logs.payload,
})
async def logs(
self,
*,
project_id: str,
session_id: str | None = None,
work_item_id: str = "",
role_id: str = "",
limit: int = 100,
) -> ServiceResult:
session_id = str(session_id or "").strip()
work_item_id = str(work_item_id or "").strip()
role_id = str(role_id or "").strip()
engine = await self.context.engine_for_project(project_id)
store = getattr(engine, "store", None)
if not self.context.store_is_ready(store):
raise ServiceError("store_not_ready", "store_not_ready", {"project_id": project_id})
runs = await self._runs(store, project_id, session_id=session_id or None)
tasks = await store.get_tasks(project_id=project_id) if hasattr(store, "get_tasks") else []
hydrate = getattr(store, "hydrate_task_work_item_links", None)
if callable(hydrate):
await hydrate(tasks)
linked_tasks = task_by_linked_work_item_id(tasks)
target_items: list[tuple[Any, Any, Any | None]] = []
target_work_item_ids: set[str] = set()
for run in runs:
run_id = str(getattr(run, "run_id", "") or "")
if not run_id:
continue
for item in await store.list_delegation_work_items(run_id):
item_id = str(getattr(item, "work_item_id", "") or "")
if work_item_id and item_id != work_item_id:
continue
if role_id and str(getattr(item, "role_id", "") or "") != role_id:
continue
linked_task = linked_tasks.get(item_id)
target_items.append((item, run, linked_task))
if item_id:
target_work_item_ids.add(item_id)
events: list[dict[str, Any]] = []
target_run_ids = {str(getattr(run, "run_id", "") or "") for run in runs}
for run_id in target_run_ids:
if hasattr(store, "list_delegation_events"):
for event in await store.list_delegation_events(run_id):
event_work_item_id = str(getattr(event, "work_item_id", "") or "")
event_role_id = str(getattr(event, "role_id", "") or "")
if work_item_id and event_work_item_id != work_item_id:
continue
if role_id and event_role_id != role_id and event_work_item_id not in target_work_item_ids:
continue
events.append(self._event_payload(event))
runtime_sessions: list[dict[str, Any]] = []
external_sessions: list[dict[str, Any]] = []
runtime_events: list[dict[str, Any]] = []
runtime_transcript_entries: list[dict[str, Any]] = []
runtime_tool_calls: list[dict[str, Any]] = []
runtime_tool_results: list[dict[str, Any]] = []
runtime_permission_grants: list[dict[str, Any]] = []
transcript: list[Any] = []
handoffs: list[Any] = []
runtime_rows_by_id: dict[str, dict[str, Any]] = {}
runtime_ids: set[str] = set()
external_keys: set[tuple[str, str, str]] = set()
def add_runtime_row(row: Any) -> None:
payload = self._model_payload(row)
runtime_id = str(payload.get("runtime_session_id", "") or "").strip()
if not runtime_id:
return
runtime_rows_by_id[runtime_id] = payload
runtime_ids.add(runtime_id)
def add_runtime_id(runtime_id: Any, *, task: Any = None, role_session: Any = None, source: str = "") -> None:
runtime_id = str(runtime_id or "").strip()
if not runtime_id:
return
runtime_ids.add(runtime_id)
if runtime_id in runtime_rows_by_id:
return
metadata = {"source": source} if source else {}
if role_session is not None:
metadata.update({"source": source or "role_runtime_session", "role_id": str(getattr(role_session, "role_id", "") or "")})
runtime_rows_by_id[runtime_id] = {
"runtime_session_id": runtime_id,
"project_id": project_id,
"session_id": str(getattr(task, "session_id", "") or session_id or ""),
"task_id": str(getattr(task, "id", "") or ""),
"status": str(getattr(role_session, "status", "") or ""),
"metadata": metadata,
"created_at": self._date_value(getattr(role_session, "created_at", None)),
"updated_at": self._date_value(getattr(role_session, "updated_at", None)),
}
def add_external_session(row: Any) -> None:
payload = self._model_payload(row)
key = (
str(payload.get("agent_type", "") or ""),
str(payload.get("session_id", "") or ""),
str(payload.get("task_id", "") or ""),
)
if key in external_keys:
return
external_keys.add(key)
external_sessions.append(payload)
metadata = dict(payload.get("metadata", {}) or {})
add_runtime_id(metadata.get("runtime_session_id"), source="external_session")
add_runtime_id(metadata.get("delegation_role_session_id"), source="external_role_session")
add_runtime_id(payload.get("opc_session_id"), source="external_opc_session")
for item, run, linked_task in target_items:
item_id = str(getattr(item, "work_item_id", "") or "")
if linked_task is not None:
linked_session_id = str(getattr(linked_task, "session_id", "") or "")
if linked_session_id and hasattr(store, "get_session_transcript"):
transcript.extend((await store.get_session_transcript(linked_session_id))[-limit:])
if hasattr(store, "list_runtime_sessions"):
for row in await store.list_runtime_sessions(project_id=project_id, task_id=getattr(linked_task, "id", ""), limit=limit):
add_runtime_row(row)
if linked_session_id:
for row in await store.list_runtime_sessions(project_id=project_id, session_id=linked_session_id, limit=limit):
add_runtime_row(row)
if hasattr(store, "list_external_sessions"):
for row in await store.list_external_sessions(project_id=project_id, task_id=getattr(linked_task, "id", ""), limit=limit):
add_external_session(row)
for runtime_id in self._task_runtime_session_ids(linked_task):
add_runtime_id(runtime_id, task=linked_task, source="task_metadata")
for runtime_id in self._work_item_runtime_session_ids(item):
add_runtime_id(runtime_id, task=linked_task, source="work_item")
if hasattr(store, "get_handoff_records") and item_id:
handoffs.extend(await store.get_handoff_records(project_id=project_id, target_work_item_id=item_id, limit=limit))
if hasattr(store, "list_role_runtime_sessions"):
for run in runs:
run_id = str(getattr(run, "run_id", "") or "")
if not run_id:
continue
try:
role_sessions = await store.list_role_runtime_sessions(run_id, role_id=role_id or None)
except TypeError:
role_sessions = await store.list_role_runtime_sessions(run_id)
if role_id:
role_sessions = [item for item in role_sessions if str(getattr(item, "role_id", "") or "") == role_id]
for role_session in role_sessions:
role_session_id = str(getattr(role_session, "role_session_id", "") or "")
focused = str(getattr(role_session, "focused_work_item_id", "") or "")
related = {focused}
related.update(str(item or "").strip() for item in list(getattr(role_session, "background_work_item_ids", []) or []))
related.update(str(item or "").strip() for item in list(getattr(role_session, "pending_work_item_ids", []) or []))
if work_item_id and work_item_id not in related:
continue
add_runtime_id(role_session_id, role_session=role_session, source="role_runtime_session")
if hasattr(store, "list_external_sessions") and role_session_id:
for row in await store.list_external_sessions(project_id=project_id, opc_session_id=role_session_id, limit=limit):
add_external_session(row)
for runtime_id in sorted(runtime_ids):
if hasattr(store, "list_runtime_events"):
runtime_events.extend(await store.list_runtime_events(runtime_id, limit=limit))
if hasattr(store, "list_runtime_transcript_entries"):
runtime_transcript_entries.extend((await store.list_runtime_transcript_entries(runtime_id))[-limit:])
if hasattr(store, "list_runtime_tool_calls"):
runtime_tool_calls.extend((await store.list_runtime_tool_calls(runtime_id))[-limit:])
if hasattr(store, "list_runtime_tool_results"):
runtime_tool_results.extend((await store.list_runtime_tool_results(runtime_id))[-limit:])
if hasattr(store, "list_runtime_permission_grants"):
runtime_permission_grants.extend((await store.list_runtime_permission_grants(runtime_session_id=runtime_id))[-limit:])
events.sort(key=lambda item: str(item.get("created_at", "")))
runtime_sessions = list(runtime_rows_by_id.values())
runtime_sessions.sort(key=lambda item: str(item.get("updated_at", "")), reverse=True)
return ServiceResult({
"project_id": project_id,
"session_id": session_id,
"work_item_id": work_item_id,
"role_id": role_id,
"work_items": [
self._work_item_payload(item, run=run, linked_task=linked_task)
for item, run, linked_task in target_items
][:limit],
"events": events[-limit:],
"runtime_sessions": runtime_sessions[:limit],
"external_sessions": external_sessions[-limit:],
"runtime_events": runtime_events[-limit:],
"runtime_transcript_entries": runtime_transcript_entries[-limit:],
"runtime_tool_calls": runtime_tool_calls[-limit:],
"runtime_tool_results": runtime_tool_results[-limit:],
"runtime_permission_grants": runtime_permission_grants[-limit:],
"transcript": transcript,
"handoffs": [self._model_payload(item) for item in handoffs],
})
async def status_by_role(self, *, project_id: str, session_id: str | None = None) -> ServiceResult:
listing = await self.list(project_id=project_id, session_id=session_id, limit=10000)
by_role: dict[str, Counter[str]] = defaultdict(Counter)
for item in listing.payload.get("work_items", []):
role_id = str(item.get("role_id") or "unassigned")
by_role[role_id][str(item.get("kanban_column") or item.get("phase") or "unknown")] += 1
return ServiceResult({
"project_id": project_id,
"session_id": str(session_id or "").strip(),
"roles": [
{"role_id": role_id, "counts": dict(counts), "total": sum(counts.values())}
for role_id, counts in sorted(by_role.items())
],
})
async def role_detail(self, *, project_id: str, role_id: str, limit: int = 100) -> ServiceResult:
role_id = str(role_id or "").strip()
if not role_id:
raise ServiceError("role_id_required", "role_id required")
listing = await self.list(project_id=project_id, role_id=role_id, limit=limit)
logs = await self.logs(project_id=project_id, role_id=role_id, limit=limit)
counts: Counter[str] = Counter()
for item in listing.payload.get("work_items", []):
counts[str(item.get("kanban_column") or item.get("phase") or "unknown")] += 1
return ServiceResult({
"project_id": project_id,
"role_id": role_id,
"counts": dict(counts),
"work_items": listing.payload.get("work_items", []),
"logs": logs.payload,
})
async def _runs(self, store: Any, project_id: str, *, session_id: str | None = None) -> list[Any]:
session_id = str(session_id or "").strip()
if hasattr(store, "list_open_delegation_runs"):
runs = await store.list_open_delegation_runs(project_id=project_id)
if session_id:
matched = [run for run in runs if self._session_id_matches(session_id, getattr(run, "session_id", ""))]
if matched:
return list(matched)
elif runs:
return list(runs)
if hasattr(store, "list_delegation_runs"):
if session_id:
for candidate_session_id in self._session_scope_candidates(session_id):
try:
runs = await store.list_delegation_runs(project_id=project_id, session_id=candidate_session_id)
except TypeError:
break
if runs:
return list(runs)
runs = list(await store.list_delegation_runs(project_id=project_id))
if session_id:
return [run for run in runs if self._session_id_matches(session_id, getattr(run, "session_id", ""))]
return runs
return []
@staticmethod
def _is_company_kanban_visible(item: Any) -> bool:
metadata = dict(getattr(item, "metadata", {}) or {})
return bool(
str(getattr(item, "parent_work_item_id", "") or "").strip()
and not bool(metadata.get("attention_work_item", False))
and not should_hide_work_item_from_company_kanban(metadata)
)
@classmethod
def _matches_session_scope(cls, session_id: str, *, run: Any = None, item: Any = None, linked_task: Any = None) -> bool:
session_id = str(session_id or "").strip()
if not session_id:
return True
for candidate in cls._session_candidates(run=run, item=item, linked_task=linked_task):
if cls._session_id_matches(session_id, candidate):
return True
return False
@staticmethod
def _session_scope_candidates(session_id: str) -> list[str]:
session_id = str(session_id or "").strip()
if not session_id:
return []
candidates = [session_id]
if ":" in session_id:
candidates.append(session_id.split(":", 1)[0])
return list(dict.fromkeys(candidates))
@classmethod
def _session_candidates(cls, *, run: Any = None, item: Any = None, linked_task: Any = None) -> list[str]:
values: list[str] = []
def add(value: Any) -> None:
text = str(value or "").strip()
if text:
values.append(text)
def add_from_mapping(mapping: Any) -> None:
if not isinstance(mapping, dict):
return
for key in (
"session_id",
"parent_session_id",
"root_session_id",
"origin_session_id",
"opc_session_id",
"company_runtime_root_session_id",
):
add(mapping.get(key))
if run is not None:
add(getattr(run, "session_id", ""))
add_from_mapping(getattr(run, "metadata", {}) or {})
add_from_mapping(getattr(run, "recovery_pointer", {}) or {})
if item is not None:
add_from_mapping(getattr(item, "metadata", {}) or {})
if linked_task is not None:
add(getattr(linked_task, "session_id", ""))
add(getattr(linked_task, "parent_session_id", ""))
add_from_mapping(getattr(linked_task, "metadata", {}) or {})
return list(dict.fromkeys(values))
@staticmethod
def _session_id_matches(scope_session_id: str, candidate_session_id: Any) -> bool:
scope = str(scope_session_id or "").strip()
candidate = str(candidate_session_id or "").strip()
if not scope or not candidate:
return False
return (
scope == candidate
or candidate.startswith(f"{scope}:")
or scope.startswith(f"{candidate}:")
)
@classmethod
def _task_runtime_session_ids(cls, task: Any) -> list[str]:
metadata = dict(getattr(task, "metadata", {}) or {})
context_snapshot = dict(getattr(task, "context_snapshot", {}) or {})
values = [
(metadata.get("runtime_v2", {}) or {}).get("runtime_session_id") if isinstance(metadata.get("runtime_v2"), dict) else "",
(context_snapshot.get("runtime_resume", {}) or {}).get("runtime_session_id") if isinstance(context_snapshot.get("runtime_resume"), dict) else "",
metadata.get("_permission_bridge_runtime_session_id"),
metadata.get("delegation_role_session_id"),
metadata.get("assigned_role_runtime_id"),
metadata.get("role_runtime_session_id"),
metadata.get("runtime_session_id"),
]
return cls._dedupe_text(values)
@classmethod
def _work_item_runtime_session_ids(cls, item: Any) -> list[str]:
metadata = dict(getattr(item, "metadata", {}) or {})
values = [
getattr(item, "role_runtime_session_id", ""),
getattr(item, "claimed_by_role_runtime_session_id", ""),
metadata.get("assigned_role_runtime_id"),
metadata.get("role_runtime_session_id"),
metadata.get("claimed_by_role_runtime_session_id"),
metadata.get("delegation_role_session_id"),
metadata.get("runtime_session_id"),
]
return cls._dedupe_text(values)
@staticmethod
def _dedupe_text(values: list[Any]) -> list[str]:
result: list[str] = []
seen: set[str] = set()
for value in values:
text = str(value or "").strip()
if not text or text in seen:
continue
seen.add(text)
result.append(text)
return result
def _work_item_payload(self, item: Any, *, run: Any = None, linked_task: Any = None) -> dict[str, Any]:
phase = coerce_phase(getattr(item, "phase", ""))
phase_value = phase.value if hasattr(phase, "value") else str(phase or "")
return {
"work_item_id": str(getattr(item, "work_item_id", "") or ""),
"run_id": str(getattr(item, "run_id", "") or ""),
"project_id": str(getattr(run, "project_id", "") or ""),
"title": str(getattr(item, "title", "") or ""),
"summary": str(getattr(item, "summary", "") or ""),
"role_id": str(getattr(item, "role_id", "") or ""),
"seat_id": str(getattr(item, "seat_id", "") or ""),
"manager_role_id": str(getattr(item, "manager_role_id", "") or ""),
"parent_work_item_id": str(getattr(item, "parent_work_item_id", "") or ""),
"role_runtime_session_id": str(getattr(item, "role_runtime_session_id", "") or ""),
"claimed_by_role_runtime_session_id": str(getattr(item, "claimed_by_role_runtime_session_id", "") or ""),
"phase": phase_value,
"kanban_column": kanban_column(phase),
"deliverable_summary": str(getattr(item, "deliverable_summary", "") or ""),
"blocked_reason": str(getattr(item, "blocked_reason", "") or ""),
"handoff_status": str(getattr(item, "handoff_status", "") or ""),
"metadata": dict(getattr(item, "metadata", {}) or {}),
"runtime_task_id": str(getattr(linked_task, "id", "") or "") if linked_task is not None else "",
"session_id": str(getattr(linked_task, "session_id", "") or "") if linked_task is not None else "",
"runtime_status": (
getattr(getattr(linked_task, "status", None), "value", getattr(linked_task, "status", ""))
if linked_task is not None else ""
),
"created_at": self._date_value(getattr(item, "created_at", None)),
"updated_at": self._date_value(getattr(item, "updated_at", None)),
}
@staticmethod
def _event_payload(event: Any) -> dict[str, Any]:
return {
"event_id": str(getattr(event, "event_id", "") or ""),
"run_id": str(getattr(event, "run_id", "") or ""),
"work_item_id": str(getattr(event, "work_item_id", "") or ""),
"cell_id": str(getattr(event, "cell_id", "") or ""),
"role_id": str(getattr(event, "role_id", "") or ""),
"event_type": str(getattr(event, "event_type", "") or ""),
"payload": dict(getattr(event, "payload", {}) or {}),
"created_at": WorkItemService._date_value(getattr(event, "created_at", None)),
}
@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__)
if isinstance(value, dict):
return dict(value)
return {"value": value}
@staticmethod
def _date_value(value: Any) -> Any:
if hasattr(value, "isoformat"):
return value.isoformat()
return value