Initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Attachment content helpers for extraction and multimodal routing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
TEXT_EXTENSIONS = {
|
||||
".txt", ".md", ".csv", ".json", ".yaml", ".yml",
|
||||
".py", ".js", ".ts", ".tsx", ".jsx", ".html", ".css",
|
||||
".xml", ".toml", ".ini", ".cfg", ".log", ".sql",
|
||||
}
|
||||
OFFICE_EXTENSIONS = {".docx", ".xlsx", ".pptx"}
|
||||
|
||||
|
||||
class _PreviewAccumulator:
|
||||
def __init__(self, max_chars: int) -> None:
|
||||
self.max_chars = max(0, max_chars)
|
||||
self.parts: list[str] = []
|
||||
self.used = 0
|
||||
|
||||
def add(self, text: str) -> bool:
|
||||
normalized = str(text or "").strip()
|
||||
if not normalized:
|
||||
return False
|
||||
if self.used >= self.max_chars:
|
||||
return True
|
||||
|
||||
available = self.max_chars - self.used
|
||||
if len(normalized) > available:
|
||||
normalized = normalized[:available].rstrip()
|
||||
if not normalized:
|
||||
return True
|
||||
|
||||
self.parts.append(normalized)
|
||||
self.used += len(normalized) + 1
|
||||
return self.used >= self.max_chars
|
||||
|
||||
def render(self) -> str:
|
||||
return "\n".join(self.parts).strip()
|
||||
|
||||
|
||||
def attachment_suffix(filename: str) -> str:
|
||||
return Path(filename).suffix.lower()
|
||||
|
||||
|
||||
def is_text_like_attachment(filename: str, mime_type: str) -> bool:
|
||||
if mime_type.startswith("text/"):
|
||||
return True
|
||||
return attachment_suffix(filename) in TEXT_EXTENSIONS
|
||||
|
||||
|
||||
def can_extract_text(filename: str, mime_type: str) -> bool:
|
||||
return is_text_like_attachment(filename, mime_type) or attachment_suffix(filename) in OFFICE_EXTENSIONS
|
||||
|
||||
|
||||
def extract_attachment_text(
|
||||
filename: str,
|
||||
mime_type: str,
|
||||
raw: bytes,
|
||||
*,
|
||||
max_chars: int = 4000,
|
||||
) -> str:
|
||||
suffix = attachment_suffix(filename)
|
||||
if is_text_like_attachment(filename, mime_type):
|
||||
return _clip_text(raw.decode("utf-8", errors="replace").strip(), max_chars)
|
||||
if suffix == ".docx":
|
||||
return _extract_docx_text(raw, max_chars=max_chars)
|
||||
if suffix == ".xlsx":
|
||||
return _extract_xlsx_text(raw, max_chars=max_chars)
|
||||
if suffix == ".pptx":
|
||||
return _extract_pptx_text(raw, max_chars=max_chars)
|
||||
return ""
|
||||
|
||||
|
||||
def _clip_text(text: str, max_chars: int) -> str:
|
||||
text = str(text or "").strip()
|
||||
if len(text) <= max_chars:
|
||||
return text
|
||||
return f"{text[:max_chars].rstrip()}\n...[truncated]"
|
||||
|
||||
|
||||
def _extract_docx_text(raw: bytes, *, max_chars: int) -> str:
|
||||
from docx import Document
|
||||
|
||||
acc = _PreviewAccumulator(max_chars)
|
||||
doc = Document(BytesIO(raw))
|
||||
|
||||
for para in doc.paragraphs:
|
||||
if acc.add(para.text):
|
||||
return _clip_text(acc.render(), max_chars)
|
||||
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
cells = [cell.text.strip() for cell in row.cells]
|
||||
line = " | ".join(cell for cell in cells if cell)
|
||||
if acc.add(line):
|
||||
return _clip_text(acc.render(), max_chars)
|
||||
|
||||
return _clip_text(acc.render(), max_chars)
|
||||
|
||||
|
||||
def _extract_xlsx_text(raw: bytes, *, max_chars: int) -> str:
|
||||
from openpyxl import load_workbook
|
||||
|
||||
acc = _PreviewAccumulator(max_chars)
|
||||
workbook = load_workbook(BytesIO(raw), read_only=True, data_only=True)
|
||||
try:
|
||||
for sheet in workbook.worksheets[:5]:
|
||||
if acc.add(f"# Sheet: {sheet.title}"):
|
||||
break
|
||||
row_count = 0
|
||||
for row in sheet.iter_rows(values_only=True):
|
||||
values = [_normalize_excel_cell(value) for value in row[:16]]
|
||||
if not any(values):
|
||||
continue
|
||||
row_count += 1
|
||||
if acc.add("\t".join(values)):
|
||||
return _clip_text(acc.render(), max_chars)
|
||||
if row_count >= 80:
|
||||
break
|
||||
finally:
|
||||
workbook.close()
|
||||
|
||||
return _clip_text(acc.render(), max_chars)
|
||||
|
||||
|
||||
def _normalize_excel_cell(value: object) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, float):
|
||||
text = f"{value:.6f}".rstrip("0").rstrip(".")
|
||||
return text or "0"
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _extract_pptx_text(raw: bytes, *, max_chars: int) -> str:
|
||||
from pptx import Presentation
|
||||
|
||||
acc = _PreviewAccumulator(max_chars)
|
||||
presentation = Presentation(BytesIO(raw))
|
||||
|
||||
for index, slide in enumerate(list(presentation.slides)[:20], start=1):
|
||||
if acc.add(f"# Slide {index}"):
|
||||
break
|
||||
for text in _iter_slide_text(slide.shapes):
|
||||
if acc.add(text):
|
||||
return _clip_text(acc.render(), max_chars)
|
||||
|
||||
return _clip_text(acc.render(), max_chars)
|
||||
|
||||
|
||||
def _iter_slide_text(shapes: Iterable[object]) -> Iterable[str]:
|
||||
for shape in shapes:
|
||||
text = getattr(shape, "text", "")
|
||||
if isinstance(text, str) and text.strip():
|
||||
yield text
|
||||
|
||||
table = getattr(shape, "table", None)
|
||||
if table is not None:
|
||||
for row in table.rows:
|
||||
cells = [cell.text.strip() for cell in row.cells]
|
||||
line = " | ".join(cell for cell in cells if cell)
|
||||
if line:
|
||||
yield line
|
||||
|
||||
subshapes = getattr(shape, "shapes", None)
|
||||
if subshapes is not None:
|
||||
yield from _iter_slide_text(subshapes)
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Attachment storage layer — disk-based file storage with lightweight references.
|
||||
|
||||
Files are stored on disk under `{opc_home}/projects/{project_id}/attachments/{id}/`.
|
||||
Only lightweight AttachmentRef objects (no binary content) flow through the engine
|
||||
pipeline, databases, and WebSocket messages. Base64 encoding is performed lazily
|
||||
at the two endpoints: ingestion (decode → disk) and LLM call (disk → encode).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import mimetypes
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ALLOWED_MIME_PREFIXES = (
|
||||
"image/",
|
||||
"text/",
|
||||
"application/pdf",
|
||||
"application/json",
|
||||
"application/x-yaml",
|
||||
"application/yaml",
|
||||
)
|
||||
ALLOWED_EXTENSIONS = {
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".tiff",
|
||||
".txt", ".md", ".pdf", ".csv", ".json", ".yaml", ".yml",
|
||||
".py", ".js", ".ts", ".tsx", ".jsx", ".html", ".css",
|
||||
".java", ".c", ".cpp", ".h", ".go", ".rs", ".rb", ".sh",
|
||||
".xml", ".toml", ".ini", ".cfg", ".log",
|
||||
".docx", ".xlsx", ".pptx",
|
||||
".mp4", ".mpeg", ".mpg", ".mov", ".webm",
|
||||
}
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB per file
|
||||
MAX_TOTAL_SIZE = 20 * 1024 * 1024 # 20 MB per message
|
||||
|
||||
ALLOWED_MIME_TYPES = {
|
||||
"video/mp4",
|
||||
"video/mpeg",
|
||||
"video/quicktime",
|
||||
"video/webm",
|
||||
}
|
||||
|
||||
_MIME_EXTENSION_OVERRIDES = {
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
"image/svg+xml": ".svg",
|
||||
"image/bmp": ".bmp",
|
||||
"image/tiff": ".tiff",
|
||||
"application/pdf": ".pdf",
|
||||
"text/plain": ".txt",
|
||||
"text/markdown": ".md",
|
||||
"application/json": ".json",
|
||||
"application/x-yaml": ".yaml",
|
||||
"application/yaml": ".yaml",
|
||||
"video/mp4": ".mp4",
|
||||
"video/mpeg": ".mpeg",
|
||||
"video/quicktime": ".mov",
|
||||
"video/webm": ".webm",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AttachmentRef:
|
||||
"""Lightweight reference to a stored attachment — safe for JSON / metadata."""
|
||||
|
||||
attachment_id: str
|
||||
filename: str
|
||||
mime_type: str
|
||||
size_bytes: int
|
||||
disk_path: str # relative to opc_home
|
||||
|
||||
@property
|
||||
def is_image(self) -> bool:
|
||||
return self.mime_type.startswith("image/")
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"attachment_id": self.attachment_id,
|
||||
"filename": self.filename,
|
||||
"mime_type": self.mime_type,
|
||||
"size_bytes": self.size_bytes,
|
||||
"disk_path": self.disk_path,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict[str, Any]) -> AttachmentRef:
|
||||
return cls(
|
||||
attachment_id=d["attachment_id"],
|
||||
filename=d["filename"],
|
||||
mime_type=d.get("mime_type", "application/octet-stream"),
|
||||
size_bytes=d.get("size_bytes", 0),
|
||||
disk_path=d.get("disk_path", ""),
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_filename(name: str) -> str:
|
||||
"""Strip path components and dangerous characters from a filename."""
|
||||
name = os.path.basename(name)
|
||||
name = name.replace("..", "").replace("/", "").replace("\\", "")
|
||||
return name or "upload"
|
||||
|
||||
|
||||
def _check_mime(filename: str, mime: str) -> bool:
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
if ext in ALLOWED_EXTENSIONS:
|
||||
return True
|
||||
if mime in ALLOWED_MIME_TYPES:
|
||||
return True
|
||||
for prefix in ALLOWED_MIME_PREFIXES:
|
||||
if mime.startswith(prefix):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _normalize_mime(mime_type: str | None) -> str:
|
||||
return str(mime_type or "").strip().lower()
|
||||
|
||||
|
||||
def _split_data_url(payload: str) -> tuple[str, str | None]:
|
||||
raw = str(payload or "").strip()
|
||||
if not raw.startswith("data:"):
|
||||
return raw, None
|
||||
header, sep, encoded = raw.partition(",")
|
||||
if not sep:
|
||||
return raw, None
|
||||
mime = header[5:].split(";", 1)[0].strip().lower()
|
||||
return encoded, mime or None
|
||||
|
||||
|
||||
def _extension_for_mime(mime_type: str) -> str:
|
||||
if not mime_type:
|
||||
return ""
|
||||
override = _MIME_EXTENSION_OVERRIDES.get(mime_type)
|
||||
if override:
|
||||
return override
|
||||
guessed = mimetypes.guess_extension(mime_type, strict=False) or ""
|
||||
return guessed.lower()
|
||||
|
||||
|
||||
def _ensure_filename_extension(filename: str, mime_type: str) -> str:
|
||||
if os.path.splitext(filename)[1]:
|
||||
return filename
|
||||
extension = _extension_for_mime(mime_type)
|
||||
if extension:
|
||||
return f"{filename}{extension}"
|
||||
return filename
|
||||
|
||||
|
||||
class AttachmentStore:
|
||||
"""Manages attachment lifecycle on disk."""
|
||||
|
||||
def __init__(self, opc_home: Path, project_id: str) -> None:
|
||||
self.opc_home = opc_home
|
||||
self.project_id = project_id
|
||||
self.base_dir = opc_home / "projects" / project_id / "attachments"
|
||||
|
||||
def _ensure_dir(self, attachment_id: str) -> Path:
|
||||
d = self.base_dir / attachment_id
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
async def save_from_base64(
|
||||
self,
|
||||
filename: str,
|
||||
b64_data: str,
|
||||
mime_type: str | None = None,
|
||||
) -> AttachmentRef:
|
||||
"""Decode base64 data and persist to disk. Returns a lightweight ref."""
|
||||
filename = _sanitize_filename(filename)
|
||||
b64_payload, inferred_mime = _split_data_url(b64_data)
|
||||
mime = _normalize_mime(mime_type) or inferred_mime or ""
|
||||
if not mime:
|
||||
guessed_mime, _ = mimetypes.guess_type(filename)
|
||||
mime = guessed_mime or "application/octet-stream"
|
||||
filename = _ensure_filename_extension(filename, mime)
|
||||
if mime == "application/octet-stream":
|
||||
guessed_mime, _ = mimetypes.guess_type(filename)
|
||||
mime = guessed_mime or mime
|
||||
try:
|
||||
raw = base64.b64decode(b64_payload)
|
||||
except Exception as exc:
|
||||
raise ValueError(f"Invalid base64 data for {filename}: {exc}") from exc
|
||||
size = len(raw)
|
||||
if size > MAX_FILE_SIZE:
|
||||
raise ValueError(f"File too large: {size} bytes (limit {MAX_FILE_SIZE})")
|
||||
|
||||
if not _check_mime(filename, mime):
|
||||
raise ValueError(f"Unsupported file type: {filename} ({mime})")
|
||||
|
||||
aid = uuid.uuid4().hex[:16]
|
||||
dest_dir = self._ensure_dir(aid)
|
||||
dest = dest_dir / filename
|
||||
dest.write_bytes(raw)
|
||||
|
||||
rel = dest.relative_to(self.opc_home)
|
||||
return AttachmentRef(
|
||||
attachment_id=aid,
|
||||
filename=filename,
|
||||
mime_type=mime,
|
||||
size_bytes=size,
|
||||
disk_path=str(rel),
|
||||
)
|
||||
|
||||
async def save_from_path(self, file_path: Path) -> AttachmentRef:
|
||||
"""Copy a local file into the attachment store. Used by CLI."""
|
||||
file_path = file_path.expanduser().resolve()
|
||||
if not file_path.is_file():
|
||||
raise FileNotFoundError(f"File not found: {file_path}")
|
||||
|
||||
size = file_path.stat().st_size
|
||||
if size > MAX_FILE_SIZE:
|
||||
raise ValueError(f"File too large: {size} bytes (limit {MAX_FILE_SIZE})")
|
||||
|
||||
filename = _sanitize_filename(file_path.name)
|
||||
mime, _ = mimetypes.guess_type(filename)
|
||||
mime = mime or "application/octet-stream"
|
||||
if not _check_mime(filename, mime):
|
||||
raise ValueError(f"Unsupported file type: {filename} ({mime})")
|
||||
|
||||
aid = uuid.uuid4().hex[:16]
|
||||
dest_dir = self._ensure_dir(aid)
|
||||
dest = dest_dir / filename
|
||||
shutil.copy2(str(file_path), str(dest))
|
||||
|
||||
rel = dest.relative_to(self.opc_home)
|
||||
return AttachmentRef(
|
||||
attachment_id=aid,
|
||||
filename=filename,
|
||||
mime_type=mime,
|
||||
size_bytes=size,
|
||||
disk_path=str(rel),
|
||||
)
|
||||
|
||||
def resolve_abs_path(self, ref: AttachmentRef) -> Path:
|
||||
"""Return the absolute path on disk for reading."""
|
||||
resolved = (self.opc_home / ref.disk_path).resolve()
|
||||
if not str(resolved).startswith(str(self.base_dir.resolve())):
|
||||
raise ValueError(f"Path traversal detected: {ref.disk_path}")
|
||||
return resolved
|
||||
|
||||
def read_bytes(self, ref: AttachmentRef) -> bytes:
|
||||
"""Read raw file content from disk."""
|
||||
return self.resolve_abs_path(ref).read_bytes()
|
||||
|
||||
def read_base64(self, ref: AttachmentRef) -> str:
|
||||
"""Read file and return as base64 string — called only at LLM call time."""
|
||||
return base64.b64encode(self.read_bytes(ref)).decode("ascii")
|
||||
|
||||
def resolve_http_path(self, ref: AttachmentRef) -> str:
|
||||
"""Return the HTTP-accessible path for frontend display."""
|
||||
return f"/api/attachments/{ref.attachment_id}/{ref.filename}"
|
||||
@@ -0,0 +1,329 @@
|
||||
"""Canonical company-mode collaboration tool names."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def company_collaboration_enabled(execution_mode: str | None) -> bool:
|
||||
"""Return whether company collaboration capabilities should be exposed."""
|
||||
return str(execution_mode or "").strip() == "company_mode"
|
||||
|
||||
|
||||
def company_collaboration_enabled_for_task(task: object | None) -> bool:
|
||||
"""Return whether collaboration capabilities are enabled for a task-like object."""
|
||||
if task is None:
|
||||
return False
|
||||
metadata = dict(getattr(task, "metadata", {}) or {})
|
||||
return company_collaboration_enabled(str(metadata.get("execution_mode", "") or ""))
|
||||
|
||||
|
||||
COLLAB_PROFILE_DISABLED = "disabled"
|
||||
COLLAB_PROFILE_WORKER_DEFAULT = "worker_default"
|
||||
COLLAB_PROFILE_WORKER_EXECUTE_REVIEW = "worker_execute_review"
|
||||
COLLAB_PROFILE_MANAGER_DEFAULT = "manager_default"
|
||||
COLLAB_PROFILE_COORDINATOR_DEFAULT = "coordinator_default"
|
||||
COLLAB_PROFILE_DEBUG_ADMIN = "debug_admin"
|
||||
|
||||
WORKER_DEFAULT_TOOL_NAMES: tuple[str, ...] = (
|
||||
"inbox",
|
||||
"send_dm",
|
||||
"ask_peer_and_wait",
|
||||
"reply_message",
|
||||
)
|
||||
|
||||
WORKER_EXECUTE_REVIEW_TOOL_NAMES: tuple[str, ...] = WORKER_DEFAULT_TOOL_NAMES
|
||||
|
||||
MANAGER_DEFAULT_TOOL_NAMES: tuple[str, ...] = (
|
||||
*WORKER_DEFAULT_TOOL_NAMES,
|
||||
"delegate_work",
|
||||
"modify_work_item",
|
||||
"delete_work_item",
|
||||
"manager_board_read",
|
||||
"broadcast_issue",
|
||||
"start_meeting",
|
||||
)
|
||||
|
||||
COORDINATOR_DEFAULT_TOOL_NAMES: tuple[str, ...] = (
|
||||
*MANAGER_DEFAULT_TOOL_NAMES,
|
||||
"propose_task_adjustment",
|
||||
"route_work",
|
||||
)
|
||||
|
||||
DEBUG_ADMIN_TOOL_NAMES: tuple[str, ...] = (
|
||||
"read_inbox",
|
||||
"read_meeting",
|
||||
"list_colleagues",
|
||||
)
|
||||
|
||||
MEETING_RESPONSE_TOOL_NAMES: tuple[str, ...] = ("respond_meeting",)
|
||||
HUMAN_REVIEW_TOOL_NAMES: tuple[str, ...] = ("close_human_review",)
|
||||
|
||||
|
||||
COMPANY_COLLABORATION_TOOL_NAMES: tuple[str, ...] = (
|
||||
*COORDINATOR_DEFAULT_TOOL_NAMES,
|
||||
*MEETING_RESPONSE_TOOL_NAMES,
|
||||
*HUMAN_REVIEW_TOOL_NAMES,
|
||||
)
|
||||
|
||||
COMPANY_DEBUG_TOOL_NAMES: tuple[str, ...] = DEBUG_ADMIN_TOOL_NAMES
|
||||
|
||||
COMPANY_ALL_COLLABORATION_TOOL_NAMES: tuple[str, ...] = tuple(
|
||||
dict.fromkeys(
|
||||
[
|
||||
*COMPANY_COLLABORATION_TOOL_NAMES,
|
||||
*COMPANY_DEBUG_TOOL_NAMES,
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
COMPANY_APPROVAL_EXEMPT_TOOL_NAMES: tuple[str, ...] = (
|
||||
*COMPANY_ALL_COLLABORATION_TOOL_NAMES,
|
||||
)
|
||||
|
||||
MULTI_TEAM_COORDINATION_TURN_MODES: frozenset[str] = frozenset(
|
||||
{
|
||||
"dispatch_required",
|
||||
"monitor_children",
|
||||
"synthesize_required",
|
||||
"deliver_required",
|
||||
}
|
||||
)
|
||||
|
||||
# Kanban-push review turn: a dedicated review Task is the only thing running
|
||||
# in the seat. The manager emits a structured verdict as part of the turn
|
||||
# output; the runtime (``_finalize_review_work_item``) auto-applies it to
|
||||
# the child work item. The restricted toolset keeps the turn focused on
|
||||
# judgement — no delegation / messaging / dispatch side-channels.
|
||||
REVIEW_EXECUTE_TURN_MODE: str = "review_execute"
|
||||
REVIEW_EXECUTE_TOOL_NAMES: frozenset[str] = frozenset(
|
||||
{
|
||||
"manager_board_read",
|
||||
}
|
||||
)
|
||||
|
||||
MULTI_TEAM_COORDINATOR_LEGACY_TOOL_NAMES: frozenset[str] = frozenset(
|
||||
{
|
||||
"propose_task_adjustment",
|
||||
"route_work",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _task_metadata(task: object | None) -> dict[str, Any]:
|
||||
return dict(getattr(task, "metadata", {}) or {}) if task is not None else {}
|
||||
|
||||
|
||||
def _task_context_snapshot(task: object | None) -> dict[str, Any]:
|
||||
return dict(getattr(task, "context_snapshot", {}) or {}) if task is not None else {}
|
||||
|
||||
|
||||
def _work_item_turn_type(metadata: dict[str, Any]) -> str:
|
||||
for key in ("work_item_turn_type", "work_item_turn_type", "work_kind", "delegation_turn_kind"):
|
||||
value = str(metadata.get(key, "") or "").strip().lower()
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def _runtime_state_dict(runtime_state: Any | None) -> dict[str, Any]:
|
||||
return dict(runtime_state or {}) if isinstance(runtime_state, dict) else {}
|
||||
|
||||
|
||||
def _role_type_hint(role_cfg: Any | None, runtime_state: dict[str, Any]) -> str:
|
||||
if role_cfg is not None:
|
||||
runtime_policy = getattr(role_cfg, "runtime_policy", None)
|
||||
hinted = str(
|
||||
getattr(runtime_policy, "role_type", "")
|
||||
or (runtime_policy.get("role_type", "") if isinstance(runtime_policy, dict) else "")
|
||||
or getattr(role_cfg, "role_type", "")
|
||||
or ""
|
||||
).strip().lower()
|
||||
if hinted:
|
||||
return hinted
|
||||
return str(runtime_state.get("role_type", "") or "").strip().lower()
|
||||
|
||||
|
||||
def _can_spawn_hint(role_cfg: Any | None, runtime_state: dict[str, Any]) -> bool:
|
||||
if role_cfg is not None:
|
||||
can_spawn = [str(item).strip() for item in list(getattr(role_cfg, "can_spawn", []) or []) if str(item).strip()]
|
||||
if can_spawn:
|
||||
return True
|
||||
return bool(
|
||||
[
|
||||
str(item).strip()
|
||||
for item in list(runtime_state.get("can_spawn", []) or [])
|
||||
if str(item).strip()
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _is_execute_or_review_work_item(task: object | None) -> bool:
|
||||
metadata = _task_metadata(task)
|
||||
if not company_collaboration_enabled(str(metadata.get("execution_mode", "") or "")):
|
||||
return False
|
||||
turn_type = _work_item_turn_type(metadata)
|
||||
return turn_type in {"execute", "review"}
|
||||
|
||||
|
||||
def _has_active_meeting(task: object | None, runtime_state: dict[str, Any]) -> bool:
|
||||
metadata = _task_metadata(task)
|
||||
peer_wait = dict(metadata.get("peer_wait", {}) or {})
|
||||
if str(peer_wait.get("kind", "") or "").strip().lower() == "meeting":
|
||||
return True
|
||||
context_snapshot = _task_context_snapshot(task)
|
||||
for bucket in (
|
||||
context_snapshot.get("company_member_inbox", []),
|
||||
context_snapshot.get("company_member_protocol_backlog", []),
|
||||
context_snapshot.get("company_member_notification_backlog", []),
|
||||
context_snapshot.get("broker_pending_inbox", []),
|
||||
):
|
||||
for item in list(bucket or []):
|
||||
if isinstance(item, dict) and (
|
||||
str(item.get("meeting_room_id", "") or "").strip()
|
||||
or str(dict(item.get("metadata", {}) or {}).get("meeting_room_id", "") or "").strip()
|
||||
):
|
||||
return True
|
||||
latest_company_notification = dict(context_snapshot.get("latest_company_notification", {}) or {})
|
||||
if str(latest_company_notification.get("meeting_room_id", "") or "").strip():
|
||||
return True
|
||||
if str(runtime_state.get("meeting_room_id", "") or "").strip():
|
||||
return True
|
||||
if bool(runtime_state.get("active_meeting", False)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _human_review_close_allowed(task: object | None, runtime_state: dict[str, Any]) -> bool:
|
||||
metadata = _task_metadata(task)
|
||||
context_snapshot = _task_context_snapshot(task)
|
||||
return any(
|
||||
bool(source.get("human_review_close_allowed", False))
|
||||
or str(source.get("human_review_checkpoint_type", "") or "").strip() == "company_delivery_feedback"
|
||||
for source in (metadata, context_snapshot, runtime_state)
|
||||
)
|
||||
|
||||
|
||||
def resolve_company_turn_mode(
|
||||
task: object | None,
|
||||
runtime_state: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
metadata = _task_metadata(task)
|
||||
context_snapshot = _task_context_snapshot(task)
|
||||
state = _runtime_state_dict(runtime_state)
|
||||
runtime_model = str(
|
||||
metadata.get("runtime_model", "")
|
||||
or context_snapshot.get("runtime_model", "")
|
||||
or state.get("runtime_model", "")
|
||||
or ""
|
||||
).strip()
|
||||
if runtime_model != "multi_team_org":
|
||||
return ""
|
||||
for candidate in (
|
||||
state.get("current_turn_mode"),
|
||||
dict(state.get("manager_digest", {}) or {}).get("current_turn_mode"),
|
||||
metadata.get("current_turn_mode"),
|
||||
context_snapshot.get("current_turn_mode"),
|
||||
dict(metadata.get("member_session_state", {}) or {}).get("current_turn_mode"),
|
||||
dict(context_snapshot.get("member_session", {}) or {}).get("current_turn_mode"),
|
||||
dict(metadata.get("resident_assignment", {}) or {}).get("metadata", {}).get("current_turn_mode"),
|
||||
dict(context_snapshot.get("resident_assignment", {}) or {}).get("metadata", {}).get("current_turn_mode"),
|
||||
dict(context_snapshot.get("manager_digest", {}) or {}).get("current_turn_mode"),
|
||||
):
|
||||
value = str(candidate or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def resolve_collaboration_profile(
|
||||
task: object | None,
|
||||
role: str = "",
|
||||
seat: str = "",
|
||||
runtime_state: dict[str, Any] | None = None,
|
||||
*,
|
||||
role_cfg: Any | None = None,
|
||||
debug_admin: bool = False,
|
||||
) -> str:
|
||||
"""Resolve the collaboration capability profile for a task/seat."""
|
||||
metadata = _task_metadata(task)
|
||||
state = _runtime_state_dict(runtime_state)
|
||||
if not company_collaboration_enabled_for_task(task):
|
||||
return COLLAB_PROFILE_DISABLED
|
||||
if debug_admin or bool(metadata.get("collaboration_debug_admin", False)) or bool(state.get("debug_admin", False)):
|
||||
return COLLAB_PROFILE_DEBUG_ADMIN
|
||||
|
||||
managed_team_id = str(
|
||||
metadata.get("managed_team_id", "")
|
||||
or state.get("managed_team_id", "")
|
||||
or dict(metadata.get("member_session_state", {}) or {}).get("metadata", {}).get("managed_team_id", "")
|
||||
or ""
|
||||
).strip()
|
||||
role_type = _role_type_hint(role_cfg, state)
|
||||
can_spawn = _can_spawn_hint(role_cfg, state)
|
||||
manager_board_summary = (
|
||||
dict(state.get("manager_board_summary", {}) or {})
|
||||
or dict(_task_context_snapshot(task).get("manager_board_summary", {}) or {})
|
||||
)
|
||||
work_item_turn_type = _work_item_turn_type(metadata)
|
||||
|
||||
if role_type == "coordinator" or can_spawn:
|
||||
return COLLAB_PROFILE_COORDINATOR_DEFAULT
|
||||
if managed_team_id or manager_board_summary or work_item_turn_type in {"intake", "plan", "dispatch", "monitor", "aggregate", "deliver"}:
|
||||
return COLLAB_PROFILE_MANAGER_DEFAULT
|
||||
if _is_execute_or_review_work_item(task):
|
||||
return COLLAB_PROFILE_WORKER_EXECUTE_REVIEW
|
||||
return COLLAB_PROFILE_WORKER_DEFAULT
|
||||
|
||||
|
||||
def resolve_allowed_collaboration_tools(
|
||||
profile: str,
|
||||
task: object | None = None,
|
||||
runtime_state: dict[str, Any] | None = None,
|
||||
) -> set[str]:
|
||||
"""Return the collaboration tools visible for the resolved profile."""
|
||||
state = _runtime_state_dict(runtime_state)
|
||||
if profile == COLLAB_PROFILE_DISABLED:
|
||||
return set()
|
||||
if profile == COLLAB_PROFILE_DEBUG_ADMIN:
|
||||
return set(DEBUG_ADMIN_TOOL_NAMES)
|
||||
if profile == COLLAB_PROFILE_WORKER_EXECUTE_REVIEW:
|
||||
allowed = set(WORKER_EXECUTE_REVIEW_TOOL_NAMES)
|
||||
elif profile == COLLAB_PROFILE_MANAGER_DEFAULT:
|
||||
allowed = set(MANAGER_DEFAULT_TOOL_NAMES)
|
||||
elif profile == COLLAB_PROFILE_COORDINATOR_DEFAULT:
|
||||
allowed = set(COORDINATOR_DEFAULT_TOOL_NAMES)
|
||||
else:
|
||||
allowed = set(WORKER_DEFAULT_TOOL_NAMES)
|
||||
turn_mode = resolve_company_turn_mode(task, runtime_state=state)
|
||||
if turn_mode == REVIEW_EXECUTE_TURN_MODE and str(_task_metadata(task).get("runtime_model", "") or "").strip() == "multi_team_org":
|
||||
return set(REVIEW_EXECUTE_TOOL_NAMES)
|
||||
if turn_mode in MULTI_TEAM_COORDINATION_TURN_MODES:
|
||||
allowed.discard("ask_peer_and_wait")
|
||||
if turn_mode and str(_task_metadata(task).get("runtime_model", "") or "").strip() == "multi_team_org":
|
||||
allowed.difference_update(MULTI_TEAM_COORDINATOR_LEGACY_TOOL_NAMES)
|
||||
if _has_active_meeting(task, state):
|
||||
allowed.update(MEETING_RESPONSE_TOOL_NAMES)
|
||||
if _human_review_close_allowed(task, state):
|
||||
allowed.update(HUMAN_REVIEW_TOOL_NAMES)
|
||||
return allowed
|
||||
|
||||
|
||||
def resolve_task_collaboration_tools(
|
||||
task: object | None,
|
||||
*,
|
||||
role: str = "",
|
||||
seat: str = "",
|
||||
runtime_state: dict[str, Any] | None = None,
|
||||
role_cfg: Any | None = None,
|
||||
debug_admin: bool = False,
|
||||
) -> tuple[str, set[str]]:
|
||||
profile = resolve_collaboration_profile(
|
||||
task,
|
||||
role=role,
|
||||
seat=seat,
|
||||
runtime_state=runtime_state,
|
||||
role_cfg=role_cfg,
|
||||
debug_admin=debug_admin,
|
||||
)
|
||||
return profile, resolve_allowed_collaboration_tools(profile, task=task, runtime_state=runtime_state)
|
||||
+1857
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,273 @@
|
||||
"""Persistent company employee registry helpers.
|
||||
|
||||
Employees are runtime/company assets, while organization configs describe the
|
||||
role graph and policies. This module keeps employee records outside org yaml
|
||||
and normalizes template-backed employees to a canonical template id.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from opc.core.config import EmployeeConfig, validate_organization_id
|
||||
|
||||
EMPLOYEE_REGISTRY_SCHEMA_VERSION = 1
|
||||
EMPLOYEE_REGISTRY_KIND = "company_employee"
|
||||
|
||||
_COUNT_KEYS = {
|
||||
"successes",
|
||||
"partial_successes",
|
||||
"failures",
|
||||
"reflection_count",
|
||||
}
|
||||
|
||||
|
||||
def _slugify(value: str) -> str:
|
||||
slug = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip().lower()).strip("-")
|
||||
return slug or "employee"
|
||||
|
||||
|
||||
def employee_registry_dir(opc_home: Path, organization_id: Any) -> Path:
|
||||
org_id = validate_organization_id(organization_id)
|
||||
return Path(opc_home) / "company_state" / org_id / "employees"
|
||||
|
||||
|
||||
def employee_registry_path(opc_home: Path, organization_id: Any, employee_id: str) -> Path:
|
||||
filename = f"{_slugify(employee_id)}.yaml"
|
||||
return employee_registry_dir(opc_home, organization_id) / filename
|
||||
|
||||
|
||||
def is_placeholder_employee(employee: EmployeeConfig | dict[str, Any]) -> bool:
|
||||
metadata = dict(employee.metadata if isinstance(employee, EmployeeConfig) else employee.get("metadata") or {})
|
||||
return bool(metadata.get("is_default_employee") or metadata.get("is_fallback_employee"))
|
||||
|
||||
|
||||
def _employee_from_payload(raw: Any) -> EmployeeConfig | None:
|
||||
if isinstance(raw, EmployeeConfig):
|
||||
return raw.model_copy(deep=True)
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
payload = raw.get("employee") if isinstance(raw.get("employee"), dict) else raw
|
||||
try:
|
||||
return EmployeeConfig.model_validate(payload)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _append_unique(items: list[Any], additions: list[Any]) -> list[Any]:
|
||||
result = list(items or [])
|
||||
for item in list(additions or []):
|
||||
if item not in result:
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
def _merge_metadata(base: dict[str, Any], incoming: dict[str, Any]) -> dict[str, Any]:
|
||||
merged = dict(base or {})
|
||||
for key, value in dict(incoming or {}).items():
|
||||
if key == "legacy_employee_ids":
|
||||
merged[key] = _append_unique(list(merged.get(key, []) or []), list(value or []))
|
||||
elif key in {"home_role_ids", "staffed_role_ids"}:
|
||||
merged[key] = _append_unique(list(merged.get(key, []) or []), list(value or []))
|
||||
elif key not in merged or _is_empty_value(merged.get(key)):
|
||||
merged[key] = value
|
||||
elif isinstance(merged.get(key), dict) and isinstance(value, dict):
|
||||
nested = dict(merged[key])
|
||||
nested.update(value)
|
||||
merged[key] = nested
|
||||
return merged
|
||||
|
||||
|
||||
def _canonicalize_employee(employee: EmployeeConfig) -> tuple[EmployeeConfig, dict[str, str]]:
|
||||
if is_placeholder_employee(employee):
|
||||
return employee.model_copy(deep=True), {}
|
||||
|
||||
old_id = str(employee.employee_id or "").strip()
|
||||
template_id = str(employee.template_id or "").strip()
|
||||
canonical_id = template_id or old_id
|
||||
metadata = dict(employee.metadata or {})
|
||||
aliases: dict[str, str] = {}
|
||||
|
||||
legacy_ids = [str(item).strip() for item in list(metadata.get("legacy_employee_ids", []) or []) if str(item).strip()]
|
||||
if old_id and old_id != canonical_id and old_id not in legacy_ids:
|
||||
legacy_ids.append(old_id)
|
||||
if legacy_ids:
|
||||
metadata["legacy_employee_ids"] = legacy_ids
|
||||
for legacy_id in legacy_ids:
|
||||
aliases[legacy_id] = canonical_id
|
||||
|
||||
role_id = str(employee.role_id or "").strip()
|
||||
if role_id:
|
||||
metadata.setdefault("home_role_id", role_id)
|
||||
metadata["home_role_ids"] = _append_unique(list(metadata.get("home_role_ids", []) or []), [role_id])
|
||||
metadata["staffed_role_ids"] = _append_unique(list(metadata.get("staffed_role_ids", []) or []), [role_id])
|
||||
if template_id:
|
||||
metadata.setdefault("canonical_employee_id", canonical_id)
|
||||
|
||||
return employee.model_copy(update={"employee_id": canonical_id, "metadata": metadata}), aliases
|
||||
|
||||
|
||||
def _merge_employee(base: EmployeeConfig, incoming: EmployeeConfig) -> EmployeeConfig:
|
||||
merged = base.model_dump()
|
||||
other = incoming.model_dump()
|
||||
for field in ("domains", "tags", "prompt_refs", "skill_refs"):
|
||||
merged[field] = _append_unique(list(merged.get(field, []) or []), list(other.get(field, []) or []))
|
||||
for field in ("name", "template_id", "description", "category", "preferred_external_agent", "seniority", "status"):
|
||||
if not merged.get(field) and other.get(field):
|
||||
merged[field] = other[field]
|
||||
elif field == "description" and len(str(other.get(field, ""))) > len(str(merged.get(field, ""))):
|
||||
merged[field] = other[field]
|
||||
if not merged.get("role_id") and other.get("role_id"):
|
||||
merged["role_id"] = other["role_id"]
|
||||
merged["metadata"] = _merge_metadata(dict(merged.get("metadata", {}) or {}), dict(other.get("metadata", {}) or {}))
|
||||
return EmployeeConfig.model_validate(merged)
|
||||
|
||||
|
||||
def normalize_employee_records(employees: list[Any]) -> tuple[list[EmployeeConfig], dict[str, str]]:
|
||||
real_by_id: dict[str, EmployeeConfig] = {}
|
||||
placeholders: list[EmployeeConfig] = []
|
||||
aliases: dict[str, str] = {}
|
||||
for raw in list(employees or []):
|
||||
employee = _employee_from_payload(raw)
|
||||
if employee is None:
|
||||
continue
|
||||
if is_placeholder_employee(employee):
|
||||
placeholders.append(employee)
|
||||
continue
|
||||
canonical, employee_aliases = _canonicalize_employee(employee)
|
||||
aliases.update(employee_aliases)
|
||||
existing = real_by_id.get(canonical.employee_id)
|
||||
real_by_id[canonical.employee_id] = _merge_employee(existing, canonical) if existing else canonical
|
||||
real = sorted(real_by_id.values(), key=lambda item: (item.category, item.name.lower(), item.employee_id))
|
||||
return [*real, *placeholders], aliases
|
||||
|
||||
|
||||
def load_employee_registry(opc_home: Path, organization_id: Any) -> list[EmployeeConfig]:
|
||||
directory = employee_registry_dir(opc_home, organization_id)
|
||||
if not directory.is_dir():
|
||||
return []
|
||||
employees: list[EmployeeConfig] = []
|
||||
for path in sorted(directory.glob("*.yaml")):
|
||||
try:
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
except Exception:
|
||||
continue
|
||||
employee = _employee_from_payload(data)
|
||||
if employee is not None:
|
||||
employees.append(employee)
|
||||
return employees
|
||||
|
||||
|
||||
def load_company_employees(
|
||||
opc_home: Path,
|
||||
organization_id: Any,
|
||||
legacy_employees: list[Any],
|
||||
) -> list[EmployeeConfig]:
|
||||
registry_employees = load_employee_registry(opc_home, organization_id)
|
||||
employees, aliases = normalize_employee_records([*registry_employees, *list(legacy_employees or [])])
|
||||
migrate_evolution_employee_ids(opc_home, aliases)
|
||||
return employees
|
||||
|
||||
|
||||
def write_employee_registry(
|
||||
opc_home: Path,
|
||||
organization_id: Any,
|
||||
employees: list[Any],
|
||||
) -> tuple[list[EmployeeConfig], dict[str, str]]:
|
||||
normalized, aliases = normalize_employee_records(employees)
|
||||
real_employees = [employee for employee in normalized if not is_placeholder_employee(employee)]
|
||||
directory = employee_registry_dir(opc_home, organization_id)
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
expected_paths: set[Path] = set()
|
||||
for employee in real_employees:
|
||||
path = employee_registry_path(opc_home, organization_id, employee.employee_id)
|
||||
expected_paths.add(path)
|
||||
payload = {
|
||||
"schema_version": EMPLOYEE_REGISTRY_SCHEMA_VERSION,
|
||||
"kind": EMPLOYEE_REGISTRY_KIND,
|
||||
"organization_id": validate_organization_id(organization_id),
|
||||
"employee": employee.model_dump(),
|
||||
}
|
||||
_atomic_write_text(
|
||||
path,
|
||||
yaml.dump(payload, default_flow_style=False, sort_keys=False, allow_unicode=True),
|
||||
)
|
||||
for path in directory.glob("*.yaml"):
|
||||
if path not in expected_paths:
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
migrate_evolution_employee_ids(opc_home, aliases)
|
||||
return normalized, aliases
|
||||
|
||||
|
||||
def migrate_evolution_employee_ids(opc_home: Path, aliases: dict[str, str]) -> None:
|
||||
canonical_aliases = {
|
||||
str(old).strip(): str(new).strip()
|
||||
for old, new in dict(aliases or {}).items()
|
||||
if str(old).strip() and str(new).strip() and str(old).strip() != str(new).strip()
|
||||
}
|
||||
if not canonical_aliases:
|
||||
return
|
||||
paths: list[Path] = [Path(opc_home) / "evolution" / "employees.json"]
|
||||
projects_dir = Path(opc_home) / "projects"
|
||||
if projects_dir.is_dir():
|
||||
paths.extend(sorted(projects_dir.glob("*/employee_evolution.json")))
|
||||
for path in paths:
|
||||
if not path.exists():
|
||||
continue
|
||||
try:
|
||||
profile = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(profile, dict):
|
||||
continue
|
||||
employees = profile.get("employees")
|
||||
if not isinstance(employees, dict):
|
||||
continue
|
||||
changed = False
|
||||
for legacy_id, canonical_id in canonical_aliases.items():
|
||||
if legacy_id not in employees:
|
||||
continue
|
||||
legacy_record = employees.pop(legacy_id)
|
||||
current = employees.get(canonical_id)
|
||||
employees[canonical_id] = _merge_evolution_records(current, legacy_record)
|
||||
changed = True
|
||||
if changed:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(profile, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _merge_evolution_records(base: Any, incoming: Any) -> Any:
|
||||
if isinstance(base, dict) and isinstance(incoming, dict):
|
||||
merged = dict(base)
|
||||
for key, value in incoming.items():
|
||||
if key in _COUNT_KEYS and isinstance(value, (int, float)):
|
||||
merged[key] = int(merged.get(key, 0) or 0) + int(value)
|
||||
elif isinstance(value, list):
|
||||
merged[key] = _append_unique(list(merged.get(key, []) or []), value)
|
||||
elif isinstance(value, dict):
|
||||
merged[key] = _merge_evolution_records(merged.get(key, {}), value)
|
||||
elif key not in merged or _is_empty_value(merged.get(key)):
|
||||
merged[key] = value
|
||||
return merged
|
||||
if isinstance(base, list) and isinstance(incoming, list):
|
||||
return _append_unique(base, incoming)
|
||||
return base if not _is_empty_value(base) else incoming
|
||||
|
||||
|
||||
def _is_empty_value(value: Any) -> bool:
|
||||
return value is None or value == "" or value == [] or value == {}
|
||||
|
||||
|
||||
def _atomic_write_text(path: Path, content: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text(content, encoding="utf-8")
|
||||
tmp.replace(path)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""In-process event bus for OPC system."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from typing import Any, Callable, Coroutine
|
||||
|
||||
from opc.core.models import OPCEvent
|
||||
|
||||
|
||||
Listener = Callable[[OPCEvent], Coroutine[Any, Any, None]]
|
||||
|
||||
|
||||
class EventBus:
|
||||
"""Simple async pub/sub event bus for inter-layer communication."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._listeners: dict[str, list[Listener]] = defaultdict(list)
|
||||
self._global_listeners: list[Listener] = []
|
||||
self._history: list[OPCEvent] = []
|
||||
self._lock: asyncio.Lock | None = None
|
||||
|
||||
def _get_lock(self) -> asyncio.Lock:
|
||||
if self._lock is None:
|
||||
self._lock = asyncio.Lock()
|
||||
return self._lock
|
||||
|
||||
def subscribe(self, event_type: str, listener: Listener) -> None:
|
||||
self._listeners[event_type].append(listener)
|
||||
|
||||
def subscribe_all(self, listener: Listener) -> None:
|
||||
self._global_listeners.append(listener)
|
||||
|
||||
async def publish(self, event: OPCEvent) -> None:
|
||||
async with self._get_lock():
|
||||
self._history.append(event)
|
||||
# Snapshot listener lists under lock to avoid mutation during iteration
|
||||
typed = list(self._listeners.get(event.event_type, []))
|
||||
globl = list(self._global_listeners)
|
||||
# Execute listeners outside lock to avoid holding it during async work
|
||||
tasks = [fn(event) for fn in typed + globl]
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
def get_history(self, event_type: str | None = None, limit: int = 50) -> list[OPCEvent]:
|
||||
events = self._history
|
||||
if event_type:
|
||||
events = [e for e in events if e.event_type == event_type]
|
||||
return events[-limit:]
|
||||
+1370
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,269 @@
|
||||
"""Dedicated storage helpers for user-defined org architectures."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from opc.core.config import (
|
||||
COMPANY_ORG_KIND,
|
||||
COMPANY_ORG_SCHEMA_VERSION,
|
||||
DEFAULT_ORGANIZATION_ID,
|
||||
OPCConfig,
|
||||
_atomic_write_text,
|
||||
_company_org_payload_to_org_mapping,
|
||||
_read_yaml_file,
|
||||
build_company_org_payload_from_config,
|
||||
slugify_organization_name,
|
||||
validate_organization_id,
|
||||
)
|
||||
|
||||
ORG_INDEX_FILENAME = "org_index.yaml"
|
||||
ORG_CONFIGS_DIRNAME = "company_orgs"
|
||||
ORG_CONFIG_KIND = COMPANY_ORG_KIND
|
||||
ORG_CONFIG_SCHEMA_VERSION = COMPANY_ORG_SCHEMA_VERSION
|
||||
RESERVED_ORG_CONFIG_IDS = frozenset({DEFAULT_ORGANIZATION_ID})
|
||||
|
||||
|
||||
class RunnableOrgConfigError(ValueError):
|
||||
"""Raised when a saved custom org cannot be safely activated or run."""
|
||||
|
||||
|
||||
def org_index_path(config_dir: Path) -> Path:
|
||||
return Path(config_dir) / ORG_INDEX_FILENAME
|
||||
|
||||
|
||||
def org_configs_dir(config_dir: Path) -> Path:
|
||||
return Path(config_dir) / ORG_CONFIGS_DIRNAME
|
||||
|
||||
|
||||
def is_reserved_org_config_id(value: Any) -> bool:
|
||||
try:
|
||||
org_id = validate_organization_id(value)
|
||||
except ValueError:
|
||||
return False
|
||||
return org_id in RESERVED_ORG_CONFIG_IDS
|
||||
|
||||
|
||||
def validate_saved_org_id(value: Any) -> str:
|
||||
org_id = validate_organization_id(value)
|
||||
if org_id in RESERVED_ORG_CONFIG_IDS:
|
||||
raise ValueError(f"Reserved organization_id for built-in company profile: {org_id!r}")
|
||||
return org_id
|
||||
|
||||
|
||||
def org_config_filename(organization_id: Any) -> str:
|
||||
return f"org_{validate_saved_org_id(organization_id)}_config.yaml"
|
||||
|
||||
|
||||
def organization_id_from_org_config_filename(path: Path) -> str | None:
|
||||
name = Path(path).name
|
||||
prefix = "org_"
|
||||
suffix = "_config.yaml"
|
||||
if not name.startswith(prefix) or not name.endswith(suffix):
|
||||
return None
|
||||
candidate = name[len(prefix):-len(suffix)]
|
||||
try:
|
||||
return validate_saved_org_id(candidate)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def org_config_path(config_dir: Path, organization_id: Any) -> Path:
|
||||
return org_configs_dir(config_dir) / org_config_filename(organization_id)
|
||||
|
||||
|
||||
def org_config_relative_path(organization_id: Any) -> str:
|
||||
return f"{ORG_CONFIGS_DIRNAME}/{org_config_filename(organization_id)}"
|
||||
|
||||
|
||||
def read_org_index(config_dir: Path) -> str | None:
|
||||
path = org_index_path(config_dir)
|
||||
if not path.exists():
|
||||
return None
|
||||
data = _read_yaml_file(path)
|
||||
active_id = data.get("active_organization_id")
|
||||
if not active_id:
|
||||
return None
|
||||
org_id = validate_organization_id(active_id)
|
||||
return None if org_id in RESERVED_ORG_CONFIG_IDS else org_id
|
||||
|
||||
|
||||
def write_org_index(config_dir: Path, organization_id: Any) -> None:
|
||||
org_id = validate_saved_org_id(organization_id)
|
||||
_atomic_write_text(
|
||||
org_index_path(config_dir),
|
||||
yaml.dump(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"active_organization_id": org_id,
|
||||
},
|
||||
default_flow_style=False,
|
||||
sort_keys=False,
|
||||
allow_unicode=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def list_org_config_paths(config_dir: Path) -> list[Path]:
|
||||
org_dir = org_configs_dir(config_dir)
|
||||
if not org_dir.is_dir():
|
||||
return []
|
||||
return sorted(path for path in org_dir.glob("org_*_config.yaml") if organization_id_from_org_config_filename(path))
|
||||
|
||||
|
||||
def allocate_org_config_id(config_dir: Path, organization_name: Any, *, preferred_id: Any = "") -> str:
|
||||
base = str(preferred_id or "").strip()
|
||||
try:
|
||||
candidate = validate_saved_org_id(base) if base else ""
|
||||
except ValueError:
|
||||
candidate = ""
|
||||
if not candidate:
|
||||
candidate = slugify_organization_name(organization_name)
|
||||
existing = {
|
||||
org_id
|
||||
for path in list_org_config_paths(config_dir)
|
||||
for org_id in [organization_id_from_org_config_filename(path)]
|
||||
if org_id
|
||||
}
|
||||
existing.update(RESERVED_ORG_CONFIG_IDS)
|
||||
if candidate not in existing:
|
||||
return candidate
|
||||
suffix = 2
|
||||
while True:
|
||||
tail = f"_{suffix}"
|
||||
stem = candidate[: max(1, 64 - len(tail))].rstrip("_-") or "org"
|
||||
next_id = f"{stem}{tail}"
|
||||
if next_id not in existing:
|
||||
return next_id
|
||||
suffix += 1
|
||||
|
||||
|
||||
def validate_org_config_payload(path: Path, data: dict[str, Any]) -> dict[str, Any]:
|
||||
schema_version = int(data.get("schema_version", 1) or 1)
|
||||
if schema_version > ORG_CONFIG_SCHEMA_VERSION:
|
||||
raise ValueError(
|
||||
f"{path.name} schema_version {schema_version} is not supported by this version of OpenOPC"
|
||||
)
|
||||
kind = str(data.get("kind", "") or "").strip()
|
||||
if schema_version >= ORG_CONFIG_SCHEMA_VERSION and kind and kind != ORG_CONFIG_KIND:
|
||||
raise ValueError(f"Unsupported org architecture kind in {path.name}: {kind}")
|
||||
data["schema_version"] = schema_version
|
||||
return data
|
||||
|
||||
|
||||
def build_org_config_payload_from_config(
|
||||
config: OPCConfig,
|
||||
*,
|
||||
organization_id: str | None = None,
|
||||
organization_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload = build_company_org_payload_from_config(
|
||||
config,
|
||||
organization_id=organization_id,
|
||||
organization_name=organization_name,
|
||||
force_profile="custom",
|
||||
)
|
||||
org_id = validate_saved_org_id(payload.get("organization_id"))
|
||||
payload["metadata"] = {
|
||||
**dict(payload.get("metadata", {}) or {}),
|
||||
"source": "org_mode",
|
||||
"organization_config_file": org_config_relative_path(org_id),
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
def apply_org_config_payload_to_config(
|
||||
base_config: OPCConfig,
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
source_path: Path | None = None,
|
||||
) -> OPCConfig:
|
||||
merged = base_config.model_dump()
|
||||
org_mapping = _company_org_payload_to_org_mapping(data, source_path=source_path)
|
||||
org_mapping["organization_id"] = validate_saved_org_id(org_mapping.get("organization_id"))
|
||||
org_mapping["company_profile"] = "custom"
|
||||
if org_mapping.get("organization_id"):
|
||||
org_mapping["organization_config_file"] = org_config_relative_path(org_mapping["organization_id"])
|
||||
merged["org"] = org_mapping
|
||||
config = OPCConfig.model_validate(merged)
|
||||
config.org.talent_templates = []
|
||||
if source_path is not None:
|
||||
try:
|
||||
from opc.core.employee_registry import load_company_employees
|
||||
|
||||
config_dir = Path(source_path).parent.parent
|
||||
config.org.employees = load_company_employees(
|
||||
config_dir.parent,
|
||||
config.org.organization_id,
|
||||
list(config.org.employees),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return config
|
||||
|
||||
|
||||
def validate_runnable_org_config(config: OPCConfig, *, organization_id: Any = "") -> None:
|
||||
"""Reject custom orgs that would silently fall back to corporate builtin roles.
|
||||
|
||||
Corporate can still fall back to builtin roles for legacy configs.
|
||||
Saved custom orgs must carry explicit roles before activation or execution.
|
||||
"""
|
||||
org = config.org
|
||||
profile = str(getattr(org, "company_profile", "") or "").strip().lower()
|
||||
if profile != "custom":
|
||||
return
|
||||
|
||||
org_id = validate_saved_org_id(organization_id or getattr(org, "organization_id", ""))
|
||||
roles = [
|
||||
role
|
||||
for role in list(getattr(org, "roles", []) or [])
|
||||
if str(getattr(role, "id", "") or "").strip()
|
||||
]
|
||||
if roles:
|
||||
return
|
||||
|
||||
raise RunnableOrgConfigError(
|
||||
f"Custom organization `{org_id}` has no roles. "
|
||||
"Refusing to activate or run it with corporate fallback roles."
|
||||
)
|
||||
|
||||
|
||||
def write_org_config_payload(config_dir: Path, organization_id: Any, payload: dict[str, Any]) -> Path:
|
||||
org_id = validate_saved_org_id(organization_id)
|
||||
path = org_config_path(config_dir, org_id)
|
||||
payload = dict(payload)
|
||||
raw_employees = list(payload.get("employees", []) or [])
|
||||
if raw_employees:
|
||||
from opc.core.employee_registry import load_employee_registry, write_employee_registry
|
||||
|
||||
opc_home = Path(config_dir).parent
|
||||
existing = load_employee_registry(opc_home, org_id)
|
||||
write_employee_registry(opc_home, org_id, [*existing, *raw_employees])
|
||||
payload["employees"] = []
|
||||
payload["talent_templates"] = []
|
||||
payload["organization_id"] = org_id
|
||||
payload.setdefault("schema_version", ORG_CONFIG_SCHEMA_VERSION)
|
||||
payload.setdefault("kind", ORG_CONFIG_KIND)
|
||||
payload["metadata"] = {
|
||||
**dict(payload.get("metadata", {}) or {}),
|
||||
"organization_config_file": org_config_relative_path(org_id),
|
||||
}
|
||||
_atomic_write_text(
|
||||
path,
|
||||
yaml.dump(payload, default_flow_style=False, sort_keys=False, allow_unicode=True),
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def load_org_config_payload(config_dir: Path, organization_id: Any | None = None) -> tuple[dict[str, Any], Path]:
|
||||
config_dir = Path(config_dir)
|
||||
org_id = validate_saved_org_id(organization_id) if organization_id else read_org_index(config_dir)
|
||||
if not org_id:
|
||||
raise FileNotFoundError("No active org architecture is selected.")
|
||||
path = org_config_path(config_dir, org_id)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Org architecture config does not exist: {path}")
|
||||
return validate_org_config_payload(path, _read_yaml_file(path)), path
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Windows-specific SSL environment helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
_REMOVED_SSLKEYLOGFILE: str | None = None
|
||||
|
||||
|
||||
def sanitize_windows_sslkeylogfile() -> str | None:
|
||||
"""Eagerly remove SSLKEYLOGFILE on Windows and remember it for one warning."""
|
||||
global _REMOVED_SSLKEYLOGFILE
|
||||
if os.name != "nt":
|
||||
return None
|
||||
removed = os.environ.pop("SSLKEYLOGFILE", None)
|
||||
if removed and not _REMOVED_SSLKEYLOGFILE:
|
||||
_REMOVED_SSLKEYLOGFILE = removed
|
||||
return removed
|
||||
|
||||
|
||||
def pop_windows_sslkeylogfile() -> str | None:
|
||||
"""Remove SSLKEYLOGFILE on Windows to avoid aiohttp/OpenSSL import crashes."""
|
||||
global _REMOVED_SSLKEYLOGFILE
|
||||
if os.name != "nt":
|
||||
return None
|
||||
removed = os.environ.pop("SSLKEYLOGFILE", None)
|
||||
if removed:
|
||||
_REMOVED_SSLKEYLOGFILE = None
|
||||
return removed
|
||||
remembered = _REMOVED_SSLKEYLOGFILE
|
||||
_REMOVED_SSLKEYLOGFILE = None
|
||||
return remembered
|
||||
|
||||
|
||||
def format_windows_sslkeylog_warning(command_label: str, keylog_path: str) -> str:
|
||||
"""Render a consistent warning when SSLKEYLOGFILE must be ignored on Windows."""
|
||||
return (
|
||||
f"Warning: ignoring SSLKEYLOGFILE for `{command_label}` on Windows "
|
||||
f"({keylog_path}) because it can crash aiohttp/OpenSSL."
|
||||
)
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Utilities for normalizing worker-facing message envelopes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
MESSAGE_CLASSES = {"chat", "protocol", "notification"}
|
||||
# Protocol types that messages may carry. Ten previously-declared values
|
||||
# (permission_*, plan_approval_*, dependency_*, approval_reply, cross_team_*)
|
||||
# were never actually written by any code path and have been removed along
|
||||
# with the matching CommsSemanticType enum values. See
|
||||
# plans/task-cleanup-dead-comms.md.
|
||||
_PROTOCOL_TYPES = {
|
||||
"approval_request",
|
||||
"shutdown_request",
|
||||
"shutdown_response",
|
||||
"ack",
|
||||
}
|
||||
_NOTIFICATION_KINDS = {
|
||||
"idle",
|
||||
"task_complete",
|
||||
"blocked",
|
||||
"handoff_ready",
|
||||
"completion",
|
||||
"status_digest",
|
||||
"permission_needed",
|
||||
"error",
|
||||
}
|
||||
|
||||
_SEMANTIC_PROTOCOL_MAP = {
|
||||
"approval_request": "approval_request",
|
||||
}
|
||||
|
||||
_SEMANTIC_NOTIFICATION_MAP = {
|
||||
"idle_notification": "idle",
|
||||
"handoff_ready": "handoff_ready",
|
||||
"work_item_result": "task_complete",
|
||||
"blocker": "blocked",
|
||||
"completion": "completion",
|
||||
"status_digest": "status_digest",
|
||||
}
|
||||
|
||||
|
||||
def _clean_text(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _clean_choice(value: Any, allowed: set[str]) -> str:
|
||||
normalized = _clean_text(value).lower()
|
||||
return normalized if normalized in allowed else ""
|
||||
|
||||
|
||||
def _coerce_bool(value: Any) -> bool | None:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"true", "1", "yes", "y", "on"}:
|
||||
return True
|
||||
if normalized in {"false", "0", "no", "n", "off"}:
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def normalize_worker_envelope_metadata(
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
msg_type: str = "",
|
||||
semantic_type: str = "",
|
||||
transport_kind: str = "",
|
||||
from_agent: str = "",
|
||||
reply_needed: bool = False,
|
||||
worker_id: str = "",
|
||||
task_id: str = "",
|
||||
projection_id: str = "",
|
||||
session_id: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Return additive worker-envelope metadata for chat/protocol/notification routing."""
|
||||
merged = dict(metadata or {})
|
||||
|
||||
protocol_type = _clean_choice(merged.get("protocol_type"), _PROTOCOL_TYPES)
|
||||
if not protocol_type:
|
||||
protocol_type = _SEMANTIC_PROTOCOL_MAP.get(_clean_text(semantic_type).lower(), "")
|
||||
if not protocol_type and _clean_text(msg_type).lower() == "ack":
|
||||
protocol_type = "ack"
|
||||
|
||||
notification_kind = _clean_choice(merged.get("notification_kind"), _NOTIFICATION_KINDS)
|
||||
if not notification_kind:
|
||||
notification_kind = _SEMANTIC_NOTIFICATION_MAP.get(_clean_text(semantic_type).lower(), "")
|
||||
resident_status = _clean_text(merged.get("resident_status")).lower()
|
||||
if not notification_kind and resident_status in {"idle", "blocked"}:
|
||||
notification_kind = resident_status
|
||||
if not notification_kind and _clean_text(merged.get("status")).lower() in {"failed", "error"}:
|
||||
notification_kind = "error"
|
||||
|
||||
message_class = _clean_choice(merged.get("message_class"), MESSAGE_CLASSES)
|
||||
if not message_class:
|
||||
if protocol_type:
|
||||
message_class = "protocol"
|
||||
elif notification_kind:
|
||||
message_class = "notification"
|
||||
else:
|
||||
message_class = "chat"
|
||||
|
||||
actionable = _coerce_bool(merged.get("actionable"))
|
||||
if actionable is None:
|
||||
actionable = message_class != "notification"
|
||||
|
||||
resolved_worker_id = (
|
||||
_clean_text(merged.get("worker_id"))
|
||||
or _clean_text(worker_id)
|
||||
or _clean_text(merged.get("member_session_id"))
|
||||
or _clean_text(merged.get("runtime_session_id"))
|
||||
or _clean_text(from_agent)
|
||||
)
|
||||
|
||||
origin_task_id = _clean_text(merged.get("origin_task_id")) or _clean_text(task_id)
|
||||
origin_projection_id = _clean_text(merged.get("origin_projection_id")) or _clean_text(projection_id)
|
||||
origin_session_id = _clean_text(merged.get("origin_session_id")) or _clean_text(session_id)
|
||||
|
||||
merged.update(
|
||||
{
|
||||
"message_class": message_class,
|
||||
"protocol_type": protocol_type or None,
|
||||
"notification_kind": notification_kind or None,
|
||||
"actionable": bool(actionable),
|
||||
"worker_id": resolved_worker_id,
|
||||
"origin_task_id": origin_task_id or None,
|
||||
"origin_projection_id": origin_projection_id or None,
|
||||
"origin_session_id": origin_session_id or None,
|
||||
}
|
||||
)
|
||||
if reply_needed and message_class == "notification":
|
||||
merged["actionable"] = True
|
||||
if _clean_text(transport_kind):
|
||||
merged.setdefault("transport_kind", _clean_text(transport_kind).lower())
|
||||
if _clean_text(semantic_type):
|
||||
merged.setdefault("semantic_type", _clean_text(semantic_type).lower())
|
||||
return merged
|
||||
|
||||
|
||||
def envelope_fields_from_message(message: dict[str, Any]) -> dict[str, Any]:
|
||||
metadata = normalize_worker_envelope_metadata(
|
||||
dict(message.get("metadata", {}) or {}),
|
||||
msg_type=_clean_text(message.get("msg_type")),
|
||||
semantic_type=_clean_text(message.get("semantic_type")),
|
||||
transport_kind=_clean_text(message.get("transport_kind")),
|
||||
from_agent=_clean_text(message.get("from_agent") or message.get("from")),
|
||||
reply_needed=bool(message.get("reply_needed")),
|
||||
worker_id=_clean_text(message.get("worker_id")),
|
||||
task_id=_clean_text(message.get("origin_task_id") or message.get("task_id")),
|
||||
projection_id=_clean_text(message.get("origin_projection_id") or message.get("projection_id")),
|
||||
session_id=_clean_text(message.get("origin_session_id") or message.get("session_id")),
|
||||
)
|
||||
return {
|
||||
"message_class": metadata.get("message_class", "chat"),
|
||||
"protocol_type": metadata.get("protocol_type"),
|
||||
"notification_kind": metadata.get("notification_kind"),
|
||||
"actionable": bool(metadata.get("actionable", True)),
|
||||
"worker_id": metadata.get("worker_id"),
|
||||
"origin_task_id": metadata.get("origin_task_id"),
|
||||
"origin_projection_id": metadata.get("origin_projection_id"),
|
||||
"origin_session_id": metadata.get("origin_session_id"),
|
||||
"metadata": metadata,
|
||||
}
|
||||
|
||||
|
||||
def classify_worker_message(message: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return a shallow copy with normalized worker-envelope fields."""
|
||||
merged = dict(message)
|
||||
envelope = envelope_fields_from_message(message)
|
||||
merged.update({k: v for k, v in envelope.items() if k != "metadata"})
|
||||
merged["metadata"] = envelope["metadata"]
|
||||
return merged
|
||||
|
||||
|
||||
def worker_message_is_actionable(message: dict[str, Any]) -> bool:
|
||||
envelope = envelope_fields_from_message(message)
|
||||
return bool(envelope.get("actionable", True)) and envelope.get("message_class") in {"chat", "protocol"}
|
||||
Reference in New Issue
Block a user