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

330 lines
13 KiB
Python

"""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()
)