aps-agent/server/state/projects.py

257 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 项目 / 会话服务端仓(moduleId: state-projects, AG-08 首切片,可重生 ✅)
# 规则:项目/会话/消息持久化到 JSON;删除项目不触碰世界状态
# ============================================================
from __future__ import annotations
import json
import os
import tempfile
import threading
import uuid
from datetime import datetime
from typing import Any
from server.timeutil import fmt_dt
PERSONAL_PROJECT_ID = "__personal__"
World = dict[str, Any]
def _now() -> str:
return fmt_dt(datetime.now())
def _uid(prefix: str) -> str:
return f"{prefix}_{uuid.uuid4().hex[:8]}"
def _seed() -> dict[str, Any]:
sess = {
"id": _uid("sess"),
"projectId": PERSONAL_PROJECT_ID,
"title": "新话题",
"status": "running",
"updatedAt": _now(),
}
return {
"projects": [],
"sessions": [sess],
"files": [],
"messages": {},
"activeProjectId": PERSONAL_PROJECT_ID,
"activeSessionId": sess["id"],
"worldKey": "default", # 与当前全局 WorldStore 绑定(单世界)
}
class ProjectStore:
"""项目制工作区:项目/会话/文件/消息(单文件 JSON + 原子写)。"""
def __init__(self, path: str | None = None) -> None:
self.path = path or os.environ.get("APS_PROJECTS_PATH", "server/data/projects.json")
self._lock = threading.Lock()
self.data: dict[str, Any] = self._load()
def _load(self) -> dict[str, Any]:
try:
with open(self.path, "r", encoding="utf-8") as f:
raw = json.load(f)
if not isinstance(raw, dict) or not raw.get("sessions"):
data = _seed()
self._write(data)
return data
raw.setdefault("projects", [])
raw.setdefault("files", [])
raw.setdefault("messages", {})
raw.setdefault("worldKey", "default")
raw.setdefault("activeProjectId", PERSONAL_PROJECT_ID)
raw.setdefault("activeSessionId", raw["sessions"][0]["id"])
return raw
except (FileNotFoundError, json.JSONDecodeError, TypeError):
data = _seed()
self._write(data)
return data
def _write(self, data: dict[str, Any]) -> None:
os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(self.path) or ".", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=1)
os.replace(tmp, self.path)
except BaseException:
if os.path.exists(tmp):
os.unlink(tmp)
raise
def save(self) -> None:
with self._lock:
self._write(self.data)
def snapshot(self, *, include_messages: bool = True) -> dict[str, Any]:
"""只读投影(P0)。"""
with self._lock:
return self._snapshot_unlocked(include_messages=include_messages)
def _snapshot_unlocked(self, *, include_messages: bool = True) -> dict[str, Any]:
out = {
"projects": list(self.data.get("projects") or []),
"sessions": list(self.data.get("sessions") or []),
"files": list(self.data.get("files") or []),
"activeProjectId": self.data.get("activeProjectId"),
"activeSessionId": self.data.get("activeSessionId"),
"worldKey": self.data.get("worldKey") or "default",
}
if include_messages:
out["messages"] = dict(self.data.get("messages") or {})
return out
def replace_workspace(self, payload: dict[str, Any]) -> dict[str, Any]:
"""整包替换工作区元数据(可含 messages);不碰 WorldStore。"""
with self._lock:
projects = list(payload.get("projects") or [])
sessions = list(payload.get("sessions") or [])
files = list(payload.get("files") or [])
if not sessions:
seeded = _seed()
sessions = seeded["sessions"]
payload = {**payload, "activeSessionId": sessions[0]["id"],
"activeProjectId": PERSONAL_PROJECT_ID}
messages = payload.get("messages")
if messages is None:
messages = self.data.get("messages") or {}
# 清掉已删除会话的消息,防泄漏
keep = {s["id"] for s in sessions if isinstance(s, dict) and s.get("id")}
messages = {k: v for k, v in dict(messages).items() if k in keep}
self.data = {
"projects": projects,
"sessions": sessions,
"files": files,
"messages": messages,
"activeProjectId": payload.get("activeProjectId") or PERSONAL_PROJECT_ID,
"activeSessionId": payload.get("activeSessionId") or sessions[0]["id"],
"worldKey": payload.get("worldKey") or self.data.get("worldKey") or "default",
}
self._write(self.data)
return self._snapshot_unlocked(include_messages=True)
def create_project(self, name: str, *, scope_label: str = "未设定作用域") -> dict[str, Any]:
with self._lock:
pid = _uid("proj")
sid = _uid("sess")
project = {
"id": pid,
"name": (name or "").strip() or "未命名项目",
"scopeLabel": scope_label or "未设定作用域",
"createdAt": _now(),
"worldKey": self.data.get("worldKey") or "default",
}
session = {
"id": sid, "projectId": pid, "title": "新话题",
"status": "running", "updatedAt": _now(),
}
self.data["projects"] = [project, *(self.data.get("projects") or [])]
self.data["sessions"] = [session, *(self.data.get("sessions") or [])]
self.data["activeProjectId"] = pid
self.data["activeSessionId"] = sid
self._write(self.data)
return {"project": project, "session": session}
def delete_project(self, project_id: str) -> dict[str, Any]:
"""删除项目元数据与其会话/文件/消息;绝不修改世界状态。"""
if project_id == PERSONAL_PROJECT_ID:
raise ValueError("个人话题作用域不可删除")
with self._lock:
before = next((p for p in self.data.get("projects") or [] if p["id"] == project_id), None)
if before is None:
raise ValueError(f"项目不存在:{project_id}")
drop_sessions = {s["id"] for s in self.data.get("sessions") or []
if s.get("projectId") == project_id}
self.data["projects"] = [p for p in self.data["projects"] if p["id"] != project_id]
self.data["sessions"] = [s for s in self.data["sessions"] if s["id"] not in drop_sessions]
self.data["files"] = [f for f in self.data.get("files") or []
if f.get("projectId") != project_id]
msgs = dict(self.data.get("messages") or {})
for sid in drop_sessions:
msgs.pop(sid, None)
self.data["messages"] = msgs
if not self.data["sessions"]:
fresh = {
"id": _uid("sess"), "projectId": PERSONAL_PROJECT_ID,
"title": "新话题", "status": "running", "updatedAt": _now(),
}
self.data["sessions"] = [fresh]
if self.data.get("activeProjectId") == project_id:
self.data["activeProjectId"] = (
self.data["projects"][0]["id"] if self.data["projects"] else PERSONAL_PROJECT_ID)
if self.data.get("activeSessionId") in drop_sessions or not self.data.get("activeSessionId"):
scope = self.data["activeProjectId"]
sib = next((s for s in self.data["sessions"] if s["projectId"] == scope), None)
self.data["activeSessionId"] = (sib or self.data["sessions"][0])["id"]
self._write(self.data)
return {
"deletedProjectId": project_id,
"deletedSessionIds": sorted(drop_sessions),
"worldUntouched": True,
"worldKey": self.data.get("worldKey") or "default",
}
def create_session(self, project_id: str, title: str = "新话题") -> dict[str, Any]:
with self._lock:
if project_id != PERSONAL_PROJECT_ID:
if not any(p["id"] == project_id for p in self.data.get("projects") or []):
raise ValueError(f"项目不存在:{project_id}")
session = {
"id": _uid("sess"),
"projectId": project_id,
"title": (title or "").strip() or "新话题",
"status": "running",
"updatedAt": _now(),
}
self.data["sessions"] = [session, *(self.data.get("sessions") or [])]
self.data["activeProjectId"] = project_id
self.data["activeSessionId"] = session["id"]
self._write(self.data)
return {"session": session}
def replace_messages(self, session_id: str, messages: list[Any]) -> dict[str, Any]:
with self._lock:
if not any(s["id"] == session_id for s in self.data.get("sessions") or []):
raise ValueError(f"会话不存在:{session_id}")
self.data.setdefault("messages", {})[session_id] = list(messages or [])
for s in self.data["sessions"]:
if s["id"] == session_id:
s["updatedAt"] = _now()
break
self._write(self.data)
return {"sessionId": session_id, "count": len(messages or [])}
def append_message(self, session_id: str, message: dict[str, Any]) -> dict[str, Any]:
with self._lock:
if not any(s["id"] == session_id for s in self.data.get("sessions") or []):
raise ValueError(f"会话不存在:{session_id}")
bucket = list((self.data.get("messages") or {}).get(session_id) or [])
bucket.append(message)
self.data.setdefault("messages", {})[session_id] = bucket
for s in self.data["sessions"]:
if s["id"] == session_id:
s["updatedAt"] = _now()
break
self._write(self.data)
return {"sessionId": session_id, "count": len(bucket)}
def get_messages(self, session_id: str) -> list[Any]:
with self._lock:
return list((self.data.get("messages") or {}).get(session_id) or [])
_store: ProjectStore | None = None
def get_project_store() -> ProjectStore:
global _store
if _store is None:
_store = ProjectStore()
return _store