feat: OpenMesh 基础平台与 MD/PDF 转换技能
- 后端: coworker 智能体框架, WS API, 文件上传, 附件处理 - 前端: Open WebUI, 文件全量走 upload API (含 MD/TXT/JSON 等文本类) - 技能: md-to-office (pandoc + wkhtmltopdf) - 修复: 上传文件路径丢失, Agent 搜索浪费, 输出文件跑到 uploads/ - 打包: PyInstaller one-dir, 预打包 pandoc/wkhtmltopdf/chromium
This commit is contained in:
140
coworker/teams/registry.py
Normal file
140
coworker/teams/registry.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""Team registry — which sessions form a team: one lead, its workers, their board.
|
||||
|
||||
A team is created at the staffing gate ("Create team & start"): worker sessions are
|
||||
PRE-SPAWNED as durable state on disk (spawn ≠ first turn — an unassigned worker costs
|
||||
zero tokens; its first model turn fires when the first assignment lands). The registry
|
||||
is the roster the wake plumbing walks each tick, and the tie that scopes staleness
|
||||
digests by role membership.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class TeamWorker:
|
||||
actor: str # the lead-given NAME — board actor id, assignee handle, @mention target
|
||||
persona: str
|
||||
session_id: str
|
||||
model: str = ""
|
||||
reason: str = "" # why the lead staffed it — surfaces in teammates' rosters
|
||||
|
||||
|
||||
@dataclass
|
||||
class Team:
|
||||
team_id: str
|
||||
space: str
|
||||
lead_session: str
|
||||
lead_actor: str
|
||||
workers: list[TeamWorker] = field(default_factory=list)
|
||||
chat_enabled: bool = False
|
||||
chat_group: str = "" # ChatStore group_id when chat is enabled
|
||||
paused: bool = False # budget/user pause: the wake gate skips a paused team
|
||||
created_at: str = field(
|
||||
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
||||
)
|
||||
# Rolling budget gate: automatic wakes this hour (reset when the hour rolls).
|
||||
wake_hour: str = ""
|
||||
wakes_this_hour: int = 0
|
||||
|
||||
|
||||
class TeamRegistry:
|
||||
def __init__(self, path: Optional[str | Path] = None) -> None:
|
||||
self.path = Path(path) if path else None
|
||||
self._lock = threading.Lock()
|
||||
self._teams: dict[str, Team] = {}
|
||||
if self.path and self.path.is_file():
|
||||
for raw in json.loads(self.path.read_text(encoding="utf-8")).get(
|
||||
"teams", []
|
||||
):
|
||||
workers = [TeamWorker(**w) for w in raw.pop("workers", [])]
|
||||
team = Team(**{**raw, "workers": []})
|
||||
team.workers = workers
|
||||
self._teams[team.team_id] = team
|
||||
|
||||
def _save(self) -> None:
|
||||
if not self.path:
|
||||
return
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.path.write_text(
|
||||
json.dumps(
|
||||
{"teams": [asdict(t) for t in self._teams.values()]}, indent=2
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
space: str,
|
||||
lead_session: str,
|
||||
lead_actor: str,
|
||||
workers: list[TeamWorker],
|
||||
chat_enabled: bool = False,
|
||||
chat_group: str = "",
|
||||
) -> Team:
|
||||
team = Team(
|
||||
team_id=uuid.uuid4().hex[:12],
|
||||
space=space,
|
||||
lead_session=lead_session,
|
||||
lead_actor=lead_actor,
|
||||
workers=workers,
|
||||
chat_enabled=chat_enabled,
|
||||
chat_group=chat_group,
|
||||
)
|
||||
with self._lock:
|
||||
self._teams[team.team_id] = team
|
||||
self._save()
|
||||
return team
|
||||
|
||||
def all(self) -> list[Team]:
|
||||
return list(self._teams.values())
|
||||
|
||||
def get(self, team_id: str) -> Optional[Team]:
|
||||
return self._teams.get(team_id)
|
||||
|
||||
def for_lead_session(self, session_id: str) -> Optional[Team]:
|
||||
for team in self._teams.values():
|
||||
if team.lead_session == session_id:
|
||||
return team
|
||||
return None
|
||||
|
||||
def for_worker_session(self, session_id: str) -> Optional[tuple[Team, TeamWorker]]:
|
||||
for team in self._teams.values():
|
||||
for worker in team.workers:
|
||||
if worker.session_id == session_id:
|
||||
return team, worker
|
||||
return None
|
||||
|
||||
def set_paused(self, team_id: str, paused: bool) -> None:
|
||||
with self._lock:
|
||||
team = self._teams.get(team_id)
|
||||
if team is not None:
|
||||
team.paused = paused
|
||||
self._save()
|
||||
|
||||
def count_wake(self, team_id: str, *, cap: int) -> bool:
|
||||
"""The budget gate at the wake gate: count one automatic wake against the
|
||||
team's rolling hour; False = over cap (the caller skips the wake and the
|
||||
team reads as paused-for-budget until the hour rolls). A runaway loop
|
||||
stops BETWEEN turns, never mid-flight."""
|
||||
hour = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H")
|
||||
with self._lock:
|
||||
team = self._teams.get(team_id)
|
||||
if team is None:
|
||||
return False
|
||||
if team.wake_hour != hour:
|
||||
team.wake_hour, team.wakes_this_hour = hour, 0
|
||||
if team.wakes_this_hour >= cap:
|
||||
self._save()
|
||||
return False
|
||||
team.wakes_this_hour += 1
|
||||
self._save()
|
||||
return True
|
||||
Reference in New Issue
Block a user