云端身份与记忆:用户画像+分级记忆,不动 thread
- 新增 cloud/identity.py:user_profiles(显示名/时区/偏好,偏好禁密钥键)
与 identity_memories(user 级仅本人可见、project 级项目成员可见,
kind fact/preference/decision/convention,supersede 链+物理删除)。
- server 新增 /users/me/profile、/users/me/memories、/projects/{id}/
identity-memories、/users/me/identity-context,项目记忆先过项目
read/create_task 门;上下文只显式取用,不自动注入任务/thread。
- PG schema 与迁移清单同步新增两张 TEXT 主键表,序列清单不动;
server 补 do_PUT 入口(/users/me/profile 首个 PUT 路由)。
- 新测试 11 个;三套件 cloud 937 OK(4 跳过)、host 89 OK、deploy 29 OK。
- 未动:host.py、worker、CODEX_HOME、旧 summaries 表、控制台 UI;
未推、未建 PR、未部署。
This commit is contained in:
1 parent
38c0797cb3
commit
6e9d6dc5e8
6 files changed
+949
No files matched your search
+316
@@ -82,6 +82,7 @@ from cloud.approvals import ensure_approvals_table as _appr_ensure
|
||||
from cloud.approvals import expire_overdue as _appr_expire
|
||||
from cloud.approvals import get_approval as _appr_get
|
||||
from cloud.approvals import list_approvals as _appr_list
|
||||
from cloud.identity import IdentityStore
|
||||
from cloud.kb import KBStore
|
||||
from cloud.memory import MemoryStore
|
||||
from cloud.pr import PrSkipped, PrStepError
|
||||
@@ -1591,6 +1592,11 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
# 项目补仓库是第一个 PATCH 路由;与其它方法一样走 _dispatch。
|
||||
self._dispatch_safe("PATCH")
|
||||
|
||||
def do_PUT(self):
|
||||
# PUT 与 PATCH 同理:此前无入口,/users/me/profile 是
|
||||
# 第一个 PUT 路由;未知 PUT 路径仍由 _dispatch 回 404。
|
||||
self._dispatch_safe("PUT")
|
||||
|
||||
def _dispatch_safe(self, method):
|
||||
"""分发兜底:_dispatch 里任何未捕获异常都回 500 JSON,而不是
|
||||
让请求线程直接断连(2026-10-27 死锁事件就是这么变成前端
|
||||
@@ -1806,6 +1812,25 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
self._send_json(404, {"error": "not found"})
|
||||
elif path == "/users/me/quota" and method == "GET":
|
||||
self._user_quota_self(user_id)
|
||||
elif path == "/users/me/profile" and method == "GET":
|
||||
self._identity_profile_get(user_id)
|
||||
elif path == "/users/me/profile" and method in ("PUT", "POST"):
|
||||
self._identity_profile_upsert(user_id)
|
||||
elif path == "/users/me/memories" and method == "GET":
|
||||
self._identity_user_memories_list(user_id)
|
||||
elif path == "/users/me/memories" and method == "POST":
|
||||
self._identity_user_memory_create(user_id)
|
||||
elif path == "/users/me/identity-context" and method == "GET":
|
||||
self._identity_context(user_id, query)
|
||||
elif path.startswith("/users/me/memories/"):
|
||||
_mid = path[len("/users/me/memories/"):]
|
||||
if _mid and "/" not in _mid and method == "DELETE":
|
||||
self._identity_user_memory_delete(user_id, _mid)
|
||||
elif _mid.endswith("/supersede") and method == "POST":
|
||||
self._identity_user_memory_supersede(
|
||||
user_id, _mid[: -len("/supersede")].rstrip("/"))
|
||||
else:
|
||||
self._send_json(404, {"error": "not found"})
|
||||
elif path == "/tasks" and method == "POST":
|
||||
self._create_task(user_id)
|
||||
elif path == "/uploads" and method == "POST":
|
||||
@@ -2567,6 +2592,48 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
else:
|
||||
self._send_json(404, {"error": "not found"})
|
||||
return
|
||||
# /projects/{id}/identity-memories (project-scope system memory)
|
||||
if len(parts) == 2 and parts[1] == "identity-memories":
|
||||
if method == "POST":
|
||||
err = self._check_project(user_id, project_id, "create_task")
|
||||
if err:
|
||||
self._send_json(err[0], {"error": err[1]})
|
||||
return
|
||||
self._project_identity_memory_create(user_id, project_id)
|
||||
elif method == "GET":
|
||||
err = self._check_project(user_id, project_id, "read")
|
||||
if err:
|
||||
self._send_json(err[0], {"error": err[1]})
|
||||
return
|
||||
self._project_identity_memories_list(project_id, query)
|
||||
else:
|
||||
self._send_json(404, {"error": "not found"})
|
||||
return
|
||||
# /projects/{id}/identity-memories/{mid}/supersede
|
||||
if len(parts) == 4 and parts[1] == "identity-memories" \
|
||||
and parts[3] == "supersede":
|
||||
if method != "POST":
|
||||
self._send_json(404, {"error": "not found"})
|
||||
return
|
||||
err = self._check_project(user_id, project_id, "create_task")
|
||||
if err:
|
||||
self._send_json(err[0], {"error": err[1]})
|
||||
return
|
||||
self._project_identity_memory_supersede(
|
||||
user_id, project_id, parts[2])
|
||||
return
|
||||
# /projects/{id}/identity-memories/{mid}
|
||||
if len(parts) == 3 and parts[1] == "identity-memories":
|
||||
if method != "DELETE":
|
||||
self._send_json(404, {"error": "not found"})
|
||||
return
|
||||
err = self._check_project(user_id, project_id, "create_task")
|
||||
if err:
|
||||
self._send_json(err[0], {"error": err[1]})
|
||||
return
|
||||
self._project_identity_memory_delete(
|
||||
user_id, project_id, parts[2])
|
||||
return
|
||||
# /projects/{id}/memory
|
||||
if len(parts) == 2 and parts[1] == "memory":
|
||||
if method == "POST":
|
||||
@@ -2863,6 +2930,255 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
auth.audit(user_id, project_id, "supersede_memory", str(mid))
|
||||
self._send_json(200, {"id": mid, "status": "deprecated"})
|
||||
|
||||
# -- System-level identity / memory (cloud only) ------------------
|
||||
# These handlers never touch host.py, CODEX_HOME or any thread:
|
||||
# they only read/write the control-plane database. The context
|
||||
# endpoint returns text for an explicit caller; it does not
|
||||
# inject anything into a task or a thread on its own.
|
||||
|
||||
def _identity_store(self):
|
||||
return IdentityStore(self.server.db_path)
|
||||
|
||||
def _identity_close(self, store):
|
||||
try:
|
||||
store.conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _identity_profile_get(self, user_id):
|
||||
store = self._identity_store()
|
||||
try:
|
||||
self._send_json(200, store.get_profile(user_id))
|
||||
finally:
|
||||
self._identity_close(store)
|
||||
|
||||
def _identity_profile_upsert(self, user_id):
|
||||
body, err = self._read_json()
|
||||
if err:
|
||||
self._send_json(400, {"error": err})
|
||||
return
|
||||
store = self._identity_store()
|
||||
try:
|
||||
try:
|
||||
profile = store.upsert_profile(
|
||||
user_id,
|
||||
display_name=body.get("display_name"),
|
||||
timezone=body.get("timezone"),
|
||||
preferences=body.get("preferences"))
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"error": str(exc)})
|
||||
return
|
||||
finally:
|
||||
self._identity_close(store)
|
||||
with _auth_store(self.server.db_path) as auth:
|
||||
auth.audit(user_id, "", "profile_updated", user_id)
|
||||
self._send_json(200, profile)
|
||||
|
||||
def _identity_user_memories_list(self, user_id):
|
||||
store = self._identity_store()
|
||||
try:
|
||||
rows = store.list_user_memories(user_id)
|
||||
finally:
|
||||
self._identity_close(store)
|
||||
self._send_json(200, {"user_id": user_id, "memories": rows})
|
||||
|
||||
def _identity_user_memory_create(self, user_id):
|
||||
body, err = self._read_json()
|
||||
if err:
|
||||
self._send_json(400, {"error": err})
|
||||
return
|
||||
store = self._identity_store()
|
||||
try:
|
||||
try:
|
||||
mem_id = store.add_memory(
|
||||
user_id, "user", body.get("kind"),
|
||||
body.get("content"),
|
||||
source_thread_id=body.get("source_thread_id"))
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"error": str(exc)})
|
||||
return
|
||||
finally:
|
||||
self._identity_close(store)
|
||||
with _auth_store(self.server.db_path) as auth:
|
||||
auth.audit(user_id, "", "identity_memory_created", mem_id)
|
||||
self._send_json(201, {"id": mem_id, "scope": "user"})
|
||||
|
||||
def _identity_user_memory_supersede(self, user_id, mem_id):
|
||||
body, err = self._read_json()
|
||||
if err:
|
||||
body = {}
|
||||
store = self._identity_store()
|
||||
try:
|
||||
row = store.get_memory(mem_id)
|
||||
if row is None or row["scope"] != "user" \
|
||||
or row["owner_user_id"] != user_id:
|
||||
self._send_json(404, {"error": "memory not found"})
|
||||
return
|
||||
try:
|
||||
_old, new_id = store.supersede_memory(
|
||||
mem_id, user_id,
|
||||
replacement_content=body.get("replacement_content"),
|
||||
replacement_kind=body.get("replacement_kind"))
|
||||
except (KeyError, PermissionError):
|
||||
self._send_json(404, {"error": "memory not found"})
|
||||
return
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"error": str(exc)})
|
||||
return
|
||||
finally:
|
||||
self._identity_close(store)
|
||||
with _auth_store(self.server.db_path) as auth:
|
||||
auth.audit(user_id, "", "identity_memory_superseded", mem_id)
|
||||
self._send_json(200, {"id": mem_id, "status": "superseded",
|
||||
"replacement_id": new_id})
|
||||
|
||||
def _identity_user_memory_delete(self, user_id, mem_id):
|
||||
store = self._identity_store()
|
||||
try:
|
||||
row = store.get_memory(mem_id)
|
||||
if row is None or row["scope"] != "user" \
|
||||
or row["owner_user_id"] != user_id:
|
||||
self._send_json(404, {"error": "memory not found"})
|
||||
return
|
||||
try:
|
||||
store.delete_memory(mem_id, user_id)
|
||||
except (KeyError, PermissionError):
|
||||
self._send_json(404, {"error": "memory not found"})
|
||||
return
|
||||
finally:
|
||||
self._identity_close(store)
|
||||
with _auth_store(self.server.db_path) as auth:
|
||||
auth.audit(user_id, "", "identity_memory_deleted", mem_id)
|
||||
self._send_json(200, {"deleted": True, "id": mem_id})
|
||||
|
||||
def _identity_context(self, user_id, query):
|
||||
project_id = None
|
||||
if query:
|
||||
vals = query.get("project_id")
|
||||
if vals:
|
||||
project_id = vals[0]
|
||||
if project_id:
|
||||
err = self._check_project(user_id, project_id, "read")
|
||||
if err:
|
||||
self._send_json(err[0], {"error": err[1]})
|
||||
return
|
||||
store = self._identity_store()
|
||||
try:
|
||||
profile = store.get_profile(user_id)
|
||||
user_rows = store.list_user_memories(user_id)
|
||||
proj_rows = store.list_project_memories(project_id) \
|
||||
if project_id else []
|
||||
context = store.build_identity_context(user_id, project_id)
|
||||
finally:
|
||||
self._identity_close(store)
|
||||
summaries_context = ""
|
||||
if project_id:
|
||||
mem_store = MemoryStore(self.server.db_path)
|
||||
try:
|
||||
summaries_context = mem_store.build_prompt_context(
|
||||
project_id)
|
||||
finally:
|
||||
try:
|
||||
mem_store.conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._send_json(200, {
|
||||
"user_id": user_id,
|
||||
"project_id": project_id,
|
||||
"profile": profile,
|
||||
"user_memories": user_rows,
|
||||
"project_memories": proj_rows,
|
||||
"identity_context": context,
|
||||
# Kept as a separate field on purpose: the existing
|
||||
# project summaries are NOT merged into identity_context
|
||||
# and neither field is injected into any task/thread here.
|
||||
"project_summaries_context": summaries_context,
|
||||
})
|
||||
|
||||
def _project_identity_memory_create(self, user_id, project_id):
|
||||
body, err = self._read_json()
|
||||
if err:
|
||||
self._send_json(400, {"error": err})
|
||||
return
|
||||
store = self._identity_store()
|
||||
try:
|
||||
try:
|
||||
mem_id = store.add_memory(
|
||||
user_id, "project", body.get("kind"),
|
||||
body.get("content"), project_id=project_id,
|
||||
source_thread_id=body.get("source_thread_id"))
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"error": str(exc)})
|
||||
return
|
||||
finally:
|
||||
self._identity_close(store)
|
||||
with _auth_store(self.server.db_path) as auth:
|
||||
auth.audit(user_id, project_id,
|
||||
"identity_memory_created", mem_id)
|
||||
self._send_json(201, {"id": mem_id, "scope": "project",
|
||||
"project_id": project_id})
|
||||
|
||||
def _project_identity_memories_list(self, project_id, query):
|
||||
try:
|
||||
top_k = int((query.get("top_k") or ["20"])[0])
|
||||
except (TypeError, ValueError):
|
||||
self._send_json(400, {"error": "top_k must be an integer"})
|
||||
return
|
||||
store = self._identity_store()
|
||||
try:
|
||||
rows = store.list_project_memories(project_id)
|
||||
finally:
|
||||
self._identity_close(store)
|
||||
self._send_json(200, {"project_id": project_id,
|
||||
"memories": rows[:top_k]})
|
||||
|
||||
def _project_identity_memory_supersede(self, user_id, project_id,
|
||||
mem_id):
|
||||
body, err = self._read_json()
|
||||
if err:
|
||||
body = {}
|
||||
store = self._identity_store()
|
||||
try:
|
||||
row = store.get_memory(mem_id)
|
||||
if row is None or row["scope"] != "project" \
|
||||
or row["project_id"] != project_id:
|
||||
self._send_json(404, {"error": "memory not found"})
|
||||
return
|
||||
try:
|
||||
_old, new_id = store.supersede_memory(
|
||||
mem_id, user_id,
|
||||
replacement_content=body.get("replacement_content"),
|
||||
replacement_kind=body.get("replacement_kind"))
|
||||
except (KeyError, PermissionError, ValueError) as exc:
|
||||
if isinstance(exc, ValueError):
|
||||
self._send_json(400, {"error": str(exc)})
|
||||
else:
|
||||
self._send_json(404, {"error": "memory not found"})
|
||||
return
|
||||
finally:
|
||||
self._identity_close(store)
|
||||
with _auth_store(self.server.db_path) as auth:
|
||||
auth.audit(user_id, project_id,
|
||||
"identity_memory_superseded", mem_id)
|
||||
self._send_json(200, {"id": mem_id, "status": "superseded",
|
||||
"replacement_id": new_id})
|
||||
|
||||
def _project_identity_memory_delete(self, user_id, project_id, mem_id):
|
||||
store = self._identity_store()
|
||||
try:
|
||||
row = store.get_memory(mem_id)
|
||||
if row is None or row["scope"] != "project" \
|
||||
or row["project_id"] != project_id:
|
||||
self._send_json(404, {"error": "memory not found"})
|
||||
return
|
||||
store.delete_memory(mem_id, user_id)
|
||||
finally:
|
||||
self._identity_close(store)
|
||||
with _auth_store(self.server.db_path) as auth:
|
||||
auth.audit(user_id, project_id,
|
||||
"identity_memory_deleted", mem_id)
|
||||
self._send_json(200, {"deleted": True, "id": mem_id})
|
||||
|
||||
def _kb_index(self, user_id, project_id):
|
||||
body, err = self._read_json()
|
||||
if err:
|
||||
|
||||
Reference in new issue
Block a user