Files
OpenMesh/coworker/projects.py
zhaolei 6f402ffcee
Some checks failed
CI / pytest (push) Has been cancelled
CI / gui-unit (push) Has been cancelled
CI / gui-e2e (push) Has been cancelled
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
2026-09-13 23:41:04 +08:00

232 lines
8.0 KiB
Python

"""Project identity — the key under boards and workspace memory, plus user names.
Twentieth-pass ladder (agent-teams-design.md): an explicit per-session binding
beats derivation; derivation prefers the git repo (all worktrees of one repo
collapse to one project); a plain folder falls back to its resolved path — the
status-quo key. Resolution happens at discrete moments (engine build, root
grant); nothing watches the filesystem.
"""
from __future__ import annotations
import sqlite3
import subprocess
import threading
from pathlib import Path
from typing import Any, Optional
NAME_KINDS = ("memory", "board")
MAX_NAME_CHARS = 60
def _git_common_dir(workspace: Path) -> Optional[Path]:
try:
out = subprocess.run(
["git", "-C", str(workspace), "rev-parse", "--git-common-dir"],
capture_output=True, text=True, timeout=5,
)
except (OSError, subprocess.TimeoutExpired):
return None
if out.returncode != 0:
return None
raw = out.stdout.strip()
if not raw:
return None
p = Path(raw)
if not p.is_absolute():
p = workspace / p
try:
return p.resolve()
except OSError:
return None
def project_key(workspace: str | Path) -> str:
"""Derive the project key for a directory. Git repo → the repo directory
(common-dir's parent — shared by every worktree); otherwise the resolved
path. Never raises; a missing directory just resolves as a path."""
ws = Path(workspace).expanduser()
try:
ws = ws.resolve()
except OSError:
ws = Path(str(workspace))
if ws.is_dir():
common = _git_common_dir(ws)
if common is not None:
# Normal layout: <repo>/.git → key is <repo>. Bare/odd layouts keep
# the common dir itself — still one stable key per repo.
return str(common.parent if common.name == ".git" else common)
return str(ws)
def project_label(key: str, *, home: Optional[str] = None) -> dict[str, Any]:
"""Display info for a derived key. kind 'git' when the key currently looks
like a repo (has a .git); label rules per UX-044: git = repo folder name,
folder = ~-collapsed path (the GUI trims to the last 3 segments)."""
p = Path(key)
is_git = (p / ".git").exists()
full = key
h = home or str(Path.home())
shown = key
if shown == h:
shown = "~"
elif shown.startswith(h + "/"):
shown = "~" + shown[len(h):]
return {
"kind": "git" if is_git else "folder",
"label": p.name if is_git else shown,
"full": full,
}
class ProjectNames:
"""User-given names for memories and boards — aliases over project keys.
One table in the session DB; a name never moves data, it only points."""
def __init__(self, conn: sqlite3.Connection, lock: threading.RLock) -> None:
self._conn = conn
self._lock = lock
with self._lock:
self._conn.execute(
"""CREATE TABLE IF NOT EXISTS project_names (
kind TEXT NOT NULL, name TEXT NOT NULL, key TEXT NOT NULL,
last_used_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (kind, name)
)"""
)
self._conn.commit()
def name_current(self, kind: str, name: str, key: str) -> dict[str, Any]:
name = (name or "").strip()[:MAX_NAME_CHARS]
if kind not in NAME_KINDS:
raise ValueError(f"unknown kind {kind!r}")
if not name:
raise ValueError("empty name")
with self._lock:
self._conn.execute(
"INSERT INTO project_names (kind, name, key) VALUES (?, ?, ?) "
"ON CONFLICT (kind, name) DO UPDATE SET key = excluded.key, "
"last_used_at = datetime('now')",
(kind, name, key),
)
self._conn.commit()
return {"kind": kind, "name": name, "key": key}
def resolve(self, kind: str, name: str) -> Optional[str]:
with self._lock:
row = self._conn.execute(
"SELECT key FROM project_names WHERE kind = ? AND name = ?",
(kind, name),
).fetchone()
return row[0] if row else None
def touch(self, kind: str, name: str) -> None:
"""Bump MRU when a binding is used."""
with self._lock:
self._conn.execute(
"UPDATE project_names SET last_used_at = datetime('now') "
"WHERE kind = ? AND name = ?",
(kind, name),
)
self._conn.commit()
def list(self, kind: str, *, limit: Optional[int] = None) -> list[dict[str, Any]]:
q = (
"SELECT name, key, last_used_at FROM project_names "
"WHERE kind = ? ORDER BY last_used_at DESC, name"
)
args: list[Any] = [kind]
if limit:
q += " LIMIT ?"
args.append(limit)
with self._lock:
rows = self._conn.execute(q, args).fetchall()
return [{"name": r[0], "key": r[1], "last_used_at": r[2]} for r in rows]
def forget(self, kind: str, name: str) -> bool:
with self._lock:
cur = self._conn.execute(
"DELETE FROM project_names WHERE kind = ? AND name = ?", (kind, name)
)
self._conn.commit()
return cur.rowcount > 0
def resolve_memory_key(
workspace: Optional[str],
*,
binding: Optional[str] = None,
names: Optional["ProjectNames"] = None,
memory_store: Any = None,
) -> Optional[str]:
"""The identity ladder for memory: explicit binding > git > path. Applies the
one-time path→git re-key as a read-path side effect (idempotent: once moved,
the path key has no rows left to move)."""
if binding and names is not None:
key = names.resolve("memory", binding)
if key:
names.touch("memory", binding)
return key
if not workspace:
return None
derived = project_key(workspace)
path_key = str(Path(workspace).expanduser().resolve()) if Path(
workspace
).expanduser().exists() else str(Path(workspace).expanduser())
if memory_store is not None and derived != path_key:
try:
memory_store.rekey_workspace(path_key, derived)
except Exception:
pass
return derived
def resolve_board_space(
workspace: Optional[str],
*,
binding: Optional[str] = None,
names: Optional["ProjectNames"] = None,
team_store: Any = None,
) -> Optional[str]:
"""The identity ladder for the board space — same ladder, board collision
rule: when both a path-keyed and a git-keyed space have events, the git key
wins and the path space stays dormant (rekey_space refuses; nothing merges)."""
if binding and names is not None:
key = names.resolve("board", binding)
if key:
names.touch("board", binding)
return key
if not workspace:
return None
derived = project_key(workspace)
path_key = str(Path(workspace).expanduser().resolve()) if Path(
workspace
).expanduser().exists() else str(Path(workspace).expanduser())
if team_store is not None and derived != path_key:
try:
team_store.rekey_space(path_key, derived)
except Exception:
pass
return derived
def project_presence(
key: str, *, memory_store: Any = None, team_store: Any = None
) -> dict[str, int]:
"""What already exists under a project key — feeds the grant-time notice.
Counts only; the notice is a pointer, never content."""
memories = 0
items = 0
if memory_store is not None:
try:
memories = len(memory_store.list(workspace=key))
except Exception:
pass
if team_store is not None:
try:
# A cheap existence probe that needs no actor: any event under the key.
items = team_store.event_count(key)
except Exception:
pass
return {"memories": memories, "board_items": items}