Initial commit
This commit is contained in:
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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 {})
|
||||
@@ -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}
|
||||
@@ -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 {})
|
||||
@@ -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)
|
||||
@@ -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
@@ -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),
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user