From 0f86cca331bafa1ab60fecbb5cab7a154bda9770 Mon Sep 17 00:00:00 2001 From: JavaLyHn <2443560753@qq.com> Date: Tue, 27 Oct 2026 10:19:37 +0800 Subject: [PATCH 1/2] feat: implement live Dashboard v2 backend --- .gitattributes | 4 + .gitignore | 6 + README.md | 2 +- ...026-10-26-issue3-task02-memory-readonly.md | 352 + ...26-10-26-issue3-task03-memory-lifecycle.md | 59 + .../2026-10-26-issue3-task04-call-logs.md | 57 + .../plans/2026-10-26-issue3-task05-usage.md | 36 + .../2026-10-26-issue3-task06-projects.md | 25 + .../2026-10-26-issue3-task07-09-identity.md | 30 + .../plans/2026-10-26-issue3-task10-13-p2.md | 55 + .../2026-10-26-issue3-task14-keys-plan.md | 298 + .../plans/2026-10-26-task01-runtime-meta.md | 58 + .../plans/2026-10-26-time-display-plan.md | 114 + .../plans/2026-10-27-auth-ui-task15.md | 10 + .../plans/2026-10-27-task14-repair.md | 15 + .../plans/2026-10-27-task16-acceptance.md | 10 + ...6-10-26-issue3-dashboard-backend-design.md | 281 + .../2026-10-26-issue3-interface-matrix.md | 75 + .../2026-10-26-issue3-task14-keys-design.md | 169 + .../specs/2026-10-26-time-display-design.md | 87 + .../specs/2026-10-27-auth-ui-task15-design.md | 13 + openapi.json | 5803 ++++++++++++++--- package.json | 2 + server/backend/.env.example | 22 + server/backend/AUTHORIZATION-RESULT.md | 14 + server/backend/AUTHORIZATION.md | 39 + server/backend/BROWSER-WRITE-FIX.md | 23 + server/backend/DELIVERY.md | 90 + server/backend/GATEWAY-RELOAD-RESULT.md | 46 + server/backend/IMPLEMENTATION-NOTES-AUDIT.md | 48 + .../ISSUE3-COMPLIANCE-REVIEW-2026-10-27.md | 67 + .../backend/ISSUE3-FIXED-ENDPOINTS-RESULT.md | 30 + server/backend/MANUAL-ACCEPTANCE.md | 322 + server/backend/PROFILE-GRAPH-REPAIR.md | 36 + server/backend/README.md | 263 + server/backend/REGISTRATION-RESULT.md | 15 + server/backend/TASK01-RESULT.md | 57 + server/backend/TASK02-RESULT.md | 105 + server/backend/TASK03-RESULT.md | 132 + server/backend/TASK04-RESULT.md | 70 + server/backend/TASK05-RESULT.md | 56 + server/backend/TASK06-RESULT.md | 45 + server/backend/TASK07-RESULT.md | 35 + server/backend/TASK08-RESULT.md | 37 + server/backend/TASK09-RESULT.md | 31 + server/backend/TASK12-13-REPAIR-2026-10-27.md | 39 + server/backend/TASK12-13-REVIEW-2026-10-27.md | 42 + server/backend/TASK12-RESULT.md | 36 + server/backend/TASK13-RESULT.md | 32 + server/backend/TASK14-RESULT.md | 53 + server/backend/TASK14-REVIEW-2026-10-27.md | 43 + server/backend/TASK15-RESULT.md | 29 + server/backend/TASK16-OPERATIONS.md | 75 + server/backend/TASK16-RESULT.md | 73 + .../backend/TIME-DISPLAY-REPAIR-2026-10-27.md | 24 + server/backend/__init__.py | 1 + server/backend/__main__.py | 26 + server/backend/acceptance/__init__.py | 1 + server/backend/acceptance/probe_gateway.py | 106 + server/backend/acceptance/smoke.py | 141 + server/backend/acceptance/start_live.ps1 | 34 + server/backend/acceptance/task14_driver.py | 250 + server/backend/acceptance/task15_driver.py | 178 + server/backend/acceptance/task16_driver.py | 295 + server/backend/app.py | 113 + server/backend/audit_index.py | 447 ++ server/backend/audit_models.py | 88 + server/backend/audit_normalize.py | 242 + server/backend/audit_routes.py | 77 + server/backend/audit_writer.py | 128 + server/backend/auth.py | 342 + server/backend/auth_ui.py | 134 + server/backend/bootstrap.py | 66 + server/backend/compatibility.py | 47 + server/backend/control.py | 361 + server/backend/errors.py | 89 + server/backend/identity_store.py | 336 + server/backend/invites.py | 311 + server/backend/kb_models.py | 84 + server/backend/kb_store.py | 143 + server/backend/key_store.py | 222 + server/backend/keys.py | 162 + server/backend/knowledge.py | 295 + server/backend/knowledge_files.py | 424 ++ server/backend/members.py | 199 + server/backend/memories.py | 129 + server/backend/memory_lifecycle.py | 246 + server/backend/memory_models.py | 133 + server/backend/memory_upstream.py | 137 + server/backend/models.py | 47 + server/backend/operation_store.py | 60 + .../deployment-v2-dashboard-support.patch | 3638 +++++++++++ .../task12-upstream-prompt-runtime.patch | 220 + .../patches/task14-gateway-tenants-file.patch | 745 +++ server/backend/product_client.py | 78 + server/backend/profile_graph.py | 97 + server/backend/profile_templates.py | 266 + server/backend/project_context.py | 53 + server/backend/project_models.py | 42 + server/backend/projects.py | 89 + server/backend/prompts.py | 162 + server/backend/read_views.py | 348 + server/backend/requirements-dev.txt | 4 + server/backend/requirements.txt | 5 + server/backend/ruff.toml | 5 + server/backend/security.py | 38 + server/backend/settings.py | 120 + server/backend/tests/conftest.py | 8 + server/backend/tests/test_accounts.py | 166 + server/backend/tests/test_audit_api.py | 200 + server/backend/tests/test_audit_index.py | 291 + server/backend/tests/test_audit_normalize.py | 173 + .../tests/test_authorization_policy.py | 98 + server/backend/tests/test_browser_bodyless.py | 65 + server/backend/tests/test_compatibility.py | 67 + server/backend/tests/test_control.py | 49 + server/backend/tests/test_entrypoint.py | 32 + server/backend/tests/test_errors.py | 93 + .../backend/tests/test_identity_boundaries.py | 112 + .../backend/tests/test_identity_contracts.py | 39 + server/backend/tests/test_invites.py | 149 + server/backend/tests/test_keys.py | 368 ++ server/backend/tests/test_keys_consistency.py | 99 + server/backend/tests/test_keys_store.py | 124 + server/backend/tests/test_knowledge.py | 199 + server/backend/tests/test_knowledge_files.py | 217 + server/backend/tests/test_members.py | 133 + server/backend/tests/test_memories.py | 483 ++ server/backend/tests/test_memory_lifecycle.py | 208 + server/backend/tests/test_meta.py | 298 + server/backend/tests/test_portable_smoke.py | 39 + server/backend/tests/test_profile_graph.py | 168 + .../backend/tests/test_profile_templates.py | 213 + server/backend/tests/test_projects.py | 249 + server/backend/tests/test_prompts.py | 245 + server/backend/tests/test_read_views.py | 262 + server/backend/tests/test_registration.py | 77 + server/backend/tests/test_settings.py | 43 + .../backend/tests/test_task14_acceptance.py | 33 + server/backend/tests/test_timefmt.py | 47 + server/backend/tests/test_usage.py | 225 + server/backend/timefmt.py | 31 + server/backend/upstream.py | 55 + server/backend/usage_models.py | 68 + server/backend/usage_routes.py | 39 + 145 files changed, 26201 insertions(+), 858 deletions(-) create mode 100644 .gitattributes create mode 100644 docs/superpowers/plans/2026-10-26-issue3-task02-memory-readonly.md create mode 100644 docs/superpowers/plans/2026-10-26-issue3-task03-memory-lifecycle.md create mode 100644 docs/superpowers/plans/2026-10-26-issue3-task04-call-logs.md create mode 100644 docs/superpowers/plans/2026-10-26-issue3-task05-usage.md create mode 100644 docs/superpowers/plans/2026-10-26-issue3-task06-projects.md create mode 100644 docs/superpowers/plans/2026-10-26-issue3-task07-09-identity.md create mode 100644 docs/superpowers/plans/2026-10-26-issue3-task10-13-p2.md create mode 100644 docs/superpowers/plans/2026-10-26-issue3-task14-keys-plan.md create mode 100644 docs/superpowers/plans/2026-10-26-task01-runtime-meta.md create mode 100644 docs/superpowers/plans/2026-10-26-time-display-plan.md create mode 100644 docs/superpowers/plans/2026-10-27-auth-ui-task15.md create mode 100644 docs/superpowers/plans/2026-10-27-task14-repair.md create mode 100644 docs/superpowers/plans/2026-10-27-task16-acceptance.md create mode 100644 docs/superpowers/specs/2026-10-26-issue3-dashboard-backend-design.md create mode 100644 docs/superpowers/specs/2026-10-26-issue3-interface-matrix.md create mode 100644 docs/superpowers/specs/2026-10-26-issue3-task14-keys-design.md create mode 100644 docs/superpowers/specs/2026-10-26-time-display-design.md create mode 100644 docs/superpowers/specs/2026-10-27-auth-ui-task15-design.md create mode 100644 server/backend/.env.example create mode 100644 server/backend/AUTHORIZATION-RESULT.md create mode 100644 server/backend/AUTHORIZATION.md create mode 100644 server/backend/BROWSER-WRITE-FIX.md create mode 100644 server/backend/DELIVERY.md create mode 100644 server/backend/GATEWAY-RELOAD-RESULT.md create mode 100644 server/backend/IMPLEMENTATION-NOTES-AUDIT.md create mode 100644 server/backend/ISSUE3-COMPLIANCE-REVIEW-2026-10-27.md create mode 100644 server/backend/ISSUE3-FIXED-ENDPOINTS-RESULT.md create mode 100644 server/backend/MANUAL-ACCEPTANCE.md create mode 100644 server/backend/PROFILE-GRAPH-REPAIR.md create mode 100644 server/backend/README.md create mode 100644 server/backend/REGISTRATION-RESULT.md create mode 100644 server/backend/TASK01-RESULT.md create mode 100644 server/backend/TASK02-RESULT.md create mode 100644 server/backend/TASK03-RESULT.md create mode 100644 server/backend/TASK04-RESULT.md create mode 100644 server/backend/TASK05-RESULT.md create mode 100644 server/backend/TASK06-RESULT.md create mode 100644 server/backend/TASK07-RESULT.md create mode 100644 server/backend/TASK08-RESULT.md create mode 100644 server/backend/TASK09-RESULT.md create mode 100644 server/backend/TASK12-13-REPAIR-2026-10-27.md create mode 100644 server/backend/TASK12-13-REVIEW-2026-10-27.md create mode 100644 server/backend/TASK12-RESULT.md create mode 100644 server/backend/TASK13-RESULT.md create mode 100644 server/backend/TASK14-RESULT.md create mode 100644 server/backend/TASK14-REVIEW-2026-10-27.md create mode 100644 server/backend/TASK15-RESULT.md create mode 100644 server/backend/TASK16-OPERATIONS.md create mode 100644 server/backend/TASK16-RESULT.md create mode 100644 server/backend/TIME-DISPLAY-REPAIR-2026-10-27.md create mode 100644 server/backend/__init__.py create mode 100644 server/backend/__main__.py create mode 100644 server/backend/acceptance/__init__.py create mode 100644 server/backend/acceptance/probe_gateway.py create mode 100644 server/backend/acceptance/smoke.py create mode 100644 server/backend/acceptance/start_live.ps1 create mode 100644 server/backend/acceptance/task14_driver.py create mode 100644 server/backend/acceptance/task15_driver.py create mode 100644 server/backend/acceptance/task16_driver.py create mode 100644 server/backend/app.py create mode 100644 server/backend/audit_index.py create mode 100644 server/backend/audit_models.py create mode 100644 server/backend/audit_normalize.py create mode 100644 server/backend/audit_routes.py create mode 100644 server/backend/audit_writer.py create mode 100644 server/backend/auth.py create mode 100644 server/backend/auth_ui.py create mode 100644 server/backend/bootstrap.py create mode 100644 server/backend/compatibility.py create mode 100644 server/backend/control.py create mode 100644 server/backend/errors.py create mode 100644 server/backend/identity_store.py create mode 100644 server/backend/invites.py create mode 100644 server/backend/kb_models.py create mode 100644 server/backend/kb_store.py create mode 100644 server/backend/key_store.py create mode 100644 server/backend/keys.py create mode 100644 server/backend/knowledge.py create mode 100644 server/backend/knowledge_files.py create mode 100644 server/backend/members.py create mode 100644 server/backend/memories.py create mode 100644 server/backend/memory_lifecycle.py create mode 100644 server/backend/memory_models.py create mode 100644 server/backend/memory_upstream.py create mode 100644 server/backend/models.py create mode 100644 server/backend/operation_store.py create mode 100644 server/backend/patches/deployment-v2-dashboard-support.patch create mode 100644 server/backend/patches/task12-upstream-prompt-runtime.patch create mode 100644 server/backend/patches/task14-gateway-tenants-file.patch create mode 100644 server/backend/product_client.py create mode 100644 server/backend/profile_graph.py create mode 100644 server/backend/profile_templates.py create mode 100644 server/backend/project_context.py create mode 100644 server/backend/project_models.py create mode 100644 server/backend/projects.py create mode 100644 server/backend/prompts.py create mode 100644 server/backend/read_views.py create mode 100644 server/backend/requirements-dev.txt create mode 100644 server/backend/requirements.txt create mode 100644 server/backend/ruff.toml create mode 100644 server/backend/security.py create mode 100644 server/backend/settings.py create mode 100644 server/backend/tests/conftest.py create mode 100644 server/backend/tests/test_accounts.py create mode 100644 server/backend/tests/test_audit_api.py create mode 100644 server/backend/tests/test_audit_index.py create mode 100644 server/backend/tests/test_audit_normalize.py create mode 100644 server/backend/tests/test_authorization_policy.py create mode 100644 server/backend/tests/test_browser_bodyless.py create mode 100644 server/backend/tests/test_compatibility.py create mode 100644 server/backend/tests/test_control.py create mode 100644 server/backend/tests/test_entrypoint.py create mode 100644 server/backend/tests/test_errors.py create mode 100644 server/backend/tests/test_identity_boundaries.py create mode 100644 server/backend/tests/test_identity_contracts.py create mode 100644 server/backend/tests/test_invites.py create mode 100644 server/backend/tests/test_keys.py create mode 100644 server/backend/tests/test_keys_consistency.py create mode 100644 server/backend/tests/test_keys_store.py create mode 100644 server/backend/tests/test_knowledge.py create mode 100644 server/backend/tests/test_knowledge_files.py create mode 100644 server/backend/tests/test_members.py create mode 100644 server/backend/tests/test_memories.py create mode 100644 server/backend/tests/test_memory_lifecycle.py create mode 100644 server/backend/tests/test_meta.py create mode 100644 server/backend/tests/test_portable_smoke.py create mode 100644 server/backend/tests/test_profile_graph.py create mode 100644 server/backend/tests/test_profile_templates.py create mode 100644 server/backend/tests/test_projects.py create mode 100644 server/backend/tests/test_prompts.py create mode 100644 server/backend/tests/test_read_views.py create mode 100644 server/backend/tests/test_registration.py create mode 100644 server/backend/tests/test_settings.py create mode 100644 server/backend/tests/test_task14_acceptance.py create mode 100644 server/backend/tests/test_timefmt.py create mode 100644 server/backend/tests/test_usage.py create mode 100644 server/backend/timefmt.py create mode 100644 server/backend/upstream.py create mode 100644 server/backend/usage_models.py create mode 100644 server/backend/usage_routes.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..c1c242ee3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +server/backend/** text eol=lf +docs/superpowers/** text eol=lf +# Unified-diff context lines intentionally contain a leading space on empty lines. +*.patch -whitespace diff --git a/.gitignore b/.gitignore index 3508c4f17..a8a01c8eb 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,12 @@ dist *.local *.log .data +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +.venv/ +.memos/ .env .env.* diff --git a/README.md b/README.md index 998c98409..34ac63ac7 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ MemOS 记忆服务的 Web 控制台:接入说明、接口密钥、调用日志 模型清单、自定义抽取、用量监控。 - 页面:Vite + React 19 + react-router-dom,全部走 `/cn/*` 路由。 -- 后端:独立的 Go 服务 `memosd`(另一个仓库),同源托管 `dist/`;本仓库只负责前端与接口契约。 +- 后端:本分支新增独立 Python 适配服务,接入 deployment-v2 的 MemOS 与网关审计数据;部署、权限、上游配套补丁及验收入口见 [后端交付说明](server/backend/DELIVERY.md)。原有独立 Go 服务 `memosd` 不属于本次实现。 - 本地 mock:`server/mock/`,只在开发时用,不部署。 - 接口契约:`shared/contract.ts`(前后端共用的类型)+ 仓库根目录的 `openapi.json`。 diff --git a/docs/superpowers/plans/2026-10-26-issue3-task02-memory-readonly.md b/docs/superpowers/plans/2026-10-26-issue3-task02-memory-readonly.md new file mode 100644 index 000000000..1465c2948 --- /dev/null +++ b/docs/superpowers/plans/2026-10-26-issue3-task02-memory-readonly.md @@ -0,0 +1,352 @@ +# 2026-10-26 执行完成说明(优先于下文原始计划) + +任务 02 已完成,当前交付与真实验收见 `server/backend/TASK02-RESULT.md`。下文保留原始设计过程,存在以下已明确裁定的差异: + +- 五个图记忆标签统一使用原生 get_memory;可选 event_memory_types 覆盖 EventMemory、Context、InsightMemory,不改旧调用默认。 +- knowledgeBaseId 真实匹配知识库元数据,不再一律 400。画像无关联则不命中。 +- 画像 createTime 使用 created_at;未知主体不伪造为 user。 +- 无项目配置时详情返回 50300,不是原计划中的 50000;固定实例默认项目,无请求可变全局状态。 +- 实现模块分为 control、memory_models、memory_upstream、memories;测试集中在 test_control、test_memories,未按原计划拆成多份重复文件。 +- 为控制模型费用,真实抽取仅一次;实际两条数据每页一条验证分页,25 条多页与大字段由合成回归覆盖。四个空类型的真实有数据详情覆盖未宣称完成。 +- 独立审查发现的极端 UTC 日期溢出已通过 3 个 RED→GREEN 用例修复。 + +完成门槛:后端 110 passed,上游相关 70 passed;pnpm verify、Ruff、Redocly(0 errors、2 条原有 warnings)、两仓库 diff --check 通过。src/shared/server/mock 基线零变更;主前端 5274 仍为 sample;独立验收前端 5276 可查看真实事实与画像。任务 01 真实 HTTP 回归 8 项通过。未提交、推送或推进任务 03。 + +--- + + +# Issue 3 任务 02:P0 记忆只读(六标签列表与详情)实施计划 + +对应设计文档:`docs/superpowers/specs/2026-10-26-issue3-dashboard-backend-design.md` 实施顺序第 2 项。 +本计划只覆盖任务 02;任务 03(删除/恢复)及之后任务不在范围内。 + +## 0. 执行约定 + +- **TDD**:每个行为先写测试(RED),实现后转 GREEN,记录实际 RED/GREEN 结果到交付文档。 +- **不执行任何 git commit / push / PR**(用户明确禁止)。所有改动保留在 `feat/dashboard-backend` 工作区。 +- 修改范围:`D:/MemOS/dashboard`(server/backend + openapi.json + 文档)与 `D:/MemOS/upstream`(最小只读补充)。 +- **禁改**:dashboard `src/`、`shared/`、`server/mock/`、`.env.local`;上游除只读补充外任何语义(尤其删除/写入/数据模型)。 +- 现有未提交改动(任务 01 实现与设计文档)完整保留;禁止 reset/clean/checkout 覆盖。 +- 基线:dashboard HEAD = 03e10145a4dd8bc3e50b6e2a50ed788d64111945;upstream HEAD = 8d6e231d7d95ea29b6a2716c9c34837644d3e7f6(已存在的 `universal_api.py` 兼容补丁保留)。 +- 工具链:`D:\MemOS\.venv\Scripts\python.exe`(3.11.17);ruff 用 `D:\MemOS\.venv\Scripts\ruff.exe`(独立二进制);前端 `pnpm`。 +- 上游重启:`D:\MemOS\setup\Start-Upstream.ps1`(uvicorn 8000,`PYTHONPATH=upstream/src`)。后端重启:`Stop-Backend.ps1` + `Start-Backend.ps1`(新 env 需在调用前导出)。 + +## 1. 已核实事实(实现前研究证据,勿凭记忆改动) + +以下均已在当前代码与运行服务上核实(2026-10-26): + +1. 上游路由前缀 `/product`(server_router.py:120)。相关端点: + - `POST /product/get_memory` → `handle_get_memories`(memory_handler.py:427):按 `include_memory_view` 返回桶;`page/page_size`(cloud 默认 1/10);`filter` 经 `normalize_filter`(product_models.py:1579);**无 status 字段**(硬编码 `LISTED_STATUSES=["activated","resolving","frozen","expired"]`)。 + - `include_memory_view` 非 None 时同步布尔标志(product_models.py:1569-1577):`"preference"→include_preference`、`"skill"→include_skill_memory`、`"tool_memory"→include_tool_memory`;`"detail_factual"`→text_mem 桶(类型 WorkingMemory/LongTermMemory/UserMemory/OuterMemory)、`"event"`→event_mem 桶(EventMemory)。 + - 响应桶形状:`data["text_mem"] = [{"cube_id", "memories": [node...], "total_nodes": int}]`,pref/skill/tool 同理(skill 行经 `_with_skill_url` 补 `metadata.url`)。 + - `GET /product/get_memory/{memory_id}?mem_cube_id=` → 租户钉死:错租户或不存在 → HTTP 200 + `code=200` + `data=None` + message "Memory with ID ... not found"(handle_get_memory 捕获一切异常,memory_handler.py:186-218)。**上游成功码是 200,不能透传为 Dashboard 的 0。** + - `POST /product/get_event_memories`:`memory_types`(默认 EventMemory/Context/InsightMemory/DreamDiary)、`status`、`search_query`(→ `filter={"memory": {"like": q}}`)、page/page_size;响应 `data={cube_id, memories, total, page, page_size}`,行是 `_graph_node_json` 投影(id/memory/memoryTruncated/memoryType/status/createdAt/updatedAt/...,**无 owner_type**)。 +2. 部署后端是 `Neo4jCommunityGraphDB`(`NEO4J_BACKEND=neo4j-community`,继承 `Neo4jGraphDB`)。其 `_parse_node` **总是**把 Qdrant 里的 1024 维向量挂回 `metadata.embedding`(neo4j_community.py:1186-1193)——所以**列表与详情的上游响应永远带 embedding(实测 len=1024)**,列表体积必须靠 Dashboard 投影控制。 +3. `export_graph`(继承):`total_nodes` 在分页前 COUNT(先筛选-再计数-后分页 ✓);`ORDER BY n.created_at DESC, n.id DESC`(稳定);`SKIP/LIMIT` 在 Cypher 内;`status` 为列表时 `n.status IN $list`(**缺失 status 的节点被排除**——Cypher 三值逻辑);`status=None` → `<> 'deleted'`。 +4. 过滤翻译(neo4j.py:1893-1961,实测全部通过 `normalize_filter` 且真实链路工作): + - `{"k": v}` 标量 → `n.k = $p`;`{"k": None}` → `n.k IS NULL` + - `{"k": {"gt/lt/gte/lte": v}}` → 比较;`created_at/updated_at` 用 `datetime(n.k) OP datetime($p)` + - `{"k": {"like": v}}` → `n.k CONTAINS $p`(**大小写敏感**,与 Mock 的不区分大小写不同——记录为契约差异证据) + - `{"k": {"in": [..]}}` → `n.k IN $p`(值必须是 list) + - `{"and": [..]}/{"or": [..]}` 嵌套(or 加括号) + - community 后端叠加日期归一化(neo4j_community.py:561-656):裸 `YYYY-MM-DD` 会展开边界(lt→次日 0 点、lte→当日末);带时区的完整 ISO 原样比较(实测 gte/lte 过滤命中真实节点)。 + - **未知操作符(ne/nin/eq/startswith/endswith)静默不产生条件**——不要使用。 + - `GetMemoryRequest.user_id` 传给 `get_all` 后被 `export_graph` 忽略(land in **kwargs)——externalUserId 筛选必须走 `filter`。 +5. 真实节点形状(issue3-project-a,实测): + ``` + {"id": "61ca5bdd-...", "memory": "用户有一个名为 MemOS-setup-...", + "metadata": {"memory_type": "UserMemory", "status": "activated", "owner_type": "user", + "owner_id": "issue3-project-a", "user_id": "issue3-project-a", + "created_at": "2026-10-25T16:16:00.233452+00:00", "updated_at": "...", + "embedding": [1024 floats], "history": [...], "sources": <仅 include_sources=true>, + "tags": [...], "key", "confidence", "time_basis": "utc", "session_id", ...}} + ``` + - `_shape_response`(server_router.py:222-233)默认剥 `metadata.sources` 并把时间归一到契约 UTC。 + - owner 语义(owner.py):租户内每节点恰一个 owner:`owner_type ∈ {user, agent, group, public}` + `owner_id`;user 视图节点 owner_id==user_id;agent 视图(`MEMOS_AGENT_MEMORY_ENABLED` 默认关)owner_id=agent id 且无 user_id;public 仅 `allow_public=True` 时写入,owner_id=租户。 + - SkillMemory:memory=description,`metadata.name`=技能名、`metadata.url`=技能 URL、`metadata.key`=name(process_skill_memory.py:913-930)。 +6. ProfileService(api/profile.py):实例存 MetadataStore(Neo4j `:CloudEntity` 或 JSON,`build_metadata_store(graph_store)`);`recall` 需要恰好一个 user_id/agent_id(否则异常被 `_profile_bucket` 吞掉返回空——项目级列表拿不到数据)。行形状:`id=f"{entity_id}_{path}"`、`memory=f"{path}: {value}"`、`memory_type="ProfileMemory"`、`template_id/template_name/profile_category/profile_field/profile_path/status="activated"`。绑定实例 `status=="bound"`;字段值空或 deprecated 跳过。 +7. 写入路径(真实链路验证用): + - `POST /product/add`:`{user_id, mem_cube_id, session_id, async_mode: "sync", mode: "fine", allow_memory_view: [...], messages: [{role, content}], info: {source_type}}` → `data: [{"memory_id": ...}]`(走 Haiku 抽取,调用量需控制)。允许的 view:detail_factual/preference/profile/event/skill/tool_memory。 + - `POST /product/add_profile_template {user_id, mem_cube_id, name, metadata(树)}` → `POST /product/bind_profile_template {mem_cube_id, bind_list:[{user_id, profile_template_id}]}` → `POST /product/edit_profile {mem_cube_id, user_id, profile_template_id, metadata:{path: value}}`。 + - 既有真实租户:issue3-project-a / issue3-project-b;既有标记记忆 61ca5bdd-65bc-4c5d-9fcc-115e7cea396c(**只读,不删除**)。 +8. Dashboard 契约(openapi.json + shared/contract.ts + Memories.tsx 实读): + - `POST /api/dashboard/project/memory/list`:请求 `{projectId*, memoryDetailType*, pageNum=1, pageSize=50, knowledgeBaseId, startTime, endTime, externalUserId, subjectType(user|agent|public), memSearchContent}`;响应 data `{memListItemResponseList: [Memory], total, current}`。 + - `POST /api/dashboard/project/memory/detail`:请求 `{memoryId*, memType*}`(六标签值之一;**没有 projectId**)。响应 data = Memory。 + - Memory 必填 `memoryId/memoryContent/createTime`;可选 `subjectType/externalUserId/memoryType/skillName/skillUrl/profileTemplateId/profileInstanceId`。前端六个标签固定取值 text_mem/pref_mem/event_mem/profile_mem/skill_mem/tool_mem;搜索框发 memSearchContent;分页默认 pageSize=10,标签计数用 pageSize=1 的 total;event 标签展示 memoryType 列;skill 标签展示 skillName 列。 + - Mock 参照(server/mock/server.ts:197-224, 555-568):搜索=内容或 skillName 的不区分大小写子串;排序 createTime desc;current=pageNum;publicMemory 白名单投影。 + - 任务 01 已建错误模式:HTTP 400/404/502/503/504/500 + code 40000/40001/40003/50000/50200/50300/50400,信封 `{code, data, message}`,错误 data=null(BackendErrorEnvelope schema 已在 openapi.json)。 +9. 现有后端结构:app.py 工厂 + lifespan 注入 `app.state.upstream`(UpstreamClient 包 httpx.AsyncClient,base_url 来自 Settings,超时 3s 默认);errors.py 有 ApiError/统一 handler;settings.py 冻结 BaseModel + from_env;tests/ 用 `httpx.MockTransport` + TestClient(见 test_meta.py 的 client_for 模式)。`test_errors.py::test_unimplemented_business_route_does_not_return_fake_success` 当前以 memory/list 为目标——**本任务后需重定向**到仍未实现的路由(如 `/api/dashboard/log/list`),保留其原意。 +10. 体积验收样本:`D:/MemOS/.local/fixtures/memories-synthetic-10-large.json`(text_mem[0].memories 10 行,每行 ~51KB:embedding 1024 + history + sources),断言投影后 10 行列表响应 < 200 KB。 + +## 2. 设计决策 + +### 2.1 六标签映射表(唯一维护点:`server/backend/memories.py`) + +| tab | 上游调用 | 上游 memory_type 集合 | 响应桶 | 备注 | +|---|---|---|---|---| +| text_mem | `POST /product/get_memory` `include_memory_view=["detail_factual"]` | WorkingMemory, LongTermMemory, UserMemory, OuterMemory | `data["text_mem"]` | | +| pref_mem | 同上 `include_memory_view=["preference"]` | PreferenceMemory | `data["pref_mem"]` | 图节点(pref_mem 立方体属性已移除) | +| skill_mem | 同上 `include_memory_view=["skill"]` | SkillMemory | `data["skill_mem"]` | 行带 metadata.name/url | +| tool_mem | 同上 `include_memory_view=["tool_memory"]` | ToolSchemaMemory, ToolTrajectoryMemory | `data["tool_mem"]` | | +| event_mem | `POST /product/get_event_memories` `memory_types=["EventMemory","Context","InsightMemory"]` | EventMemory, Context, InsightMemory | `data["memories"]` | **DreamDiary 不在映射范围**:其状态为 completed/context_only,被 activated 口径排除(上游默认四类型里含它,本阶段显式排除并记录) | +| profile_mem | **新增** `POST /product/list_profile_memories` | (画像字段行,非图节点) | `data["memories"]` | 主体可以是项目内任意 user/agent | + +每 tab 的 `page/page_size` 直传上游;**status 一律 `["activated"]`**(text/pref/skill/tool 需要上游新增 status 字段;event 端点已支持)。 + +详情:非 profile → `GET /product/get_memory/{memory_id}?mem_cube_id=`;profile → **新增** `POST /product/get_profile_memory`。详情校验返回节点的 memory_type ∈ 请求 memType 的类型集合,不匹配 → 40000。 + +### 2.2 项目映射与授权边界(最小身份) + +- 新模块 `server/backend/control.py`:SQLite(`.data/control.db`,路径可由 `DASHBOARD_DATA_DIR` 覆盖),表 `projects(id TEXT PK, name TEXT NOT NULL, upstream_cube_id TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL)`(UTC ISO)。WAL,短连接(每次操作独立连接,本地毫秒级)。 +- 种子来自 env `DASHBOARD_PROJECTS`,格式 `":[;:...]"`(Settings 校验格式),启动时幂等导入(INSERT OR IGNORE,name 用 projectId)。**不把客户端传入的 projectId 当租户**:列表先查映射,查不到 → 40001。 +- 详情请求无 projectId → 用进程级固定 `DASHBOARD_DEFAULT_PROJECT_ID`(必须存在于映射,否则启动即失败)。**不可变、非请求级**,无跨请求串租户风险;跨项目详情因上游租户钉死而返回 40001(与不存在不可区分,不泄露存在性)。完整账号/会话体系仍属 P1(任务 07),本阶段不宣称生产级身份管理。 +- 隔离证明:列表用两个 projectId(a/b)对比;详情用**临时第二后端实例**(临时端口 + `DASHBOARD_DEFAULT_PROJECT_ID=issue3-project-b`)请求 A 租户记忆 → 40001。 + +### 2.3 状态口径 + +- 六标签默认且唯一口径 `status=["activated"]`;列表与总数同口径(上游先筛选-再 COUNT-后分页,天然满足)。 +- **缺失 status 的节点被排除**(Cypher 三值逻辑)——写入路径总是写 status(默认 activated),历史缺失属异常数据;规则写明并测试。 +- resolving/frozen/expired/archived/deleted 均不在默认口径(不偷偷混算)。 + +### 2.4 筛选(先组合、上游执行,不得静默忽略) + +| 请求参数 | 处理 | +|---|---| +| projectId | 查 control.db → mem_cube_id;未知 → 40001 | +| memoryDetailType | 六标签枚举校验 → 上游 view/types | +| pageNum/pageSize | `pageNum ≥ 1`;`1 ≤ pageSize ≤ 100`;非法 → 40000 | +| knowledgeBaseId | **显式 40000**:"本阶段不支持 knowledgeBaseId 筛选"(记忆-知识库关联属 P2 任务 11,图节点无 kb 字段;不静默忽略) | +| startTime/endTime | 接受 RFC3339 或裸 `YYYY-MM-DD`(上游 community 归一化会按天展开边界);组合成 `{"and":[{"created_at":{"gte":S}},{"created_at":{"lte":E}}]}`(单边只放一边);闭区间;缺 created_at 的节点自然排除 | +| externalUserId | `{"or": [{"user_id": v}, {"owner_id": v}]}`(与投影主体 ID 同口径:user 行两者同值;agent 行匹配 owner_id) | +| subjectType | `{"owner_type": st}`(等值;public 匹配 owner_type=public 节点) | +| memSearchContent | text/pref/skill/tool:`{"or": [{"memory": {"like": q}}, {"name": {"like": q}}]}`(对齐 Mock 的"内容或技能名";Neo4j CONTAINS **大小写敏感**,与 Mock 差异记录为证据);event:`search_query`(仅 memory,上游语义);profile:新端点 search 参数(大小写不敏感子串,服务端实现) | + +多条件 → `{"and": [每个条件的结构]}` 传 `filter`。`user_id/agent_id` 请求字段**不用**(user_id 被图层忽略、agent_id 会合并进 filter)。 + +### 2.5 排序与分页 + +- 排序交给上游 `ORDER BY n.created_at DESC, n.id DESC`(稳定全序);不在本地重排(本地只有一页,无法全局排序)。 +- `total` = 上游过滤后总数(分页前 COUNT);`current` = pageNum;空桶返回 `[]`/0,不返回假数据。 + +### 2.6 字段投影(白名单,集中 `memories.py`) + +| 契约字段 | 来源(非 profile) | 来源(profile 行) | +|---|---|---| +| memoryId | node.id | row.id(`{entity_id}_{path}`) | +| memoryContent | node.memory;**列表截断 500 字符 + "…",详情全文** | row.memory(同截断规则) | +| subjectType | metadata.owner_type(user/agent/public;缺失或 group → "user" 兜底,记录) | row.subject_type(user/agent) | +| externalUserId | owner_type=user → metadata.user_id or owner_id;agent → owner_id or agent_id;public → "" | row.subject_id | +| createTime | metadata.created_at(上游已归一 UTC) | row.updated_at(**实例最后写入时间**;字段级创建时间上游不单独记录,口径写明;筛选同口径) | +| memoryType | metadata.memory_type | "ProfileMemory" | +| skillName | metadata.name(仅存在时) | — | +| skillUrl | metadata.url(仅存在时) | — | +| profileTemplateId | — | row.template_id | +| profileInstanceId | — | row.profile_instance_id(entity_id) | + +- **绝不输出**:metadata.embedding、sources、history、info、internal_info 及其他一切字段。 +- 列表体积预算:10 行 × (截断正文 + ID + 时间) ≪ 200KB(用 .local/fixtures 大样本单测断言);详情全文不截断(单行 embedding/sources 已剔除)。 +- 不得伪造:缺失的 skillName/skillUrl/profile 字段一律省略(模型 Optional,序列化省 null——与 Mock `publicMemory` 一致)。 + +### 2.7 错误口径(复用任务 01 机制,不新增业务码) + +| 场景 | HTTP | code | 说明 | +|---|---|---|---| +| 参数非法(分页越界、枚举错误、时间格式、knowledgeBaseId、memoryId 模式、类型不匹配) | 400 | 40000 | | +| 未知 projectId / 记忆不存在(含跨租户) | 404 | 40001 | 跨租户与不存在不可区分 | +| 上游 HTTP 非 200 / code≠200 / JSON 损坏 / 形状不符 | 502 | 50200 | 不透传上游 message 细节 | +| 上游连接失败 | 503 | 50300 | **绝不包装成成功空列表** | +| 上游超时 | 504 | 50400 | | +| 内部异常 / 未配置默认项目 | 500 | 50000 | 启动时已验证默认项目存在;运行中映射缺失属内部错误 | +- memoryId 入 URL 前校验 `^[A-Za-z0-9_.-]{1,128}$`(覆盖 UUID 与 profile 行 ID `profile__`),不匹配 → 40000。 + +### 2.8 上游补充范围(最小、只读、向后兼容) + +1. `GetMemoryRequest.status: list[str] | None = None`(None → 沿用 LISTED_STATUSES,现行为不变;给值 → 直传 get_all)。`handle_get_memories` 的 `list_of` 改用 `get_mem_req.status or LISTED_STATUSES`。 +2. `ProfileService.list_field_rows(user_name, *, subject_type, subject_id, search, start_time, end_time, page, page_size)` 与 `get_field_row(user_name, memory_id)`: + - list:`_instances(user_name)`(bound)→ 展平字段行(含 row id、主体、template、实例时间戳)→ 主体/搜索(大小写不敏感子串,匹配 `f"{path}: {value}"`)/时间(实例 updated_at,闭区间)过滤 → **先 total 后分页** → 排序 `(updated_at DESC, entity_id ASC, path ASC)`。 + - get:上游解析行 ID(`profile_<32hex>_`,按前两个 `_` 切分;路径可含 `_`)→ 取实例 → **租户必须匹配** → status==bound → 字段存在 → 返回行;任一不满足 → None(路由返回 data=None,语义与 get_memory 的 not-found 一致)。 + - 行形状:`{id, memory, memory_type: "ProfileMemory", template_id, template_name, profile_category, profile_field, profile_path, profile_instance_id, subject_type, subject_id, status: "activated", confidence, created_at, updated_at}`。 +3. 两个新路由(server_router.py):`POST /product/list_profile_memories`(请求:mem_cube_id(必填)、subject_type/subject_id、search、start_time/end_time、page=1、page_size=20;响应 BaseResponse[dict] data={cube_id, memories, total, page, page_size})与 `POST /product/get_profile_memory`(请求:mem_cube_id、memory_id;响应 data=行或 None)。实现放 cloud 侧(与 get_profile 同层,复用 `get_profile_service()`)。 +4. **不做**:画像字段删除/恢复(任务 03)、删除语义、写入算法、数据模型变更。 +5. 上游 docs/openapi.json 再生:`python -m memos.cli export_openapi --output docs/openapi.json`(PYTHONPATH=src)。 + +## 3. 任务分解(TDD) + +每步先写测试、跑出 RED(记录输出摘要),再实现转 GREEN(记录)。测试文件命名、用例名按下述执行,不得偷改断言迁就实现。 + +### Phase A:上游最小只读补充(工作目录 D:\MemOS\upstream) + +**A1. status 参数(先测后实现)** +- 新增 `tests/api/test_memory_handler_get_views.py::test_cloud_status_field_overrides_listed_statuses`:`GetMemoryRequest(mem_cube_id="c1", status=["activated"])` → `get_all` 收到 `status=["activated"]`;`status=None` → 收到 LISTED_STATUSES(向后兼容)。 +- 新增 `tests/api/test_product_models.py`(追加用例):`GetMemoryRequest(status=["activated"])` 合法;`status="activated"`(非 list)校验失败。 +- 实现:product_models.py 加字段(描述注明"None keeps LISTED_STATUSES";参考 GetMemoryGraphRequest.status 的写法);memory_handler.list_of 改一行。 +- 运行:`$env:PYTHONPATH="D:\MemOS\upstream\src"; & D:\MemOS\.venv\Scripts\python.exe -m pytest tests/api/test_memory_handler_get_views.py tests/api/test_product_models.py -q`。 + +**A2. ProfileService 行级读取(先测后实现)** +- 新增 `tests/api/test_profile_memory_rows.py`(服务级,`JsonMetadataStore(tmp_path)` 模式,参考 test_profile.py): + - `test_list_rows_covers_all_subjects_without_explicit_user`:两个主体(user u1 + agent a1)各绑一实例 → 不带 subject 参数列出全部行(每实例至少 1 字段)。 + - `test_list_rows_subject_and_search_filters`:subject_type=user 只回 user 行;search 命中 `path: value`(大小写不敏感)。 + - `test_list_rows_time_range_on_updated_at`:edit_profile 改值 → updated_at 变化 → 时间窗过滤按实例 updated_at。 + - `test_list_rows_total_before_pagination_and_stable_order`:≥5 行、page_size=2 → total=全量、页间不重叠、排序 (updated_at desc, entity_id, path)。 + - `test_list_rows_empty_tenant_returns_zero`。 + - `test_get_row_roundtrip_and_tenant_isolation`:行 ID → get_field_row 返回同行;**另一租户请求同 ID → None**;detached 实例 → None;不存在的 path → None;坏 ID 格式 → None。 + - `test_get_row_id_with_underscore_in_path`:路径含 `_`(如 `basic.user_name`)解析正确(切分只取前两段)。 +- 实现:profile.py 加 `list_field_rows` / `get_field_row`(+ 私有 `_field_row(instance, path)` 复用 recall 的行构造;recall 重构为调用它,行为不变——现有 test_profile.py 必须全绿作为回归)。 +- 运行:`-m pytest tests/api/test_profile.py tests/api/test_profile_memory_rows.py -q`。 + +**A3. 两个新路由(先测后实现)** +- 新增 `tests/api/test_profile_memory_routes.py`(TestClient + `patch("memos.api.handlers.init_server", ...)` 模式,参考 test_cloud_routes.py / test_contract_api.py;profile service 用 JsonMetadataStore(tmp_path)): + - `test_list_profile_memories_envelope_and_pagination` + - `test_list_profile_memories_requires_cube`(缺 mem_cube_id → code=400) + - `test_get_profile_memory_found_and_not_found`(data=None + message) + - `test_get_profile_memory_tenant_isolation`(b 租户请求 a 租户行 → data=None) +- 实现:product_models.py 两个请求/响应模型;server_router.py 两条路由;handler 函数放 profile 相关 cloud handler 处(找到 `handle_profile_query` 所在模块并同风格新增 `handle_list_profile_memories` / `handle_get_profile_memory`,service 经 `get_profile_service()`)。 +- 再生上游 docs/openapi.json(export_openapi),diff 确认只增两条路径。 +- 运行:`-m pytest tests/api/test_profile_memory_routes.py tests/api/test_cloud_routes.py -q`;`& D:\MemOS\.venv\Scripts\ruff.exe check src/memos/api/product_models.py src/memos/api/routers/server_router.py src/memos/api/profile.py`(上游 ruff 配置若存在则用其规则)。 + +### Phase B:Dashboard 后端(工作目录 D:\MemOS\dashboard) + +**B1. settings + control(先测后实现)** +- 新增 `server/backend/tests/test_control.py`: + - `test_seed_and_resolve`:`Settings(projects_seed=(("p-a","issue3-project-a"),))` → resolve_cube("p-a") == "issue3-project-a"。 + - `test_seed_is_idempotent`:两次启动种子不重复、不报错。 + - `test_unknown_project_returns_none`。 + - `test_default_project_validated_at_startup`:`default_project_id="missing"` → create_app 启动即抛(ValidationError/ValueError,含清晰 message);合法则通过。 + - `test_projects_env_format_rejected`:`DASHBOARD_PROJECTS="bad-format"` → Settings 校验失败且错误信息不含其他敏感内容。 +- 实现:settings.py 加 `projects_seed`(tuple[tuple[str,str],...])、`default_project_id`、`data_dir`(validator 解析 `DASHBOARD_PROJECTS`);新 control.py(见 2.2;`open_store(settings)` / `ControlStore.resolve_cube(project_id)` / 启动校验)。app.py lifespan 里初始化 `app.state.control`。 +- 注意:`.data/` 已在 .gitignore(任务 01 已加)。 + +**B2. upstream client 扩展(先测后实现)** +- 新增 `server/backend/tests/test_memories_upstream.py`(MockTransport 断言**请求形状** + 响应解析): + - `test_list_memories_sends_view_status_and_filter`:捕获请求体 → `include_memory_view==["detail_factual"]`、`status==["activated"]`、`page/page_size`、`filter` 精确匹配、`mem_cube_id`。 + - `test_list_memories_parses_bucket_rows_and_total`(用真实节点形状 fixture,含 embedding)。 + - `test_list_memories_rejects_upstream_code_400`(→ ApiError 50200/502)。 + - `test_list_memories_rejects_malformed_bucket`(code=200 但桶缺失/非 list → 50200)。 + - `test_list_event_memories_request_shape_and_parse`。 + - `test_get_memory_maps_null_data_to_none`(code=200 + data=None → None;code=400 → 50200)。 + - `test_get_memory_rejects_invalid_id_pattern`(调用前校验)。 + - `test_profile_endpoints_request_shape_and_parse`(新端点)。 + - `test_upstream_body_size_guard`:流式响应超过上限(常量 32 MiB,可注入小值测试)→ 50200。 +- 实现:upstream.py 扩展 `list_memories / list_event_memories / get_memory / list_profile_rows / get_profile_row`(统一:`asyncio.timeout(client.timeout.read)`、`client.stream` + 最大字节数守卫、严格 JSON、code==200 校验、形状校验、ApiError 50200/50300/50400 映射——照抄 health() 的模式)。 + +**B3. 投影模块(先测后实现)** +- 新增 `server/backend/tests/test_memories_projection.py`(纯单元,无 HTTP): + - `test_projection_whitelist_drops_large_fields`:真实形状节点(embedding 1024/history/sources/extra)→ 输出 dict 键集 ⊆ 白名单。 + - `test_projection_field_mapping`:id→memoryId、memory→memoryContent、created_at→createTime、owner 映射。 + - `test_projection_owner_variants`:user/agent/public/缺失。 + - `test_projection_skill_fields_present_only_when_real`。 + - `test_projection_truncates_list_content_only`:列表 500+"…";详情全文。 + - `test_profile_row_projection`:行 → MemoryItem(含 profileTemplateId/profileInstanceId、subject、createTime=updated_at)。 + - `test_list_response_size_under_200kb`:加载 .local/fixtures/memories-synthetic-10-large.json 的 10 行 → 投影 + envelope 序列化 → `len(json) < 200*1024`。 +- 实现:memories.py(TabSpec 表 + build_list_filter + project_node + project_profile_row + LIST_CONTENT_LIMIT=500)。 + +**B4. 路由 + 模型 + openapi(先测后实现)** +- 新增 `server/backend/tests/test_memories_list.py`(TestClient + MockTransport + 临时 control seed): + - 六标签各一条 happy path(断言上游请求形状 + 信封 `memListItemResponseList/total/current` + 白名单投影)。 + - `test_unknown_project_404_40001`。 + - `test_knowledge_base_id_explicit_400`(40000,message 含"knowledgeBaseId")。 + - `test_pagination_validation`(pageNum=0、pageSize=0/101、memoryDetailType 非法 → 40000)。 + - `test_filters_combined`(search+time+subject+externalUserId → 上游 filter 精确断言)。 + - `test_multipage_disjoint_and_total`(fixture 25 行、pageSize=10:页 1/2/3 id 不重叠、total=25、current 正确)。 + - 上游故障矩阵:500/连接失败/超时/坏 JSON/code=400 → 50200/50300/50400,**绝不 200+空列表**。 + - `test_obsolete_task01_check_retargeted` 不在此文件(见 B5)。 +- 新增 `server/backend/tests/test_memories_detail.py`: + - 非 profile 详情 happy path(投影 + 全文)。 + - `test_detail_same_id_content_matches_upstream`(MockTransport 双份同源数据:Dashboard detail 的 memoryContent == 上游 get_memory/{id} 的 memory)。 + - `test_detail_not_found_or_cross_tenant_40001`(上游 data=None)。 + - `test_detail_type_mismatch_40000`(请求 text_mem,节点 memory_type=EventMemory)。 + - `test_detail_profile_row_path`(新端点)+ 行 ID 带 `_` 的路径。 + - `test_detail_without_default_project_config_500`(未配置 → 50000,message 指明 DASHBOARD_DEFAULT_PROJECT_ID)。 + - `test_detail_invalid_memory_id_pattern_400`。 + - `test_detail_second_project_isolation`(第二实例 Settings default=project-b:A 的记忆 → 40001;B 列表不含 A 的标记)。 + - 上游故障 → 502x。 +- 实现:models.py(MemoryDetailType/MemorySubjectTypeL/MemoryListRequest/MemoryDetailRequest/MemoryItem/MemoryListData,strict);app.py 两条路由(list:resolve→校验→filter→分标签调用→envelope;detail:default project→profile 或 graph 路径→类型校验→envelope;`Cache-Control: no-store`)。 +- openapi.json(dashboard):给两条 memory 路径补 400/404/502/503/504/default 错误响应(BackendErrorEnvelope,照 /api/meta 的任务 01 模式);pageNum/pageSize 补 minimum/maximum;knowledgeBaseId 描述注明"本阶段显式拒绝(40000),知识库范围属后续任务";不改 shared/ 与 src/。 +- 运行:`& D:\MemOS\.venv\Scripts\python.exe -m pytest server/backend/tests -q`(旧 56 + 新增全绿)。 + +**B5. 过时测试重定向** +- `test_errors.py::test_unimplemented_business_route_does_not_return_fake_success`:目标从 `/api/dashboard/project/memory/list` 改为 `/api/dashboard/log/list`(日志列表属任务 04,仍未实现)。断言不变(404/40001、无假成功)。语义保留:未实现接口不能假成功。 +- 同步改 `D:\MemOS\setup\verify_task01.py` 中 `unimplemented_route_not_fake_success` 探测目标(同为 log/list)并重跑其脚本确认全部通过(该脚本属工作区环境,允许更新;报告覆盖写 .local/task01-runtime-verification.json,保留其历史意义并在 TASK02-RESULT 说明)。 + +### Phase C:真实链路验证 + +**C1. 重启上游(加载新代码)** +1. 停:按 .local/processes.json 的 upstream 条目(wrapper pid + 8000 python pid),先核对命令行标记再 Stop-Process(照 Stop-Backend.ps1 的归属校验思路)。 +2. 起:`powershell -NoProfile -ExecutionPolicy Bypass -File D:\MemOS\setup\Start-Upstream.ps1`(后台;需 infra Docker 已起——Start-Upstream 自己 `compose up -d`)。 +3. 等待 /health healthy;**回归**:GET /product/get_memory/61ca5bdd-...?mem_cube_id=issue3-project-a 仍返回原记忆正文(真实数据未受重启影响);`POST /product/get_memory` status=["activated"] 仍能列出该记忆。 +4. 记录重启证据(旧/新 PID、健康检查、回归结果)。 + +**C2. 重启 5281 后端(新 env)** +```powershell +$env:DASHBOARD_PROJECTS = "issue3-project-a:issue3-project-a;issue3-project-b:issue3-project-b" +$env:DASHBOARD_DEFAULT_PROJECT_ID = "issue3-project-a" +$env:DASHBOARD_UPSTREAM_TIMEOUT_SECONDS = "10" +powershell -NoProfile -ExecutionPolicy Bypass -File D:\MemOS\setup\Stop-Backend.ps1 +powershell -NoProfile -ExecutionPolicy Bypass -File D:\MemOS\setup\Start-Backend.ps1 +``` +- 验证 /health 与 /api/meta(live)正常;memory/list 可用。 + +**C3. setup/verify_task02.py(真实链路证据,报告写 .local/task02-runtime-verification.json)** +脚本结构(照 verify_task01.py:httpx 直连、expect() 记录、临时后端实例模式): +1. `upstream_health_and_version`。 +2. **写入**(带唯一标记 `task02-`,控制模型调用量并记录 usage): + - 12+ 条 text 记忆:循环 `POST /product/add`(每次一条不同事实、`allow_memory_view=["detail_factual"]`、async sync、`info.source_type="issue3_task02_verification"`),直到上游搜索 total ≥ 12(上限 15 次调用)。 + - 尝试 1 条 pref(`allow_memory_view=["preference"]`)与 1 条 event(`allow_memory_view=["event"]`)标记;**实际产生什么类型就记录什么**(是否生成 PreferenceMemory/EventMemory 以列表实测为准,不宣称)。 + - 画像:`add_profile_template`(模板树含 `basic.display_name/city` + `work.role`,name 带标记)→ `bind_profile_template`(user_id=标记主体)→ `edit_profile`(设 display_name="task02-"、city="Shanghai")。租户 issue3-project-a;不删除既有内容。 +3. **列表发现**:Dashboard `POST /api/dashboard/project/memory/list`(projectId=issue3-project-a、memoryDetailType=text_mem、memSearchContent=标记、pageSize=10)→ 断言命中 ≥1 → 取 memoryId(多页场景记录 total 与页遍历:pageSize=5 全量翻页,id 两两不重叠、总数一致)。 +4. **同 ID 对照**:Dashboard detail(memoryId+memType=text_mem)vs 上游 `GET /product/get_memory/{id}?mem_cube_id=issue3-project-a` → `memoryContent` 逐字节相等;`metadata` 大字段(embedding/sources/history)不出现在 Dashboard 响应。 +5. **画像链路**:profile_mem 列表能搜到标记行 → 行 detail(profile 行 ID,路径含 `_` 的用例另设字段 `user_name` 验证)→ 与上游 get_profile_memory 同 ID 同值。 +6. **项目隔离**:临时后端实例(ephemeral port + default project = issue3-project-b):A 的 memoryId → 40001;B 列表不含 A 标记;主实例列表(projectId=b)同断言。 +7. **六标签真实覆盖盘点**:对每个 tab 真实列表一次(total 与真实行数记录),标注哪些 tab 有真实数据(text/pref/event/profile 视写入结果)与哪些仅合成覆盖(skill/tool——无 REST 写入路径,诚实记录"仅单测+上游形状 fixture 覆盖")。 +8. **体积**:真实列表(pageSize=10)响应字节数记录(真实数据小;大样本体积断言在单测 B3 已证)。 +9. **任务 01 回归**:/api/meta live、health、未实现路由(log/list)404 不假成功、前端 .env.local 哈希前后一致、src/ 与基线 diff 为空。 +10. 输出 JSON 报告(含 marker、memory_ids、usage、每检查 passed/failed、耗时)。 + +### Phase D:质量门与交付 + +**D1. 质量门(全部真实执行并记录输出摘要)** +1. 后端全量:`& D:\MemOS\.venv\Scripts\python.exe -m pytest server/backend/tests -q`。 +2. 上游相关:`-m pytest tests/api/test_memory_handler_get_views.py tests/api/test_product_models.py tests/api/test_profile.py tests/api/test_profile_memory_rows.py tests/api/test_profile_memory_routes.py tests/api/test_cloud_routes.py tests/graph_dbs/test_filter_conditions.py -q`(在 upstream 目录 + PYTHONPATH)。 +3. Ruff:dashboard `& D:\MemOS\.venv\Scripts\ruff.exe check server/backend` + `ruff format --check server/backend`(用现有 ruff.toml);上游按其配置对改动文件 check。 +4. `pnpm verify`(typecheck + eslint + prettier check + build)。 +5. Redocly:`npx redocly@latest lint openapi.json`(若仓库无配置则用默认规则;记录结果)。 +6. `git diff --check`(两个仓库)。 +7. 基线核对:`git -C D:\MemOS\dashboard diff 03e10145a4dd8bc3e50b6e2a50ed788d64111945 -- src/ shared/ server/mock/` 输出为空;`.env.local` 哈希与验证前一致;5274 前端仍连 Mock(探测其请求目标或查 .env.local 未变)。 +8. 上游 docs/openapi.json 再生后 `git -C D:\MemOS\upstream diff --stat` 仅含预期文件。 + +**D2. 交付文档** +- `server/backend/TASK02-RESULT.md`(主交付,模板): + 1. 新增/修改文件清单(Dashboard 与上游分列)。 + 2. 实施思路摘要 + 六标签映射表 + 项目映射与权限边界说明。 + 3. 状态/筛选/排序/分页/投影口径(引用本计划 2.x 的表,标注已实现)。 + 4. 实际测试命令与结果(RED/GREEN 记录、pytest/ruff/pnpm/redocly 输出摘要)。 + 5. 唯一标记、测试项目、memory ID、同 ID 对照证据(引用 .local/task02-runtime-verification.json,敏感内容不入库)。 + 6. 响应体积测量(真实 + 大样本单测数字)。 + 7. 可直接运行的请求示例(curl/PowerShell + 人工验证步骤)。 + 8. 已知限制与未实现(knowledgeBaseId 显式拒绝、skill/tool 无真实数据、搜索大小写敏感差异、DreamDiary 排除、画像时间戳口径、无账号体系)。 + 9. 结论:仅任务 02 完成,不宣称 Issue #3 全部完成。 +- `server/backend/README.md`:任务 02 运行说明(env 三变量、启动、验证脚本)。 +- `server/backend/.env.example`:补三个新变量示例。 +- `docs/superpowers/specs/2026-10-26-issue3-dashboard-backend-design.md`:仅"当前进度"小节追加任务 02 状态(不改设计正文)。 + +## 4. 验收对照(用户要求 → 证据位置) + +| 用户要求 | 证据 | +|---|---| +| 六标签列表/详情 + 字段映射 | B3/B4 测试 + TASK02-RESULT 映射表 | +| 项目范围/隔离(两项目) | test_memories_detail 隔离用例 + C3.6 真实证据 | +| 筛选分页不静默忽略 | B2/B4 请求形状断言 + C3.3 | +| 先筛选再 total 再分页 | 上游 export_graph COUNT 语义 + test_multipage_disjoint_and_total | +| activated 口径 + 缺失状态规则 | A1 + 2.3 规则文档 + 测试 | +| 白名单投影 + <200KB | test_projection_* + size 用例(fixtures 大样本) | +| 失败行为矩阵 | B2/B4 故障用例(不包装成空列表) | +| 同 ID REST 对照 | C3.4(正文逐字节相等) | +| 任务 01 回归 + 过时测试重定向 | B5 + C3.9 + D1.7 | +| 检查清单(pytest/ruff/pnpm/redocly/git/基线) | D1 全项真实输出 | + +## 5. 风险与回退 + +- 上游重启后行为差异(如 embedding 是否仍随行返回):C1.3 已含真实数据回归检查;差异如实记录,不影响 Dashboard 投影正确性(白名单不依赖上游是否带大字段)。 +- /add 抽取产出不稳定(条数/类型):C3.2 用实际列表结果记录,脚本按实际断言而非期望值。 +- 若 event/pref 标记未生成对应类型:记录为真实覆盖缺口(合成单测覆盖),不伪造。 +- 所有改动未提交 git,出问题可直接定位文件回退;上游仅 4 个文件 + docs,dashboard 新增模块独立。 diff --git a/docs/superpowers/plans/2026-10-26-issue3-task03-memory-lifecycle.md b/docs/superpowers/plans/2026-10-26-issue3-task03-memory-lifecycle.md new file mode 100644 index 000000000..1538683b3 --- /dev/null +++ b/docs/superpowers/plans/2026-10-26-issue3-task03-memory-lifecycle.md @@ -0,0 +1,59 @@ +# Task03 Memory Lifecycle Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans. Execute inline with TDD and one fresh final review. + +**Goal:** Deliver recoverable soft deletion of ordinary memories and individual profile fields, verified graph/vector convergence, durable retries and fault tests. + +**Architecture:** The upstream owns durable lifecycle intents in MetadataStore; a per-resource database/file lock serializes transitions across service instances. Persist intent before graph mutation, strictly synchronize and read back existing vector points, and reconcile unfinished intents on startup and periodically. Dashboard persists operation receipts and adapts strict upstream outcomes without hiding partial failures. Profile transitions update one field and retain its value; all profile writers share a store lock. + +**Tech Stack:** Existing Python/FastAPI/httpx, SQLite, Neo4j CloudEntity documents and Qdrant; no new dependency. + +**Spec:** docs/superpowers/specs/2026-10-26-issue3-dashboard-backend-design.md, lifecycle and task03 sections. Already approved implementation sequence; user now requests task03. + +## Global Constraints + +- Work only in D:/MemOS/dashboard and authorized upstream implementation modules; preserve Task01/02 changes. No commit, push, PR or destructive cleanup. +- src/, shared/, server/mock/ and main frontend5274 remain unchanged. Optional isolated5276 frontend is for testing. +- Deletion verification targets only new disposable task03 records. Restore each real record after testing; never delete existing task01/02 data. +- Ordinary control-plane delete accepts only activated records. Restore uses upstream previous_status, not client-supplied arbitrary states. Frozen records cannot be deleted. +- Response code0 only when every requested operation is confirmed successful. Partial failures include durable operation IDs and per-item outcomes with nonzero code. +- Scope remains local configured operator projects; no claim of P1 authentication. Mutation checks trusted localhost Origin when present. + +## Review Focus + +- Crash after graph write leaves a recoverable intent and cannot leak a stale vector hit. +- Same operation ID with different arguments conflicts; replay after a later opposite operation cannot reapply the older effect. +- Vector unavailable, missing indexed point or failed verification never counts as success. +- Opposite concurrent operations serialize and drain prior unfinished work before progressing. +- Template sync, manual edit and extraction cannot resurrect or overwrite a deleted profile field; other fields remain unchanged. + +### Task 1: Upstream durable lifecycle engine + +- [x] Add failing tests for delete/restore, pre-intent failure, vector failure/readback mismatch/missing point, graph-write crash, startup replay, idempotency conflicts and concurrent opposite transitions. +- [x] Add lifecycle.py (separate from existing API lifecycle shutdown), control locking, models/routes and bounded background reconciliation. +- [x] Run new lifecycle tests and existing storage tests; verify confirmed success only after graph and vector agree. + +### Task 2: Profile lifecycle and ordinary visibility + +- [x] Add failing tests for single-field deletion, all read surfaces, retained original value, template/edit/extract updates, restore, tenant isolation and restart. +- [x] Implement profile field transitions using the same intents and serialized document writes. Filter deleted fields in instance views/recall. +- [x] Add final graph-state validation to search output; ensure REST single/batch/list reads and graph exposure omit deleted rows; unavailable graph fails closed. +- [x] Run relevant API/read/search/profile regressions and expand as required by AGENTS.md. + +### Task 3: Dashboard contract and operation receipts + +- [x] Add failing backend tests for validated batch delete, restore, operation query/retry, preflight-all scope/type checks, partial failures, idempotency, timeout recovery and Origin checking. +- [x] Extend control SQLite with operation receipts. Add lifecycle client/router; supplement OpenAPI projectId/memType/operation IDs and restore/status/retry routes while preserving deleted count. +- [x] Run entire backend suite, Ruff and task01/02 regression. + +### Task 4: Real acceptance and delivery + +- [x] Restart upstream/backend after code changes, export actual upstream OpenAPI. +- [x] Seed one independent fact and a two-field profile. Delete -> list/detail/batch/search absence -> raw graph/vector agreement -> restore -> same IDs and original values. +- [x] Exercise real local fault harness with process restart, vector outage, replay and opposite operations without disrupting user records. +- [x] Run API tests, backend tests, pnpm verify, Redocly, Ruff and protected frontend diff checks. Perform one fresh read-only final review, fix findings with RED/GREEN. +- [x] Write TASK03-RESULT.md with exact evidence and limitations, update design progress, then stop before Task04. + +## Completion evidence and rulings + +See server/backend/TASK03-RESULT.md. Backend119passed; upstream537passed/18deselected (same18failures independently reproduced against unmodified HEAD source). Full API is not claimed all green. pnpm verify/Ruff/Redocly(0errors,2existingwarnings)/diff checks pass; Task01/02 real regressions pass. Real crash/vector connection outage and lost HTTP acknowledgement/restarted Dashboard retry all passed; every disposable memory restored. One fresh review P1 legacy subgraph leak fixed with RED/GREEN. Community parser tenant loss fixed using scoped native state reads. No commits/push/PR, stop before Task04. diff --git a/docs/superpowers/plans/2026-10-26-issue3-task04-call-logs.md b/docs/superpowers/plans/2026-10-26-issue3-task04-call-logs.md new file mode 100644 index 000000000..c1eb3db78 --- /dev/null +++ b/docs/superpowers/plans/2026-10-26-issue3-task04-call-logs.md @@ -0,0 +1,57 @@ +# Task04 Call Logs Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans, inline TDD, one fresh final read-only review. + +**Goal:** Index real audit JSONL incrementally; deliver scoped log list/detail, legacy compatibility, async folding, and measured 10,000-line first-page latency below one second. + +**Architecture:** A local SQLite audit index stores physical-line identities/checkpoints plus logical calls. File identity and content anchors detect rotation/replacement/truncation; line imports and cursor updates commit together. Explicit callId folds asynchronous request/terminal rows inside source+tenant scope. Console memory operations emit separate sanitized JSONL. Querying logs never emits another log. + +**Tech Stack:** Existing Python/FastAPI/Pydantic/SQLite; no dependency, frontend or model changes. + +**Spec:** docs/superpowers/specs/2026-10-26-issue3-dashboard-backend-design.md, audit/usage section and task04. Approved implementation sequence; current user instruction starts task04. + +## Global Constraints + +- Work in D:/MemOS/dashboard; retain all prior Task01/02/03 changes and unrelated .memos data. No commit/push/PR; no frontend src/shared/server/mock edits; main5274 remains Mock. +- Only gateway calls-*.jsonl and console console-*.jsonl are business call sources. requests/searches JSONL are protocol/retrieval diagnostics and must not be double-counted. +- Tenant comes from explicit trusted source fields and configured project mapping, never inferred from subject ID or assigned to the default project. +- Success envelope code0; list key logs; detail requestBody/responseBody JSON strings without double encoding. Detail without projectId uses the fixed instance default project. +- Missing historical durations stay null; missing tokens are0; missing unknown status is UNKNOWN; no invented timestamps/subjects. Preserve unknown event labels; invalid event filters return400. +- Current real gateway uses synchronous add and has no callId. Test and disclose async folding with isolated request/terminal fixtures rather than claiming production async history exists. +- Source directories are explicit process config, not request-controlled filesystem paths. Missing/unreadable configured source fails clearly; valid empty directories can return empty lists. +- Cold 10k benchmark includes import+SQL+HTTP first page, not just a preloaded in-memory query. Also measure persisted-index restart and incremental append. Target <1s, record fixture bytes and real timings. + +## Review Focus + +- Same filename replaced or truncated/regrown past old offset must not lose new rows; renamed same physical file must not duplicate rows. +- Half lines, invalid JSON, oversized lines and malformed nested fields cannot corrupt cursor progress or hide later valid records. +- Async terminal-first/repeated terminal/request-late ordering cannot duplicate calls or downgrade terminal state; equal callIds across tenants/sources never merge. +- Log detail is tenant-scoped even when its ID is copied from a different project; unscoped legacy records are quarantined, not leaked. +- JSON body handling preserves already encoded strings, redacts credentials, never invents successful business status from HTTP200. + +### Task 1: Contracts and normalization + +- [x] Add failing normalization/filter tests, including legacy missing fields, aliases, status precedence, JSON-string bodies, unknown events, source/callId and timezone boundaries. +- [x] Implement audit_models.py and audit_normalize.py. Supplement OpenAPI scope/project detail semantics/validation and unknown output event labels. +- [x] Run focused tests then preserve decisions in execution ledger. + +### Task 2: Incremental SQLite index + +- [x] Add failing tests for initial/repeated/restarted import, append, half lines, rename/rotation, same-name replacement, truncate/regrow, bad/oversized JSON, cursor transaction rollback and source failure. +- [x] Implement audit_index.py: raw line identity, durable checkpoints, async logical-call folding, indexed scoped filters/pagination and detail projection. +- [x] Add async race/order/tenant/source collision tests and measure a 10k corpus early; optimize only from evidence. + +### Task 3: HTTP and console audit integration + +- [x] Add failing API tests for list/detail/default scope, combined filters, validation, errors, JSON strings and no log-read feedback loop. +- [x] Implement audit_routes.py/audit_writer.py, Settings/env controls and lifespan wiring. Console writes include only delivered memory operations; missing identity never defaults to another tenant. +- [x] Retarget obsolete Task01 unimplemented-route check to usage/query (Task05); preserve its no-fake-success meaning. +- [x] Run full backend suite, Ruff and existing task regressions. + +### Task 4: Real acceptance, performance and delivery + +- [x] Configure real gateway audit directory, restart owned backend only. Verify actual gateway read invocation -> JSONL -> list -> same source record/detail; generate actual console memory read -> console JSONL -> source filter. +- [x] Verify isolated async fixture association and file faults through HTTP, clearly labelled synthetic. +- [x] Measure 10k new-index first page, persisted-index restart, warm p50/p95 and incremental append, with HTTP wall time and bytes. Require <1s first page. +- [x] Fresh final review, fix important findings using RED/GREEN, then final suite/format/Redocly/protected source diff checks. +- [x] Write TASK04-RESULT.md, update plan/design/matrix, stop before Task05 usage aggregation. Keep inherited upstream18 baseline failures disclosed, do not claim they were repaired. diff --git a/docs/superpowers/plans/2026-10-26-issue3-task05-usage.md b/docs/superpowers/plans/2026-10-26-issue3-task05-usage.md new file mode 100644 index 000000000..132bc66fa --- /dev/null +++ b/docs/superpowers/plans/2026-10-26-issue3-task05-usage.md @@ -0,0 +1,36 @@ +# Task05 P1 Usage Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox syntax for tracking. + +**Goal:** Serve real daily usage from the existing logical audit index with explicit scope and UTC+8 boundaries. + +**Architecture:** A strict request model resolves date bounds; ControlStore supplies authorized cube IDs; AuditIndex aggregates its existing gateway audit_calls. The route reuses AuditService incremental ingestion. No second JSONL importer or frontend changes. + +**Tech Stack:** FastAPI, Pydantic, SQLite, Python 3.11, pytest. + +**Spec:** docs/superpowers/specs/2026-10-26-issue3-dashboard-backend-design.md + +## Global Constraints + +Preserve src/, shared/, server/mock/, current main frontend Mock and previous tasks. Stop after Task05. No commits, upstream edits or real memory mutations. Current authorization is the configured local operator allowlist, not completed account authentication. + +## Review Focus + +- Persisted projects removed from configuration must not enter all-project totals. +- UTC timestamps at 16:00Z belong to the next Beijing date; end date is inclusive through next midnight exclusive. +- Missing timestamps remain visible in logs but cannot enter a dated aggregate. +- Async terminal updates replace one logical call and never add another usage call. +- Nonempty unsupported apiKeyId must fail explicitly; includeDailyDetails=false must still return a documented summary. + +### Task 1: Daily usage behavior + +- [x] Write failing API tests for date ranges/defaults/zero days, source/event/status rules, tokens, async updates, authorization and errors; run RED. +- [x] Add usage_models.py and usage_routes.py, ControlStore.authorized_cubes and AuditIndex.usage using only existing audit_calls; integrate app router. +- [x] Run targeted tests GREEN and fix regressions. + +### Task 2: Contract and acceptance + +- [x] Supplement root OpenAPI request ambiguity and additive summary/metadata; validate actual envelopes. +- [x] Retarget the obsolete Task01 unimplemented usage check to the next unimplemented module. +- [x] Add and run setup/verify_task05.py using isolated HTTP fixtures and read-only real index comparison. +- [x] Run full backend pytest and Ruff, record actual results and precise limitations in TASK05-RESULT.md/README/design progress. diff --git a/docs/superpowers/plans/2026-10-26-issue3-task06-projects.md b/docs/superpowers/plans/2026-10-26-issue3-task06-projects.md new file mode 100644 index 000000000..92be481cc --- /dev/null +++ b/docs/superpowers/plans/2026-10-26-issue3-task06-projects.md @@ -0,0 +1,25 @@ +# Task06 Projects Implementation Plan + +**Goal:** Deliver persisted real workspace lifecycle and independent selected-project contexts, without implementing Task07 account authentication. + +**Spec:** ../specs/2026-10-26-issue3-dashboard-backend-design.md; Task06 user instruction. + +**Execution:** Main agent inline. Existing feature workspace and uncommitted Task01–05 changes retained. No src/shared/mock/env edits, upstream changes, commits or destructive memory operations. + +## Decisions / interfaces + +- SQLite projects are the authoritative workspaces, each with immutable upstream_cube_id (new project defaults to its generated ID). Native product memory endpoints use that namespace without requiring the separate owner-based /create_cube registry; verify an actual new namespace through all six read views. +- Migrate existing projects transactionally, preserve stored mappings, disabled/archive state and names on restart. Seed visibility remains configuration-controlled; API-created projects have a persisted local-operator ownership marker and survive configuration-only restart. +- Selection is a persisted opaque HttpOnly/SameSite cookie context, not a global status update and not an authenticated account. Task07 will attach/replace this preference context with real account sessions. No fake /api/auth/me implementation. +- Project projection: status ENABLED only for selected active workspace; disabled and archived independent. Dictionary excludes inaccessible/inactive projects; list includes disabled/archived for recovery. Local trusted operator has management role owner, explicitly not complete account authorization. +- update ENABLED on an active project selects it; on a disabled project it re-enables without switching. DISABLED stops it. Archive is reversible, revoke is an archive compatibility alias; never delete memory/cube data. Invalidate/fallback only contexts selecting an inactive target. +- Existing memory/detail and log/detail must use selected context; resolve_cube and usage authorized_cubes exclude inactive projects. Use existing Origin checks on writes. Same transaction for lifecycle state, context fallback, uniqueness and persistence. + +## Steps + +- [x] RED tests: migration/restart, creation/update/pagination/validation/duplicates, two context selections, stopped/archive gates and restore, immutable mapping, Origin, concurrent writes. +- [x] Implement project models/control persistence/routes/context; wire delivered APIs and root OpenAPI; retarget obsolete Task01 negative route to /api/auth/me. +- [x] GREEN full backend tests + Ruff. Verify actual isolated project lifecycle, browser selection, restart persistence, existing memory data preserved and Mock unchanged. +- [x] Main-agent review against contract/fault cases; use additional agent only if necessary. pnpm verify/Redocly/protected diff; Task06 result and progress matrix. Stop Task07. + +Evidence and remaining frontend/auth/key boundaries: server/backend/TASK06-RESULT.md. Full backend 204 passed; live three OS-process restarts; pnpm verify, Ruff, Redocly passed. Task07 not started. diff --git a/docs/superpowers/plans/2026-10-26-issue3-task07-09-identity.md b/docs/superpowers/plans/2026-10-26-issue3-task07-09-identity.md new file mode 100644 index 000000000..14ba2de19 --- /dev/null +++ b/docs/superpowers/plans/2026-10-26-issue3-task07-09-identity.md @@ -0,0 +1,30 @@ +# Tasks07–09 identity implementation plan + +**Goal:** Implement the approved account/session, member and invitation design inline in the existing backend. +**Spec:** ../specs/2026-10-26-issue3-dashboard-backend-design.md, identity section and steps7–9. +**Architecture:** Same SQLite control database, transactional account and membership invariants; scrypt password hashes and opaque hashed sessions; request-bound project permissions. Backend HTML login/logout/invitation routes use existing Vite proxy prefixes. Explicit authenticated deployment is enabled after bootstrap; legacy local operator mode remains explicitly available for earlier isolated tests. +**Constraints:** Main agent implements; preserve src/shared/mock/env/upstream; no commits or pushes; no paid models or mutations of existing memories. User directly authorized all three tasks; report each then continue without further approval. + +## Review focus +- Concurrent demotion/removal/disable/delete must preserve an effective admin and active project owner. +- Account disable/delete invalidates existing sessions; role revocation takes effect next request, including explicit projectId and all-project usage. +- Anonymous and cross-origin callers cannot mutate authenticated state; login and invitation forms use bounded bodies and CSRF tokens, safe redirects and escaped HTML. +- Invite digest only, expiration/revocation/single use enforced transactionally; existing members never get silently downgraded. +- Migration preserves Task06 data; authenticated selections are session-bound and cannot inherit arbitrary anonymous project cookies. + +## Task07 +- [x] RED: login, logout, expiry/restart, anonymous/non-admin denial, strict account updates, immediate disable, effective last-admin and owner safeguards, deletion relationships. +- [x] Implement identity_store.py, auth.py, bootstrap.py; wire settings/meta/middleware and request-bound project store. Keep schema/password/session internals out of API projection. +- [x] GREEN tests and genuine HTTP restart/login evidence; report Task07 before continuing. + +## Task08 +- [x] RED: owner/editor/viewer matrix, scoped reads/writes/all-project usage, candidates, add/change/remove/leave, last effective owner and concurrent transitions. +- [x] Implement members.py and membership operations; project creator becomes owner in the same transaction, no personal-space membership editing. +- [x] GREEN and HTTP/browser project-member evidence; report Task08. + +## Task09 +- [x] RED: create/list/role/revoke/expiry/reuse/concurrent accept, disabled project, existing member protection, signup and logged-in accept CSRF. +- [x] Implement invites.py and backend invitation landing/acceptance through /__login/invite/{token}; CLI account initialization and invitation signup supply real account creation without modifying React. +- [x] GREEN all backend tests, Ruff, pnpm verify, Redocly, protected diff and genuine browser/HTTP flows; update Task07–09 reports/design/matrix and stop before Task10. + +Evidence: TASK07/08/09-RESULT.md;229 backend tests, genuine HTTP/OS restart and browser flows. One inherited-model read-only security review found no concrete unresolved issue. No Task10. diff --git a/docs/superpowers/plans/2026-10-26-issue3-task10-13-p2.md b/docs/superpowers/plans/2026-10-26-issue3-task10-13-p2.md new file mode 100644 index 000000000..98d9b0fdf --- /dev/null +++ b/docs/superpowers/plans/2026-10-26-issue3-task10-13-p2.md @@ -0,0 +1,55 @@ +# Tasks10–13 P2 implementation plan + +**Scope update (2026-10-26, user instruction):** Freeze Tasks10–11 at their current implementation. Do not add knowledge-base features, expand knowledge-base tests, or require additional knowledge-base acceptance work. Preserve existing entity/file code and data. Remaining delivery scope is Tasks12–13; this scope reduction does not assert that all original knowledge-base acceptance criteria have been completed. + +**Goal:** Complete extraction prompts and read-only graphs/models; preserve the user-frozen knowledge entity/file implementation. Execute inline; user forbids subagents. No commits or source/frontend/mock/env edits outside the backend and root contract/docs. Preserve upstream code unless an actual missing capability requires a minimal, documented correction. +**Spec:** ../specs/2026-10-26-issue3-dashboard-backend-design.md, P2 section/tasks10–13. +**Architecture:** Existing authenticated FastAPI adapter, bounded upstream REST client, SQLite knowledge-home/project-link registry (entities/bytes remain upstream). Request-bound project roles and strict response projections. Existing Vite proxy and disabled knowledge UI stay unchanged. + +## Rulings +- KB has immutable upstream home namespace; sharing is an explicit local association, not moving/duplicating an upstream object. Association/full replacement requires owners on all affected projects and the KB home. Reads expose only caller-visible project IDs. Global mutations require editor on all linked/home projects, deletion owner. Inactive home blocks access; unlink and entity deletion are distinct. Orphan KBs remain manageable by their home owner. +- New KB without project selection uses current session workspace. Existing upstream KBs are discovered only through authorized namespaces; a registry row is never accepted as proof of upstream entity existence. +- Files accept bounded Data URL or literal UTF8 text, never remote URLs/path traversal. Canonical base64 transport prevents text decoding ambiguity. Ordinary document storage only; NoopIngestor is not parsing/chunking/recall. +- Prompt detail is readable; save/reset owner and generate/debug editor (paid inference). Preserve ${conversation}. Runtime saved-rule proof uses an isolated synthetic project and minimal genuine extraction, never changes existing user prompts. +- Graph uses activated tenant-scoped upstream graph; <=120-char labels and correct truncation, bounded nodes/edges/response. Full-text lookup must use same source. Model response is a strict allowlist with endpoint credentials/query/fragment stripped or rejected, never local env/static guesses. + +## Task10 + +Frozen at the current implementation by user instruction; unchecked items below are historical scope, not remaining work for this delivery. +- [ ] RED entity CRUD, response contract, full-replacement associations, project read/write roles, cross-tenant negative, restart/discovery and deletion failure/retry. +- [ ] Implement product_client.py, kb_models.py, kb_store.py, knowledge.py; wire app and root spec. +- [ ] GREEN isolated tests + genuine upstream CRUD/REST proof, report milestone. + +## Task11 + +Frozen at the current implementation by user instruction; unchecked items below are historical scope, not remaining work for this delivery. +- [ ] RED upload/decode/size/name validation, file filters, download byte/hash equality, deletion, restart, cross-project/viewer negatives and mixed-batch preflight. +- [ ] Implement knowledge_files.py; project-scoped download metadata preflight, bounded raw bytes and safe download headers; no raw-path persistence in dashboard. +- [ ] GREEN genuine upload/download/restart/delete, report milestone. + +## Task12 +- [x] RED detail/save/generate/reset/debug, parameter/type validation, permission, upstream errors, saved-rule propagation/isolation. +- [x] Implement prompts.py, appropriate inference timeout setting and root request contract. +- [x] GREEN real generation/debug and actual /product/add saved-prompt extraction in an isolated project; report exact evidence and cleanup. + +## Task13 +- [x] RED graph projection/truncation/filter/permission/dangling links/full-text/deleted negatives, model allowlist/credential stripping and malformed upstream failures. +- [x] Implement graph_models.py/read_views.py using real upstream endpoints. +- [x] GREEN genuine graph/model contrast, browser prompt/graph/models, full tests/Ruff/pnpm/Redocly/protected diff, individual Task12–13 reports. Stop before14/15/16. + + +## Actual delivery +- Task12 genuine runtime proof: isolated saved-only marker persisted in memory ef3c3567-e47e-4c50-9e2f-c58943f8ccba and same-ID native REST. Prompt restored; other project unchanged; browser debug produced 4 candidates without memory writes. +- Task13 genuine graph: 120-character label, 228-character same-source full text; foreign-project 404; three actual model configuration rows. Browser graph expansion/models passed. +- 249 dashboard tests, 15 focused tests and 102 upstream related tests passed; scoped Ruff, pnpm verify and Redocly (0 errors/5 existing warnings) passed. Protected paths unchanged; sample5274/live5276. +- Upstream runtime correction: multimodal base-template lookup, memory_list alias, per-cube fan-out context. Exported patch and test in server/backend/patches/task12-upstream-prompt-runtime.patch. +- Two isolated synthetic memories retained for review. Original business data and prompts untouched. No Task14/15/16 work and no further knowledge-base changes. + + +## 2026-10-27复核问题关闭 +- [x] 正常空数组与主键优先回归先RED,再修复为零候选;非法结构仍raw。 +- [x] 原生/规范类型统一映射后筛选,三类选择集合交叉回归先RED再GREEN。 +- [x] Docker/上游/后端/辅助前端真实就绪;新唯一标记实际抽取、新ID REST/图谱全文对照及跨项目404通过。 +- [x] 真实空结果、生成不保存、原生上游进程重启后的Prompt持久性通过,测试项目Prompt恢复。 +- [x] 页面空结果/新记忆展开/模型清单复验及截图;280后端/108上游/18专项、Ruff、pnpm verify、Redocly0错误5既有警告通过。 +- [x] 更新上游可移植补丁及复核报告关闭记录。未扩展知识库或任务16。 diff --git a/docs/superpowers/plans/2026-10-26-issue3-task14-keys-plan.md b/docs/superpowers/plans/2026-10-26-issue3-task14-keys-plan.md new file mode 100644 index 000000000..42d0ec58d --- /dev/null +++ b/docs/superpowers/plans/2026-10-26-issue3-task14-keys-plan.md @@ -0,0 +1,298 @@ +# Task 14 · P3 密钥(持久化 + 授权下发 + 网关配置同步 + 重启生效)实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans(本仓执行方式已定:无 subagent、用户本人在场互相校对,逐任务执行)。步骤用 checkbox(`- [ ]`)跟踪。 + +**Goal:** 把 5 条 `/api/dashboard/keys/*` 路由接到真实控制面:SQLite 持久化 + 网关 `tenants.conf` 合写 + 真实网关重启生效验证(401→200→401)。 + +**Architecture:** 新增 `key_store.py`(ApiKeyStore + 原子 manifest 写入器,落 control.db 的 `api_keys` 表)与 `keys.py`(5 条路由,全 owner);dashboard 启动自愈重写 manifest;网关 `deploy/mcp-gateway/` 最小改动合成加载 `tenants.conf`(env 老 token 不动,同 token 冲突拒绝启动),以补丁交付。live 验证用 `setup/Restart-Gateway.ps1`。 + +**Tech Stack:** FastAPI + pydantic + sqlite3(既有栈);unittest 脚本(网关侧);PowerShell(运维脚本)。 + +**Spec:** `docs/superpowers/specs/2026-10-26-issue3-task14-keys-design.md` + +## Global Constraints(每任务隐含) + +- 不执行任何 `git commit`/`add`;不碰 `src/`、`shared/`、`server/mock/`、`.env.local`;不碰 18000 的 Linux 参照环境。 +- 网关侧所有改动 = 改动 + 导出补丁到 `server/backend/patches/` + SHA256 + `git apply --reverse --check` 取证。 +- **任何终端输出、日志、证据 JSON、RESULT 都不得包含完整 token 原文**(`sk-` 开头即打码/换哈希)。 +- `openapi.json` 只改描述文字,五条路径形状零变化;Redocly保持 0 错误、≤5 警告。 +- pytest 全量保持 ≥249 全绿;ruff 干净;前端契约字段 `id/keyName/apiKey/createTime` 驼峰不变。 +- Redis/密钥格式:`sk-` + 48 hex(`secrets.token_hex(24)`);id = `key_` + 32 hex。 + +## Review Focus(谁最可能咬人 & 各自钉进哪个任务) + +1. **create 半途写文件失败还报成功**(前端注释原话「接口返回成功只代表配置写进去了」)→ Task 2 `test_create_rolls_back_if_manifest_write_fails`(HTTP 503 + 库无行 + 文件不变)。 +2. **软撤销后网关重启仍收旧密钥** → Task 1 `active_entries` 排除断言 + Task 7 live 第 9 步(revoke→重启→401)。 +3. **已撤销 id 经 copy/rename 再取密钥** → Task 2 `test_revoke_soft_and_aftermath`(revoked → 404,行仍在库)。 +4. **非 owner 会话拿到完整密钥** → Task 2 role 矩阵(viewer/editor/跨项目 membership 全 403)。 +5. **密钥原文漏进 console 审计 JSONL** → Task 2 `test_console_audit_redacts_token`(读 console JSONL 断言无 `sk-…`);Task 7 全程不打印。 + +--- + +### Task 1: control.db 迁移 + `key_store.py` + +**Files:** +- Modify: `server/backend/control.py:87-90 之后`(project_contexts 建表后、种子循环前追加 `api_keys` 建表) +- Create: `server/backend/key_store.py` +- Test: `server/backend/tests/test_keys_store.py` + +**Interfaces:** +- Consumes: `ControlStore.connection()`、`Settings(data_dir=…, projects_seed=…, default_project_id=…, auth_enabled=…)`(与 test_accounts.py 相同构造法);`identity_store.now()`。 +- Produces(后续任务按此调用,勿改名): + - `ApiKeyStore(control: ControlStore)`,方法一律带 `db: sqlite3.Connection | None = None`,`db=None` 时自开 `connection()`: + - `insert(db, project_id: str, name: str, created_by: str | None) -> dict`(行 dict,键=表列名;id `key_`+32hex、token `sk-`+48hex 唯一冲突时换 token 重试 3 次) + - `get(db, key_id: str) -> dict | None`(含 revoked 行) + - `page(db, project_id: str, page_num: int, page_size: int) -> tuple[list[dict], int]`(仅 active;`ORDER BY create_time DESC, id DESC`) + - `active_entries(db) -> list[tuple[str, str]]`(token, project_id;仅 active,desc 稳定序) + - `rename(db, key_id: str, name: str) -> dict | None`(不动 token/status;None=查无) + - `mark_revoked(db, key_id: str, revoked_by: str | None) -> dict | None`(置 status='revoked'、revoke_time=now()、revoked_by;None=查无) + - `write_tenants_conf(path: Path, entries: Sequence[tuple[str, str]]) -> None`:原子写入(同目录临时文件 `f"tenants.conf.{os.getpid()}.tmp"` + `os.replace`),内容 `json.dumps({token: {"user_id": u, "cube_id": u}, …}, indent=2) + "\n"` + - `sync_manifest(store: ApiKeyStore, path: Path) -> None`:`write_tenants_conf(path, store.active_entries())` + +- [x] **Step 1: RED 写测试 `tests/test_keys_store.py`**(import `from server.backend.key_store import ApiKeyStore, write_tenants_conf, sync_manifest`;fixture 复用 test_accounts 的 Settings 构造) + +```python +CASES = ( + test_create_format_and_listing, # 3 条密钥:id == "key_"+32hex、token == "sk-"+48hex、token_hash 为 sha256(token);page(1,2) desc + total=3 + test_rename_keeps_token_and_status, # rename 后 token/status/create_time 不变、name 变 + test_revoke_soft_row_remains, # mark_revoked 后:active_entries 不含、get 仍在、revoke_time/revoked_by 落值;重复 revoke → None + test_persistence_across_control_instances, # 同一 data_dir 新开 ControlStore 后行仍在、状态保留 + test_write_tenants_conf_shape_and_atomic, # 值断言: json.loads == {"tok1": {"user_id":"a","cube_id":"a"}};目录里无 .tmp 残留 + test_sync_manifest_from_store, # revoke 一把后 sync → 文件只剩 active +) +``` + +- [x] **Step 2: 跑红** `python -m pytest server/backend/tests/test_keys_store.py -v` → FAIL(ModuleNotFoundError: key_store) +- [x] **Step 3: GREEN 实现** —— control.py 在 project_contexts 建表语句后**原位追加**: + +```sql +CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id), + name TEXT NOT NULL, + token TEXT NOT NULL UNIQUE, + token_hash TEXT NOT NULL UNIQUE, + status TEXT NOT NULL, + create_time TEXT NOT NULL, + created_by TEXT, + revoke_time TEXT, + revoked_by TEXT +) +``` +加 `CREATE INDEX IF NOT EXISTS api_keys_active ON api_keys(project_id, status, create_time DESC, id DESC)`(同一连接同一事务内)。`key_store.py` 按 Interfaces 的签名实现,token 生成 `secrets.token_hex(24)` 加 `"sk-"` 前缀,id `secrets.token_hex(16)` 加 `"key_"` 前缀,`token_hash=hashlib.sha256(token.encode()).hexdigest()`,create_time `now()`。 +- [x] **Step 4: 跑绿** `python -m pytest server/backend/tests/test_keys_store.py -v` → 6 PASSED;再 `python -m pytest server/backend/tests -q` 全绿(≥249) +- [x] **Step 5: 台账登记**(计划文件内勾选 + 一行结论写 `.data/task14-keys/ledger.md`,此后每任务同) + +### Task 2: `keys.py` 路由 + 设置项 + 审计脱敏证明 + +**Files:** +- Modify: `server/backend/settings.py`(字段 + from_env)、`server/backend/app.py`(include keys_router + 状态注入) +- Create: `server/backend/keys.py` +- Test: `server/backend/tests/test_keys.py` + +**Interfaces:** +- Consumes: `control_for(request)`(auth.py:23,返回已带 actor 的 store)、`store._row(db, project_id)`(未知/不可见 → ApiError 40001/404「项目不存在或未配置访问范围」)、`store.require_role(project_id, minimum, db)`(不足 → 403)、`identity_store.missing()`(code 40001「资源不存在或不可访问」status 404 —— 与 Mock「密钥不存在」同为 40001)、Task 1 的 ApiKeyStore/write_tenants_conf/sync_manifest、`kb_models.Strict`、`models.Envelope`。 +- Produces: + - Settings 新字段 `gateway_keys_file: Path | None = None`(from_env 取 `DASHBOARD_GATEWAY_KEYS_FILE`,`or None`)。 + - 路由(prefix `/api/dashboard/keys`,全 owner): + - POST `/list` {projectId, pageNum?, pageSize?}(pageNum∈[1,10000] pageSize∈[1,100] 默认 1/10,Strict)→ `Envelope[{records:[{id,keyName,apiKey,createTime}], total}]`(HTTP 200) + - POST `/create` {projectId, keyName:1-64} → `Envelope[ApiKey]`(含完整密钥) + - POST `/revoke/{id}` → `Envelope[data={"ok": True}]`(对齐契约 components/responses/Ok) + - POST `/copy` {apiKeyId} → `Envelope[str]`(**data 即密钥字符串本身**,对齐契约「`data` 是密钥字符串本身,不是对象」) + - POST `/rename/{id}` {keyName:1-64} → `Envelope[ApiKey]` + - 错误语义:未知/不可见项目 → 404(`_row`);disabled/archived 项目 → 404(missing);无/低角色 → 403;未知/已撤销 id → 404(missing,code 40001);未配置 `gateway_keys_file` 的写操作 → ApiError 503「未配置网关交付路径,无法签发密钥」;manifest 写失败 → ApiError 503「网关配置写入失败,密钥未创建/未撤销」(事务回滚)。 + +- [x] **Step 1: RED 写 `tests/test_keys.py`**(夹具照 test_accounts:`application(tmp_path, gateway_keys_file=tmp/tenants.json, console_dir=tmp/audit)` 辅助 helper;登录用 `login(web)`;成员用 `POST /admin/tenants/a/members {account_id, role}` 后再 `login(web,"member",“member-password-123")`) + +```python +CASES = ( + test_create_contract_shape_and_format, # data 字段四键驼峰;apiKey 全文匹配 ^sk-[0-9a-f]{48}$;两次 create token 不同 + test_list_contract_with_stable_pagination, # 3 条 → total=3;page(1,2) 按 createTime desc 稳定;apiKey 为完整密钥(契约原样) + test_copy_returns_raw_string, # json()["data"] == create 时 apiKey + test_revoke_soft_and_aftermath, # revoke → {"ok":True};list 无此行;copy/rename 均 404;直查 sqlite 行仍在(status='revoked') + test_rename_contract_and_manifest_untouched, # rename 200 → 改名生效;manifest 文件原始字节 == 改名后字节 + test_manifest_written_on_create, # create 响应 200 后该 token 在 manifest 中且身份 = {"user_id":"a","cube_id":"a"} + test_role_matrix_forbidden_probes, # viewer/editor 5 路由全 403;仅 b 项目 membership 的 member 对 a → 404/403(_row 语义);匿名(未登录另一 client)401 + test_auth_off_mode_owner_pass, # auth_enabled=False 时未登录 create/list 均 200(actor=None 天然 owner) + test_create_not_configured_503, # settings 不给 gateway_keys_file → 5 条写路由 503、list 正常(读不依赖交付) + test_create_rolls_back_if_manifest_write_fails, # monkeypatch keys.write_tenants_conf → OSError → 503 + 库 0 行 + list 空 + test_revoke_rolls_back_if_manifest_write_fails, # 同上:revoke → 503 + 行仍 active + copy 可取 + test_console_audit_redacts_token, # create 后读 console JSONL(create 的 audit 行),全文不含该 token + test_self_heal_on_startup, # (Task 3 落点,本任务先写测试) 同 data_dir 建第二 app → 孤儿 token 被拭除、真 key 保留、空库 → "{}" +) +``` + +- [x] **Step 2: 跑红** `python -m pytest server/backend/tests/test_keys.py -q` → FAIL(no module keys / 404 route missing) +- [x] **Step 3: GREEN 实现** + - `settings.py`:`gateway_keys_file: Path | None = None` + from_env 最后一行 `gateway_keys_file=os.environ.get("DASHBOARD_GATEWAY_KEYS_FILE") or None,` + - `keys.py`:Strict 模型 + `store = ApiKeyStore(control_for(request))`;写序(create/revoke 同构,MANIFEST 写在 `BEGIN IMMEDIATE` 事务内、成功则随事务提交,写失败抛 OSError → 外层 `except OSError: raise ApiError(50301, "网关配置写入失败,密钥未创建/未撤销", status_code=503)`,连接上下文自助回滚);create 先 `store._row` 校项目(404)再 `require_role(project_id,"owner",db)`(403)再 insert+写文件;revoke/copy/rename 以行反查:`get(key_id)` → None 或 status!='active' → `missing()`;再 `_row(db,row["project_id"])` + `require_role(...,"owner")`;copy 返回 `Envelope(row["token"])`;路由函数不是 async(纯 sqlite 快操作,零上游 IO)→ 用 `def`,响应头照 prompts.py `Cache-Control: no-store`; + - `app.py`:`app.state.keys = ApiKeyStore(...)` 可不做(路由内每请求现取 control),include `keys_router`。 + - 启动自愈留给 Task 3(app.py 的 lifespan"sync"那两行也属于 Task 3)。 +- [x] **Step 4: 跑绿** `python -m pytest server/backend/tests/test_keys.py -v` → 13 PASSED;`python -m pytest server/backend/tests -q` 全绿 +- [x] **Step 5: ruff & 台账** `ruff check server/backend` 全绿;登记台账 + +### Task 3: 启动自愈同步 + Start/Restart-Backend 参数透传 + +**Files:** +- Modify: `server/backend/app.py`(lifespan 内 store 装配后两行)+ **Restart/Start-Backend.ps1 的参数递交** +- Test: 复用 Task 2 的 `test_self_heal_on_startup`(本任务转绿) + +**Interfaces:** +- Consumes: Settings.gateway_keys_file、`sync_manifest`(Task 1)。 +- Produces: lifespan 内(`app.state.knowledge` 装配行之后): + `settings.gateway_keys_file and sync_manifest(...)` —— best-effort,`except OSError as error: print-行日志(不含密钥)`;不阻断 app 启动(可用性优先,且控制台后续写操作会如实报错)。 + - `Start-Backend.ps1`:param 块加 `[string]$GatewayKeysFile = 'D:/MemOS/upstream/deploy/mcp-gateway/tenants.conf'`;`$env:DASHBOARD_GATEWAY_KEYS_FILE = $GatewayKeysFile`;state['backend'] 加字段;differ 检查加一行(不一致 throw)。 + - `Restart-Backend.ps1`:Start 调用追加 `-GatewayKeysFile $state.gatewayKeysFile`(字段可能不存在于旧 state → `if ($state.PSObject.Properties['gatewayKeysFile'])` 兜底,缺省仍用默认值)。 +- [x] **Step 1: 跑 Step 2 已写测试确认红**(test_self_heal_on_startup 当前 FAIL:文件未在启动重写) +- [x] **Step 2: 实现三处**(app.py lifespan / Start-Backend / Restart-Backend) +- [x] **Step 3: 跑绿** `test_keys.py` 全绿 + 全量 suite -q 全绿 + 启动日志断言由测试覆盖 +- [x] **Step 4: 台账** + +### Task 4: 网关合成加载 + 上游测试锁 + 补丁导出 + +**Files:** +- Modify: `upstream/deploy/mcp-gateway/gateway/config.py`、`gateway/domain/tenants.py`、`gateway/app.py:42`、`deploy/mcp-gateway/README.md` +- Create: `upstream/deploy/mcp-gateway/test_gateway_tenants_file.py` +- Create: `server/backend/patches/task14-gateway-tenants-file.patch`(导出产物) + +**Interfaces:** +- Consumes(既有): `parse_tenants(raw:str) -> dict[str,Tenant]`(tenants.py:15)、`TenantRegistry(mapping)`、`settings.tenants_raw`;app.py:42 `tenants = TenantRegistry.from_raw(settings.tenants_raw)`。 +- Produces: + - `tenants.py` 新函数: + - `load_tenants_file(path: str | os.PathLike) -> dict[str, Tenant]`:文件不存在 → `{}` 且返回;读文本 → `parse_tenants(text)`(JSON 对象形态);JSON 解析失败 → `ValueError(f"tenants file unreadable: {path}")` + - `merge_tenants(base: dict[str, Tenant], extra: dict[str, Tenant], *, extra_source: str) -> dict[str, Tenant]`:token 冲突 → `ValueError(f"MEMOS_TENANTS 与 {extra_source} 冲突:token {mask_token(tok)}")`;否则 `{**base, **extra}` + - `config.py`:Settings 增字段 `tenants_file: str`;from_env 里 `tenants_file=os.getenv("MEMOS_TENANTS_FILE") or str(pathlib.Path(__file__).resolve().parent.parent / "tenants.conf")`(import pathlib 顶部加) + - `app.py:42` 替换为:`tenants = TenantRegistry(merge_tenants(parse_tenants(settings.tenants_raw), load_tenants_file(settings.tenants_file), extra_source="tenants.conf"))`(import 相应) + - `README.md` 增段(置 MEMOS_TENANTS 段落后):文件归属控制台、重启生效、冲突拒启、手工 token 走 env 不走文件 +- [x] **Step 1: RED 写 `test_gateway_tenants_file.py`**(unittest 脚本风格;`sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))`;只 import `gateway.domain.tenants` 不触 fastmcp;末尾 `unittest.main()`) + +```python +CASES = ( + test_load_missing_file_returns_empty, # 临时目录不存在文件 → {} + test_load_valid_json_map, # {"tok": {"user_id":"a","cube_id":"a"}} → Tenant("a","a") + test_load_invalid_json_raises, # 坏 JSON → ValueError + test_merge_union_and_env_kept, # env 2 token + file 2 token → 4 全在 + test_merge_collision_raises_with_both_labels, # 同 token → ValueError 含 "MEMOS_TENANTS" 与 "tenants.conf" + test_env_alone_unchanged, # merge_tenants(parse_tenants(raw), {}) == parse_tenants(raw) +) +``` + +- [x] **Step 2: 跑红** `python deploy/mcp-gateway/test_gateway_tenants_file.py` → FAIL(ImportError: merge_tenants) +- [x] **Step 3: GREEN 实现 config/tenants/app/README 四处** +- [x] **Step 4: 跑绿** 网关新测试 PASS + 既有契约锁 `python deploy/mcp-gateway/test_gateway_contract.py` 仍 PASS(证明 env 单独使用零回归) +- [x] **Step 5: 补丁导出与取证**(在 `D:\MemOS\upstream`) + 1. `git status --short deploy/` 先看脏区(应只有本任务四文件 + 新测试) + 2. `git add -N deploy/mcp-gateway/test_gateway_tenants_file.py` + 3. `git diff -- deploy/mcp-gateway > D:\MemOS\dashboard\server\backend\patches\task14-gateway-tenants-file.patch` + 4. `git reset -- deploy/mcp-gateway`(解除 intent-to-add;**不 commit**) + 5. `git apply --reverse --check` 该补丁 → exit 0 + 6. `git apply --reverse` 该补丁(还原工作区为原样)→ `git status --short deploy/` 应空 → `git apply` 正装回补丁内容 → 工作区回到“改动存在”状态(**全程不 commit**) + 7. `Get-FileHash` patch → SHA256 记入台账 +- [x] **Step 6: 台账**(patch 名、SHA、reverse-check exit code) + +### Task 5: `setup/Restart-Gateway.ps1` + +**Files:** +- Create: `setup/Restart-Gateway.ps1` + +**Interfaces:** Consumes `Common.ps1`($MemosPython/$MemosLocal、`Import-MemosEnv`)、`.local/gateway.env`(KEY=VALUE)、`.local/processes.json` 的 `gateway` 条目(marker="memos_gateway.py", executable=$MemosPython, port)。脚本按 Start/Stop-Backend 既有姿态写,全文如下(照抄即交付): + +```powershell +param([int]$Port = 8002) +. (Join-Path $PSScriptRoot 'Common.ps1') +$statePath = Join-Path $MemosLocal 'processes.json' +if (Test-Path -LiteralPath $statePath) { + $saved = Get-Content -Raw -LiteralPath $statePath | ConvertFrom-Json + $entry = $saved.gateway +} +if ($entry) { + $service = Get-CimInstance Win32_Process -Filter "ProcessId = $($entry.id)" -ErrorAction SilentlyContinue + if ($service -and $service.ExecutablePath -ne $entry.executable -or ($service -and -not $service.CommandLine.Contains($entry.marker))) { + throw 'Recorded PID does not belong to this gateway; refusing to stop it' + } + if ($service) { + # 按记录的 pid 杀进程树(镜像 Stop-Backend 的 owned 集合逻辑) + $owned = @($entry.id); $all = @(Get-CimInstance Win32_Process) + for ($i = 0; $i -lt $owned.Count; $i++) { + foreach ($child in @($all | Where-Object { $_.ParentProcessId -eq $owned[$i] })) { + if ($child.ProcessId -notin $owned) { $owned += $child.ProcessId } + } + } + [array]::Reverse($owned) + foreach ($id in $owned) { Stop-Process -Id $id -ErrorAction SilentlyContinue } + Start-Sleep -Milliseconds 500 + } +} +if (Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue) { + $occupier = (Get-NetTCPConnection -State Listen -LocalPort $Port | Select-Object -First 1).OwningProcess + throw "Gateway port $Port still occupied by PID $occupier; handle it manually instead of blind-killing" +} +Import-MemosEnv (Join-Path $MemosLocal 'gateway.env') +$hostIp = [Environment]::GetEnvironmentVariable('MEMOS_HOST', 'Process'); if (-not $hostIp) { $hostIp = '127.0.0.1' } +$port = [Environment]::GetEnvironmentVariable('MEMOS_PORT', 'Process'); if ($port) { $Port = [int]$port } +$service = Start-Process -FilePath $MemosPython -ArgumentList @((Join-Path $MemosRoot 'upstream\deploy\mcp-gateway\memos_gateway.py'), '--host', $hostIp, '--port', "$Port") -WorkingDirectory (Join-Path $MemosRoot 'upstream\deploy\mcp-gateway') -WindowStyle Hidden -RedirectStandardOutput (Join-Path $MemosLocal 'logs/gateway.out.log') -RedirectStandardError (Join-Path $MemosLocal 'logs/gateway.err.log') -PassThru +$state = if ($statePath -and (Test-Path -LiteralPath $statePath)) { Get-Content -Raw -LiteralPath $statePath | ConvertFrom-Json } else { [pscustomobject]@{} } +$state | Add-Member -Force -MemberType NoteProperty -Name gateway -Value @{ id = $service.Id; marker = 'memos_gateway.py'; executable = $MemosPython; port = $Port } +$state | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $statePath -Encoding UTF8 +$ready = $false +foreach ($i in 1..30) { + try { Invoke-WebRequest -UseBasicParsing -Uri "http://127.0.0.1:$Port/health" -TimeoutSec 2 | Out-Null; $ready = $true; break } catch { Start-Sleep -Seconds 1 } +} +if (-not $ready) { + if (Test-Path -LiteralPath (Join-Path $MemosLocal 'logs/gateway.err.log')) { Get-Content (Join-Path $MemosLocal 'logs/gateway.err.log') -Tail 20 } + throw "Gateway did not become healthy on port $Port" +} +Write-Host "Gateway restarted on port $Port (PID $($service.Id))" +``` + +(实现时若发现 gateway 无 `/health` 公共路由,则以 README 记载的探活等价物替换、并在台账注明。) +- [x] **Step 1: 写脚本 + 语法门** `[void][ScriptBlock]::Create((Get-Content -Raw setup/Restart-Gateway.ps1))` exit 0 +- [x] **Step 2: offline 断言 checklist**(-WhatIf 不可用;用 `MEMOS_PORT=7999` 副本演练 stop 分支的 pid 校验与端口防误杀 throw —— 台账记录两分支行为) +- [x] **Step 3: 台账** + +### Task 6: 契约描述 + interface matrix + README + +**Files:** +- Modify: `openapi.json`(5 路由 description + ApiKey.apiKey description,**只动文本**) +- Modify: `docs/superpowers/specs/2026-10-26-issue3-interface-matrix.md`(keys 行改「已实现(task14)」) +- Modify: `server/backend/README.md`(新节「接口密钥(task14)」) + +**Produces(描述文字照抄):** +- list description 追加:「`apiKey` 始终为完整密钥(前端自截断展示)。完整下发仅限项目 owner(平台管理员视同 owner)。点击层级与权限、撤销行不返回。」 +- create description:「密钥创建即写入网关配置文件 `DASHBOARD_GATEWAY_KEYS_FILE`(成功返回 ⟺ 已写入),网关须重启方可生效;未配置交付路径或写入失败返回 503,密钥不落库。完整密钥仅创建响应可见一次(前端弹窗),后续 list/copy 仍按契约返回完整密钥(仅 owner)。」 +- revoke description:「软撤销:行保留(审计可用),立即从网关配置文件移除,重启网关后失效;撤销成功返回 `{ok:true}`。」 +- copy description:「返回完整密钥字符串本身(仅 owner;已撤销/不存在返回 404)。」 +- rename description:「仅改名;网关配置文件不含名称,字节不变。」 +- ApiKey.apiKey description 追加:「明文保存于本地 control.db 与网关配置文件(gitignore 范围内,无 KMS 不加密——诚实声明)。」 +- README 新节:env 用法、重启流程(Restart-Gateway.ps1)、单向权威 & 手工 token 走 env、限制三条(明文/两段式收敛/重启生效)。 +- [x] **Step 1: 三文件编辑**(openapi 编辑用精确片段替换,防 JSON 失配) +- [x] **Step 2: 校验** —— Redocly(命令沿用 `.data/task10-13-p2/redocly.log` 所记)0 错误 ≤5 警告;`pnpm verify` 通过;`python -m pytest server/backend/tests -q` 全绿(README/契约不动代码) +- [x] **Step 3: 台账** + +### Task 7: live 真链路验收 + 质量门 + RESULT 留档 + +**Files:** +- Create: `.data/task14-keys/probe_gateway.py`、`.data/task14-keys/task14-live.json`、`.data/task14-keys/verification.json`、`server/backend/TASK14-RESULT.md` + +**Step 序(每步有可检查产物):** +- [x] **Step 1: 预备** —— 读 live 平台管理员凭据(来源:`server/backend/TASK07-RESULT.md` / `.data/task07-09-identity/` 记载;不打印密码);写 `probe_gateway.py`:`httpx` 探 `/mcp`,Authorization 从环境变量读(**argv 不带 token**),initialize→tools/list(stateless http,Accept: `application/json, text/event-stream`),只输出 `{step, http_status, tool_count}`(工具名以 tools/list 实取为准);读 `gateway/mcp_api/http.py` 钉 initialize 载荷。 +- [x] **Step 2: 后端重启** `setup/Restart-Backend.ps1`(经 Task 3 参数透传带上 `DASHBOARD_GATEWAY_KEYS_FILE`);`/health` 200、空 keys 启动不炸(自愈写 `{}` 文件存在且合法 JSON) +- [x] **Step 3: 401 基线** 恶意 Bearer → 401 invalid_token +- [x] **Step 4: 老 token 200** 从 `.local/gateway.env` 读老 token(进程内、不打印)→ probe 200 +- [x] **Step 5: 登录 create** owner 会话 POST create → 响应含 `sk-…`(响应体不进任何证据文件,token 只在内存传递) +- [x] **Step 6: 新 token 重启前 401** probe → 401(复现前端注释语义) +- [x] **Step 7: 文件证据** tenants.conf SHA256 变化 + `Select-String -Pattern <新token> -SimpleMatch` 命中 1(输出只报「命中数」) +- [x] **Step 8: 重启网关** `setup/Restart-Gateway.ps1` → health 活 + processes.json 新 PID +- [x] **Step 9: 新 token 200 + 真检索** probe 200 + 用 `tools/call` 调 `search_memory`(只读(名称以 tools/list 为准),query 带唯一 marker)→ 审计 JSONL 新行 `tenant` = 项目 id,grep marker 证明写入/读侧一致 +- [x] **Step 10: revoke + 重启 + 401 回归** dashboard POST revoke → 文件 SHA 再变 + 命中 0 → Restart-Gateway → probe 401 +- [x] **Step 11: 老 token 终验 200**(合并对存量零回归的最终证词) +- [x] **Step 12: 质量门全跑** —— `pytest server/backend/tests -q` 全绿(记录数字)、`ruff check server/backend` 绿、网关两个测试脚本 exit 0、`pnpm verify`、Redocly、保护面 diff vs 03e10145(patch 命令沿用 task12 惯例)、`git diff --check` 干净 +- [x] **Step 13: 汇总**写 `verification.json` 无密钥原文(SHA/exit-code/计数)+ `task14-live.json` 十步记录;写 `server/backend/TASK14-RESULT.md`(What/How/如何复验/限制三条/预留后续);计划 checkbox 全勾;台账结行;**停住**(不触 Task 15/16) + +## Self-Review 记录(2026-10-26) + +1. **Spec coverage:** 设计文档 §4→T1、§5→T2/T3、§6→T4、§7→T3/T5、§8→T6、§9→T6/T7、§10→T1/T2/T4/T7、§11→全部——无格。 +2. **Step scan:** 每步单一可判定产物;ps1 脚本作为「整段照抄」的精确拷贝(skill 容许 exact copy 由规格固定)。 +3. **Type consistency:** `write_tenants_conf / sync_manifest / insert / get / page / rename / mark_revoked / active_entries / load_tenants_file / merge_tenants` 在 T1/T2/T3/T4 各引用处拼写一致。 +4. **Review Focus:** 五类失败模式全部有归属测试(T2×4、T1+T7×1)。 +5. **Proportion:** 计划 ~380 行,主件为签名/测试名/断言与照抄脚本,无程序正文转写。 diff --git a/docs/superpowers/plans/2026-10-26-task01-runtime-meta.md b/docs/superpowers/plans/2026-10-26-task01-runtime-meta.md new file mode 100644 index 000000000..36cccb6a3 --- /dev/null +++ b/docs/superpowers/plans/2026-10-26-task01-runtime-meta.md @@ -0,0 +1,58 @@ +# Task 01 Runtime Metadata Implementation Plan + +> **For agentic workers:** Use superpowers:executing-plans for this task only. User explicitly authorized Task 01 and requires stopping after its delivery. + +**Goal:** Start a standalone Dashboard backend exposing a contract-compatible `/api/meta`, with real MemOS health validation and uniform errors. + +**Architecture:** Independent FastAPI application factory; one lifespan-owned httpx client probes the configured MemOS `/health`. Only safe health fields are projected. No memory, log, account, SQLite, or frontend integration is implemented in this task. + +**Tech Stack:** Python >=3.11, FastAPI 0.115.14, httpx 0.28.1, Pydantic 2.12.4, uvicorn 0.38.0; pytest 9.0.2 and JSON Schema tests. + +**Spec:** `docs/superpowers/specs/2026-10-26-issue3-dashboard-backend-design.md`, implementation sequence item 1; root `openapi.json`. + +## Global Constraints + +- Work in the user-selected `D:/MemOS/dashboard` checkout on `feat/dashboard-backend`; do not move the existing running project or switch branches. +- Keep `src/`, `shared/`, and `server/mock/` unchanged; do not modify `.env.local` or the running frontend proxy. +- Backend binds to `127.0.0.1:5281`; current frontend remains on Mock through port 5274. +- Response envelope is `{code, data, message}`; success code 0, errors nonzero with suitable HTTP status. +- No real model requests, memory writes, secret reads/exports, or unrelated upstream modifications. +- User authorized implementing this item directly; no additional design/plan approval loop. No automatic progression to Task 02, Git commit, push, or PR. + +## Review Focus + +- Healthy-looking non-MemOS JSON or incompatible contract version must not produce live success. +- Connection failures, timeouts, redirects, HTTP errors, malformed JSON, and unhealthy payloads must produce bounded, uniform failures. +- Health extras and exception messages must not leak API keys, credentials, internal URLs, or debug stacks. +- Unknown paths, wrong methods, request validation, controlled errors, and unexpected errors must use the same envelope. +- Frontend continues reporting sample while backend independently reports live; service restart preserves this separation. + +## Task 1: Runtime foundation and metadata + +**Files:** +- Create: `server/backend/{__init__,__main__,app,settings,upstream,errors,models}.py`. +- Create: `server/backend/tests/{test_meta,test_errors,test_settings,test_entrypoint}.py`. +- Create: `server/backend/{README.md,requirements.txt,requirements-dev.txt,.env.example,ruff.toml}`. +- Modify: `package.json` for optional backend commands; `.gitignore` for Python artifacts. +- Modify: `openapi.json` only to document actual metadata failure responses/error codes. +- Local environment: add `D:/MemOS/setup/Start-Backend.ps1` and `Stop-Backend.ps1` with owned-process checks, without changing frontend configuration. + +**Interfaces:** +- `Settings.from_env() -> Settings`, with `upstream_url` and finite `upstream_timeout_seconds` configuration. +- `UpstreamClient.health() -> UpstreamHealth`, translating transport/protocol problems to `ApiError`. +- `create_app(settings: Settings | None = None, *, transport: httpx.AsyncBaseTransport | None = None) -> FastAPI` for production and network-isolated tests. +- `GET /health`: local process liveness, wrapped JSON, independent of upstream. +- `GET /api/meta`: real upstream health, mode live, service name, keyPrefix and five configured gateway tool descriptors; no auth claimed. + +- [x] Write tests for healthy response/schema, health projection, all upstream failures, settings validation, standard error envelopes, and `python -m server.backend --help`. +- [x] Run pytest and record expected RED before implementation (backend module absent). +- [x] Document metadata 502/503/504 response contracts before implementing them. +- [x] Implement the focused modules and CLI; close the HTTP client during lifespan shutdown, disable implicit HTTP retries/proxy inheritance and redirects for the internal upstream. +- [x] Run backend tests until GREEN; use real tests for error handlers without adding production test routes. +- [x] Add minimal dependencies, README, example configuration, and cross-platform pnpm commands; add local PowerShell launch/stop scripts with process ownership and readiness checks. +- [x] Verify backend start, same-ID process idempotence, stop/restart, genuine `/api/meta`, and frontend `/api/meta` still sample. Persist a credential-free report outside the repository in `.local/`. +- [x] Run scoped Ruff, backend pytest, `pnpm verify`, Redocly lint, `git diff --check`, and baseline frontend-zero-diff check. +- [x] Perform an independent review of Task 01 only and fix material findings with RED/GREEN evidence. +- [x] Record completion evidence and report to the user. Stop before Task 02. + +Expected backend failure mapping: timeout 504/50400; unavailable or unhealthy 503/50300; HTTP/protocol/identity/contract mismatch 502/50200. Generic validation 400/40000; unknown route 404/40001; unsupported method 405/40000; unexpected failure 500/50000. Messages must be safe fixed descriptions, not raw upstream error bodies. diff --git a/docs/superpowers/plans/2026-10-26-time-display-plan.md b/docs/superpowers/plans/2026-10-26-time-display-plan.md new file mode 100644 index 000000000..f3eaf05a7 --- /dev/null +++ b/docs/superpowers/plans/2026-10-26-time-display-plan.md @@ -0,0 +1,114 @@ +# 时间显示统一格式实施计划(2026-10-26) + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans(本仓执行方式已定:无 subagent、内联逐任务 TDD)。步骤用 checkbox(`- [ ]`)跟踪。 + +**Goal:** 后端对外呈现的时间统一为 `YYYY-MM-DD HH:MM:SS`(固定 UTC+8),存储/机器接口保持 ISO UTC 不变。 + +**Architecture:** 新增单一转换器 `timefmt.py`;只在 serve/展示边界调用;库存 ISO UTC、JSONL 写入格式、上游过滤条件零改动。 + +**Tech Stack:** Python stdlib(datetime/timedelta/timezone);pytest。 + +**Spec:** `docs/superpowers/specs/2026-10-26-time-display-design.md`(批准方案 A)。 + +## Global Constraints + +- 不 commit / 不 push(用户全局禁令);工作树改动 + 档案落 `.data/time-display-fix/`。 +- 不改 `src/`、`shared/`、`server/mock/`、`.env.local`、前端;openapi 零改动(时间字段本为自由 `type: string`)。 +- 存储不变量:凡 DB 列/JSONL `ts`/上游请求条件里的时间保持 ISO UTC,逐字节不动。 +- 时区:固定 `timezone(timedelta(hours=8))`,格式 `%Y-%m-%d %H:%M:%S`。 +- 测试命令:cwd `D:\MemOS\dashboard`,`$env:DASHBOARD_AUTH_ENABLED='true'; D:\MemOS\.venv\Scripts\python.exe -m pytest server/backend/tests -q`;ruff:`python -m ruff check server/backend`。 +- 每任务完成后全量 pytest 必须绿;先红后绿,红与绿都要亲眼看。 + +## Review Focus + +1. **None/空串时间字段**(如未归档的 `archived_at`、未撤销的 `revoked_at`)→ 必须原样 None/"",不炸不转。钉在 Task 1。 +2. **带 +0800 等非 UTC 时区的输入**(诚实容错)→ 按其时区转同一显示。钉在 Task 1。 +3. **排序正确性**(新旧行混排、会话/邀请过期、审计 ts 元组比较)→ 存储不动的证明=既有排序/比较测试零改动保持绿(每任务全量绿即证)。 +4. **审计归并机器比较**(`audit_index` ingest 的字符串比较、SQL `ts<=?` 微秒)→ JSONL 写入不改;Task 5 验证 `test_audit_index.py:156`(ingest detail createTime=ISO)零改动保持绿。 +5. **审计分页 startTime/endTime 输入**是机器语义(date/iso → 微秒 SQL)→ 不转换(无新测试,代码不触碰即证)。 + +--- + +### Task 1: timefmt 转换器 + +**Files:** +- Create: `server/backend/timefmt.py` +- Create: `server/backend/tests/test_timefmt.py` + +**Interfaces:** +- Produces: `timefmt.display(value: str | None) -> str | None`(ISO/带时区时刻 → `"YYYY-MM-DD HH:MM:SS"`;None/"" 原样返回);`timefmt.display_epoch(seconds: float | None) -> str | None`(epoch 秒 → 同显示)。 + +- [x] **Step 1: 写失败测试** `tests/test_timefmt.py`: + - `display("2026-10-26T15:30:02.075819+00:00") == "2026-10-26 23:30:02"`(用户原样示例) + - `display("2026-10-26T01:02:03+00:00") == "2026-10-26 09:02:03"`;`display("2026-10-25T23:00:00+00:00") == "2026-10-26 07:00:00"`(跨日) + - `display("2026-10-26T10:00:00+0800") == "2026-10-26 10:00:00"`(非 UTC 时区诚实转换) + - `display(None) is None`;`display("") == ""` + - `display_epoch(0) == "1970-01-01 08:00:00"`;`display_epoch(None) is None` + - 末位秒舍位断言:`display("2026-10-26T15:30:02.999999+00:00") == "2026-10-26 23:30:02"` +- [x] **Step 2: 跑红** `pytest server/backend/tests/test_timefmt.py -q` → FAIL:`ModuleNotFoundError: No module named 'timefmt'`(或 ImportError)。 +- [x] **Step 3: 实现** `timefmt.py` 两函数(约 15 行;`replace("Z","+00:00")` 兼容;naive 输入按 UTC 处置——库内不变量为 aware,此为防御分支,注释言明)。 +- [x] **Step 4: 跑绿** 同命令 → 全 PASS。 +- [x] **Step 5: 记台账** `.data/time-display-fix/ledger.md` 建 Task 1 节(含 ruff)。 + +### Task 2: keys 面(create/list/copy 的 createTime) + +**Files:** +- Modify: `server/backend/tests/test_keys.py`(create 用例断言处) +- Modify: `server/backend/keys.py:80` + +**Interfaces:** +- Consumes: `timefmt.display`(Task 1)。 + +- [x] **Step 1: 写失败测试**:`test_keys.py` 新增格式钉 `re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", data["createTime"])`(create 响应),既有 `test_keys_store.py` 排序断言**一律不碰**(存储不动证明)。 +- [x] **Step 2: 跑红** 该钉 FAIL(现值 ISO)。 +- [x] **Step 3: 改** `keys.py:80` `createTime=display(row["create_time"])`(`from .timefmt import display`)。 +- [x] **Step 4: 跑绿** test_keys 全文件 + 全量 pytest → 全 PASS(269+1=270 passed 预期)。 +- [x] **Step 5: 台账**。 + +### Task 3: projects / invites / 账号面 + +**Files:** +- Modify: `server/backend/tests/test_control.py`(或既有 projects 断言文件)、invites 既有测试文件、账号面既有测试文件(实施时以 grep 定位精确文件与断言行) +- Modify: `server/backend/control.py:238` (`createTime`)、`:247` (`archivedAt`);`server/backend/invites.py:68` (`expiresAt`,用 `display_epoch`)、`:71` (`revokedAt`);`server/backend/identity_store.py:99-100`(`created_at`/`last_login_at`,`account()` 是纯 serve 映射器) + +**Interfaces:** +- Consumes: `timefmt.display` / `timefmt.display_epoch`。 + +- [x] **Step 1: 写失败测试**:三处正则钉(projects page 的 createTime+archivedAt;invite 行 expiresAt/revokedAt;`/admin/accounts` 或 `/api/auth/me` 的 created_at)。 +- [x] **Step 2: 跑红**(三钉皆 FAIL=现值 ISO)。 +- [x] **Step 3: 改** 三文件五站点(invites 的 `iso()` 只服务 expiresAt;实现时 grep `\.iso\(` 确认无其他机器消费点)。 +- [x] **Step 4: 跑绿** 全量(272 passed 预期)。 +- [x] **Step 5: 台账**。 + +### Task 4: kb / 知识文件 / 记忆面 + +**Files:** +- Modify: 既有 kb/memories 测试文件的 createTime 断言处(实施时 grep 定位) +- Modify: `server/backend/knowledge.py:51`(映射元组处)、`knowledge_files.py:181`、`memories.py:42` + +**Interfaces:** +- Consumes: `timefmt.display`。 + +- [x] **Step 1: 写失败测试**(三面各一钉)。 +- [x] **Step 2: 跑红**。 +- [x] **Step 3: 改**(`memories.py:42` 整行替换为 `display()`)。 +- [x] **Step 4: 跑绿** 全量(275 passed 预期)。 +- [x] **Step 5: 台账**。 + +### Task 5: 审计 serve + 终验 + 存档 + +**Files:** +- Modify: `server/backend/tests/test_audit_api.py`(格式钉) +- Modify: `server/backend/audit_index.py`(`page` 返回行与 `detail` 返回 dict 的 createTime 出口) +- Create: `.data/time-display-fix/ledger.md` 补完 + `docs/superpowers/specs/2026-10-26-time-display-design.md` 追加「执行记录」段 + +**Interfaces:** +- Consumes: `timefmt.display`。 +- Note: ingest 侧(line 219 起 detail 构造)与 `ts` 比较/SQL 全部不碰;`test_audit_index.py:156` 的 ingest ISO 钉零改动保持绿。 + +- [x] **Step 1: 写失败测试**:test_audit_api.py 行级 createTime 正则钉(读该测试现断言后加钉,不破坏既有行为断言)。 +- [x] **Step 2: 跑红**。 +- [x] **Step 3: 改** page/detail 两出口 `display()`。 +- [x] **Step 4: 跑绿** 全量(276 passed 预期)+ `test_audit_index.py` 零改动绿证。 +- [x] **Step 5: 终验**:全量 pytest + ruff + Redocly(`pnpm --package=@redocly/cli dlx redocly lint openapi.json`,0 错 ≤5 警)+ `git diff --check`(双仓)+ 保护面 diff(dashboard 仓 `git diff --stat 03e10145 -- src shared server/mock .env.local` 必须为空)。 +- [x] **Step 6: 存档**:台账终节(RED/GREEN 每站记录 + Ruling)+ spec 执行记录段 + 计划 checkbox 全勾。**不 commit。** diff --git a/docs/superpowers/plans/2026-10-27-auth-ui-task15.md b/docs/superpowers/plans/2026-10-27-auth-ui-task15.md new file mode 100644 index 000000000..43be9d488 --- /dev/null +++ b/docs/superpowers/plans/2026-10-27-auth-ui-task15.md @@ -0,0 +1,10 @@ +# 登录界面与 Task15 实施计划 + +Goal: 先完成简洁的登录/登出 UI,再完成属性树模板接口验证与交付。 + +Spec: docs/superpowers/specs/2026-10-27-auth-ui-task15-design.md + +- [x] UI:独立 auth_ui.py 管理 HTML/CSS;保留 auth.py 表单和安全机制;认证回归、真实浏览器桌面检查(窄屏CSS已加,移动视口未实测)。 +- [x] Task15:核对 OpenAPI 与原生请求/响应;补充 profile_templates.py 和契约/权限/异常测试。 +- [x] Task15:真链路创建、列表/详情同 ID、更新、跨项目拒绝、重启持久化、删除清理。 +- [x] 全量测试、静态检查、保护文件检查;输出独立结果及限制。 diff --git a/docs/superpowers/plans/2026-10-27-task14-repair.md b/docs/superpowers/plans/2026-10-27-task14-repair.md new file mode 100644 index 000000000..b41d51205 --- /dev/null +++ b/docs/superpowers/plans/2026-10-27-task14-repair.md @@ -0,0 +1,15 @@ +# Task14独立复核修复计划 + +用户授权完整修复,主agent执行,不使用subagent。保留现有业务密钥、知识库、前端和Mock;不提交Git或推进Task15/16。 + +- [x] RED:创建/撤销提交失败补偿、补偿失败阻断、恢复自愈;损坏配置不泄漏;驱动断言/记录脱敏/隔离清理。 +- [x] GREEN:统一文件发布锁(跨进程advisory lock)、pending标记、SQLite失败补偿、唯一临时文件与清理。网关加载相同锁并拒绝pending状态;共享配置目录须允许双方创建协调锁文件。 +- [x] GREEN:解析错误全部脱敏,保留正常env/file合并与冲突语义。 +- [x] 安全重演脚本进入可交付源码,旧.data入口仅包装;直连loopback,逐步断言、每轮run_id、新项目、只撤销本轮ID、finally清理、凭据扫描后才写证据。 +- [x] 全量回归、上游专项、Ruff、pnpm/Redocly、补丁reverse-check及保护目录检查。 +- [x] 真链路:现有key200、恶意401、新key重启前401、后端重启持久性、网关重启后200与审计归因、撤销重启后401,既有manifest字节不变。 +- [x] 更新任务报告、verification/live/ledger和复核关闭记录。 + +不声称SQLite和文件跨介质原子事务:发布锁避免网关读取未提交状态,pending标记让崩溃/补偿失败显式fail-closed,启动按数据库恢复。已运行网关仍按原设计等重启才切换凭据。 + +最终:287后端测试,网关10/17/18;单轮20条真链路,末条passed=true;本轮资源清理、原manifest字节及既有key保持;交付TASK14-RESULT和新verification/live/ledger。 diff --git a/docs/superpowers/plans/2026-10-27-task16-acceptance.md b/docs/superpowers/plans/2026-10-27-task16-acceptance.md new file mode 100644 index 000000000..7f33e78bd --- /dev/null +++ b/docs/superpowers/plans/2026-10-27-task16-acceptance.md @@ -0,0 +1,10 @@ +# Task16 汇总验收计划 + +按用户明确授权,在 Task15 完成后继续。不启动子 agent,不提交、不改前端源码。知识库按先前要求保持冻结,报告区分冻结能力与本次验收。 + +- [x] 停止本项目 Mock,主5274改为启动参数代理真实5281;保持.env.local不变,记录归属检查和可重演脚本。 +- [x] 全量后端、相关上游回归、pnpm verify、Redocly、两仓diff检查、保护目录零变更。 +- [x] 对齐OpenAPI全部操作与实现路由,生成真实实现/明确豁免/前端限制矩阵。 +- [x] 浏览器检查所有可达页面、六标签、日志详情、项目、用量、Prompt、图谱与模型;占位项明确列出。 +- [x] 重跑1万行冷启动性能及增量/重启;故障、重试、授权和时间边界回归。 +- [x] 生成最终报告、证据索引、启动说明;列出准确的未验证与已知限制。 diff --git a/docs/superpowers/specs/2026-10-26-issue3-dashboard-backend-design.md b/docs/superpowers/specs/2026-10-26-issue3-dashboard-backend-design.md new file mode 100644 index 000000000..de2d7c21a --- /dev/null +++ b/docs/superpowers/specs/2026-10-26-issue3-dashboard-backend-design.md @@ -0,0 +1,281 @@ +# Issue 3:真实 Dashboard 后端设计(逐任务实施中) + +本设计记录整体方案,不代表所有功能已经实现。用户已明确授权按顺序逐项实施,当前交付至任务 09;语言由实现者选择,前端源码保持不变,知识库 P2 在用户关注范围内。 + +## 当前进度 + +- 任务 01 已完成:独立后端、统一错误、真实上游检查、/api/meta;后端 56 项测试与 8 项真实 HTTP 验证通过。详见 server/backend/TASK01-RESULT.md。 +- 任务 02 已实现六标签列表与详情、项目范围、组合筛选、原生分页、响应投影及画像字段定位。真实唯一标记与同 ID REST 对照已验证,完整验收记录见 server/backend/TASK02-RESULT.md。 +- 任务 03 已实现普通记忆与画像字段软删除/恢复、持久操作回执、双边状态确认、失败重试、启动/运行期间补偿。真实进程中断、向量连接故障和恢复通过,审查发现的旧子图已删除邻居泄漏已修复,详见 server/backend/TASK03-RESULT.md;该报告另列上游既有 18 项基线失败,不宣称全套测试全绿。 +- 当前主前端 5274 仍使用 Mock;独立验收前端 5276 指向真实后端。任务04调用日志与任务05同索引用量已实现;任务06项目工作区已交付;任务07账号、任务08成员、任务09邀请已交付;任务10—16尚未实现,本轮停在任务09。 + +## 目标和范围 + +- 在 dashboard-v2 的工作分支 feat/dashboard-backend 实现独立控制台后端。 +- P0/P1 全部完成;本次同时覆盖 P2 知识库及文件、抽取 Prompt、图谱和模型清单。 +- P3 密钥和属性树模板作为第二阶段扩展;不能用成功空数据将未实现模块算作完成。交付表逐操作写明实现、未启用或明确豁免。 +- 额度、订阅、skillMD 仅在 Issue 明确允许的范围返回固定数据;用量缺失 token 字段按约定填 0。 +- 停掉 Mock 后,前端仍能查看真实记忆、网关调用、项目、成员与用量。 +- 不重新实现记忆抽取算法,不把控制面数据库做成独立的记忆真相,不默认引入 Redis、PostgreSQL 或额外模型。 + +## 已有依据 + +- Issue:https://github.com/hugegraph/MemOS/issues/3。 +- Dashboard 基线:03e10145a4dd8bc3e50b6e2a50ed788d64111945。 +- MemOS 基线:8d6e231d7d95ea29b6a2716c9c34837644d3e7f6。 +- 接口基线:openapi.json 的 57 条路径、60 个 HTTP 操作;shared/contract.ts 和 src/lib/api.ts 给出实际消费方式。 +- 上游 8000、网关 8002、数据库和真实模型已经验证。已有真实测试租户 issue3-project-a / issue3-project-b,可以导入控制面映射。 +- 原始 Mock 缺部分管理接口;知识库菜单被写死禁用,属性树模板页面为占位页。保留这些前端行为,在报告中说明直接访问知识库路由的方法。 + +## 方案与取舍 + +### 采用:独立 FastAPI 后端 + SQLite + +浏览器经现有 Vite 同源代理访问控制台后端;控制台后端通过 MemOS REST 访问记忆、知识库和 Prompt,通过 SQLite 管理控制面与审计查询索引。 + +选择由实现者提出:复用 D:/MemOS/.venv 的 FastAPI、httpx、pytest 依赖和现有 Python 服务经验;SQLite 使用标准库,避免为本地及单实例交付再部署数据库。SQLite 开启 foreign_keys、WAL、busy_timeout,事务保护成员与账号约束。 + +代价:多一个轻量进程;SQLite 的单机写入能力是明确部署边界。后续多副本部署需要共享控制面数据库和集中日志摄取,不声称本方案天然支持多实例。 + +### 未采用:直接扩展 MemOS 为完整管理后台 + +可以少一个进程,但账号、邀请、审计索引与记忆服务初始化耦合,测试容易依赖数据库和模型。仅在上游缺少必要能力时补最小接口/语义,不将整个控制面塞入 MemOS。 + +### 未采用:直接查询 Neo4j/Qdrant 实现整个控制台 + +查询自由,但耦合内部 schema,尤其删除容易造成图、向量状态不一致。默认通过 REST;能力缺口在上游解决。 + +## 进程、目录与配置 + +- 新后端目录:server/backend/,代码和测试放在 Dashboard 仓库,独立 requirements.txt、README、启动入口。 +- 拟定模块:app.py(装配、错误处理)、settings.py、upstream.py、auth.py、control.py、memories.py、audit.py、knowledge.py、extensions.py;tests/ 按行为组织。 +- 后端默认监听 127.0.0.1:5281;Mock 保留 5280。确认真实联通后将本地 MOCK_ORIGIN 指向 5281,前端仍是 5274。 +- 可修改 package.json 的后端命令、仓库级文档与后端配置;src/、shared/ 和 server/mock/ 保持原样。 +- 控制面 SQLite、登录初始化信息、审计索引及私密配置位于被忽略的 .data/ 或 D:/MemOS/.local/;不提交 .env、完整密钥、个人数据、真实审计记录。 +- README 提供跨平台前台启动命令;D:/MemOS/setup 的启停脚本补充 live 模式并只管理自己启动的进程。 +- upstream URL、audit directory、数据目录、会话参数和 bootstrap 设置通过环境变量提供,不把当前机器的绝对路径写死进业务代码。 + +## 数据来源与最小数据模型 + +| 数据 | 权威来源 | 本地保存内容 | +|---|---|---| +| 记忆、类型、状态、图关系 | MemOS REST | 不复制成长期业务主库;只保留操作记录和必要恢复元信息 | +| 知识库与文件内容 | 上游知识库接口及其存储 | 本地仅保存项目关联映射(如上游无同等能力) | +| Prompt、实际模型清单 | 上游对应接口 | 不独立维护一份伪配置 | +| 账号、项目、成员、邀请、会话 | SQLite | 事务与唯一约束维护真实控制面 | +| 网关调用记录 | calls-*.jsonl | 增量导入 SQLite 查询索引,保留来源文件及偏移 | +| 控制台操作日志 | 后端追加的 console 审计记录 | 导入同一查询索引,scope 区分来源 | + +控制面至少包含 accounts、projects、memberships、sessions、invites、kb_project_links、audit_rows、audit_cursors、operation_records。 + +- project.id 不等于业务 user_id:projects 保存明确的 upstream_cube_id,默认新建时两者同值。 +- 当前项目保存于账号/会话,不通过全局改变所有人的项目状态实现切换。 +- status=ENABLED 表示当前选中;disabled 表示被停用;archived 表示归档,三者分别处理。 +- 角色严格使用现有前端的 viewer / editor / owner。 +- 平台管理员、项目 owner、普通成员的权限分别判断;每次服务端校验,不依赖页面按钮隐藏。 + +## 身份与权限 + +- 使用后端会话 Cookie;新增后端自己的 /__login、/__logout 页面,不修改 React 前端。Vite 已具备相应代理。 +- bootstrap 管理员通过本地一次性初始化命令创建,密码使用带盐 scrypt 哈希;不硬编码公开默认密码,也不把任意浏览器默认当管理员。 +- 会话使用随机 token,服务端仅保存摘要、账号、到期时间;Cookie HttpOnly、SameSite=Lax,生产 HTTPS 开启 Secure。 +- 写请求检查 Origin/CSRF;登录/邀请页表单提供 CSRF token,同源 API 按允许的控制台 Origin 校验。 +- 管理员可维护账号;owner/管理员管理项目成员与邀请;editor 允许项目内写操作;viewer 只读。 +- 邀请仅创建时返回明文 token,数据库保存摘要;原子校验过期、撤销、已使用状态。 +- 管理操作不能移除/降级最后一个 owner 或最后一个有效平台管理员;禁用账号立即使其会话失效。 +- 邀请落地与账号创建由后端页面/初始化 CLI 提供,避免依赖前端不存在的注册界面。 +- 账号删除为有约束的控制面操作:个人空间和成员关系按契约处理;团队密钥/邀请只清空创建者,不误删团队资源。 + +## 接口契约 + +- 逐 method + path 登记,不能仅统计 URL 路径数量。实施时生成 60 操作能力矩阵并建立显式路由覆盖测试。 +- 成功统一 code=0;上游成功 code=200 必须适配。失败统一 code/data/message,并返回合理 HTTP 状态。 +- 检查上游 HTTP、JSON 格式、业务 code 以及 data.status=failed/failure 或 success=false,不把业务失败包装成成功。 +- 保留 records / logs / memListItemResponseList / list 四种列表键;分页、总数和模板分页附加字段按具体 schema。 +- 分页从 1 开始;默认值按各操作定义;正整数和有限最大页大小统一校验。账号列表保留实际 page/pageSize,其余使用各接口规定的 pageNum/pageSize。 +- 所有日期明确 UTC/RFC3339;日汇总使用文档化的 Asia/Shanghai 时区,先转换边界再过滤 UTC 时间。 +- 日志 requestBody/responseBody 是 JSON 字符串;历史记录已是字符串时不二次编码。 +- scope、projectId、删除参数等实际消费字段与 spec 的缺漏,先在 openapi.json 补齐,再实现;不对 src/ 打补丁。 +- 不实现通配路由“所有未知请求都返回成功”;未启用功能返回明确的非零错误。 + +## 记忆列表、详情、筛选与删除 + +### 类型和查询 + +- 支持 text_mem、pref_mem、event_mem、profile_mem、skill_mem、tool_mem 六个标签。 +- 事实类映射 WorkingMemory / LongTermMemory / UserMemory / OuterMemory;偏好 PreferenceMemory;技能 SkillMemory;工具 ToolSchemaMemory / ToolTrajectoryMemory。 +- 事件类根据当前上游 EventMemory 与旧类型兼容情况验证,不能只按旧截图假设 Context/InsightMemory;画像通过上游 profile 实例能力获取,不能伪造为普通事实。 +- 项目条件在发给上游前确定,所有列表/详情/删除都限定同一 upstream_cube_id。 +- 详情请求没有 projectId 时使用当前账号已选项目并检查资源归属,不能凭 ID 跨租户读取。 +- 先筛选,再计算 total,再分页;不能先拿上游第一页再本地过滤,造成假总数或漏页。 +- 默认口径仅 activated。明确排除 archived/deleted;对 resolving/frozen/expired 不偷偷混算。上游缺少组合过滤时优先补查询能力。 +- 搜索按接口的内容检索语义实现,不能把 top_k 向量检索当作全量分页列表。 +- 集中白名单投影 memoryId、memoryContent、subjectType、externalUserId、createTime 及类型专有字段;剔除 embedding、sources、history。长正文设置明确投影限额,详情按需返回;10 条列表总响应必须 <200KB。 + +### 删除语义需要的最小上游修正 + +当前上游按 ID 删除会物理删除,而 Issue 同时要求软删除、恢复和普通 REST 查不到。这三项不能靠 Dashboard 本地隐藏一条记录满足。 + +拟定行为: + +1. 新增上游受控 POST /product/transition_memory,参数为 mem_cube_id、memory_id、target_status(deleted 或恢复为原状态)、operation_id。不改变现有硬删除接口,也不假设现有 update_memory 提供可靠的图/向量原子更新。 +2. Dashboard 删除先校验全部目标属于当前项目和标签,再请求上游变更状态,逐项记录结果;部分失败不能假装全部成功。 +3. 上游普通单条、多条读取和相关列表/图谱/检索不暴露 deleted;存储中仍保留记录。 +4. 通过同一受控操作恢复到删除前状态;上游记录 previous_status。冻结记忆遵循现有生命周期限制,不能绕过;本阶段普通页面只能删除 activated 条目。 +5. Dashboard operation_records 保存操作 ID、目标与结果用于查询;上游使用现有 MetadataStore 的新 document kind 保存生命周期意图与重试进度,负责恢复,不依赖 Dashboard 存储作为记忆真相。 + +### 图与向量同步确认(不能直接沿用现有 best-effort 行为) + +源码 Neo4j.update_node 会先提交图,再尝试同步向量;同步异常仅记 warning。因此受控 transition 操作必须自行确认状态,不能将 update_node 返回视为操作成功。 + +- 先持久化幂等的生命周期意图,再进行图状态更新、向量 payload 更新和双边读回确认。意图包含租户、记忆 ID、原状态、目标状态和阶段,不包含向量或完整正文。 +- 意图使用现有上游 CloudEntity 元数据文档存储,不新建业务记忆副本。写入意图失败则不开始修改;同一 operation_id 重放返回当前进度。 +- 当向量点不存在时,只有明确确认该记忆本来未被索引,才允许作为无向量记录处理;Qdrant 连接失败或读取失败不等价于不存在。 +- 确认 graph.status 与存在的 vector.payload.status 都为目标值后,才返回业务成功。否则返回明确的可重试失败及 operation_id,不把部分写入当成功。 +- 上游后台任务在启动时和运行期间重试未完成意图,按 ID 与租户检查当前状态;同一记忆的并发生命周期操作串行化,不用盲目回滚覆盖后续修改。 +- 普通检索的最终结果按图中当前状态再次过滤;图库不可用时返回错误,不能透传可能已被删除的旧向量命中。因此向量补偿期间也不会继续展示已删除记忆。 +- 恢复也经过相同确认和重试;deleted 意图尚未完成时,恢复操作先串行解决前一操作。意图的原状态保存在上游,Dashboard 重建不会丢失恢复依据。 +- 测试必须注入 Qdrant 不可用、图更新后进程中断、重复 operation_id、相反操作并发和服务重启,证明没有假成功、没有删除后再召回。 + +### 画像标签单独处理 + +画像条目不是普通 Memory 图节点。ProfileService.recall 将实例 fields 的各个叶子字段合成为 instance_id + path 的行,现有接口还要求显式 user_id 或 agent_id。因此不能让画像标签落入普通记忆 ID 查询或更新逻辑。 + +- 新增上游受租户约束的 POST /product/list_profile_memories、POST /product/get_profile_memory、POST /product/transition_profile_memory。列表允许项目内所有主体,并支持 subject_type/subject_id、内容、时间过滤和分页;不能要求控制台先知道每个主体 ID。 +- 复用现有 profile_instance 文档。每个字段增加 status、deleted_at、previous_status;删除保留 value,只改字段状态,不删除整个实例、不改其他字段。 +- 字段身份由上游按 instance_id 和 profile_path 解析并检查归属,沿用现有行 ID;不允许后端通过不可靠的字符串拆分猜主体和字段。 +- 单条详情、项目级枚举、普通 get_profile 及 recall 统一过滤 deleted 字段;返回 profileInstanceId、profileTemplateId 和字段生成的记忆内容,满足现有标签投影。 +- 模板同步及算法抽取更新必须保留字段状态,不得重新写入 value 时清除 deleted 标记;只有显式受控恢复才能重新激活。 +- 恢复粒度为单个字段,保留原值及算法更新设置;账号、项目与普通记忆权限相同。画像不写 Qdrant,因此字段状态在单个实例文档中完成一致更新。 +- 测试覆盖项目级多主体枚举、同字段 ID 稳定、单字段删除不影响其他字段、抽取不复活已删除字段、恢复、跨租户拒绝与重启持久化。 + +影响范围预期为 upstream 的请求模型、server_router 路由、生命周期服务及规则、ProfileService、普通读取/检索最终过滤、对应 tests/api 与导出的 OpenAPI。允许复用现有图/向量客户端确认写入,不默认改变所有 graph_db.update_node 调用方的错误语义。新增端点仅供可信控制面调用;原上游仍需限制在内网,不能将无鉴权的 8000 端口公开。 + +真实删除验收只针对新建的独立测试记忆,不删除已有环境验证记忆或用户数据。 + +## 审计日志和用量 + +- 初次完整导入,之后按文件标识、偏移增量读取;唯一键由来源文件代次及行位置确定,重启不重复计数。 +- 处理轮转、截断、同名替换、半行追加、非法 JSON、缺字段;半行保留至下一次完成,坏行记录计数不导致所有页面失败。 +- 解析 ts/tool/tenant/ms/ok/input/output 并保留原始关联信息;租户归属来自受控项目映射。 +- tool 映射到 addMessage/searchMemory/getMemory/deleteMemory/addFeedback;不认识的事件不伪造为已知工具。 +- 支持 source scope、主体、事件、状态、关键词、时间、分页;未知 eventType 按前端约定拒绝。 +- SQLite 索引覆盖 tenant、ts、event、source、status;冷启动和已建索引两种场景均记录 1 万行首屏响应,不只测内存热缓存。 +- 异步 add 按 callId 合并请求行和终态行,pending 耗时为 null,不重复计算实际调用次数。 +- 用量复用同一份归一化审计索引,按天计数;全项目查询只覆盖当前账号有权访问的项目。 +- token 缺失按 Issue 允许填 0,不凭文本长度推测;日界线、日期范围和空日期天行为在测试中固定。 + +## P2 与 P3 边界 + +### P2:本次完成 + +- 知识库复用上游 create/get/update/delete_knowledgebase;项目关联维护明确的租户边界,禁止跨项目关联泄漏。 +- 文件上传适配前端 Data URL,做格式、解码后体积和文件名校验;文件清单、下载原字节、删除均走上游能力。下载成功按 spec 返回文件流,不强行包装成 JSON;失败仍返回统一错误。 +- 通过原文件 SHA256 对比验证下载,测试路径穿越、无权下载、删除后再下载和服务重启后的持久性。 +- 不把上游 NoopIngestor 的文件 CRUD 宣称为已完成文档解析、切块或知识库召回;这些不在本次知识库文件加分项范围内。 +- Prompt 的读取、保存、生成、重置、调试逐项映射现有上游接口;保存规则需要在真实抽取链路生效,不只保存在 Dashboard。 +- 图谱和模型清单读取上游真实能力,模型配置只投影允许字段,不返回密钥;图节点正文按契约截断并正确设置 labelTruncated。 + +### P3:后续可选扩展,单列交付状态 + +- 模板 CRUD 可以复用上游,但原属性树模板页面为 ComingSoon,不能改 src/ 假装已有 UI;用接口测试证明范围。 +- 密钥完整下发仅限授权项目管理者;新增、改名、撤销需要真实持久化和网关配置同步、重启生效验证。没有实际网关调用验证前不能算完成。 +- 如果用户选择本次一并实现 P3,则沿用本设计的数据源与权限,不改前端,扩展对应实施任务。 + +## 实施顺序与完成门槛 + +优先级沿用 Issue 的 P0 → P1 → P2 → P3,不将完整账号管理或 P1 用量模块提前当作 P0 的交付任务。P0 需要的后端启动、最小身份和已有项目映射作为必要依赖实现,不因此提前展开整个 P1 控制面。 + +按以下可独立验收的任务依次推进: + +1. P0 运行信息:独立后端启动、统一响应与错误、真实上游连接检查、/api/meta。此时仅交付基础接口,不宣称记忆和日志模块已完成,不为展示 live 标记而提前切断当前前端的 Mock。 +2. P0 记忆只读:六标签列表与详情、字段映射、项目范围、筛选分页和响应投影。以唯一标识记忆及同一 ID REST 对照证明真实链路。 +3. P0 记忆删除:普通记忆与画像字段的软删除、恢复、图向量同步确认、失败重试及故障测试。 +4. P0 调用日志:JSONL 增量导入、列表/详情、历史缺字段、来源和查询筛选、异步关联、1 万行性能。 +5. P1 用量:复用同一审计索引,按天统计、时间边界、授权范围和 token 默认值。 +6. P1 项目:真实项目列表、创建、更新、选中、停用、归档和恢复,重启持久化。 +7. P1 账号:登录会话、管理员权限、列表、更新、禁用/启用、删除及最后有效管理员约束。 +8. P1 成员:角色权限、候选搜索、添加、变更、移除、退出及最后 owner 约束。 +9. P1 邀请:创建、角色、撤销、过期、单次使用与接受流程。 +10. P2 知识库实体:列表、详情、创建、更新、删除和项目关联边界。 +11. P2 知识库文件:上传、文件列表、下载字节校验、删除、持久性和权限负例。 +12. P2 Prompt:读取、保存、生成、重置、调试与真实抽取生效。 +13. P2 图谱与模型:真实只读接口及投影、截断、权限和配置脱敏。 +14. P3 密钥(后续加分任务):持久化、完整密钥授权下发、网关配置与重启生效验证。 +15. P3 属性树模板(后续加分任务):复用上游能力验证列表/详情及约定接口,说明当前前端占位限制。 +16. 汇总验收:停止 Mock 后完整检查页面和契约,pnpm verify、Redocly、src/ 零变更、性能及故障回归。 + +P0 同级任务内先做运行信息,目的是先有可运行、可诊断的真实后端,不代表将记忆降为 P1。前端在对应业务链路具备可用条件后进行有记录的真实联调;模块完成后立即验证,不将所有浏览器检查都拖到最后。 + +## 逐任务交付方式(用户新增要求) + +- 使用 brainstorming 逐项明确目标和方案,已经确认的要求不重复询问。 +- 每次只实现一个上述任务;需要缩小或扩大任务时先说明具体原因。 +- 任务完成后立即给出结果:解决范围、实现思路、修改文件、实际测试结果、用户验证步骤、剩余限制。 +- 每个任务完成并汇报后结束当前轮次,等待用户决定下一步;不在用户未继续前自动推进后续任务。 +- 逐项记录实际进度;设计确认不是功能验收,基础服务启动也不是整个 Issue 完成。 + +每项使用行为测试先失败再实现。外部模型单元测试 mock;真实集成测试显式运行,使用独立测试租户和可识别的合成内容,控制 API 用量。 + +## 验收与交付 + +- 60 个 HTTP 操作的覆盖矩阵,逐项标出真实实现、明确豁免、后续 P3,不用一个“全通过”掩盖未实现项。 +- 所有本次实现操作的请求/响应契约测试,包括验证错误的统一信封;Redocly lint 通过。 +- pnpm verify 通过;git diff 基线 -- src/ 为空;原 Mock 可独立启动。 +- 停 Mock 后 mode=live;唯一标识记忆可查、同 ID 对照、删除后普通 REST 不可见、恢复可见。 +- 双项目隔离;账号禁用即时生效;最后 owner/admin 约束;权限负例通过。 +- 网关真实调用可以从页面、JSONL 和数据库索引三方对上;旧记录缺字段仍可读。 +- 列表 10 条 <200KB;1 万行日志首屏 <1秒;上游不可用返回明确错误,页面不白屏。 +- 知识库文件上传/下载字节一致/删除/重启持久化通过;自定义 Prompt 真实生效。 +- README 包含配置模板、初始化与启动命令、统计口径、上游补丁、测试命令、验收证据及已知限制。 +- 不提交密钥、完整真实日志或数据库;不自动向 GitHub 发评论、提交 PR 或发布部署。 + +## 留给实现者的常规选择 + +具体函数分拆、迁移编号、索引命名、测试 fixture 组织和内部异常类由实现者决定。若实验显示上游分页过滤或生命周期需要超出本设计的改动,先把证据写入设计,再说明其影响,不能静默缩减六标签或其他验收范围。 + + +## 任务 04 实施验收(2026-10-26) + +调用日志列表/详情、JSONL 增量索引、旧字段兼容、来源/组合筛选与异步关联已完成,具体证据见 `server/backend/TASK04-RESULT.md`。162 项后端测试通过;实际网关只读调用、原始 JSONL 与同 ID 详情对照通过,独立真实前端 5276 页面查询/筛选/详情通过。三轮全新索引 10k 首屏 HTTP 为 903.312/859.519/893.486ms,均小于一秒。主 5274 仍为 Mock,前端保护目录零改动。 + +实施口径:来源仅接受原始 `.jsonl` 或数字后缀轮转;旧文件须保留至导入完成。SQLite 采用 WAL/NORMAL,原子回退与重放由同一事务保护。实际网关无异步 callId,异步逻辑以隔离夹具经 HTTP 验证;详情按已确认的实例固定默认项目查持久索引。追加式来源以外的任意原位篡改、无保留协议的 copytruncate 不作无遗漏承诺。坏行诊断只持久保存代次、位置、原因,不保存原始坏行。大正文审计预览与真实项目/业务码元数据分开处理。 + +任务04当时到调用日志为止;Task05已在后续本轮实现,其余未交付模块不提前宣布完成。上游既有基线失败维持任务 03 的说明。 + + +## 任务05 实施验收(2026-10-26) + +P1用量接口复用Task04同一AuditService和audit_calls,不新增第二套JSONL统计。gateway来源按北京时间固定UTC+8日期聚合,默认当月1日至今天,最多366天,空日补0;缺时间行不放到今天,缺token填0。状态不限,异步logical call计一次,token随终态更新。具体项目来自配置allowlist,全项目只覆盖当前允许配置,不包含数据库已撤权旧项目;当前账号会话与成员权限仍待后续任务。非空apiKeyId因源缺可信标识明确400。 + +次数口径addMessage/searchMemory匹配当前页面;chat/extractMemory仅token,rerank按已有schema有calls和token。summary.totalCalls仅add/search;summary.totalTokens涵盖五个已知事件真实token。includeDailyDetails=false返回空dailyStats和同一summary。具体边界/测试及真实HTTP证据见server/backend/TASK05-RESULT.md;主5274保持Mock,Task06以后与Task16整体验收未做。 + +## 任务06 实际交付记录 + +项目列表、创建、更新、选中、停用、可恢复归档及恢复已交付,详见 `server/backend/TASK06-RESULT.md`。SQLite 事务迁移保留旧映射与数据;新建工作区具有不可变 ID 和独立上游 namespace。当前项目采用持久 cookie 偏好上下文,记忆/日志详情随当前项目隔离;停用/归档同步限制记忆、日志和用量查询。账号/成员鉴权未提前实现,仍是本机受信操作员环境。 + +204项后端测试、Ruff、pnpm verify、OpenAPI响应结构和Redocly校验通过。真实5281服务完成三次操作系统进程重启,分别验证选中、停用、归档持久化;另以同库三次服务器实例验证隔离生命周期。原有标识记忆保持可读,主5274与Mock保持sample。独立5276页面完成新建、编辑、切换、停用、启用、归档和恢复;归档当前项目时观察到既有前端表格缓存滞后,重新载入后正确,未修改受保护前端。网关密钥失效联动不在本任务完成范围。 + +## 任务07—09 实际交付记录 + +账号登录会话、管理员列表/更新/禁用启用/删除、成员角色/候选搜索/添加变更移除退出,以及邀请创建/改角色/撤销/过期/单次接受已交付,分别见 `server/backend/TASK07-RESULT.md`、`TASK08-RESULT.md`、`TASK09-RESULT.md`。默认开启鉴权,SQLite保存scrypt密码摘要及带到期的随机会话摘要;禁用/删除立即撤销会话。BEGIN IMMEDIATE串行保护最后有效管理员与团队owner;项目读写及全项目用量按真实账号范围过滤。 + +真实5276登录、账号列表、成员候选搜索、邀请生成/改角色/撤销页面通过;真实5281经过操作系统进程重启验证会话、项目选择、邀请消费状态持久化。229项后端测试、Ruff和pnpm verify通过;Redocly0错误、5条警告(原有2条,新增3条是HTML POST成功仅303而无2xx,按真实行为保留)。安全只读复核无具体遗留问题。主5274仍为Mock,前端保护目录和上游源码不变,不推进知识库或任务16。 + +Task06的本机偏好cookie仅作为显式关闭鉴权的历史隔离模式保留;认证模式使用会话上下文,匿名偏好不能跨入账号。现有前端关于名称即ID、归档当前项表格滞后,以及网关密钥失效的提示仍按Task06报告的限制说明;本次没有实现P3网关密钥联动。 + + +## 任务12—13实际交付与范围更新(2026-10-26) + +用户明确要求知识库实体/文件保留现状,本轮不继续任务10/11开发或扩大验收。任务12的读取、保存、真实生成、重置、调试与运行时抽取生效,以及任务13真实图谱/全文和模型清单已交付,详见server/backend/TASK12-RESULT.md与TASK13-RESULT.md。 + +真实验收发现并修复上游多模态读取器忽略保存模板、memory_list键名不兼容,以及multi-cube fan-out串用首项目模板的问题;仅改内部逻辑并提供可移植补丁,未改上游公共API/数据库结构。独立项目中Prompt专有标记已进入真实抽取记忆,同ID REST对照成功;另项目Prompt不变,验收项目Prompt恢复默认。浏览器真实调试4条候选,记忆数量未增加。 + +图谱标签120字、同源全文228字,跨项目同ID404;节点/连线预算及truncated、viewer边界与模型配置白名单脱敏通过,辅助5276图谱展开及模型页面通过。249项后端回归、15项专项、102项上游相关测试、范围内Ruff、pnpm verify通过;Redocly0错误5条已有警告。保护目录零变更,主5274保持Mock。没有推进P3或任务16,不把本轮专项验证称为全Issue总验收。 + + +## Task15与Task16最终记录(2026-10-27) + +按用户新指令连续完成Task15与16。模板5个接口复用原生能力,12条专项测试,真实8项含重启持久化;前端继续占位。汇总验收后端302条、相关上游104条、网关45条通过;pnpm verify通过,Redocly0错误5警告,保护目录零差异。Mock5280已停,主5274改为live;真实上游停机时页面显示受控错误,恢复后重新显示数据。1万行三轮冷启动首屏805/779/795ms。 + +69操作状态中54项有效范围实现、12项知识库冻结(11有处理路由,memory/list未实现)、3项明确豁免。完整证据、前端限制及非全仓测试声明见server/backend/TASK15-RESULT.md、TASK16-RESULT.md、TASK16-OPERATIONS.md。未提交代码。 diff --git a/docs/superpowers/specs/2026-10-26-issue3-interface-matrix.md b/docs/superpowers/specs/2026-10-26-issue3-interface-matrix.md new file mode 100644 index 000000000..c1743069e --- /dev/null +++ b/docs/superpowers/specs/2026-10-26-issue3-interface-matrix.md @@ -0,0 +1,75 @@ +# Issue 3:接口实施矩阵(逐项交付) + +来源:当前 openapi.json;逐 method + path 统计。已交付任务 01 的 GET /api/meta,任务 02 的 memory/list、memory/detail 和记忆页最小 project/dict 映射,任务 03 的 memory/delete、restore、retry、operation。详见 server/backend/TASK01-RESULT.md、TASK02-RESULT.md、TASK03-RESULT.md。任务 04 的 log/list、log/detail 已交付,支持来源筛选、JSONL 增量索引和异步关联,详见 server/backend/TASK04-RESULT.md。任务05用量、任务06项目生命周期已交付,详见 TASK05-RESULT.md、TASK06-RESULT.md;任务07账号、任务08成员、任务09邀请已交付,另见 TASK07-RESULT.md、TASK08-RESULT.md、TASK09-RESULT.md。 + +任务12 Prompt与任务13图谱/模型已交付,见TASK12-RESULT.md、TASK13-RESULT.md;知识库任务10/11按用户要求冻结现状,不计为本轮完整验收。 + +共 63 条路径,69 个 HTTP 操作(任务03补充恢复/重试/回执,任务07—09补充登录、退出和邀请落地HTML各GET/POST)。P3密钥与模板已交付(模板前端仍占位),明确豁免模块仍需正确返回约定结构。 + +| 方法 | 路径 | 优先级 | 必填 JSON 字段(非完整参数清单) | 200 响应定义 | +|---|---|---|---|---| +| GET | /api/dashboard/project/dict | P1(任务06已验收) | 见 schema / query / path | ProjectDict | +| POST | /api/dashboard/project/list | P1(任务06已验收) | 见 schema / query / path | ProjectRecords | +| POST | /api/dashboard/project/create | P1(任务06已验收) | name, description | Project | +| POST | /api/dashboard/project/update | P1(任务06已验收) | id + 至少一个可写字段 | Project | +| POST | /api/dashboard/project/revoke/{id} | P1(任务06已验收) | 见 schema / query / path | Ok | +| POST | /api/dashboard/keys/list | 已实现(task14) | projectId | ApiKeyRecords | +| POST | /api/dashboard/keys/create | 已实现(task14) | projectId, keyName | ApiKey | +| POST | /api/dashboard/keys/revoke/{id} | 已实现(task14) | 见 schema / query / path | Ok | +| POST | /api/dashboard/keys/copy | 已实现(task14) | apiKeyId | StringData | +| POST | /api/dashboard/log/list | P0(任务04已验收) | projectId | LogList | +| POST | /api/dashboard/log/detail | P0(任务04已验收) | logId | LogDetail | +| POST | /api/dashboard/project/memory/list | P0 | projectId, memoryDetailType | MemoryList | +| POST | /api/dashboard/project/memory/detail | P0 | memoryId, memType | Memory | +| POST | /api/dashboard/project/memory/delete | P0 | projectId, memory_ids, memType | MemoryOperation(保留 deleted) | +| POST | /api/dashboard/project/memory/restore | P0 | projectId, memory_ids, memType | MemoryOperation | +| POST | /api/dashboard/project/memory/retry | P0 | projectId, operationId | MemoryOperation | +| GET | /api/dashboard/project/memory/operation | P0 | query: projectId, operationId | MemoryOperation | +| POST | /api/dashboard/knowledgebase/list | P2(用户冻结现状) | 见 schema / query / path | KnowledgeBaseRecords | +| POST | /api/dashboard/knowledgebase/create | P2(用户冻结现状) | knowledgebaseName | KnowledgeBase | +| POST | /api/dashboard/knowledgebase/update | P2(用户冻结现状) | id | KnowledgeBase | +| POST | /api/dashboard/knowledgebase/detail | P2(用户冻结现状) | knowledgeBaseId | KnowledgeBase | +| POST | /api/dashboard/knowledgebase/file/list | P2(用户冻结现状) | knowledgeBaseId | UntypedRecords | +| POST | /api/dashboard/knowledgebase/memory/list | P2(用户冻结现状) | knowledgeBaseId | MemoryRecords | +| POST | /api/dashboard/knowledgebase/project/associate | P2(用户冻结现状) | projectId, knowledgeBaseIds | Ok | +| POST | /api/dashboard/knowledgebase/project/remove | P2(用户冻结现状) | projectId, knowledgeBaseId | Ok | +| POST | /api/dashboard/profileTemplate/list | P3(任务15已实现,前端占位) | projectId | ProfileTemplateList | +| GET | /api/dashboard/profileTemplate/detail | P3(任务15已实现,前端占位) | 见 schema / query / path | ProfileTemplate | +| POST | /api/dashboard/profileTemplate/create | P3(任务15已实现,前端占位) | 见 schema / query / path | Ok | +| POST | /api/dashboard/profileTemplate/update | P3(任务15已实现,前端占位) | 见 schema / query / path | Ok | +| POST | /api/dashboard/profileTemplate/delete | P3(任务15已实现,前端占位) | projectId, templateId | Ok | +| POST | /api/dashboard/prompt-config/detail | P2(任务12已验收) | 见 schema / query / path | PromptConfig | +| POST | /api/dashboard/prompt-config/save | P2(任务12已验收) | 见 schema / query / path | Ok | +| POST | /api/dashboard/prompt-config/generate | P2(任务12已验收) | 见 schema / query / path | Prompt | +| POST | /api/dashboard/prompt-config/reset-default | P2(任务12已验收) | 见 schema / query / path | Prompt | +| POST | /api/dashboard/prompt-config/debug/extract | P2(任务12已验收) | 见 schema / query / path | ExtractDebug | +| POST | /api/dashboard/usage/query | P1(任务05已验收) | 无(projectId可选,省略仅查询授权范围) | UsageQuery | +| POST | /api/dashboard/skill/skillMD | 固定成功返回(已实现) | oss_url | StringData | +| GET | /api/user/quota/summary | 固定成功返回(已实现) | 见 schema / query / path | QuotaSummary | +| POST | /api/user/subscription/details | 固定成功返回(已实现) | 见 schema / query / path | SubscriptionDetails | +| GET | /api/meta | P0 | 见 schema / query / path | Meta | +| GET | /api/auth/me | P1(任务07已验收) | 见 schema / query / path | Account | +| POST | /api/dashboard/project/delete/{id} | P1(任务06已验收) | 见 schema / query / path | ProjectArchive | +| POST | /api/dashboard/project/restore/{id} | P1(任务06已验收) | 见 schema / query / path | Project | +| POST | /api/dashboard/keys/rename/{id} | 已实现(task14) | keyName | ApiKey | +| POST | /api/dashboard/knowledgebase/delete | P2(用户冻结现状) | projectId, knowledgeBaseId | KnowledgeBaseDelete | +| POST | /api/dashboard/knowledgebase/file/add | P2(用户冻结现状) | projectId, knowledgeBaseId, file | KnowledgeFileRecords | +| POST | /api/dashboard/knowledgebase/file/delete | P2(用户冻结现状) | projectId, fileIds | KnowledgeFileDelete | +| GET | /api/dashboard/knowledgebase/file/download | P2(用户冻结现状) | 见 schema / query / path | FileDownload | +| GET | /api/dashboard/graph/memories | P2(任务13已验收) | 见 schema / query / path | GraphMemoryGraph | +| GET | /api/dashboard/graph/memory | P2(任务13已验收) | 见 schema / query / path | GraphMemory | +| GET | /api/dashboard/models | P2(任务13已验收) | 见 schema / query / path | ModelRecords | +| GET | /admin/accounts | P1(任务07已验收) | 见 schema / query / path | AdminAccountRecords | +| POST | /admin/accounts/{id} | P1(任务07已验收) | 见 schema / query / path | AdminAccount | +| DELETE | /admin/accounts/{id} | P1(任务07已验收) | 见 schema / query / path | Ok | +| GET | /admin/tenants/{tenantId}/members | P1(任务08已验收) | 见 schema / query / path | ProjectMemberRecords | +| POST | /admin/tenants/{tenantId}/members | P1(任务08已验收) | role | MemberSet | +| POST | /admin/tenants/{tenantId}/members/{accountId}/remove | P1(任务08已验收) | 见 schema / query / path | Ok | +| POST | /admin/tenants/{tenantId}/members/leave | P1(任务08已验收) | 见 schema / query / path | Ok | +| GET | /admin/tenants/{tenantId}/member-candidates | P1(任务08已验收) | 见 schema / query / path | MemberCandidateRecords | +| GET | /admin/tenants/{tenantId}/invites | P1(任务09已验收) | 见 schema / query / path | ProjectInviteRecords | +| POST | /admin/tenants/{tenantId}/invites | P1(任务09已验收) | role | CreatedInvite | +| POST | /admin/tenants/{tenantId}/invites/{inviteId}/revoke | P1(任务09已验收) | 见 schema / query / path | Ok | +| POST | /admin/tenants/{tenantId}/invites/{inviteId}/role | P1(任务09已验收) | role | InviteRole | + +后端HTML入口:GET/POST `/__login`(任务07)、GET/POST `/__logout`(任务07)、GET/POST `/__login/invite/{token}`(任务09)。GET成功返回HTML,POST成功返回303;JSON错误仍为统一信封。业务接口默认要求 `memos_session`,权限不足401/403,事务约束冲突409,邀请过期410。 diff --git a/docs/superpowers/specs/2026-10-26-issue3-task14-keys-design.md b/docs/superpowers/specs/2026-10-26-issue3-task14-keys-design.md new file mode 100644 index 000000000..febde12cd --- /dev/null +++ b/docs/superpowers/specs/2026-10-26-issue3-task14-keys-design.md @@ -0,0 +1,169 @@ +# Issue #3 · Task 14 —— P3 密钥:真实持久化、授权完整下发、网关配置同步与重启生效验证 + +- 日期:2026-10-26 +- 状态:设计定稿,待批准 +- 规格出处:《2026-10-26-issue3-dashboard-backend-design.md》P3 段(179–183 行)——「密钥完整授权下发;新增、改名、撤销需要真实持久化和网关配置同步、重启生效验证;沿用本设计的数据源与权限,不改前端」 +- 前置事实:任务 07–13 已交付真实登录体系(owner/editor/viewer 项目角色 + platform_admin)、control.db 控制面、网关合并文件能力(task12)、`openapi.json` 中 5 条 keys 路径与 ApiKey/ApiKeyRecords/StringData 契约已预定义 + +--- + +## 1. 目标 + +把密钥页从「Mock 假数据」升级为**真链路**: + +1. **新增 / 改名 / 撤销**:密钥真实落库(dashboard 重启后仍在),且同步进网关配置文件。 +2. **完整密钥授权下发**:只有高权限(项目 owner,含平台管理员)能看到完整密钥。 +3. **重启生效**:新建的密钥在网关重启前不可用(实测 401),重启后真实可用(实测 200);撤销的密钥重启后真实失效(实测 401 回归)。 +4. **不改前端 / 不改 Mock**:冻结前端、server/mock/ 一字不动;契约(openapi.json)只改描述文字,不改形状。 + +## 2. 已定决策(用户拍板或既有事实) + +| # | 决策 | 依据 | +|---|------|------| +| D1 | 密钥身份 = 「这个项目」:签发的 token 落网关 `{"user_id": <项目名>, "cube_id": <项目名>}`,网关调用就落在项目自己的记忆空间,用量/日志归因(audit 行 `tenant` 字段)与既有租户口径一致 | 用户拍板(写法一) | +| D2 | 同步载体 = 网关侧预留给控制台的 `deploy/mcp-gateway/tenants.conf` 文件(upstream .gitignore 已预留「含明文 token,任何情况下都不要提交」);dashboard 持库为准、启动自愈重写该文件;网关最小改动合成加载(env 老 token + 文件新 token 并存) | 方案 A(用户选定) | +| D3 | 创建效果 = 冻结前端既有文案逐字对齐:`密钥已创建。完整密钥只显示这一次,关闭后无法再查看。` + `已写入网关配置。重启网关后这把密钥才能用。`——即 **create 成功返回 ⟺ 网关配置文件已写入**;写失败必须报真实错误、不得假成功,key 一并回滚 | 前端 ApiKeys.tsx 弹窗 + 注释原话(2026-10-19 实测注释) | +| D4 | 「只显示这一次」的显示效果由冻结前端保证(列表页自行 前7+星·7+后8 遮罩,弹窗直接吃 create 响应的完整密钥 + 复制按钮);后端**照契约原样**:list/copy 仍下发完整密钥,不再另造遮罩语义(不发明 copy 返回遮罩这种新契约) | 用户确认(2026-10-26) | +| D5 | token 格式与 Mock 逐字一致:`sk-` + 48 位十六进制(`secrets.token_hex(24)`,parity with mock `newSecret`) | mock data.ts:54 | +| D6 | 权限:5 条路由全部 `require_role(项目, minimum="owner")`;platform_admin 经 control.role 隐式 owner;`DASHBOARD_AUTH_ENABLED=false` 的老运维模式 actor=None 天然全 owner | control.py 24–51 行既有语义 | +| D7 | 撤销 = 软撤销:行保留(status 置 revoked),立即从配置文件移除;已撤销 id 再 copy/rename → 40001「密钥不存在」(对齐 Mock 删行语义) | Mock copy 语义 | +| D8 | 改名不触网关配置文件(文件里只有 token→身份,没有名字),测试以「文件字节不变」证明 | 文件格式推论 | +| D9 | 网关 token 冲突(env 与文件出现同一 token)→ 网关拒绝启动并明确报错;不静默任选一边 | 诚实工程 | +| D10 | live 验证必须真打网关:401 → 200 → 401 三段 + 老 token 持续可用 + 一条真实只读记忆检索证明 tenant 归因 | 规格原文「重启生效验证」 | + +## 3. 被否掉的备选 + +- **方案 B(重写 `MEMOS_TENANTS` 环境变量进 gateway.env)**:改的是网关进程外环境,需要额外脚本编排,且 env 体积随 key 增长、极易覆盖手工项——否。 +- **方案 C(网关 HTTP 轮询 dashboard)**:网关属上游超窄子集、不该携带控制面逻辑与网络依赖,task12 已按此裁决——否。 + +## 4. 数据模型(control.db 新表 `api_keys`) + +沿用 ControlStore 连接(identity/control 同库已是事实): + +```sql +CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, -- "key_" + 32hex(对前端不透明) + project_id TEXT NOT NULL, -- dashboard 项目 id(D1:同时是网关 user_id/cube_id) + name TEXT NOT NULL, -- 密钥名称(rename 的对象) + token TEXT NOT NULL, -- "sk-" + 48hex,唯一;明文保存(见 §9 限制) + token_hash TEXT NOT NULL UNIQUE, -- sha256(token),查重用 + status TEXT NOT NULL, -- "active" / "revoked" + create_time TEXT NOT NULL, -- ISO8601(list 按 desc 排序) + created_by TEXT, -- 账号 id;auth-off 模式为 NULL + revoke_time TEXT, + revoked_by TEXT +); +``` + +- 索引:`(project_id, status, create_time DESC)`。 +- list 仅回 `status='active'` 行(撤销行从 UI 消失,但库中留档)。 +- response 模型按既有 camelCase 别名模式:`id / keyName / apiKey / createTime`(ApiKey 契约字段不变,D4)。 + +## 5. 模块与路由(server/backend/keys.py 新模块 + app.py 接线) + +### 5.1 路由(全部 owner) + +| 路由 | 请求 | 响应 | 关键语义 | +|------|------|------|----------| +| POST `/api/dashboard/keys/list` | `{projectId, pageNum?, pageSize?}` | `Envelope[ApiKeyRecords]`(records 按 createTime desc + total) | apiKey=完整密钥(D4);仅 active | +| POST `/api/dashboard/keys/create` | `{projectId, keyName}` | `Envelope[ApiKey]`(完整密钥唯一出口之一) | 成功 ⟺ 配置文件已写入(D3);写失败 → 503「网关配置写入失败」,库回滚 | +| POST `/api/dashboard/keys/revoke/{apiKeyId}` | path id | 成功信封 | 文件先移除、后落库(安全优先序);已撤销/不存在 → 40001 | +| POST `/api/dashboard/keys/copy` | `{apiKeyId}` | `Envelope[StringData]`(完整密钥) | 仅 owner;契约照旧(D4);前端不调用此接口 | +| POST `/api/dashboard/keys/rename/{apiKeyId}` | `{keyName}` | `Envelope[ApiKey]` | 纯库操作;文件字节不变(D8,测试证明) | + +- **行级规则**:revoke/copy/rename 只带 `apiKeyId`,项目从行反查——未知 id 或已撤销 id → 40001「密钥不存在」;行存在但非该项目 owner → 403(权限语义不因 id 可猜测性放水;本地运维控制台规模下可接受)。 +- list 排序 `create_time DESC`,并次键 `id DESC` 保稳定。 +- 鉴权样板复用 auth.py:`actor_for(request)` → `control_for(settings).with_actor(actor)` → `store.require_role(project_id, minimum="owner")`;写请求过 SessionMiddleware 的 Origin/CSRF 既有约束(与 members/prompts 完全同款)。 +- 审计:`SECRET_VALUE` 正则(`sk-[A-Za-z0-9_-]{20,}`,audit_normalize.py 既有)自动把 create/copy 响应中的密钥在 console/审计载荷里打码;测试断言之。 + +### 5.2 文件同步(`gateway_keys_file` 配置项) + +- `settings.py` 新增:`gateway_keys_file: Path | None = None` ← env `DASHBOARD_GATEWAY_KEYS_FILE`(沿用 `or None` 模式)。未配置 → create 显式 503「未配置网关交付路径,拒绝假成功」。 +- 文件格式:一个 JSON 对象 `{"": {"user_id": P, "cube_id": P}, ...}`(网关 parse_tenants 已同时支持 JSON 与紧凑串,选 JSON 无歧义)。 +- 写法 = 原子替换:同目录临时文件 + `os.replace`;manifest 写入器**收显式 token 集参数**(由调用方以事务连接提供含未提交行的最后状态),不自行查库——避免跨连接读不到未提交数据。 +- 归属声明:`tenants.conf` 是本控制台**单向权威管辖**的文件(启动重写即拭除任何库外条目)。README 明示:手工追加 token 请走 `gateway.env` 的 `MEMOS_TENANTS` 环境变量,不要手编本文件。 +- **create 序**:库内 `BEGIN IMMEDIATE` 插行(未提交)→ 以「新行在内」重写文件 → 提交。文件失败 → 回滚 + 50;提交失败 → 回滚 + 自愈重写文件(剔除该 token)+ 500。 +- **revoke 序**(安全优先):以「剔除该 token」重写文件 → 库置 revoked → 提交。提交失败 → 自愈重写(恢复该 token)+ 500。任何时刻网关重启看到的库/文件差异由启动重写收敛。 +- **启动自愈**:app lifespan 在 store 装配后,若配置了路径 → 以库中 active 集合整体重写文件(孤儿 token 由这台 dashboard 的库拭除——文件属此 dashboard 管辖,单向权威)。 + +## 6. 网关侧最小改动(upstream/deploy/mcp-gateway,补丁导出) + +对 task12 的合并文件能力再推进一小步。网关为包结构(入口 `memos_gateway.py` 在 `deploy/mcp-gateway/` 根,代码在 `gateway/` 包内): + +| 文件 | 改动 | +|------|------| +| `gateway/config.py` | Settings 增加 `tenants_file`(env `MEMOS_TENANTS_FILE`,默认与入口脚本同目录的 `deploy/mcp-gateway/tenants.conf`;缺省文件不存在 → 一行日志后跳过) | +| `gateway/domain/tenants.py` | TenantRegistry 增加 `merge`:env 与文件注册表合并;同 token 双源自变量冲突 → ValueError 明文报「MEMOS_TENANTS 与 tenants.conf 冲突」(D9) | +| `gateway/app.py` | build_app 装配时读文件并合并;其余零改动 | +| `README.md` | 一段说明:文件语义、重启要求、「冲突会拒绝启动」 | +| 新测试 `test_gateway_tenants_file.py` | 与同目录 test_gateway_contract.py 同款脚本式(python 原生、无 fastmcp 依赖):文件解析、env+file 合并、冲突拒绝、文件不存在继续、verifier_tokens 双源齐全 | + +- 导出补丁:`server/backend/patches/task14-gateway-tenants-file.patch` + SHA256 + `git apply --reverse --check` 取证(task12 惯例);上游测试文件纳入补丁。 +- 老语义不动:`MEMOS_TENANTS` env 单独使用时行为与今天完全一致(回归用同目录既有契约锁证明)。 + +## 7. 运维脚本(setup/) + +- 新增 `setup/Restart-Gateway.ps1`:镜像 Start/Stop-Backend 模式—— + 1. 从 `.local/gateway.env` 重建 env(**不打印任何值**); + 2. 由 `.local/processes.json` 的 gateway 条目拿 pid/端口/工作目录,Stop-Process 后以原命令行(`D:\MemOS\.venv\Scripts\python.exe …memos_gateway.py --host 127.0.0.1 --port 8002`)重启; + 3. 轮询 `/health` 到活,更新 processes.json 新 pid; + 4. **端口安全**:只按 processes.json 记录的 pid 停进程;记录失效而 8002 被未知进程占用 → 明确报错并给出占用 pid,提示人工处置(不盲杀); + 5. 始终 127.0.0.1 本地网关,绝不碰 18000 的 Linux 参照环境。 +- dashboard 侧启动注入:Start-Backend.ps1 增加 `-GatewayKeysFile` 参数(默认 `D:\MemOS\upstream\deploy\mcp-gateway\tenants.conf`),不改 `.env.local`。 + +## 8. 契约与文档 + +- `openapi.json`:只改 5 条 keys 路径 + ApiKey 字段描述(写明:完整密钥授权下发仅 owner;创建即写入网关配置、重启后生效;撤销即从配置移除、重启后失效;改名不影响网关配置)。形状零变化 → Redocly 保持 0 错误、不超过既有 5 条警告。 +- `docs/superpowers/specs/2026-10-26-issue3-interface-matrix.md`:keys 行由「P3 后续扩展」改为「已实现(task14)」+ 描述要点。 +- `server/backend/README.md`:`DASHBOARD_GATEWAY_KEYS_FILE` 用法、重启要求、明文保存的诚实声明。 + +## 9. 诚实限制(写进 README/RESULT,不粉饰) + +1. **明文保存**:token 明文存于 `.data/control.db` 与网关 `tenants.conf`(两者均在 gitignore 范围内)。无 KMS 的本地部署里,任何「加密存储」都是装饰品——选择如实不加密,靠目录权限与 gitignore 兜底;因此「关闭后无法再查看」是**前端显示效果(冻结前端保证)+ 契约字段照旧**,取回通道(list/copy)仍是 owner 权限的完整下发(D4)。 +2. **双资源非全局原子**:文件+库两段式各带回滚/自愈,收敛点 = 库为准的启动重写;不做超出 SQLite 事务能力的承诺。 +3. **重启生效是设计使然**:网关读配置是一次性的(README 既有语义),弹窗文案如实提示「重启后才能用」。 + +## 10. 验证清单(TDD + 真链路) + +### 10.1 RED→GREEN(dashboard 侧,test_keys.py) +匿名 401 / 非 owner(viewer、editor、他项目 owner)403 / 未配置交付路径 create 503 / create 契约形状与 sk-48hex 唯一性 / list 排序分页 + 仅 active / revoke 软撤销 + 撤销后 copy/rename 40001 / rename 文件字节不变 / 持久化:同一 data_dir 重建 app 实例后行仍在 / 文件同步:create 响应成功前文件已含 token;写失败(只读目录模拟)→ 503 且库无行 / 自愈:启动重写收敛孤儿 token / 审计载荷打码断言。 + +### 10.2 RED→GREEN(网关侧,test_gateway_tenants_file.py + 既有契约锁) +§6 表列全部。 + +### 10.3 真链路(live, MUST,证据入 `.data/task14-keys/`) +1. 后端带 `DASHBOARD_GATEWAY_KEYS_FILE` 重启(先验证 keys 空表启动不炸 = 幂等;自愈写 `{}` 空清单为合法文件)。 +2. 恶意 Bearer → 网关 401 invalid_token(基线)。 +3. 老 env token → 200(合并后持续可用,重启前各验一次)。 +4. owner 登录 → HTTP create → 响应含完整 sk-(**不打印进任何日志/证据**)。 +5. 立拿新 token 打网关 → 401(复现前端注释的实测语义:重启前不存在)。 +6. 确认 tenants.conf 已含该 token(证据= 文件 SHA256 前后变化 + 命中计数,不落地 token 原文)。 +7. `setup/Restart-Gateway.ps1` → /health 活。 +8. 新 token 打 `/mcp` initialize/tools(stateless)→ 200;并发起一条真实只读记忆检索 → 审计 JSONL(`.local/audit/`)出现 `tenant=<项目>` 行(D1 归因端到端证明)。 +9. HTTP revoke → 文件不含该 token(SHA256 再证)→ 重启网关 → 同 token → 401 回归。 +10. 老 token 终验 200;传输链全程不打印密钥原文。 + +**实现期开放项**(写计划时定,不凭记忆假设):① 读 `gateway/app.py` build_app 的 http transport 实参(是否 stateless_http)来定 MCP 探测载荷形态;② 只读记忆检索的 MCP 工具名以 `tools/list` 实取结果为准,不硬编码。 + +### 10.4 质量门 +dashboard 全量 pytest(≥249 + 新增)、ruff 全绿(58+ 文件)、`pnpm verify`、Redocly 0 错误、保护面 diff 对基线 03e10145 通过、`git diff --check` 干净、补丁逆应用校验通过。 + +## 11. 留档(用户明示要求:主会话可审) + +| 产物 | 位置 | +|------|------| +| 本设计文档 | `docs/superpowers/specs/2026-10-26-issue3-task14-keys-design.md` | +| 实施计划(checkbox 逐项) | `docs/superpowers/plans/2026-10-26-issue3-task14-keys-plan.md` | +| 执行台账(每步 RED/GREEN、命令、结论) | `docs/superpowers/plans/…plan.md` 内嵌 + 进度账 | +| 网关补丁 + SHA + 逆应用证据 | `server/backend/patches/task14-gateway-tenants-file.patch` 等 | +| live 证据 | `.data/task14-keys/` 下 `verification.json` / `task14-live.json`(无密钥原文;token 一律打码或仅存哈希) | +| 验收文档 | `server/backend/TASK14-RESULT.md` | +| 红绿证据 | 各测试文件 + 证据目录内命令输出摘录 | + +## 12. builder 自检记录(2026-10-26,禁 subagent 故由设计者冷读代行) + +以「从未读过本对话的实施者」身份复读全文,修正 7 处:① 网关路径精确到包内(`gateway/config.py` 等);② manifest 写入器改为收显式参数,防跨连接读不到事务内未提交行;③ 补 revoke/copy/rename 行级规则(未知/撤销 40001、跨项目 403);④ 声明 tenants.conf 单向权威、手工 token 走 env;⑤ Restart-Gateway.ps1 加端口占用防误杀;⑥ 证据目录按 `.data/task14-keys/` 惯例定点(核自 `.data/task10-13-p2/`);⑦ MCP 探测依赖的 transport 配置与工具名列为实现期实证项。 + +## 13. 后续(本任务之后停下) + +任务 14 交付并自验后**停住**,等独立验收;属性树模板(task15/16 相关)不沾。 diff --git a/docs/superpowers/specs/2026-10-26-time-display-design.md b/docs/superpowers/specs/2026-10-26-time-display-design.md new file mode 100644 index 000000000..7c047ea49 --- /dev/null +++ b/docs/superpowers/specs/2026-10-26-time-display-design.md @@ -0,0 +1,87 @@ +# 时间显示统一格式(短设计 · 2026-10-26) + +用户要求:后端对外呈现的时间从 `2026-10-26T15:30:02.075819+00:00`(ISO UTC) +统一改为 `2026-10-26 21:32:05`(可读、本地时刻)。**不影响正确性**,并**存档**本次修改。 + +## 意图与 Why + +- 后端所有时刻产自 `identity_store.now()` = `datetime.now(timezone.utc).isoformat()`, + 存库后 API 直接透传 ISO UTC 字符串(keys/projects/invites/kb/记忆/审计全部如此)。 +- 已确认用户选定方案 **A**(三选一里唯一不动数据落到机器格式的)。 +- 展示时区取固定 **UTC+8**(中国无夏令时,无 tzdata 依赖,Windows 环境确定性)。 + +## 红线(不做的事) + +- 库内存储格式**不变**:全部保持 ISO UTC。`ORDER BY created_at`、会话过期、邀请过期、 + `audit_index` 对 `ts` 的字符串元组比较与 SQL 区间,全部原样工作——这是"不影响正确性"的根。 +- JSONL 文件里写入的 `ts` **不改**(机器格式;改写会牵动审计归并解析与比较,属方案 B,已否决)。 +- 上游(MemOS :8000)接受的过滤条件(start_time/end_time、`memory_models`/`usage_models`) + 是机器接口,**不动**。 +- 不 commit;不动 src/shared/server/mock/.env.local/前端。openapi 状态码与形状零变化 + (时间字段 openapi 本就是自由 `type: string`,无需扫动)。 + +## 落点(新增 `server/backend/timefmt.py` 单一转换器) + +```python +ZH = timezone(timedelta(hours=8)) +def display(value) -> str | None: # ISO/UTC → "YYYY-MM-DD HH:MM:SS"(UTC+8);None/""→原样 +def display_epoch(seconds) -> str # epoch 秒 → 同上(invites 的 iso()) +``` + +仅在"给人看的位置"调用(库存 ISO → 出口转换): + +| 站点 | 字段 | +|---|---| +| `control.py` project(238/247) | createTime / archivedAt | +| `keys.py` KeyRecord(80) | createTime | +| `invites.py` iso/revokedAt(68/71) | expiresAt / revokedAt | +| `knowledge.py`(51) / `knowledge_files.py`(181) | createTime | +| `memories.py`(42) | createTime | +| 审计 serve 出口(`audit_index.page/detail` 返回的时间字段) | createTime 等展示字段 | +| 账号/成员列表 serve 出口(实现时按实测行数清点) | createTime 等 | + +`audit_index` 入库细节里的 `time` 若出现在 serve 出口则同样转换; +**ingest 比较、SQL 条件一律绕开**。 + +## 测试(TDD:先红后绿) + +1. `tests/test_timefmt.py`:转换器单元(用户原样示例 + `2026-10-26T15:30:02.075819+00:00` → `2026-10-26 23:30:02`;+0800 输入;None/""; + 展示格式回归钉)。 +2. 路由层格式钉(把既有断言全面改为正则 + `^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$`):keys(create/list)、audit API 行、 + projects page、invite 行——这些钉在改动前**会红**(现在还是 ISO),即 RED 面。 +3. 排序不回归:既有 `ORDER BY created_at DESC` 与 test_keys_store 排序断言原样全绿(零改动即证存储未动)。 + +## 存档 + +- `.data/time-display-fix/ledger.md`:RED→GREEN 逐站记录 + Ruling。 +- 计划文档:`docs/superpowers/plans/2026-10-26-time-display-plan.md`(本次小改,执行步骤精简)。 +- 完成后在本 spec 末尾补「执行记录」段(终值数字 + 文件清单)。 + +## 批准后流向 + +`superpowers:writing-plans` → 内联执行(无 subagent,任务态同 task14 全局约束)。 + +--- + +## 执行记录(2026-10-26 当日完成) + +**终值**:全量 pytest **277 passed**(269 基线 + 8 条 timefmt 单测;格式钉皆并入既有用例,测试计数不变);ruff 全绿;Redocly 0 错 5 警;双仓 `git diff --check` 干净;保护面 vs `03e10145` **空变化**;openapi 零改动;不 commit。 + +**实装与设计稿的差异(全部经 Ruling 记录)**: + +1. invites 投影比计划多转 **createdAt/usedAt**,create 响应的 createdAt 同转——同出口同族字段半新半旧不可接受;`iso()` 死助手与失效 `datetime/timezone` import 一并清除。 +2. knowledge.py 投影把 **updateTime** 与 createTime 一并转换(同上理);映射改三列 `(source, target, convert)` 注入 `display`。 +3. `test_audit_index.py:156` 实为 **serve 级**钉(经 `page(index)` 助手走 `index.page()`),计划误判为 ingest 级;按方案 A 该钉翻转至 `"2026-10-26 10:00:01"`(属设计内行为变更,非回归)。 +4. 账号面实测:`identity_store.account()` 是唯一出口(admin/accounts、auth 域共用),其 `created_at/last_login_at` 在 mapper 转换;机器消费面(with_actor/会话)核实无时间比较后放行。 + +**转换器**:`server/backend/timefmt.py`(`display` + `display_epoch`,固定 `timezone(timedelta(hours=8))`,`%Y-%m-%d %H:%M:%S`;`Z` 容错、naive 按 UTC 防御分支、None/"" 原样)。 + +**接入站点终清单(全部 serve 边界)**:`keys.py`(createTime)、`control.py`(createTime/archivedAt)、`invites.py`(createdAt/expiresAt/usedAt/revokedAt + create 响应)、`identity_store.py account()`(created_at/last_login_at)、`knowledge.py`(createTime/updateTime)、`knowledge_files.py`(createTime)、`memories.py`(createTime)、`audit_index.py` serve 出口(page 行 + detail 体 createTime)。**未触碰**:所有 DB 列、JSONL `ts` 写入、上游过滤/SQL 条件、`memory_models/usage_models` 机器接口。 + +**测试动作清单**:`test_timefmt.py` 新建(8 钉);`test_keys.py`(正则钉替换 fromisoformat 弱钉)、`test_projects.py`(迁移用例硬期望 `2026-01-01 08:00:00`,证 UTC 零点 +8)、`test_invites.py`(expiresAt/revokedAt 钉)、`test_accounts.py`(created_at 钉)、`test_memories.py`(全 tab 正则钉)、`test_knowledge.py`(createTime/updateTime 硬期望)、`test_knowledge_files.py`(正则钉)、`test_audit_api.py`(list+detail 硬期望)、`test_audit_index.py:156`(serve 钉翻转)。`test_keys_store.py` 排序断言等**存储面测试零改动全绿** = 正确性证明。 + +**live 验证**(5281,两代进程均验):匿名 401 → 管理员登录 303 → accounts 1 行 + projects 10 行(**含改造前写入的库存行**)在 serve 出口全部 19 字符显示格式。重启 helper:`.data/time-display-fix/restart_via_popen.py`(坐实外层 shell 文件重定向仍会因句柄继承挂起;先例 `verify_task06.py run_ps` 同构)。 + +**档案**:台账 `.data/time-display-fix/ledger.md`(5 任务 RED→GREEN + Ruling 全程);pytest 各任务日志、`redocly.log`、`restart_via_popen.py`、`live_display_check.py`;计划 `docs/superpowers/plans/2026-10-26-time-display-plan.md`(checkbox 全勾)。 diff --git a/docs/superpowers/specs/2026-10-27-auth-ui-task15-design.md b/docs/superpowers/specs/2026-10-27-auth-ui-task15-design.md new file mode 100644 index 000000000..15b1a92f6 --- /dev/null +++ b/docs/superpowers/specs/2026-10-27-auth-ui-task15-design.md @@ -0,0 +1,13 @@ +# 登录界面与 Task15 设计 + +用户要求先美化登录/登出,再实现属性树模板加分任务;由主 agent 完成,不提交代码,不修改 src/。 + +## 界面 + +沿用控制台浅灰画布、白卡片、紫色按钮和冷灰文字。选择居中窄卡片,优于大幅插图或双栏营销布局,更适合简洁的管理控制台。提供品牌、标题、说明、显式标签、完整宽度按钮、键盘焦点与手机布局。退出页提供返回控制台入口。复用后端 HTML,保持登录重定向、会话、CSRF、同源和表单行为。CSS 使用 CSP 内容哈希授权,不放宽脚本策略,不依赖外部资源。 + +## Task15 + +以 openapi.json 的五个 profileTemplate 接口为准,复用上游 profile_template 原生 CRUD,项目 ID 由控制存储解析,读需 viewer,写需 owner。详情必须验证归属,分页投影为 list/current/size/total/pages,字段树在后端严格校验并映射上游格式,拒绝伪造系统字段及越权。复用上游持久化,不另建假数据源。当前前端 ComingSoon/禁用菜单保留,在报告中明确只能通过 API 使用。 + +验收包括认证权限、两项目隔离、树结构、分页、真实上游同 ID 对照、持久化和清理测试数据。若发现上游不支持契约中的能力,明确记录而不伪造成功。 diff --git a/openapi.json b/openapi.json index 04eea16bb..3dedf9b7e 100644 --- a/openapi.json +++ b/openapi.json @@ -3,13 +3,21 @@ "info": { "title": "MemOS Dashboard API", "version": "0.1.0", - "description": "控制台前端依赖的后端接口定义。\n\n## 通用约定\n\n**统一返回格式。** 所有接口返回 `{ code, data, message }`。`code === 0` 表示成功,前端解包后只拿 `data`;非 0 时前端按错误处理,把 `message` 弹成提示。HTTP 状态码不参与判断,200 + 非 0 code 也算失败。\n\n**请求体。** 除少数 GET 外,参数一律放 body,不放 query。字段值为空串或 null 时前端会**删掉该字段**再发出去,后端要按「字段不存在」处理,不要依赖空串。\n\n**分页。** 参数名固定 `pageNum` / `pageSize`,从 1 开始。\n\n**列表响应的键不统一。** 同一个前端里四种写法并存,这是最容易被忽略的地方:\n\n| 接口 | 列表键 | 分页字段 |\n|---|---|---|\n| `project/list`、`keys/list`、`knowledgebase/list`、`knowledgebase/memory/list` | `records` | `total` |\n| `log/list` | `logs` | `total` |\n| `project/memory/list` | `memListItemResponseList` | `total`、`current` |\n| `profileTemplate/list` | `list` | `total`、`current`、`size`、`pages` |\n\n**时间字段。** 一律叫 `createTime`(不是 `createdAt`),值是可被 `dayjs` 直接解析的字符串。\n\n## 错误码\n\n| code | 含义 |\n|---|---|\n| 0 | 成功 |\n| 40000 | 参数错误 |\n| 40001 | 资源不存在 |\n| 40002 | 请求过于频繁 |\n| 40003 | 无权限 |\n| 40100 | 未登录 |\n| 40104 | 未授权 |\n| 50106 | 知识库数量超限 |\n| 50107 | 文档删除中 |\n| 60004 | 容量超限 |\n\n## 模型列表\n\n「模型列表」页的数据来自 `GET /api/dashboard/models`,返回上游实际生效的模型配置(形状 `{ records: [...] }`)。\n\n## 鉴权\n\n**控制台不发任何鉴权头**,请求里只有 `Content-Type`;但同源请求会自动带 Cookie。根级 `security: []` 表示不要求在请求头里放 token。\n\nv2 新增了 `/api/auth/me` 与 `/admin/*`:前者返回当前登录主体,后者需要管理员身份。会话方式(Cookie / 反向代理注入 / 其他)由后端决定;若改成要求 token,前端也要相应改动。\n\n**字段命名。** `/api/*` 用 camelCase;`/admin/*` 沿用 snake_case(`display_name`、`platform_admin`、`created_at`),不要统一改成 camelCase。" + "description": "控制台前端依赖的后端接口定义。\n\n## 通用约定\n\n**统一返回格式。** 所有接口返回 `{ code, data, message }`。`code === 0` 表示成功,前端解包后只拿 `data`;非 0 时前端按错误处理,把 `message` 弹成提示。HTTP 状态码不参与判断,200 + 非 0 code 也算失败。\n\n**请求体。** 除少数 GET 外,参数一律放 body,不放 query。字段值为空串或 null 时前端会**删掉该字段**再发出去,后端要按「字段不存在」处理,不要依赖空串。\n\n**分页。** 参数名固定 `pageNum` / `pageSize`,从 1 开始。\n\n**列表响应的键不统一。** 同一个前端里四种写法并存,这是最容易被忽略的地方:\n\n| 接口 | 列表键 | 分页字段 |\n|---|---|---|\n| `project/list`、`keys/list`、`knowledgebase/list`、`knowledgebase/memory/list` | `records` | `total` |\n| `log/list` | `logs` | `total` |\n| `project/memory/list` | `memListItemResponseList` | `total`、`current` |\n| `profileTemplate/list` | `list` | `total`、`current`、`size`、`pages` |\n\n**时间字段。** 一律叫 `createTime`(不是 `createdAt`),值是可被 `dayjs` 直接解析的字符串。\n\n## 错误码\n\n| code | 含义 |\n|---|---|\n| 0 | 成功 |\n| 40000 | 参数错误 |\n| 40001 | 资源不存在 |\n| 40002 | 请求过于频繁 |\n| 40003 | 无权限 |\n| 40100 | 未登录 |\n| 40104 | 未授权 |\n| 50106 | 知识库数量超限 |\n| 50107 | 文档删除中 |\n| 60004 | 容量超限 |\n\n## 模型列表\n\n「模型列表」页的数据来自 `GET /api/dashboard/models`,返回上游实际生效的模型配置(形状 `{ records: [...] }`)。\n\n## 鉴权\n\n**控制台不发任何鉴权头**,请求里只有 `Content-Type`;但同源请求会自动带 Cookie。根级 `security: []` 表示不要求在请求头里放 token。\n\nv2 新增了 `/api/auth/me` 与 `/admin/*`:前者返回当前登录主体,其中 `/admin/accounts*` 要求平台管理员,`/admin/tenants/*` 按目标项目角色授权。会话方式(Cookie / 反向代理注入 / 其他)由后端决定;若改成要求 token,前端也要相应改动。\n\n**字段命名。** `/api/*` 用 camelCase;`/admin/*` 沿用 snake_case(`display_name`、`platform_admin`、`created_at`),不要统一改成 camelCase。\n\n## Layered authorization\nThe /admin URL namespace contains both platform and project administration. /admin/accounts requires platform_admin. /admin/tenants operations use the target project role: members may read its member list and leave themselves; owners manage members, candidate search and invitations. Platform administrators override project-role checks, but business invariants still apply. See each operation x-authorization and server/backend/AUTHORIZATION.md. This is the chosen implementation interpretation of Issue 3, not a claim of upstream maintainer approval.", + "license": { + "name": "Apache License 2.0", + "identifier": "Apache-2.0" + } }, - "security": [], + "security": [ + { + "CookieSession": [] + } + ], "servers": [ { - "url": "http://localhost:5280", - "description": "本地开发(`pnpm mock`)" + "url": "/", + "description": "Same-origin Dashboard API; local development proxy may target the real backend or Mock" } ], "tags": [ @@ -64,16 +72,18 @@ { "name": "模型", "description": "上游实际生效的模型清单" + }, + { + "name": "认证", + "description": "Backend-owned login and registration HTML forms." } ], "paths": { "/api/dashboard/project/dict": { "get": { - "tags": [ - "项目" - ], + "tags": ["项目"], "summary": "项目下拉字典", - "description": "返回**数组**,不是分页对象。`status === \"ENABLED\"` 的那条是当前项目。", + "description": "Returns active accessible workspaces. Exactly one status=ENABLED indicates the current opaque browser project context when any active workspace exists. Inactive/archived workspaces are excluded. HttpOnly SameSite=Lax project preference cookie persists for 30 days; it is not an authenticated account session.", "parameters": [ { "name": "language", @@ -81,25 +91,80 @@ "required": false, "schema": { "type": "string", - "examples": [ - "zh-CN", - "en-US" - ] + "examples": ["zh-CN", "en-US"] } } ], "responses": { "200": { "$ref": "#/components/responses/ProjectDict" + }, + "400": { + "description": "Invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Origin rejected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Unknown or unavailable project", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Name or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Project storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/project/list": { "post": { - "tags": [ - "项目" - ], + "tags": ["项目"], "summary": "项目分页列表", "requestBody": { "required": true, @@ -110,13 +175,18 @@ "properties": { "pageNum": { "type": "integer", - "default": 1 + "default": 1, + "minimum": 1, + "maximum": 2147483647 }, "pageSize": { "type": "integer", - "default": 10 + "default": 10, + "minimum": 1, + "maximum": 200 } - } + }, + "additionalProperties": false } } } @@ -124,40 +194,98 @@ "responses": { "200": { "$ref": "#/components/responses/ProjectRecords" + }, + "400": { + "description": "Invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Origin rejected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Unknown or unavailable project", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Name or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Project storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } - } + }, + "description": "SQLite-backed workspaces visible to the authenticated account (or explicit legacy local operator mode); includes accessible disabled/archived rows for recovery. Records counted before pagination. status=DISABLED alone means not selected; disabled=true means stopped. role is the effective project role; platform admins may manage every visible configured/locally-created workspace." } }, "/api/dashboard/project/create": { "post": { - "tags": [ - "项目" - ], + "tags": ["项目"], "summary": "新建项目", - "description": "返回新建的项目对象,前端拿它的 `id` 立刻切过去。", + "description": "Persist a workspace and immutable upstream namespace mapping. ID is server generated, upstream_cube_id defaults to that ID. No memory/cube data is fabricated or deleted. NFKC/casefold trimmed names must be unique, including archived names.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": [ - "name", - "description" - ], + "additionalProperties": false, + "required": ["name", "description"], "properties": { "name": { - "type": "string" + "type": "string", + "minLength": 1, + "maxLength": 128 }, "description": { - "type": "string" + "type": "string", + "maxLength": 4000 }, "isMultiView": { "type": "integer", - "enum": [ - 0, - 1 - ] + "enum": [0, 1], + "default": 0 } } } @@ -167,45 +295,109 @@ "responses": { "200": { "$ref": "#/components/responses/Project" + }, + "400": { + "description": "Invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Origin rejected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Unknown or unavailable project", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Name or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Project storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/project/update": { "post": { - "tags": [ - "项目" - ], + "tags": ["项目"], "summary": "更新项目 / 切换当前项目", - "description": "切换当前项目就是传 `{ id, status: \"ENABLED\" }`,后端要把其它项目置回 `DISABLED`。", + "description": "Nonempty partial update. ENABLED selects an active project only for the calling browser context; for a stopped project it re-enables without selecting. DISABLED stops the project and resets only contexts selecting it. Archived projects must be restored first. Namespace and ownership fields are not writable. Authenticated deployments use session-bound selection and actual membership roles. Owner/platform admin manages lifecycle; viewer may select an accessible active project.", "requestBody": { "required": true, "content": { "application/json": { "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/Project" + "type": "object", + "additionalProperties": false, + "required": ["id"], + "minProperties": 2, + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_.-]+$" }, - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "status": { - "type": "string", - "enum": [ - "ENABLED", - "DISABLED" - ] - } - }, - "required": [ - "id" - ] + "name": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "description": { + "type": "string", + "maxLength": 4000 + }, + "isMultiView": { + "type": "integer", + "enum": [0, 1] + }, + "status": { + "type": "string", + "enum": ["ENABLED", "DISABLED"] } - ] + } } } } @@ -213,15 +405,73 @@ "responses": { "200": { "$ref": "#/components/responses/Project" + }, + "400": { + "description": "Invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Origin rejected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Unknown or unavailable project", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Name or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Project storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/project/revoke/{id}": { "post": { - "tags": [ - "项目" - ], + "tags": ["项目"], "summary": "删除项目", "parameters": [ { @@ -236,26 +486,83 @@ "responses": { "200": { "$ref": "#/components/responses/Ok" + }, + "400": { + "description": "Invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Origin rejected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Unknown or unavailable project", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Name or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Project storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } - } + }, + "description": "Reversible archive (legacy revoke is the same compatibility alias). Preserve workspace, namespace and all memories. Listed for recovery; unavailable to normal memory/log/usage calls; active dictionary excludes it." } }, "/api/dashboard/keys/list": { "post": { - "tags": [ - "接口密钥" - ], + "tags": ["接口密钥"], "summary": "密钥分页列表", - "description": "列表里下发的 `apiKey` 就是**完整密钥**,没有掩码字段,前端自己截断成 `${key.slice(0,7)}*******${key.slice(-8)}` 展示。", + "description": "列表里下发的 `apiKey` 就是**完整密钥**,没有掩码字段,前端自己截断成 `${key.slice(0,7)}*******${key.slice(-8)}` 展示。`apiKey` 始终为完整密钥(前端自截断展示)。完整下发仅限项目 owner(平台管理员视同 owner)。点击层级与权限、撤销行不返回。", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": [ - "projectId" - ], + "required": ["projectId"], "properties": { "projectId": { "type": "string" @@ -276,26 +583,52 @@ "responses": { "200": { "$ref": "#/components/responses/ApiKeyRecords" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/keys/create": { "post": { - "tags": [ - "接口密钥" - ], + "tags": ["接口密钥"], "summary": "新建密钥", + "description": "密钥创建即写入网关配置文件 `DASHBOARD_GATEWAY_KEYS_FILE`(成功返回 ⟺ 已写入),网关须重启方可生效;未配置交付路径或写入失败返回 503,密钥不落库。完整密钥仅创建响应可见一次(前端弹窗),后续 list/copy 仍按契约返回完整密钥(仅 owner)。\n\nThe upgraded gateway detects confirmed file publications before each authentication request. A newly created key works without restarting the gateway. Initial deployment of the reload code and changes to MEMOS_TENANTS environment variables still require restart.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": [ - "projectId", - "keyName" - ], + "required": ["projectId", "keyName"], "properties": { "projectId": { "type": "string" @@ -311,16 +644,45 @@ "responses": { "200": { "$ref": "#/components/responses/ApiKey" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/keys/revoke/{id}": { "post": { - "tags": [ - "接口密钥" - ], + "tags": ["接口密钥"], "summary": "吊销密钥", + "description": "软撤销:行保留(审计可用),立即从网关配置文件移除,已升级网关在下一次鉴权前自动重载,后续请求失效(不取消已授权执行中的请求);撤销成功返回 `{ok:true}`。", "parameters": [ { "name": "id", @@ -334,26 +696,52 @@ "responses": { "200": { "$ref": "#/components/responses/Ok" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/keys/copy": { "post": { - "tags": [ - "接口密钥" - ], + "tags": ["接口密钥"], "summary": "取完整密钥", - "description": "`data` 是密钥字符串本身,不是对象。", + "description": "返回完整密钥字符串本身(仅 owner;已撤销/不存在返回 404)。", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": [ - "apiKeyId" - ], + "required": ["apiKeyId"], "properties": { "apiKeyId": { "type": "string" @@ -366,65 +754,102 @@ "responses": { "200": { "$ref": "#/components/responses/StringData" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/log/list": { "post": { - "tags": [ - "调用日志" - ], + "tags": ["调用日志"], "summary": "调用日志分页列表", - "description": "`pageSize` 默认 50,前端只渲染返回结果的前 100 条。\n\n`subjectType` 取 `all` / `user` / `agent`;`subjectId` 为空时 `subjectType` 会被强制清空。", + "description": "`pageSize` 默认 50,前端只渲染返回结果的前 100 条。\n\n`subjectType` 取 `all` / `user` / `agent`;`subjectId` 为空时 `subjectType` 会被强制清空。\n\nThe source of truth is configured JSONL. Missing source returns 503 rather than an empty success. Empty optional strings disable filters. Unknown event filters return 400. Time boundaries require an offset, or a UTC date; end date includes that entire UTC day. Keyword is a case-insensitive literal substring of the sanitized event, call ID, subject IDs and request/response bodies. Async rows with an explicit call ID count as one logical call within source and tenant.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": [ - "projectId" - ], + "required": ["projectId"], "properties": { "projectId": { - "type": "string" + "type": "string", + "minLength": 1, + "maxLength": 128 }, "pageNum": { "type": "integer", - "default": 1 + "default": 1, + "minimum": 1, + "maximum": 2147483647 }, "pageSize": { "type": "integer", - "default": 50 + "default": 50, + "minimum": 1, + "maximum": 100 }, "subjectId": { "type": "string" }, "subjectType": { "type": "string", - "enum": [ - "all", - "user", - "agent" - ] + "enum": ["all", "user", "agent"] }, "eventType": { "$ref": "#/components/schemas/EventType" }, "query": { - "type": "string" + "type": "string", + "maxLength": 1000 }, "status": { - "type": "string" + "type": "string", + "description": "SUCCESS / FAILURE / PENDING / UNKNOWN; case insensitive; FAIL and FAILED are aliases. Empty means no filter." }, "startTime": { "type": "string" }, "endTime": { "type": "string" + }, + "scope": { + "type": "string", + "enum": ["all", "gateway", "console"], + "default": "all", + "description": "Trusted configured audit source. all requires the gateway source to be configured and available." } - } + }, + "additionalProperties": false } } } @@ -432,59 +857,174 @@ "responses": { "200": { "$ref": "#/components/responses/LogList" - } - } - } - }, - "/api/dashboard/log/detail": { - "post": { - "tags": [ - "调用日志" - ], - "summary": "调用日志详情", - "description": "`requestBody` 与 `responseBody` 是 **JSON 字符串**,不是对象,前端要二次 `JSON.parse`。\n\n`eventType === \"chat\"` 时 `responseBody` 再解析一次:解析结果带 `code` 与 `data` 就直接用,否则包成 `{ code: responseCode, data: <解析结果> }`。\n\n### 详情抽屉按 eventType 取哪些字段\n\n通用字段(每条日志都有):`status`、`duration`、`traceId`、`userId`、`agentId`、`createTime`、`requestTokens`、`responseTokens`、`requestCode`、`responseCode`、`eventType`。\n\n各类型额外读取的路径(点号分隔,`requestBody` / `responseBody` 解析后的字段):\n\n| eventType | 请求 | 响应 |\n|---|---|---|\n| `addMessage` | `async_mode` `conversation_id` `allow_memory_view` `messages` `info` | `code` `data.memory_views` `message` |\n| `addFeedback` | `feedback_target` `profile_id` `attr_id` `feedback_content` `feedback_time` | `code` `data.memory_views` `message` |\n| `searchMemory` | `conversation_id` `conversationId` `query` `include_memory_view` `memory_limit_number` `includePreference` `preferenceLimitNumber` `includeToolMemory` `toolMemoryLimitNumber` `includeSkill` `skillLimitNumber` `knowledgebase_ids` `filter` | `code` `data` `message` |\n| `getMessage` | `message_limit_number` | `code` `data.message_detail_list` `message` |\n| `getMemory` | `page` `size` `filter` `include_memory_view` | `code` `data` `message` |\n| `deleteMemory` | `memory_ids` `message_id` | `code` `data` `message` |\n| `manualAddMemory` | `memory_view` `add_time` `algorithm_updatable` `content` | `code` `data` `message` |\n| `updateMemory` | `memory_id` `title` `algorithm_updatable` `content` | `code` `data` `message` |\n| `bindProfileTemplate` | `bind_list` | `code` `message` |\n| `editProfile` | `profile_template_id` `metadata` `remove_fields` | `code` `message` |\n| `deleteProfile` | `profile_template_id` | `code` `message` |\n| `chat` | `conversation_id` `knowledgebase_ids` `query` `filter` `base_prompt` `model_name` `include_preference` `add_message_on_answer` | `code` `data.response` `message` |\n| `extractMemory` | `messages` `extraction_types` | `code` `message`、`data.choices.0.message.content` |\n| `rerank` | `model` `query` `documents` `top_n` | `code` `data.results` `message` |\n\n请求体里的键名风格不统一,既有下划线(`conversation_id`)也有驼峰(`conversationId`),`searchMemory` 两种都会去取。", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "logId" - ], - "properties": { - "logId": { - "type": "string" - } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" } } } + }, + "404": { + "description": "Project or log not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Audit source or index unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "500": { + "description": "Unexpected internal error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + } + } + } + }, + "/api/dashboard/log/detail": { + "post": { + "tags": ["调用日志"], + "summary": "调用日志详情", + "description": "`requestBody` 与 `responseBody` 是 **JSON 字符串**,不是对象,前端要二次 `JSON.parse`。\n\n`eventType === \"chat\"` 时 `responseBody` 再解析一次:解析结果带 `code` 与 `data` 就直接用,否则包成 `{ code: responseCode, data: <解析结果> }`。\n\n### 详情抽屉按 eventType 取哪些字段\n\n通用字段(每条日志都有):`status`、`duration`、`traceId`、`userId`、`agentId`、`createTime`、`requestTokens`、`responseTokens`、`requestCode`、`responseCode`、`eventType`。\n\n各类型额外读取的路径(点号分隔,`requestBody` / `responseBody` 解析后的字段):\n\n| eventType | 请求 | 响应 |\n|---|---|---|\n| `addMessage` | `async_mode` `conversation_id` `allow_memory_view` `messages` `info` | `code` `data.memory_views` `message` |\n| `addFeedback` | `feedback_target` `profile_id` `attr_id` `feedback_content` `feedback_time` | `code` `data.memory_views` `message` |\n| `searchMemory` | `conversation_id` `conversationId` `query` `include_memory_view` `memory_limit_number` `includePreference` `preferenceLimitNumber` `includeToolMemory` `toolMemoryLimitNumber` `includeSkill` `skillLimitNumber` `knowledgebase_ids` `filter` | `code` `data` `message` |\n| `getMessage` | `message_limit_number` | `code` `data.message_detail_list` `message` |\n| `getMemory` | `page` `size` `filter` `include_memory_view` | `code` `data` `message` |\n| `deleteMemory` | `memory_ids` `message_id` | `code` `data` `message` |\n| `manualAddMemory` | `memory_view` `add_time` `algorithm_updatable` `content` | `code` `data` `message` |\n| `updateMemory` | `memory_id` `title` `algorithm_updatable` `content` | `code` `data` `message` |\n| `bindProfileTemplate` | `bind_list` | `code` `message` |\n| `editProfile` | `profile_template_id` `metadata` `remove_fields` | `code` `message` |\n| `deleteProfile` | `profile_template_id` | `code` `message` |\n| `chat` | `conversation_id` `knowledgebase_ids` `query` `filter` `base_prompt` `model_name` `include_preference` `add_message_on_answer` | `code` `data.response` `message` |\n| `extractMemory` | `messages` `extraction_types` | `code` `message`、`data.choices.0.message.content` |\n| `rerank` | `model` `query` `documents` `top_n` | `code` `data.results` `message` |\n\n请求体里的键名风格不统一,既有下划线(`conversation_id`)也有驼峰(`conversationId`),`searchMemory` 两种都会去取。\n\nScope is the calling browser selected project; without a recognized preference cookie use the configured bootstrap or another active accessible project. This contract carries no projectId. A foreign project ID is not readable. Detail reads the persisted index; list refreshes it. Missing historical bodies are JSON string null, duration is null, tokens are 0, missing timestamp is an empty string and unknown status is UNKNOWN. Oversized bodies are explicit truncated JSON objects; credentials are redacted.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["logId"], + "properties": { + "logId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_.-]+$" + } + }, + "additionalProperties": false + } + } } }, "responses": { "200": { "$ref": "#/components/responses/LogDetail" + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Project or log not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Audit source or index unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "500": { + "description": "Unexpected internal error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/project/memory/list": { "post": { - "tags": [ - "记忆" - ], + "tags": ["记忆"], "summary": "记忆分页列表", - "description": "列表键是 `memListItemResponseList`,不是 `records`。\n\n`memoryDetailType` 决定返回哪一类记忆,取值见 `MemoryDetailType`。", + "description": "列表键是 `memListItemResponseList`,不是 `records`。\n\n`memoryDetailType` 决定返回哪一类记忆,取值见 `MemoryDetailType`。 任务02:真实只读查询;项目由服务器配置白名单映射,详情使用该实例固定默认项目。列表仅统计 activated,缺失状态不计入;先筛选计数后分页。", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": [ - "projectId", - "memoryDetailType" - ], + "required": ["projectId", "memoryDetailType"], "properties": { "projectId": { "type": "string" @@ -494,20 +1034,26 @@ }, "pageNum": { "type": "integer", - "default": 1 + "default": 1, + "minimum": 1 }, "pageSize": { "type": "integer", - "default": 50 + "default": 50, + "minimum": 1, + "maximum": 100 }, "knowledgeBaseId": { - "type": "string" + "type": "string", + "description": "按图节点 knowledgebase_id / knowledge_base_id / knowledgebase_ids 关联元数据过滤;无关联记录不匹配。画像按实例 knowledgebase_id 过滤。" }, "startTime": { - "type": "string" + "type": "string", + "description": "带时区 RFC3339 时间,或按 UTC 自然日解释的 YYYY-MM-DD;起点包含,日期终点包含整日,时间戳终点包含。" }, "endTime": { - "type": "string" + "type": "string", + "description": "带时区 RFC3339 时间,或按 UTC 自然日解释的 YYYY-MM-DD;起点包含,日期终点包含整日,时间戳终点包含。" }, "externalUserId": { "type": "string" @@ -516,9 +1062,11 @@ "$ref": "#/components/schemas/MemorySubjectType" }, "memSearchContent": { - "type": "string" + "type": "string", + "description": "图节点正文或技能名的区分大小写子串;画像字段路径和值的不区分大小写子串。" } - } + }, + "additionalProperties": false } } } @@ -526,15 +1074,83 @@ "responses": { "200": { "$ref": "#/components/responses/MemoryList" + }, + "400": { + "description": "统一非零业务错误,data=null", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "统一非零业务错误,data=null", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "502": { + "description": "统一非零业务错误,data=null", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "统一非零业务错误,data=null", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "504": { + "description": "统一非零业务错误,data=null", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/project/memory/detail": { "post": { - "tags": [ - "记忆" - ], + "tags": ["记忆"], "summary": "记忆详情", "requestBody": { "required": true, @@ -542,10 +1158,7 @@ "application/json": { "schema": { "type": "object", - "required": [ - "memoryId", - "memType" - ], + "required": ["memoryId", "memType"], "properties": { "memoryId": { "type": "string" @@ -561,34 +1174,120 @@ "responses": { "200": { "$ref": "#/components/responses/Memory" + }, + "400": { + "description": "统一非零业务错误,data=null", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "统一非零业务错误,data=null", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "502": { + "description": "统一非零业务错误,data=null", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "统一非零业务错误,data=null", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "504": { + "description": "统一非零业务错误,data=null", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } - } + }, + "description": "Read an activated memory in the calling browser project context; without a recognized preference cookie use the configured bootstrap project or another active accessible workspace. No projectId field is accepted; copying an ID cannot grant access to a different namespace." } }, "/api/dashboard/project/memory/delete": { "post": { - "tags": [ - "记忆" - ], - "summary": "批量删除记忆", + "tags": ["记忆"], + "summary": "批量软删除记忆", + "description": "任务03:先校验全部目标的项目、标签和状态,再逐项执行。普通记忆保留上游图节点与向量,双边状态读回一致才确认;画像按单字段软删除并保留原值。恢复使用上游 previous_status。部分失败不回滚已完成项,通过同一 operationId 查询和重试。", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": [ - "memory_ids" - ], + "additionalProperties": false, + "required": ["projectId", "memory_ids", "memType"], "properties": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, "memory_ids": { "type": "array", + "minItems": 1, + "maxItems": 100, + "uniqueItems": true, "items": { - "type": "string" + "type": "string", + "minLength": 1, + "maxLength": 1024 } }, "memType": { "$ref": "#/components/schemas/MemoryDetailType" + }, + "operationId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_.-]+$", + "description": "客户端可指定幂等 ID;省略时服务端生成,响应中返回。复用到不同目标或动作返回 409。" } } } @@ -598,15 +1297,116 @@ "responses": { "200": { "$ref": "#/components/responses/DeleteResult" + }, + "503": { + "description": "存在未确认项,code=50301,data 保留操作 ID、确认数和逐项结果,可查询/重试。", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/MemoryOperation" + } + } + } + ] + } + } + } + }, + "400": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "502": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "504": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "500": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/knowledgebase/list": { "post": { - "tags": [ - "知识库" - ], + "tags": ["知识库"], "summary": "知识库分页列表", "requestBody": { "required": true, @@ -637,15 +1437,43 @@ "responses": { "200": { "$ref": "#/components/responses/KnowledgeBaseRecords" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/knowledgebase/create": { "post": { - "tags": [ - "知识库" - ], + "tags": ["知识库"], "summary": "新建知识库", "requestBody": { "required": true, @@ -653,9 +1481,7 @@ "application/json": { "schema": { "type": "object", - "required": [ - "knowledgebaseName" - ], + "required": ["knowledgebaseName"], "properties": { "knowledgebaseName": { "type": "string" @@ -677,15 +1503,43 @@ "responses": { "200": { "$ref": "#/components/responses/KnowledgeBase" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/knowledgebase/update": { "post": { - "tags": [ - "知识库" - ], + "tags": ["知识库"], "summary": "更新知识库", "requestBody": { "required": true, @@ -693,9 +1547,7 @@ "application/json": { "schema": { "type": "object", - "required": [ - "id" - ], + "required": ["id"], "properties": { "id": { "type": "string" @@ -720,15 +1572,43 @@ "responses": { "200": { "$ref": "#/components/responses/KnowledgeBase" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/knowledgebase/detail": { "post": { - "tags": [ - "知识库" - ], + "tags": ["知识库"], "summary": "知识库详情", "requestBody": { "required": true, @@ -736,9 +1616,7 @@ "application/json": { "schema": { "type": "object", - "required": [ - "knowledgeBaseId" - ], + "required": ["knowledgeBaseId"], "properties": { "knowledgeBaseId": { "type": "string" @@ -751,15 +1629,43 @@ "responses": { "200": { "$ref": "#/components/responses/KnowledgeBase" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/knowledgebase/file/list": { "post": { - "tags": [ - "知识库" - ], + "tags": ["知识库"], "summary": "知识库文档列表", "requestBody": { "required": true, @@ -767,9 +1673,7 @@ "application/json": { "schema": { "type": "object", - "required": [ - "knowledgeBaseId" - ], + "required": ["knowledgeBaseId"], "properties": { "knowledgeBaseId": { "type": "string" @@ -805,15 +1709,43 @@ "responses": { "200": { "$ref": "#/components/responses/UntypedRecords" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/knowledgebase/memory/list": { "post": { - "tags": [ - "知识库" - ], + "tags": ["知识库"], "summary": "知识库的记忆列表", "description": "比其它列表多一个 `size` 字段。", "requestBody": { @@ -822,9 +1754,7 @@ "application/json": { "schema": { "type": "object", - "required": [ - "knowledgeBaseId" - ], + "required": ["knowledgeBaseId"], "properties": { "knowledgeBaseId": { "type": "string" @@ -860,15 +1790,43 @@ "responses": { "200": { "$ref": "#/components/responses/MemoryRecords" - } - } - } - }, - "/api/dashboard/knowledgebase/project/associate": { - "post": { - "tags": [ - "知识库" - ], + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + } + } + } + }, + "/api/dashboard/knowledgebase/project/associate": { + "post": { + "tags": ["知识库"], "summary": "把知识库关联到项目", "description": "传的是该项目的**全量**知识库 id 列表,不在列表里的会被解除关联。", "requestBody": { @@ -877,10 +1835,7 @@ "application/json": { "schema": { "type": "object", - "required": [ - "projectId", - "knowledgeBaseIds" - ], + "required": ["projectId", "knowledgeBaseIds"], "properties": { "projectId": { "type": "string" @@ -899,15 +1854,43 @@ "responses": { "200": { "$ref": "#/components/responses/Ok" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/knowledgebase/project/remove": { "post": { - "tags": [ - "知识库" - ], + "tags": ["知识库"], "summary": "解除知识库与项目的关联", "requestBody": { "required": true, @@ -915,10 +1898,7 @@ "application/json": { "schema": { "type": "object", - "required": [ - "projectId", - "knowledgeBaseId" - ], + "required": ["projectId", "knowledgeBaseId"], "properties": { "projectId": { "type": "string" @@ -938,15 +1918,43 @@ "responses": { "200": { "$ref": "#/components/responses/Ok" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/profileTemplate/list": { "post": { - "tags": [ - "属性树模板" - ], + "tags": ["属性树模板"], "summary": "属性树模板分页列表", "description": "列表键是 `list`,不是 `records`,且多一个 `pages`。空态是 `{ current: 1, size, total: 0, pages: 0, list: [] }`。", "requestBody": { @@ -955,9 +1963,7 @@ "application/json": { "schema": { "type": "object", - "required": [ - "projectId" - ], + "required": ["projectId"], "properties": { "projectId": { "type": "string" @@ -978,15 +1984,43 @@ "responses": { "200": { "$ref": "#/components/responses/ProfileTemplateList" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/profileTemplate/detail": { "get": { - "tags": [ - "属性树模板" - ], + "tags": ["属性树模板"], "summary": "属性树模板详情", "description": "少数 GET 之一,参数走 query。", "parameters": [ @@ -1010,15 +2044,43 @@ "responses": { "200": { "$ref": "#/components/responses/ProfileTemplate" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/profileTemplate/create": { "post": { - "tags": [ - "属性树模板" - ], + "tags": ["属性树模板"], "summary": "新建属性树模板", "requestBody": { "required": true, @@ -1033,15 +2095,43 @@ "responses": { "200": { "$ref": "#/components/responses/Ok" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/profileTemplate/update": { "post": { - "tags": [ - "属性树模板" - ], + "tags": ["属性树模板"], "summary": "更新属性树模板", "requestBody": { "required": true, @@ -1056,15 +2146,43 @@ "responses": { "200": { "$ref": "#/components/responses/Ok" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/profileTemplate/delete": { "post": { - "tags": [ - "属性树模板" - ], + "tags": ["属性树模板"], "summary": "删除属性树模板", "requestBody": { "required": true, @@ -1072,10 +2190,7 @@ "application/json": { "schema": { "type": "object", - "required": [ - "projectId", - "templateId" - ], + "required": ["projectId", "templateId"], "properties": { "projectId": { "type": "string" @@ -1091,22 +2206,50 @@ "responses": { "200": { "$ref": "#/components/responses/Ok" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/prompt-config/detail": { "post": { - "tags": [ - "自定义抽取" - ], + "tags": ["自定义抽取"], "summary": "读取抽取 Prompt", "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProjectScopedRequest" + "$ref": "#/components/schemas/PromptReadRequest" } } } @@ -1114,37 +2257,91 @@ "responses": { "200": { "$ref": "#/components/responses/PromptConfig" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Project missing, inactive or inaccessible", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "502": { + "description": "Upstream contract or operation failure", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "504": { + "description": "Upstream/model deadline exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } - } + }, + "description": "Read the active project prompt. Viewer or higher." } }, "/api/dashboard/prompt-config/save": { "post": { - "tags": [ - "自定义抽取" - ], + "tags": ["自定义抽取"], "summary": "保存抽取 Prompt", "requestBody": { "required": true, "content": { "application/json": { "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ProjectScopedRequest" - }, - { - "type": "object", - "required": [ - "prompt" - ], - "properties": { - "prompt": { - "type": "string" - } - } - } - ] + "$ref": "#/components/schemas/PromptSaveRequest" } } } @@ -1152,37 +2349,91 @@ "responses": { "200": { "$ref": "#/components/responses/Ok" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Project missing, inactive or inaccessible", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "502": { + "description": "Upstream contract or operation failure", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "504": { + "description": "Upstream/model deadline exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } - } + }, + "description": "Owner only. Preserve ${conversation}; saved rules apply to genuine chat extraction." } }, "/api/dashboard/prompt-config/generate": { "post": { - "tags": [ - "自定义抽取" - ], + "tags": ["自定义抽取"], "summary": "按一句话描述生成 Prompt", "requestBody": { "required": true, "content": { "application/json": { "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ProjectScopedRequest" - }, - { - "type": "object", - "required": [ - "description" - ], - "properties": { - "description": { - "type": "string" - } - } - } - ] + "$ref": "#/components/schemas/PromptGenerateRequest" } } } @@ -1190,22 +2441,91 @@ "responses": { "200": { "$ref": "#/components/responses/Prompt" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Project missing, inactive or inaccessible", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "502": { + "description": "Upstream contract or operation failure", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "504": { + "description": "Upstream/model deadline exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } - } + }, + "description": "Editor or higher. Real model call; generated text is not saved automatically." } }, "/api/dashboard/prompt-config/reset-default": { "post": { - "tags": [ - "自定义抽取" - ], + "tags": ["自定义抽取"], "summary": "恢复默认 Prompt", "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProjectScopedRequest" + "$ref": "#/components/schemas/PromptResetRequest" } } } @@ -1213,64 +2533,92 @@ "responses": { "200": { "$ref": "#/components/responses/Prompt" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Project missing, inactive or inaccessible", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "502": { + "description": "Upstream contract or operation failure", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "504": { + "description": "Upstream/model deadline exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } - } + }, + "description": "Owner only. Remove saved configuration and return the upstream default." } }, "/api/dashboard/prompt-config/debug/extract": { "post": { - "tags": [ - "自定义抽取" - ], + "tags": ["自定义抽取"], "summary": "快速调试:对一段文本跑抽取", - "description": "`messages` 是标准的 chat 消息数组,前端传一条 `user` 消息。", + "description": "Editor or higher. Real model call without memory writes; raw output is explicitly typed raw.", "requestBody": { "required": true, "content": { "application/json": { "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ProjectScopedRequest" - }, - { - "type": "object", - "required": [ - "messages" - ], - "properties": { - "messages": { - "type": "array", - "items": { - "type": "object", - "required": [ - "role", - "content" - ], - "properties": { - "role": { - "type": "string", - "enum": [ - "system", - "user", - "assistant" - ] - }, - "content": { - "type": "string" - } - } - } - }, - "extractionTypes": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MemoryDetailType" - } - } - } - } - ] + "$ref": "#/components/schemas/PromptDebugRequest" } } } @@ -1278,47 +2626,142 @@ "responses": { "200": { "$ref": "#/components/responses/ExtractDebug" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Project missing, inactive or inaccessible", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "502": { + "description": "Upstream contract or operation failure", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "504": { + "description": "Upstream/model deadline exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/usage/query": { "post": { - "tags": [ - "用量" - ], + "tags": ["用量"], "summary": "按天聚合的用量", - "description": "`projectId` 传 `\"all\"` 时会被改写成空串再发出。`startDate` / `endDate` 格式 `YYYY-MM-DD`,默认当月 1 号到今天。\n\n`dailyStats` 的每项里,`extractMemory` 与 `rerank` 可能缺失,前端用可选链读。响应里除 `dailyStats` 外的字段前端没有读取。", + "description": "复用调用日志的逻辑审计索引,仅统计 gateway 来源。projectId 可省略、为空串或为 all,此时范围仅为当前配置允许的项目;具体项目必须在允许范围内。当前为本机操作员配置范围,不代表账号鉴权已交付。startDate/endDate 严格 YYYY-MM-DD,按北京时间固定 UTC+8(Asia/Shanghai)切天;默认当月1日至今天,首尾日期均包含,最长366天,缺失时间的历史日志不归入任何一天。状态不限:成功、失败、pending、unknown 各逻辑调用均计一次,异步请求及终态复用 callId 合并。addMessage/searchMemory/rerank 返回 calls 和真实 totalTokens;chat/extractMemory 仅返回 token 字段,不因对象存在增加调用次数。缺失 token 为0,未知事件和 console 来源不计入本接口。非空 apiKeyId 因审计缺可信密钥标识明确返回400。includeDailyDetails=false 返回空 dailyStats,summary 与 true 相同;summary.totalCalls 仅为 addMessage+searchMemory,匹配当前页面总量;summary.totalTokens 为五个已知事件的 token 总和。 Task06 adds persisted locally-owned API-created projects to the allowed scope; stopped/archived projects are excluded, and explicitly requesting them returns404.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": [ - "projectId" - ], "properties": { "projectId": { - "type": "string", - "description": "传 \"all\" 表示全部" + "type": ["string", "null"], + "maxLength": 128, + "description": "省略、null、空串或all:当前配置允许的全部项目;其他值必须为已允许项目。" }, "apiKeyId": { - "type": "string" + "type": ["string", "null"], + "maxLength": 256, + "description": "目前仅接受省略/null/空白;非空明确400,不静默忽略。" }, "startDate": { - "type": "string", - "format": "date" + "description": "北京时间UTC+8日期;省略/null/纯空白按默认;非空必须严格YYYY-MM-DD,不接受时间戳或日期两侧空白。", + "anyOf": [ + { + "type": "null" + }, + { + "type": "string", + "pattern": "^\\s*$" + }, + { + "type": "string", + "format": "date", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } + ] }, "endDate": { - "type": "string", - "format": "date" + "description": "北京时间UTC+8日期;省略/null/纯空白按默认;非空必须严格YYYY-MM-DD,不接受时间戳或日期两侧空白。", + "anyOf": [ + { + "type": "null" + }, + { + "type": "string", + "pattern": "^\\s*$" + }, + { + "type": "string", + "format": "date", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } + ] }, "includeDailyDetails": { "type": "boolean", "default": true } - } + }, + "additionalProperties": false } } } @@ -1326,15 +2769,73 @@ "responses": { "200": { "$ref": "#/components/responses/UsageQuery" + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Project or log not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Audit source or index unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "500": { + "description": "Unexpected internal error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/skill/skillMD": { "post": { - "tags": [ - "技能" - ], + "tags": ["技能"], "summary": "取技能包 Markdown", "requestBody": { "required": true, @@ -1342,9 +2843,7 @@ "application/json": { "schema": { "type": "object", - "required": [ - "oss_url" - ], + "required": ["oss_url"], "properties": { "oss_url": { "type": "string" @@ -1357,15 +2856,43 @@ "responses": { "200": { "$ref": "#/components/responses/StringData" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/user/quota/summary": { "get": { - "tags": [ - "账号" - ], + "tags": ["账号"], "summary": "额度概览", "parameters": [ { @@ -1386,9 +2913,7 @@ }, "/api/user/subscription/details": { "post": { - "tags": [ - "账号" - ], + "tags": ["账号"], "summary": "订阅周期", "requestBody": { "required": true, @@ -1417,39 +2942,108 @@ }, "/api/meta": { "get": { - "tags": [ - "运行信息" - ], + "tags": ["运行信息"], "summary": "控制台运行信息", - "description": "控制台自有的扩展接口,用于在界面上标注当前数据来源、密钥前缀与可用工具。", + "description": "控制台自有的扩展接口,用于在界面上标注当前数据来源、密钥前缀与可用工具。真实后端每次检查 MemOS 健康状态及 contract_version=2,成功才返回 mode=live;此标记不代表其他业务模块均已实现。上游异常返回非零业务码,错误体不透传上游敏感信息。", "responses": { "200": { "$ref": "#/components/responses/Meta" + }, + "502": { + "description": "上游 HTTP、JSON、服务身份或契约不符合预期,业务 code=50200", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "上游连接失败或健康状态异常,业务 code=50300", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "504": { + "description": "上游检查超时,业务 code=50400", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "default": { + "description": "统一错误。参数错误 40000、不存在 40001、无权限 40003、内部异常 50000;data 为 null", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } - } + }, + "security": [] } }, "/api/auth/me": { "get": { - "tags": [ - "账号" - ], + "tags": ["账号"], "summary": "当前登录主体", - "description": "返回账号、平台管理员标记、当前项目与可访问项目列表。", + "description": "Real authenticated account, platform administrator flag, active accessible projects and current session workspace; no password hashes, session tokens or CSRF secrets.\n\nAuthorization: valid login session; returns only the current account.", "responses": { "200": { "$ref": "#/components/responses/Account" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } + }, + "x-authorization": { + "loginRequired": true, + "scope": "session" } } }, "/api/dashboard/project/delete/{id}": { "post": { - "tags": [ - "项目" - ], + "tags": ["项目"], "summary": "归档项目(软删除)", - "description": "团队项目删除后进入归档态,可在项目列表恢复;上游记忆不删。", + "description": "Reversible archive (legacy revoke is the same compatibility alias). Preserve workspace, namespace and all memories. Listed for recovery; unavailable to normal memory/log/usage calls; active dictionary excludes it.", "parameters": [ { "name": "id", @@ -1463,15 +3057,73 @@ "responses": { "200": { "$ref": "#/components/responses/ProjectArchive" + }, + "400": { + "description": "Invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Origin rejected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Unknown or unavailable project", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Name or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Project storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/project/restore/{id}": { "post": { - "tags": [ - "项目" - ], + "tags": ["项目"], "summary": "恢复已归档项目", "parameters": [ { @@ -1486,16 +3138,76 @@ "responses": { "200": { "$ref": "#/components/responses/Project" + }, + "400": { + "description": "Invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Origin rejected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Unknown or unavailable project", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Name or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Project storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } - } + }, + "description": "Remove archive state without selecting the project. Preserve prior disabled flag and immutable namespace mapping." } }, "/api/dashboard/keys/rename/{id}": { "post": { - "tags": [ - "接口密钥" - ], + "tags": ["接口密钥"], "summary": "重命名密钥", + "description": "仅改名;网关配置文件不含名称,字节不变。", "parameters": [ { "name": "id", @@ -1512,9 +3224,7 @@ "application/json": { "schema": { "type": "object", - "required": [ - "keyName" - ], + "required": ["keyName"], "properties": { "keyName": { "type": "string" @@ -1527,15 +3237,43 @@ "responses": { "200": { "$ref": "#/components/responses/ApiKey" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/knowledgebase/delete": { "post": { - "tags": [ - "知识库" - ], + "tags": ["知识库"], "summary": "删除知识库", "requestBody": { "required": true, @@ -1543,10 +3281,7 @@ "application/json": { "schema": { "type": "object", - "required": [ - "projectId", - "knowledgeBaseId" - ], + "required": ["projectId", "knowledgeBaseId"], "properties": { "projectId": { "type": "string" @@ -1562,15 +3297,43 @@ "responses": { "200": { "$ref": "#/components/responses/KnowledgeBaseDelete" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/knowledgebase/file/add": { "post": { - "tags": [ - "知识库" - ], + "tags": ["知识库"], "summary": "添加知识库文件", "description": "文件内容以 data URL 或纯文本放在 `file.content` 里。", "requestBody": { @@ -1579,11 +3342,7 @@ "application/json": { "schema": { "type": "object", - "required": [ - "projectId", - "knowledgeBaseId", - "file" - ], + "required": ["projectId", "knowledgeBaseId", "file"], "properties": { "projectId": { "type": "string" @@ -1593,10 +3352,7 @@ }, "file": { "type": "object", - "required": [ - "name", - "content" - ], + "required": ["name", "content"], "properties": { "name": { "type": "string" @@ -1617,15 +3373,43 @@ "responses": { "200": { "$ref": "#/components/responses/KnowledgeFileRecords" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/knowledgebase/file/delete": { "post": { - "tags": [ - "知识库" - ], + "tags": ["知识库"], "summary": "删除知识库文件", "requestBody": { "required": true, @@ -1633,10 +3417,7 @@ "application/json": { "schema": { "type": "object", - "required": [ - "projectId", - "fileIds" - ], + "required": ["projectId", "fileIds"], "properties": { "projectId": { "type": "string" @@ -1655,15 +3436,43 @@ "responses": { "200": { "$ref": "#/components/responses/KnowledgeFileDelete" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/knowledgebase/file/download": { "get": { - "tags": [ - "知识库" - ], + "tags": ["知识库"], "summary": "下载知识库文件", "description": "直接返回文件字节,不套统一返回格式。", "parameters": [ @@ -1687,42 +3496,76 @@ "responses": { "200": { "$ref": "#/components/responses/FileDownload" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/graph/memories": { "get": { - "tags": [ - "图谱" - ], + "tags": ["图谱"], "summary": "记忆星座图", - "description": "只读视图,参数放 query,便于直接打开或重放。", + "description": "Read-only, authorized active project. Combines ordinary memory graph nodes with bound ProfileMemory fields using their original field IDs. Both sources exclude deleted/archived entries. Profile fields have no invented session or relationship edges. Ordinary nodes consume the shared limit first; stats.truncated reports omitted nodes or byte limits. Source failures return an error rather than a partial success.", "parameters": [ { "name": "tenant", "in": "query", "required": false, "schema": { - "type": "string" + "type": "string", + "minLength": 1, + "maxLength": 128 }, - "description": "租户;留空用当前项目" + "description": "Dashboard project ID; defaults to the authenticated session selection. Never accepts an arbitrary upstream cube ID." }, { "name": "types", "in": "query", "required": false, "schema": { - "type": "string" + "type": "string", + "maxLength": 1024 }, - "description": "逗号分隔的记忆类型" + "description": "Comma-separated native memory type names. Only activated nodes are returned." }, { "name": "limit", "in": "query", "required": false, "schema": { - "type": "integer" + "type": "integer", + "minimum": 1, + "maximum": 2000, + "default": 2000 }, "description": "节点上限" } @@ -1730,24 +3573,94 @@ "responses": { "200": { "$ref": "#/components/responses/GraphMemoryGraph" - } - } - } + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid scope/input, upstream contract failure or deadline exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Invalid scope/input, upstream contract failure or deadline exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "502": { + "description": "Invalid scope/input, upstream contract failure or deadline exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "504": { + "description": "Invalid scope/input, upstream contract failure or deadline exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + } + } + } }, "/api/dashboard/graph/memory": { "get": { - "tags": [ - "图谱" - ], + "tags": ["图谱"], "summary": "单条记忆全文", - "description": "与图谱节点同源,供抽屉里的「展开全文」使用。", + "description": "Read-only, authorized active project. Resolves ordinary IDs from the memory graph store and profile field IDs from the profile store. Full text excludes deleted/archived entries and is bounded to 1 MiB of JSON.", "parameters": [ { "name": "id", "in": "query", "required": true, "schema": { - "type": "string" + "type": "string", + "minLength": 1, + "maxLength": 1024 } }, { @@ -1755,36 +3668,175 @@ "in": "query", "required": false, "schema": { - "type": "string" - } + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "description": "Dashboard project ID; defaults to the authenticated session selection. Never accepts an arbitrary upstream cube ID." } ], "responses": { "200": { "$ref": "#/components/responses/GraphMemory" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid scope/input, upstream contract failure or deadline exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Invalid scope/input, upstream contract failure or deadline exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "502": { + "description": "Invalid scope/input, upstream contract failure or deadline exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "504": { + "description": "Invalid scope/input, upstream contract failure or deadline exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/models": { "get": { - "tags": [ - "模型" - ], + "tags": ["模型"], "summary": "模型清单", - "description": "返回上游实际生效的模型配置,不是前端静态副本。", + "description": "Authenticated read-only inventory from effective upstream configuration. Exposes only known model/source/role/group and sanitized host/dimension/backend; never API keys, credentials, query strings or configuration bodies.", "responses": { "200": { "$ref": "#/components/responses/ModelRecords" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid scope/input, upstream contract failure or deadline exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Invalid scope/input, upstream contract failure or deadline exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "502": { + "description": "Invalid scope/input, upstream contract failure or deadline exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "504": { + "description": "Invalid scope/input, upstream contract failure or deadline exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/admin/accounts": { "get": { - "tags": [ - "账号" - ], + "tags": ["账号"], "summary": "账号分页列表", "parameters": [ { @@ -1792,7 +3844,8 @@ "in": "query", "required": false, "schema": { - "type": "string" + "type": "string", + "maxLength": 128 }, "description": "按用户名或显示名搜索" }, @@ -1802,7 +3855,9 @@ "required": false, "schema": { "type": "integer", - "default": 1 + "default": 1, + "minimum": 1, + "maximum": 2147483647 } }, { @@ -1811,22 +3866,88 @@ "required": false, "schema": { "type": "integer", - "default": 20 + "default": 20, + "minimum": 1, + "maximum": 200 } } ], "responses": { "200": { "$ref": "#/components/responses/AdminAccountRecords" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid fields or pagination", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Resource outside visible scope or nonexistent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Last effective admin/owner or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } - } + }, + "x-authorization": { + "loginRequired": true, + "scope": "platform", + "platformAdminRequired": true + }, + "description": "Authorization: platform administrator required. Project ownership alone grants no platform account-management permission. Last effective administrator and project-owner invariants remain enforced." } }, "/admin/accounts/{id}": { "post": { - "tags": [ - "账号" - ], + "tags": ["账号"], "summary": "更新账号", "parameters": [ { @@ -1846,7 +3967,9 @@ "type": "object", "properties": { "display_name": { - "type": "string" + "type": "string", + "minLength": 1, + "maxLength": 128 }, "disabled": { "type": "boolean" @@ -1854,7 +3977,9 @@ "platform_admin": { "type": "boolean" } - } + }, + "additionalProperties": false, + "minProperties": 1 } } } @@ -1862,15 +3987,79 @@ "responses": { "200": { "$ref": "#/components/responses/AdminAccount" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid fields or pagination", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Resource outside visible scope or nonexistent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Last effective admin/owner or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } - } + }, + "x-authorization": { + "loginRequired": true, + "scope": "platform", + "platformAdminRequired": true + }, + "description": "Authorization: platform administrator required. Project ownership alone grants no platform account-management permission. Last effective administrator and project-owner invariants remain enforced." }, "delete": { - "tags": [ - "账号" - ], + "tags": ["账号"], "summary": "删除账号", - "description": "个人项目与成员关系一起删;团队 Key / 邀请保留但清空创建者。", + "description": "个人项目与成员关系一起删;团队 Key / 邀请保留但清空创建者。\n\nAuthorization: platform administrator required. Project ownership alone grants no platform account-management permission. Last effective administrator and project-owner invariants remain enforced.", "parameters": [ { "name": "id", @@ -1884,15 +4073,78 @@ "responses": { "200": { "$ref": "#/components/responses/Ok" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid fields or pagination", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Resource outside visible scope or nonexistent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Last effective admin/owner or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } + }, + "x-authorization": { + "loginRequired": true, + "scope": "platform", + "platformAdminRequired": true } } }, "/admin/tenants/{tenantId}/members": { "get": { - "tags": [ - "账号" - ], + "tags": ["账号"], "summary": "项目成员列表", "parameters": [ { @@ -1907,13 +4159,80 @@ "responses": { "200": { "$ref": "#/components/responses/ProjectMemberRecords" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid fields or pagination", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Resource outside visible scope or nonexistent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Last effective admin/owner or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } - } + }, + "x-authorization": { + "loginRequired": true, + "scope": "project", + "minimumRole": "viewer", + "platformAdminOverride": true, + "selfOnly": false, + "teamOnly": true + }, + "description": "Authorization: target team project viewer or platform administrator. No access to unrelated projects. Insufficient role on a visible project returns 403; an inaccessible project or foreign resource returns 404. Business-state and last-owner constraints remain enforced." }, "post": { - "tags": [ - "账号" - ], + "tags": ["账号"], "summary": "添加成员或修改角色", "parameters": [ { @@ -1931,20 +4250,32 @@ "application/json": { "schema": { "type": "object", - "required": [ - "role" - ], + "required": ["role"], "properties": { "account_id": { - "type": "string" + "type": "string", + "minLength": 1, + "maxLength": 128 }, "username": { - "type": "string" + "type": "string", + "minLength": 1, + "maxLength": 64 }, "role": { - "type": "string" + "type": "string", + "enum": ["viewer", "editor", "owner"] } - } + }, + "additionalProperties": false, + "oneOf": [ + { + "required": ["account_id"] + }, + { + "required": ["username"] + } + ] } } } @@ -1952,15 +4283,82 @@ "responses": { "200": { "$ref": "#/components/responses/MemberSet" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid fields or pagination", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Resource outside visible scope or nonexistent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Last effective admin/owner or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } - } + }, + "x-authorization": { + "loginRequired": true, + "scope": "project", + "minimumRole": "owner", + "platformAdminOverride": true, + "selfOnly": false, + "teamOnly": true + }, + "description": "Authorization: target team project owner or platform administrator. No access to unrelated projects. Insufficient role on a visible project returns 403; an inaccessible project or foreign resource returns 404. Business-state and last-owner constraints remain enforced." } }, "/admin/tenants/{tenantId}/members/{accountId}/remove": { "post": { - "tags": [ - "账号" - ], + "tags": ["账号"], "summary": "移除成员", "parameters": [ { @@ -1994,17 +4392,84 @@ "responses": { "200": { "$ref": "#/components/responses/Ok" - } - } - } + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid fields or pagination", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Resource outside visible scope or nonexistent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Last effective admin/owner or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + } + }, + "x-authorization": { + "loginRequired": true, + "scope": "project", + "minimumRole": "owner", + "platformAdminOverride": true, + "selfOnly": false, + "teamOnly": true + }, + "description": "Authorization: target team project owner or platform administrator. No access to unrelated projects. Insufficient role on a visible project returns 403; an inaccessible project or foreign resource returns 404. Business-state and last-owner constraints remain enforced." + } }, "/admin/tenants/{tenantId}/members/leave": { "post": { - "tags": [ - "账号" - ], + "tags": ["账号"], "summary": "自己退出项目", - "description": "不需要管理权限;最后一个 owner 会被后端拒绝。", + "description": "不需要管理权限;最后一个 owner 会被后端拒绝。\n\nAuthorization: target team project viewer or platform administrator. Self-exit only; the final effective owner cannot leave.", "parameters": [ { "name": "tenantId", @@ -2029,17 +4494,83 @@ "responses": { "200": { "$ref": "#/components/responses/Ok" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid fields or pagination", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Resource outside visible scope or nonexistent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Last effective admin/owner or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } + }, + "x-authorization": { + "loginRequired": true, + "scope": "project", + "minimumRole": "viewer", + "platformAdminOverride": true, + "selfOnly": true, + "teamOnly": true } } }, "/admin/tenants/{tenantId}/member-candidates": { "get": { - "tags": [ - "账号" - ], + "tags": ["账号"], "summary": "可加入成员候选", - "description": "`@` 搜索可加入的人;只有项目 owner 能调。", + "description": "Owner or platform admin may search usernames/display names. Returns at most100 candidates with total matching count and existing-member/disabled flags.\n\nAuthorization: target team project owner or platform administrator. No access to unrelated projects. Insufficient role on a visible project returns 403; an inaccessible project or foreign resource returns 404. Business-state and last-owner constraints remain enforced.", "parameters": [ { "name": "tenantId", @@ -2054,22 +4585,89 @@ "in": "query", "required": false, "schema": { - "type": "string" + "type": "string", + "maxLength": 128 } } ], "responses": { "200": { "$ref": "#/components/responses/MemberCandidateRecords" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid fields or pagination", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Resource outside visible scope or nonexistent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Last effective admin/owner or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } + }, + "x-authorization": { + "loginRequired": true, + "scope": "project", + "minimumRole": "owner", + "platformAdminOverride": true, + "selfOnly": false, + "teamOnly": true } } }, "/admin/tenants/{tenantId}/invites": { "get": { - "tags": [ - "账号" - ], + "tags": ["账号"], "summary": "邀请列表", "parameters": [ { @@ -2084,13 +4682,80 @@ "responses": { "200": { "$ref": "#/components/responses/ProjectInviteRecords" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid fields or pagination", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Resource outside visible scope or nonexistent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Last effective admin/owner or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } - } + }, + "x-authorization": { + "loginRequired": true, + "scope": "project", + "minimumRole": "owner", + "platformAdminOverride": true, + "selfOnly": false, + "teamOnly": true + }, + "description": "Authorization: target team project owner or platform administrator. No access to unrelated projects. Insufficient role on a visible project returns 403; an inaccessible project or foreign resource returns 404. Business-state and last-owner constraints remain enforced." }, "post": { - "tags": [ - "账号" - ], + "tags": ["账号"], "summary": "创建邀请", "parameters": [ { @@ -2108,18 +4773,20 @@ "application/json": { "schema": { "type": "object", - "required": [ - "role" - ], + "required": ["role"], "properties": { "role": { - "type": "string" + "type": "string", + "enum": ["viewer", "editor", "owner"] }, "expiresIn": { "type": "string", - "description": "例如 `168h`;留空用后端默认值" + "pattern": "^[1-9][0-9]{0,6}[smh]$", + "default": "168h", + "description": "Positive seconds/minutes/hours, maximum30days; e.g.168h. Values above30days rejected400." } - } + }, + "additionalProperties": false } } } @@ -2127,15 +4794,82 @@ "responses": { "200": { "$ref": "#/components/responses/CreatedInvite" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid fields or pagination", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Resource outside visible scope or nonexistent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Last effective admin/owner or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } - } + }, + "x-authorization": { + "loginRequired": true, + "scope": "project", + "minimumRole": "owner", + "platformAdminOverride": true, + "selfOnly": false, + "teamOnly": true + }, + "description": "Authorization: target team project owner or platform administrator. No access to unrelated projects. Insufficient role on a visible project returns 403; an inaccessible project or foreign resource returns 404. Business-state and last-owner constraints remain enforced." } }, "/admin/tenants/{tenantId}/invites/{inviteId}/revoke": { "post": { - "tags": [ - "账号" - ], + "tags": ["账号"], "summary": "撤销邀请", "parameters": [ { @@ -2169,15 +4903,82 @@ "responses": { "200": { "$ref": "#/components/responses/Ok" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid fields or pagination", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Resource outside visible scope or nonexistent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Last effective admin/owner or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } - } + }, + "x-authorization": { + "loginRequired": true, + "scope": "project", + "minimumRole": "owner", + "platformAdminOverride": true, + "selfOnly": false, + "teamOnly": true + }, + "description": "Authorization: target team project owner or platform administrator. No access to unrelated projects. Insufficient role on a visible project returns 403; an inaccessible project or foreign resource returns 404. Business-state and last-owner constraints remain enforced." } }, "/admin/tenants/{tenantId}/invites/{inviteId}/role": { "post": { - "tags": [ - "账号" - ], + "tags": ["账号"], "summary": "修改邀请角色", "parameters": [ { @@ -2200,15 +5001,1238 @@ "requestBody": { "required": true, "content": { - "application/json": { + "application/json": { + "schema": { + "type": "object", + "required": ["role"], + "properties": { + "role": { + "type": "string", + "enum": ["viewer", "editor", "owner"] + } + }, + "additionalProperties": false + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/InviteRole" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid fields or pagination", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Resource outside visible scope or nonexistent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Last effective admin/owner or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + } + }, + "x-authorization": { + "loginRequired": true, + "scope": "project", + "minimumRole": "owner", + "platformAdminOverride": true, + "selfOnly": false, + "teamOnly": true + }, + "description": "Authorization: target team project owner or platform administrator. No access to unrelated projects. Insufficient role on a visible project returns 403; an inaccessible project or foreign resource returns 404. Business-state and last-owner constraints remain enforced." + } + }, + "/api/dashboard/project/memory/restore": { + "post": { + "tags": ["记忆"], + "summary": "恢复软删除记忆", + "description": "任务03:先校验全部目标的项目、标签和状态,再逐项执行。普通记忆保留上游图节点与向量,双边状态读回一致才确认;画像按单字段软删除并保留原值。恢复使用上游 previous_status。部分失败不回滚已完成项,通过同一 operationId 查询和重试。", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["projectId", "memory_ids", "memType"], + "properties": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "memory_ids": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + } + }, + "memType": { + "$ref": "#/components/schemas/MemoryDetailType" + }, + "operationId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_.-]+$", + "description": "客户端可指定幂等 ID;省略时服务端生成,响应中返回。复用到不同目标或动作返回 409。" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "操作回执。仅全部 confirmed 时写操作 code=0;查询接口 code=0 仅表示查询成功,仍须检查 state。", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/MemoryOperation" + } + } + } + ] + } + } + } + }, + "503": { + "description": "存在未确认项,code=50301,data 保留操作 ID、确认数和逐项结果,可查询/重试。", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/MemoryOperation" + } + } + } + ] + } + } + } + }, + "400": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "502": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "504": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "500": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + } + } + } + }, + "/api/dashboard/project/memory/retry": { + "post": { + "tags": ["记忆"], + "summary": "重试未确认的记忆生命周期操作", + "description": "仅重试持久回执中尚未确认的项;沿用原子操作 ID,不重复执行已确认项。", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["projectId", "operationId"], + "properties": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "operationId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_.-]+$", + "description": "客户端可指定幂等 ID;省略时服务端生成,响应中返回。复用到不同目标或动作返回 409。" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "操作回执。仅全部 confirmed 时写操作 code=0;查询接口 code=0 仅表示查询成功,仍须检查 state。", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/MemoryOperation" + } + } + } + ] + } + } + } + }, + "503": { + "description": "存在未确认项,code=50301,data 保留操作 ID、确认数和逐项结果,可查询/重试。", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/MemoryOperation" + } + } + } + ] + } + } + } + }, + "400": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "502": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "504": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "500": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + } + } + } + }, + "/api/dashboard/project/memory/operation": { + "get": { + "tags": ["记忆"], + "summary": "查询记忆生命周期操作回执", + "description": "查询自身成功时 code=0;data.state=pending 表示仍未完成,不能解读为删除成功。按项目限定回执,必要时读取上游意图进度,不触发新的状态变更。", + "parameters": [ + { + "in": "query", + "name": "projectId", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + { + "in": "query", + "name": "operationId", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_.-]+$", + "description": "客户端可指定幂等 ID;省略时服务端生成,响应中返回。复用到不同目标或动作返回 409。" + } + } + ], + "responses": { + "200": { + "description": "操作回执。仅全部 confirmed 时写操作 code=0;查询接口 code=0 仅表示查询成功,仍须检查 state。", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/MemoryOperation" + } + } + } + ] + } + } + } + }, + "400": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "502": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "504": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "500": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + } + } + } + }, + "/__login": { + "get": { + "tags": ["账号"], + "summary": "Login form", + "security": [], + "responses": { + "200": { + "description": "Escaped HTML form with CSRF token; no-store, same-origin referrer policy and restrictive CSP", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "Malformed or oversized form", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Invalid/disabled credentials or expired account session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Missing/invalid CSRF or foreign Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Unknown invitation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Invite consumed/revoked, account exists, existing member, or project inactive", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "410": { + "description": "Invite expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "429": { + "description": "Login attempt limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + } + } + }, + "post": { + "tags": ["账号"], + "summary": "Login form submit", + "security": [], + "responses": { + "303": { + "description": "Same-origin continuation after committed action; session cookie is issued for login/new invite account" + }, + "400": { + "description": "Malformed or oversized form", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Invalid/disabled credentials or expired account session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Missing/invalid CSRF or foreign Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Unknown invitation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Invite consumed/revoked, account exists, existing member, or project inactive", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "410": { + "description": "Invite expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "429": { + "description": "Login attempt limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "required": ["csrf", "username", "password"], + "properties": { + "csrf": { + "type": "string", + "minLength": 43, + "maxLength": 43 + }, + "username": { + "type": "string", + "maxLength": 64 + }, + "password": { + "type": "string", + "maxLength": 256 + }, + "next": { + "type": "string", + "description": "Only same-origin /__login/invite/{token} continuation accepted" + } + } + } + } + } + } + } + }, + "/__logout": { + "get": { + "tags": ["账号"], + "summary": "Logout confirmation", + "security": [ + { + "CookieSession": [] + } + ], + "responses": { + "200": { + "description": "Escaped HTML form with CSRF token; no-store, same-origin referrer policy and restrictive CSP", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "Malformed or oversized form", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Invalid/disabled credentials or expired account session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Missing/invalid CSRF or foreign Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Unknown invitation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Invite consumed/revoked, account exists, existing member, or project inactive", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "410": { + "description": "Invite expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "429": { + "description": "Login attempt limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + } + } + }, + "post": { + "tags": ["账号"], + "summary": "Logout confirmation submit", + "security": [ + { + "CookieSession": [] + } + ], + "responses": { + "303": { + "description": "Same-origin continuation after committed action; session cookie is issued for login/new invite account" + }, + "400": { + "description": "Malformed or oversized form", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Invalid/disabled credentials or expired account session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Missing/invalid CSRF or foreign Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Unknown invitation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Invite consumed/revoked, account exists, existing member, or project inactive", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "410": { + "description": "Invite expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "429": { + "description": "Login attempt limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "required": ["csrf"], + "properties": { + "csrf": { + "type": "string", + "minLength": 43, + "maxLength": 43 + } + } + } + } + } + } + } + }, + "/__login/invite/{token}": { + "get": { + "tags": ["账号"], + "summary": "Invitation landing and acceptance/signup", + "security": [], + "responses": { + "200": { + "description": "Escaped HTML form with CSRF token; no-store, same-origin referrer policy and restrictive CSP", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "Malformed or oversized form", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Invalid/disabled credentials or expired account session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Missing/invalid CSRF or foreign Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Unknown invitation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Invite consumed/revoked, account exists, existing member, or project inactive", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "410": { + "description": "Invite expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "429": { + "description": "Login attempt limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + } + }, + "parameters": [ + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{43}$" + } + } + ] + }, + "post": { + "tags": ["账号"], + "summary": "Invitation landing and acceptance/signup submit", + "security": [], + "responses": { + "303": { + "description": "Same-origin continuation after committed action; session cookie is issued for login/new invite account" + }, + "400": { + "description": "Malformed or oversized form", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Invalid/disabled credentials or expired account session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Missing/invalid CSRF or foreign Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Unknown invitation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Invite consumed/revoked, account exists, existing member, or project inactive", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "410": { + "description": "Invite expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "429": { + "description": "Login attempt limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + } + }, + "parameters": [ + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{43}$" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "required": ["csrf"], + "properties": { + "csrf": { + "type": "string", + "minLength": 43, + "maxLength": 43 + }, + "username": { + "type": "string", + "description": "New account username; omit for an authenticated accept" + }, + "password": { + "type": "string", + "minLength": 12, + "maxLength": 256 + } + } + } + } + } + } + } + }, + "/__login/register": { + "get": { + "tags": ["认证"], + "summary": "注册页面:账号、密码、确认密码", + "responses": { + "200": { + "description": "CSRF-protected registration HTML", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + } + } + } + }, + "post": { + "tags": ["认证"], + "summary": "注册普通账号及个人空间,成功后返回登录页", + "requestBody": { + "required": true, + "content": { + "application/x-www-form-urlencoded": { "schema": { "type": "object", - "required": [ - "role" - ], + "additionalProperties": false, + "required": ["csrf", "username", "password", "confirm_password"], "properties": { - "role": { - "type": "string" + "csrf": { + "type": "string", + "minLength": 43, + "maxLength": 43 + }, + "username": { + "type": "string", + "pattern": "^[A-Za-z0-9_.-]{1,64}$" + }, + "password": { + "type": "string", + "minLength": 12, + "maxLength": 256 + }, + "confirm_password": { + "type": "string", + "minLength": 12, + "maxLength": 256 } } } @@ -2216,8 +6240,53 @@ } }, "responses": { - "200": { - "$ref": "#/components/responses/InviteRole" + "303": { + "description": "Account committed; redirect to /__login?registered=1" + }, + "400": { + "description": "Invalid form or mismatched passwords; never echoes password", + "content": { + "text/html": { + "schema": { + "type": "string" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "CSRF or Origin rejected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Username already exists", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + } + }, + "503": { + "description": "Identity storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } @@ -2227,10 +6296,7 @@ "schemas": { "Envelope": { "type": "object", - "required": [ - "code", - "message" - ], + "required": ["code", "message"], "properties": { "code": { "type": "integer", @@ -2242,12 +6308,33 @@ "data": {} } }, + "BackendErrorEnvelope": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "required": ["code", "data", "message"], + "properties": { + "code": { + "type": "integer", + "minimum": 1 + }, + "data": { + "type": "null" + }, + "message": { + "type": "string", + "minLength": 1 + } + } + } + ] + }, "Project": { "type": "object", - "required": [ - "id", - "name" - ], + "required": ["id", "name"], "properties": { "id": { "type": "string" @@ -2260,10 +6347,7 @@ }, "status": { "type": "string", - "enum": [ - "ENABLED", - "DISABLED" - ], + "enum": ["ENABLED", "DISABLED"], "description": "`ENABLED` 的那条是当前项目" }, "createTime": { @@ -2271,14 +6355,11 @@ }, "isMultiView": { "type": "integer", - "enum": [ - 0, - 1 - ] + "enum": [0, 1] }, "role": { "type": "string", - "description": "当前账号在这个项目里的角色" + "enum": ["viewer", "editor", "owner"] }, "disabled": { "type": "boolean", @@ -2286,10 +6367,7 @@ }, "kind": { "type": "string", - "enum": [ - "team", - "personal" - ] + "enum": ["team", "personal"] }, "archived": { "type": "boolean", @@ -2302,10 +6380,7 @@ }, "ProjectDictItem": { "type": "object", - "required": [ - "id", - "name" - ], + "required": ["id", "name"], "properties": { "id": { "type": "string" @@ -2315,21 +6390,13 @@ }, "status": { "type": "string", - "enum": [ - "ENABLED", - "DISABLED" - ] + "enum": ["ENABLED", "DISABLED"] } } }, "ApiKey": { "type": "object", - "required": [ - "id", - "keyName", - "apiKey", - "createTime" - ], + "required": ["id", "keyName", "apiKey", "createTime"], "properties": { "id": { "type": "string" @@ -2339,7 +6406,7 @@ }, "apiKey": { "type": "string", - "description": "完整密钥,列表里也下发,前端自己截断展示" + "description": "完整密钥,列表里也下发,前端自己截断展示。明文保存于本地 control.db 与网关配置文件(gitignore 范围内,无 KMS 不加密——诚实声明)。" }, "createTime": { "type": "string" @@ -2368,16 +6435,11 @@ }, "LogStatus": { "type": "string", - "description": "`SUCCESS` 与 `success` 都判为成功,其余判为失败" + "description": "SUCCESS (or historical success), FAILURE, PENDING, UNKNOWN. Unknown/missing evidence is not treated as successful; pending duration is null." }, "RequestLog": { "type": "object", - "required": [ - "id", - "createTime", - "eventType", - "status" - ], + "required": ["id", "createTime", "eventType", "status"], "properties": { "id": { "type": "string" @@ -2386,7 +6448,8 @@ "type": "string" }, "eventType": { - "$ref": "#/components/schemas/EventType" + "type": "string", + "description": "Known normalized event or original unknown event label; an absent event is unknown." }, "userIds": { "type": "array", @@ -2404,10 +6467,7 @@ "$ref": "#/components/schemas/LogStatus" }, "duration": { - "type": [ - "number", - "null" - ], + "type": ["number", "null"], "description": "毫秒数;异步 add 还没跑完时是 null" }, "callId": { @@ -2419,17 +6479,16 @@ }, "responseTokens": { "type": "integer" + }, + "source": { + "type": "string", + "enum": ["gateway", "console"] } } }, "LogDetail": { "type": "object", - "required": [ - "id", - "createTime", - "eventType", - "status" - ], + "required": ["id", "createTime", "eventType", "status"], "properties": { "id": { "type": "string" @@ -2438,16 +6497,14 @@ "type": "string" }, "eventType": { - "$ref": "#/components/schemas/EventType" + "type": "string", + "description": "Known normalized event or original unknown event label; an absent event is unknown." }, "status": { "$ref": "#/components/schemas/LogStatus" }, "duration": { - "type": [ - "number", - "null" - ] + "type": ["number", "null"] }, "traceId": { "type": "string" @@ -2484,42 +6541,35 @@ "llmCalls": { "type": "integer", "description": "上游 add 回报的 LLM 调用次数" + }, + "source": { + "type": "string", + "enum": ["gateway", "console"] + }, + "requestId": { + "type": "string" } } }, "MemorySubjectType": { "type": "string", - "enum": [ - "user", - "agent", - "public" - ] + "enum": ["user", "agent", "public"] }, "MemoryDetailType": { "type": "string", "description": "记忆类型。`text_mem` 事实记忆、`pref_mem` 偏好记忆、`event_mem` 事件记忆、`profile_mem` 属性树记忆、`skill_mem` 技能、`tool_mem` 工具记忆。", - "enum": [ - "text_mem", - "pref_mem", - "event_mem", - "profile_mem", - "skill_mem", - "tool_mem" - ] + "enum": ["text_mem", "pref_mem", "event_mem", "profile_mem", "skill_mem", "tool_mem"] }, "Memory": { "type": "object", - "required": [ - "memoryId", - "memoryContent", - "createTime" - ], + "required": ["memoryId", "memoryContent", "createTime"], "properties": { "memoryId": { "type": "string" }, "memoryContent": { - "type": "string" + "type": "string", + "description": "列表最多500字符加省略号,详情返回全文;不包含 embedding、sources、history。" }, "subjectType": { "$ref": "#/components/schemas/MemorySubjectType" @@ -2554,10 +6604,7 @@ }, "KnowledgeBase": { "type": "object", - "required": [ - "kbId", - "knowledgebaseName" - ], + "required": ["kbId", "knowledgebaseName"], "properties": { "kbId": { "type": "string" @@ -2590,15 +6637,7 @@ }, "DocumentStatus": { "type": "string", - "enum": [ - "waiting", - "uploading", - "processing", - "available", - "deleting", - "failed", - "processFailed" - ] + "enum": ["waiting", "uploading", "processing", "available", "deleting", "failed", "processFailed"] }, "ProfileTemplateField": { "type": "object", @@ -2661,7 +6700,8 @@ "type": "object", "properties": { "type": { - "$ref": "#/components/schemas/MemoryDetailType" + "type": "string", + "enum": ["text_mem", "pref_mem", "event_mem", "raw"] }, "content": { "type": "string" @@ -2670,9 +6710,7 @@ }, "UsageDailyStat": { "type": "object", - "required": [ - "date" - ], + "required": ["date"], "properties": { "date": { "type": "string", @@ -2699,10 +6737,12 @@ "type": "object", "properties": { "calls": { - "type": "integer" + "type": "integer", + "minimum": 0 }, "totalTokens": { - "type": "integer" + "type": "integer", + "minimum": 0 } } }, @@ -2710,13 +6750,16 @@ "type": "object", "properties": { "requestTokens": { - "type": "integer" + "type": "integer", + "minimum": 0 }, "responseTokens": { - "type": "integer" + "type": "integer", + "minimum": 0 }, "totalTokens": { - "type": "integer" + "type": "integer", + "minimum": 0 } } }, @@ -2781,33 +6824,27 @@ }, "Meta": { "type": "object", - "required": [ - "mode", - "keyPrefix", - "tools" - ], + "required": ["mode", "keyPrefix", "tools"], "properties": { "mode": { "type": "string", - "enum": [ - "sample", - "live" - ], + "enum": ["sample", "live"], "description": "`sample` 示例后端,`live` 真实后端" }, "service": { "type": "string" }, "health": { - "type": [ - "object", - "null" - ] + "type": ["object", "null"] }, "keyPrefix": { "type": "string", "description": "新建密钥的展示与校验用它" }, + "authEnabled": { + "type": "boolean", + "description": "当前控制台后端是否已启用鉴权;基础运行信息阶段为 false" + }, "tools": { "type": "array", "items": { @@ -2816,21 +6853,9 @@ } } }, - "ProjectScopedRequest": { - "type": "object", - "properties": { - "projectId": { - "type": "string" - } - } - }, "AccountProject": { "type": "object", - "required": [ - "id", - "name", - "role" - ], + "required": ["id", "name", "role"], "properties": { "id": { "type": "string" @@ -2839,26 +6864,18 @@ "type": "string" }, "role": { - "type": "string" + "type": "string", + "enum": ["viewer", "editor", "owner"] }, "kind": { "type": "string", - "enum": [ - "team", - "personal" - ] + "enum": ["team", "personal"] } } }, "Account": { "type": "object", - "required": [ - "username", - "displayName", - "platformAdmin", - "currentProjectId", - "projects" - ], + "required": ["username", "displayName", "platformAdmin", "currentProjectId", "projects"], "properties": { "accountId": { "type": "string", @@ -2887,14 +6904,7 @@ "AdminAccount": { "type": "object", "description": "`/admin/*` 的账号对象,字段沿用 snake_case。", - "required": [ - "id", - "username", - "display_name", - "platform_admin", - "disabled", - "created_at" - ], + "required": ["id", "username", "display_name", "platform_admin", "disabled", "created_at"], "properties": { "id": { "type": "string" @@ -2924,14 +6934,7 @@ }, "ProjectMember": { "type": "object", - "required": [ - "account_id", - "username", - "display_name", - "disabled", - "role", - "created_at" - ], + "required": ["account_id", "username", "display_name", "disabled", "role", "created_at"], "properties": { "account_id": { "type": "string" @@ -2946,7 +6949,8 @@ "type": "boolean" }, "role": { - "type": "string" + "type": "string", + "enum": ["viewer", "editor", "owner"] }, "created_at": { "type": "string" @@ -2955,13 +6959,7 @@ }, "MemberCandidate": { "type": "object", - "required": [ - "accountId", - "username", - "displayName", - "disabled", - "member" - ], + "required": ["accountId", "username", "displayName", "disabled", "member"], "properties": { "accountId": { "type": "string" @@ -2983,14 +6981,7 @@ }, "ProjectInvite": { "type": "object", - "required": [ - "id", - "tenantId", - "role", - "status", - "createdAt", - "expiresAt" - ], + "required": ["id", "tenantId", "role", "status", "createdAt", "expiresAt"], "properties": { "id": { "type": "string" @@ -2999,16 +6990,12 @@ "type": "string" }, "role": { - "type": "string" + "type": "string", + "enum": ["viewer", "editor", "owner"] }, "status": { "type": "string", - "enum": [ - "active", - "used", - "expired", - "revoked" - ] + "enum": ["active", "used", "expired", "revoked"] }, "createdAt": { "type": "string" @@ -3030,14 +7017,7 @@ "CreatedInvite": { "type": "object", "description": "明文 token 只在创建响应里出现一次(放在 `path` 里),列表不返回 token。", - "required": [ - "id", - "tenantId", - "role", - "path", - "createdAt", - "expiresAt" - ], + "required": ["id", "tenantId", "role", "path", "createdAt", "expiresAt"], "properties": { "id": { "type": "string" @@ -3046,7 +7026,8 @@ "type": "string" }, "role": { - "type": "string" + "type": "string", + "enum": ["viewer", "editor", "owner"] }, "path": { "type": "string", @@ -3062,13 +7043,7 @@ }, "KnowledgeFile": { "type": "object", - "required": [ - "id", - "fileId", - "knowledgebaseId", - "name", - "size" - ], + "required": ["id", "fileId", "knowledgebaseId", "name", "size"], "properties": { "id": { "type": "string" @@ -3101,24 +7076,15 @@ }, "GraphNode": { "type": "object", - "required": [ - "id", - "label", - "labelTruncated", - "memoryType", - "createdAt", - "sessionId", - "clusterId", - "userId", - "sourceIds" - ], + "required": ["id", "label", "labelTruncated", "memoryType", "createdAt", "sessionId", "clusterId", "userId", "sourceIds"], "properties": { "id": { "type": "string" }, "label": { "type": "string", - "description": "记忆正文的截断(≤120 字)" + "description": "记忆正文的截断(≤120 字)", + "maxLength": 120 }, "labelTruncated": { "type": "boolean" @@ -3145,15 +7111,12 @@ "type": "string" } } - } + }, + "additionalProperties": false }, "GraphLink": { "type": "object", - "required": [ - "source", - "target", - "kind" - ], + "required": ["source", "target", "kind"], "properties": { "source": { "type": "string" @@ -3163,41 +7126,34 @@ }, "kind": { "type": "string", - "enum": [ - "session", - "parent", - "merged", - "derived" - ] + "enum": ["session", "parent", "merged", "derived"] } - } + }, + "additionalProperties": false }, "MemoryGraphStats": { "type": "object", - "required": [ - "nodes", - "links", - "sessions", - "clusters", - "orphans", - "truncated", - "types" - ], + "required": ["nodes", "links", "sessions", "clusters", "orphans", "truncated", "types"], "properties": { "nodes": { - "type": "integer" + "type": "integer", + "minimum": 0 }, "links": { - "type": "integer" + "type": "integer", + "minimum": 0 }, "sessions": { - "type": "integer" + "type": "integer", + "minimum": 0 }, "clusters": { - "type": "integer" + "type": "integer", + "minimum": 0 }, "orphans": { - "type": "integer" + "type": "integer", + "minimum": 0 }, "truncated": { "type": "boolean" @@ -3206,10 +7162,7 @@ "type": "array", "items": { "type": "object", - "required": [ - "name", - "count" - ], + "required": ["name", "count"], "properties": { "name": { "type": "string" @@ -3220,27 +7173,26 @@ } } } - } + }, + "additionalProperties": false }, "MemoryGraph": { "type": "object", - "required": [ - "nodes", - "links", - "stats" - ], + "required": ["nodes", "links", "stats"], "properties": { "nodes": { "type": "array", "items": { "$ref": "#/components/schemas/GraphNode" - } + }, + "maxItems": 2000 }, "links": { "type": "array", "items": { "$ref": "#/components/schemas/GraphLink" - } + }, + "maxItems": 8000 }, "tenant": { "type": "string", @@ -3249,17 +7201,12 @@ "stats": { "$ref": "#/components/schemas/MemoryGraphStats" } - } + }, + "additionalProperties": false }, "ModelRecord": { "type": "object", - "required": [ - "role", - "model", - "spec", - "source", - "group" - ], + "required": ["role", "model", "spec", "source", "group"], "properties": { "role": { "type": "string" @@ -3275,10 +7222,190 @@ }, "group": { "type": "string", - "enum": [ - "memory", - "chat" - ] + "enum": ["memory", "chat"] + } + }, + "additionalProperties": false + }, + "MemoryOperation": { + "type": "object", + "required": ["operationId", "state", "results"], + "properties": { + "operationId": { + "type": "string" + }, + "state": { + "type": "string", + "enum": ["confirmed", "pending"] + }, + "deleted": { + "type": "integer", + "minimum": 0 + }, + "restored": { + "type": "integer", + "minimum": 0 + }, + "results": { + "type": "array", + "items": { + "type": "object", + "required": ["memoryId", "operationId", "state", "retryable"], + "properties": { + "memoryId": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "state": { + "type": "string", + "enum": ["queued", "pending", "confirmed"] + }, + "retryable": { + "type": "boolean" + }, + "errorCode": { + "type": "integer" + }, + "vectorState": { + "type": "string", + "enum": ["unconfirmed", "confirmed", "explicitly_not_indexed", "not_applicable"] + } + } + } + } + } + }, + "UsageSummary": { + "type": "object", + "required": ["addMessage", "searchMemory", "chat", "extractMemory", "rerank", "totalCalls", "totalTokens"], + "properties": { + "addMessage": { + "$ref": "#/components/schemas/UsageCalls" + }, + "searchMemory": { + "$ref": "#/components/schemas/UsageCalls" + }, + "chat": { + "$ref": "#/components/schemas/UsageTokens" + }, + "extractMemory": { + "$ref": "#/components/schemas/UsageTokens" + }, + "rerank": { + "$ref": "#/components/schemas/UsageCalls" + }, + "totalCalls": { + "type": "integer", + "minimum": 0, + "description": "仅addMessage.calls+searchMemory.calls,匹配页面总量。" + }, + "totalTokens": { + "type": "integer", + "minimum": 0, + "description": "五个已知事件的真实token总和;缺失0。" + } + } + }, + "PromptReadRequest": { + "type": "object", + "additionalProperties": false, + "required": ["projectId"], + "properties": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + } + }, + "PromptSaveRequest": { + "type": "object", + "additionalProperties": false, + "required": ["projectId", "prompt"], + "properties": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "prompt": { + "type": "string", + "minLength": 1, + "maxLength": 20000, + "pattern": "\\$\\{conversation\\}" + } + } + }, + "PromptGenerateRequest": { + "type": "object", + "additionalProperties": false, + "required": ["projectId", "description"], + "properties": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2000 + } + } + }, + "PromptResetRequest": { + "type": "object", + "additionalProperties": false, + "required": ["projectId"], + "properties": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + } + }, + "PromptDebugRequest": { + "type": "object", + "additionalProperties": false, + "required": ["projectId", "messages"], + "properties": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "messages": { + "type": "array", + "minItems": 1, + "maxItems": 30, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["role", "content"], + "properties": { + "role": { + "type": "string", + "enum": ["system", "user", "assistant"] + }, + "content": { + "type": "string", + "minLength": 1, + "maxLength": 20000 + } + } + } + }, + "extractionTypes": { + "type": ["array", "null"], + "maxItems": 3, + "uniqueItems": true, + "items": { + "type": "string", + "enum": ["text_mem", "pref_mem", "event_mem"] + } } } } @@ -3330,7 +7457,7 @@ } }, "DeleteResult": { - "description": "成功,返回删除条数", + "description": "操作回执。仅全部 confirmed 时写操作 code=0;查询接口 code=0 仅表示查询成功,仍须检查 state。", "content": { "application/json": { "schema": { @@ -3340,14 +7467,10 @@ }, { "type": "object", + "required": ["data"], "properties": { "data": { - "type": "object", - "properties": { - "deleted": { - "type": "integer" - } - } + "$ref": "#/components/schemas/MemoryOperation" } } } @@ -3417,10 +7540,7 @@ "properties": { "data": { "type": "object", - "required": [ - "records", - "total" - ], + "required": ["records", "total"], "properties": { "records": { "type": "array", @@ -3476,10 +7596,7 @@ "properties": { "data": { "type": "object", - "required": [ - "records", - "total" - ], + "required": ["records", "total"], "properties": { "records": { "type": "array", @@ -3513,10 +7630,7 @@ "properties": { "data": { "type": "object", - "required": [ - "logs", - "total" - ], + "required": ["logs", "total"], "properties": { "logs": { "type": "array", @@ -3594,10 +7708,7 @@ "properties": { "data": { "type": "object", - "required": [ - "memListItemResponseList", - "total" - ], + "required": ["memListItemResponseList", "total"], "properties": { "memListItemResponseList": { "type": "array", @@ -3634,10 +7745,7 @@ "properties": { "data": { "type": "object", - "required": [ - "records", - "total" - ], + "required": ["records", "total"], "properties": { "records": { "type": "array", @@ -3674,10 +7782,7 @@ "properties": { "data": { "type": "object", - "required": [ - "records", - "total" - ], + "required": ["records", "total"], "properties": { "records": { "type": "array", @@ -3734,10 +7839,7 @@ "properties": { "data": { "type": "object", - "required": [ - "records", - "total" - ], + "required": ["records", "total"], "properties": { "records": { "type": "array", @@ -3793,10 +7895,7 @@ "properties": { "data": { "type": "object", - "required": [ - "list", - "total" - ], + "required": ["list", "total"], "properties": { "list": { "type": "array", @@ -3892,8 +7991,10 @@ "extractionTypes": { "type": "array", "items": { - "$ref": "#/components/schemas/MemoryDetailType" - } + "type": "string", + "enum": ["text_mem", "pref_mem", "event_mem"] + }, + "maxItems": 3 }, "results": { "type": "array", @@ -3924,15 +8025,32 @@ "properties": { "data": { "type": "object", - "required": [ - "dailyStats" - ], + "required": ["dailyStats", "summary", "startDate", "endDate", "timezone", "source"], "properties": { "dailyStats": { "type": "array", "items": { "$ref": "#/components/schemas/UsageDailyStat" } + }, + "summary": { + "$ref": "#/components/schemas/UsageSummary" + }, + "startDate": { + "type": "string", + "format": "date" + }, + "endDate": { + "type": "string", + "format": "date" + }, + "timezone": { + "type": "string", + "enum": ["Asia/Shanghai"] + }, + "source": { + "type": "string", + "enum": ["gateway"] } } } @@ -4067,10 +8185,7 @@ "properties": { "data": { "type": "object", - "required": [ - "records", - "total" - ], + "required": ["records", "total"], "properties": { "records": { "type": "array", @@ -4104,10 +8219,7 @@ "properties": { "data": { "type": "object", - "required": [ - "records", - "total" - ], + "required": ["records", "total"], "properties": { "records": { "type": "array", @@ -4141,10 +8253,7 @@ "properties": { "data": { "type": "object", - "required": [ - "records", - "total" - ], + "required": ["records", "total"], "properties": { "records": { "type": "array", @@ -4178,10 +8287,7 @@ "properties": { "data": { "type": "object", - "required": [ - "records", - "total" - ], + "required": ["records", "total"], "properties": { "records": { "type": "array", @@ -4237,10 +8343,7 @@ "properties": { "data": { "type": "object", - "required": [ - "records", - "total" - ], + "required": ["records", "total"], "properties": { "records": { "type": "array", @@ -4274,10 +8377,7 @@ "properties": { "data": { "type": "object", - "required": [ - "ok", - "id" - ], + "required": ["ok", "id"], "properties": { "ok": { "type": "boolean" @@ -4308,10 +8408,7 @@ "properties": { "data": { "type": "object", - "required": [ - "ok", - "deleted" - ], + "required": ["ok", "deleted"], "properties": { "ok": { "type": "boolean" @@ -4342,11 +8439,7 @@ "properties": { "data": { "type": "object", - "required": [ - "ok", - "id", - "archived" - ], + "required": ["ok", "id", "archived"], "properties": { "ok": { "type": "boolean" @@ -4402,10 +8495,7 @@ "properties": { "data": { "type": "object", - "required": [ - "id", - "memory" - ], + "required": ["id", "memory"], "properties": { "id": { "type": "string" @@ -4436,9 +8526,7 @@ "properties": { "data": { "type": "object", - "required": [ - "records" - ], + "required": ["records"], "properties": { "records": { "type": "array", @@ -4469,11 +8557,7 @@ "properties": { "data": { "type": "object", - "required": [ - "account_id", - "tenant_id", - "role" - ], + "required": ["account_id", "tenant_id", "role"], "properties": { "account_id": { "type": "string" @@ -4507,10 +8591,7 @@ "properties": { "data": { "type": "object", - "required": [ - "ok", - "role" - ], + "required": ["ok", "role"], "properties": { "ok": { "type": "boolean" @@ -4538,6 +8619,14 @@ } } } + }, + "securitySchemes": { + "CookieSession": { + "type": "apiKey", + "in": "cookie", + "name": "memos_session", + "description": "Opaque persisted login session. HttpOnly, SameSite=Lax; Secure on HTTPS. Disabled/deleted accounts and expired sessions are rejected on every request." + } } } } diff --git a/package.json b/package.json index 94c07d164..c5306c6b4 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "format:check": "prettier --check .", "verify": "pnpm typecheck && pnpm lint && pnpm format:check && pnpm build", "mock": "node --watch server/mock/server.ts", + "backend": "python -m server.backend", + "backend:test": "python -m pytest server/backend/tests -q", "dev:all": "concurrently -n mock,web -c cyan,green \"pnpm mock\" \"pnpm dev\"" }, "dependencies": { diff --git a/server/backend/.env.example b/server/backend/.env.example new file mode 100644 index 000000000..c2e4806bb --- /dev/null +++ b/server/backend/.env.example @@ -0,0 +1,22 @@ +# Export these process environment variables before starting the backend. +# This adapter uses the configured upstream; never put model API keys here. +DASHBOARD_UPSTREAM_URL=http://127.0.0.1:8000/ +DASHBOARD_UPSTREAM_TIMEOUT_SECONDS=10 +# Seed allowlist: projectId:cubeId separated by semicolons; locally created projects persist too. +DASHBOARD_PROJECTS=issue3-project-a:issue3-project-a;issue3-project-b:issue3-project-b +# Bootstrap selection when no valid persisted project preference is present. +DASHBOARD_DEFAULT_PROJECT_ID=issue3-project-a +DASHBOARD_DATA_DIR=.data +# Required to read real gateway call logs; use the gateway's calls-*.jsonl directory. +DASHBOARD_AUDIT_GATEWAY_DIR=/absolute/path/to/gateway/audit +# Share this path with the gateway MEMOS_TENANTS_FILE (and its writable parent directory). +DASHBOARD_GATEWAY_KEYS_FILE=/absolute/path/to/mcp-gateway/tenants.conf + +# Authentication is enabled by default; initialize a local administrator first. +DASHBOARD_AUTH_ENABLED=true +DASHBOARD_SESSION_TTL_SECONDS=86400 +# Optional semicolon-separated explicit origins, no trailing path/slash. +# DASHBOARD_ALLOWED_ORIGINS=http://127.0.0.1:5276;http://127.0.0.1:5281 + +# Total deadline for genuine prompt generation/debug calls (seconds; >0 and <=180). +DASHBOARD_MODEL_TIMEOUT_SECONDS=120 diff --git a/server/backend/AUTHORIZATION-RESULT.md b/server/backend/AUTHORIZATION-RESULT.md new file mode 100644 index 000000000..52c4fb7ab --- /dev/null +++ b/server/backend/AUTHORIZATION-RESULT.md @@ -0,0 +1,14 @@ +# 分层授权约定交付 + +2026-10-27。用户明确选择平台管理员与项目角色分层授权。本次将既有权限规则补齐为可审阅的接口规格与权限矩阵,没有修改运行时策略或前端。 + +- AUTHORIZATION.md 逐接口定义当前账号、全站账号管理、项目成员、候选搜索、邀请与退出权限;注明最后管理员/owner、项目隔离和令牌接受边界。 +- openapi.json 修正原笼统管理员说明,为当前账号及全部 /admin/* 操作补充 description 和 x-authorization;不改 URL、请求/响应形状或角色校验。 +- README、Task16报告和第三节审查记录增加已选方案说明。历史审查记录保留,不声称上游维护者已批准解释。 +- test_authorization_policy.py 新增6项:四种非平台管理员身份(viewer/editor/owner/外项目账号)的全套管理接口权限、匿名拒绝、规格元数据一致性。 + +验证:权限、账号、成员、邀请共23项通过(18.67秒);Ruff check/format通过;Redocly0错误、6条已有警告;git diff --check通过。src/shared/server/mock/.env.local相对03e10145仍为空diff。没有重跑此前已通过的全量测试,没有操作真实账号或重启服务。 + +日志:.data/authorization-policy-tests.log、.data/authorization-redocly.log。规格测试曾先因缺少权限元数据失败,再补齐通过;新增测试夹具的邀请有效期按现有契约使用24h,不扩展接口支持单位。 + +交付说明:Issue #3 的管理员要求在本实现中解释为平台操作需要平台管理员,项目管理需要目标项目owner;成员可读本项目成员列表并退出自己。路径中的 /admin/ 不是全局平台管理员门禁。 diff --git a/server/backend/AUTHORIZATION.md b/server/backend/AUTHORIZATION.md new file mode 100644 index 000000000..f959558fd --- /dev/null +++ b/server/backend/AUTHORIZATION.md @@ -0,0 +1,39 @@ +# 分层授权约定 + +2026-10-27:用户选择分层授权方案。本文解释 Issue #3 的管理员要求:平台操作由平台管理员执行,项目操作由项目角色授权;URL 中的 /admin/ 前缀不意味着全部操作都要求平台管理员。此解释应随交付说明提交,未声称已获得上游维护者确认。 + +## 接口权限矩阵 + +以下接口均要求有效登录。表中“成员”指目标项目的 viewer/editor/owner,不包含其他项目的成员。平台管理员可覆盖项目角色检查,但个人空间、项目状态、最后 owner 等业务约束仍生效。 + +| 接口 | 最低权限 | 范围/约束 | +|---|---|---| +| GET /api/auth/me | 已登录 | 只返回当前会话账号 | +| GET /admin/accounts | 平台管理员 | 全站账号列表 | +| POST /admin/accounts/{id} | 平台管理员 | 更新账号,保护最后有效管理员及 owner | +| DELETE /admin/accounts/{id} | 平台管理员 | 删除账号,保护最后有效管理员及 owner | +| GET /admin/tenants/{tenantId}/members | 项目 viewer | 仅目标团队项目 | +| GET /admin/tenants/{tenantId}/member-candidates | 项目 owner | 候选账号搜索,不授予全站账号管理权限 | +| POST /admin/tenants/{tenantId}/members | 项目 owner | 添加或修改成员;项目需可用,保护最后有效 owner | +| POST /admin/tenants/{tenantId}/members/{accountId}/remove | 项目 owner | 仅目标项目;保护最后有效 owner | +| POST /admin/tenants/{tenantId}/members/leave | 本项目成员 | 仅退出自己;保护最后有效 owner | +| GET /admin/tenants/{tenantId}/invites | 项目 owner | 仅目标团队项目 | +| POST /admin/tenants/{tenantId}/invites | 项目 owner | 目标项目需可用 | +| POST /admin/tenants/{tenantId}/invites/{inviteId}/role | 项目 owner | 邀请必须属于该项目 | +| POST /admin/tenants/{tenantId}/invites/{inviteId}/revoke | 项目 owner | 邀请必须属于该项目 | + +## 授权边界 + +- 匿名、过期会话、禁用或已删除账号访问受保护接口返回401。 +- 可见项目中角色不足返回403;不可访问的项目或不属于该项目的资源返回404,避免泄露资源存在性。 +- project owner 不是 platform_admin,不能列出、更新或删除全站账号,也不能管理其他项目。 +- 邀请接受走独立的令牌与表单校验流程;可以使用有效邀请加入项目,不要求接受者已有该项目权限。撤销、过期、重复使用不授予权限。 +- 成员离开和管理操作仍受事务内最后有效 owner 保护;账号管理受最后有效平台管理员保护。 +- 浏览器写请求保留 Origin/CSRF 校验;本约定没有放宽来源或会话保护。 +- auth_enabled=false 是已有本地受信操作员模式,不是生产角色策略;正式验收使用默认开启的鉴权。 + +## 接口规格与验证 + +openapi.json 在 /api/auth/me 和每条 /admin/* 操作的 description 与 x-authorization 中明确上述要求。扩展字段是权限文档元数据,运行时仍由既有会话、actor_for、ControlStore 与 workspace 校验执行,不宣称 OpenAPI 本身能执行鉴权。 + +本次仅补齐规格、文档和回归测试,不修改运行时权限逻辑、前端、上游或真实账号。test_authorization_policy.py 覆盖 viewer/editor/非平台管理员 owner/外项目账号的全套管理操作、跨项目拒绝、匿名拒绝和规格矩阵一致性。已有账号、成员、邀请测试继续覆盖最后管理员/owner、禁用、过期和撤销。 diff --git a/server/backend/BROWSER-WRITE-FIX.md b/server/backend/BROWSER-WRITE-FIX.md new file mode 100644 index 000000000..bca8c12da --- /dev/null +++ b/server/backend/BROWSER-WRITE-FIX.md @@ -0,0 +1,23 @@ +# 浏览器无请求体写请求兼容修复 + +2026-10-27。用户反馈memos-admin撤销密钥提示无权限。 + +## 根因 + +真实auth/me确认memos-admin的platformAdmin=true。前端keys.revoke使用无body POST,不带Content-Type;浏览器附带Origin。原会话中间件把所有带Origin的非JSON写请求统一403,导致路由尚未执行就显示无权限。相同问题影响无body DELETE账号、POST项目归档/恢复等前端调用。 + +修复前已用同一管理员会话复现:带合法Origin且无body请求未知key,403;同路径改发JSON空对象则404,证明角色检查不是拒绝来源。没有撤销用户密钥。 + +## 修复 + +先检查Origin白名单,再允许application/json或真正空的无Content-Type请求。对后者实际读取首个ASGI请求消息验证,不只相信Content-Length;含正文、非空分块、非JSON媒体类型仍拒绝。空请求消息原样重放,下游需要body的接口仍按原契约返回400。所有原有角色检查保留。 + +## 验证 + +新增3条回归:管理员真实请求形状撤销测试密钥;跨源/无类型正文/伪造Content-Length与viewer写拒绝;无body账号删除及项目归档恢复。新用例先失败后修复。 + +主站5274实测本轮临时测试密钥,合法Origin且无请求体撤销200/code0;列表和配置均移除本轮key,最终tenants.conf与测试前逐字节一致。没有删除用户已有密钥,也未重启网关使临时key生效。 + +后端已重启,全量后端310 passed(103.51秒),Ruff与本轮3文件格式检查通过;src/shared/server/mock/.env.local对基线无差异。日志见.data/bodyless-tests.log,真实证据见.data/bodyless-live.json。未提交代码。 + +使用:刷新原密钥页后重新撤销目标密钥。撤销落盘后仍需重启网关,才让运行中的旧凭据失效。 diff --git a/server/backend/DELIVERY.md b/server/backend/DELIVERY.md new file mode 100644 index 000000000..148cbfedd --- /dev/null +++ b/server/backend/DELIVERY.md @@ -0,0 +1,90 @@ +# Dashboard v2 backend delivery + +Related to hugegraph/MemOS#3. Target branch: dashboard-v2. The React frontend, shared TypeScript contracts and Mock sources remain unchanged. + +## Scope + +Implemented: live metadata, six memory tabs and detail, soft-delete/restore with receipts and retry, JSONL call logs and daily usage, persistent projects/accounts/members/invitations, project extraction Prompt configuration, graph/fulltext, model inventory, project API keys and profile-template APIs. Quota/subscription and skillMD use the fixed responses allowed by the issue. + +Authorization is layered: platform administrators manage global accounts; project owners manage their own members/invitations/keys; members can read their project's member list and leave themselves. Cross-project access and last-effective-admin/owner invariants are enforced. See AUTHORIZATION.md. This interpretation is explicit for maintainer review. + +Knowledge-base development was stopped at the requester's direction. Existing entity CRUD, project links and file upload/list/download/delete are retained, with prior tests and real-service evidence. POST /api/dashboard/knowledgebase/memory/list is not implemented. This PR therefore uses “Related to #3”, not an automatic closing keyword; it does not assert full delivery of the original knowledge-base scope. + +## Deploy the upstream support + +The adapter depends on additional APIs/behavior in deployment-v2. Use a separate checkout at commit 8d6e231d7d95ea29b6a2716c9c34837644d3e7f6, then apply the complete support patch from this PR: + +```sh +git checkout 8d6e231d7d95ea29b6a2716c9c34837644d3e7f6 +git apply --check /path/to/dashboard/server/backend/patches/deployment-v2-dashboard-support.patch +git apply /path/to/dashboard/server/backend/patches/deployment-v2-dashboard-support.patch +``` + +The patch includes graph/vector lifecycle confirmation, profile-field reads/lifecycle, event filtering, Prompt runtime fixes, the API schema and regression tests, embedding-provider compatibility, and gateway key publication/automatic reload. Start/rebuild the upstream service and gateway using deployment-v2's existing deployment instructions; configure its model providers there. The adapter does not need or load model API keys. + +The older Task12/14 patches are retained as historical/component artifacts. Do not apply them in addition to the complete patch: their changes overlap. Apply the complete patch once on the stated clean baseline. Necessary support is packaged here instead of requiring unpublished changes in another local checkout. + +## Start the adapter + +Python 3.11+ is required. Run from the dashboard repository root: + +```sh +python -m venv .venv +# Activate .venv using the command appropriate for your shell. +python -m pip install -r server/backend/requirements-dev.txt +``` + +Export process environment variables using server/backend/.env.example as reference. The program does not automatically load that file. Both bootstrap and the server must use the same configuration: + +```sh +export DASHBOARD_DATA_DIR="$PWD/.data" +export DASHBOARD_UPSTREAM_URL=http://127.0.0.1:8000/ +export DASHBOARD_PROJECTS='demo:demo' +export DASHBOARD_DEFAULT_PROJECT_ID=demo +export DASHBOARD_AUDIT_GATEWAY_DIR=/absolute/path/to/gateway/audit +export DASHBOARD_GATEWAY_KEYS_FILE=/absolute/path/to/mcp-gateway/tenants.conf +python -m server.backend.bootstrap admin --bootstrap +python -m server.backend --host 127.0.0.1 --port 5281 +``` + +PowerShell equivalents use `$env:NAME = 'value'`; the commands otherwise remain Python module commands. Bootstrap prompts for a password without putting it in command-line arguments. Authentication is enabled by default. Keep the adapter and unauthenticated upstream on a trusted network; do not expose the upstream directly to untrusted clients. + +Set the gateway's MEMOS_TENANTS_FILE to the same absolute configuration path. Dashboard and gateway need access to the shared publication directory and lock. Deploy/restart the upgraded gateway once; later key creation/revocation takes effect on its next authenticated request without restart. Environment-variable changes still require restart. Invalid/unconfirmed file publications temporarily deny file credentials, then recover automatically; existing in-flight operations are not cancelled. + +In another terminal, install the existing frontend dependencies and start its existing proxy against the adapter: + +```sh +pnpm install --frozen-lockfile +MOCK_ORIGIN=http://127.0.0.1:5281 pnpm dev +``` + +For PowerShell: set `$env:MOCK_ORIGIN = 'http://127.0.0.1:5281'`, then run `pnpm dev`. Do not run `pnpm mock`. Open http://127.0.0.1:5274/__login, log in, and select the seeded project. No frontend source edit is required. For another origin configure DASHBOARD_ALLOWED_ORIGINS explicitly. No absolute Windows paths or private setup directory are needed for these steps. + +## Verify + +```sh +python -m pytest server/backend/tests -q +python -m ruff check server/backend +pnpm verify +pnpm --package=@redocly/cli dlx redocly lint openapi.json +git diff origin/dashboard-v2 -- src/ shared/ server/mock/ +python -m server.backend.acceptance.smoke --base http://127.0.0.1:5281 --project demo +``` + +The portable smoke command prompts for credentials (or reads DASHBOARD_TEST_USERNAME/DASHBOARD_TEST_PASSWORD), checks real read endpoints and emits counts/check names only. It creates no project, key or memory. Use --native and --cube to additionally compare returned memory IDs/content with the native REST API. Do not commit credential files, .data evidence, JSONL, databases or model settings. + +The earlier task14/15/16 acceptance drivers and start_live.ps1 reproduce the original Windows lab using private fixtures under D:/MemOS. They are historical lab tools, not prerequisites for deployment. Their old results document real writes/restores, process restarts and outage experiments; the portable smoke command does not claim to reperform those destructive or paid operations. + +## Recorded validation and limitations + +- Latest complete adapter suite before final packaging: 318 passed. Later authorization regression: 23 passed. Automatic-reload gateway suites: 56 passed; adapter key regressions: 27 passed. These are separate runs, not a fabricated sum or a claim that the entire upstream test suite passes. +- pnpm verify passed; schema validation passed. Real Mock-off browser inspection, same-ID REST reads, reversible deletion, JSONL correlation and upstream outage recovery were performed. +- 10,000-line JSONL cold first-page timings: 805.350 / 779.142 / 795.091 ms on the local fixture. This is not a general multi-user performance SLA. +- Memory list output is projected and bounded; active-state counts exclude archived/deleted records. Graph node/byte limits set truncated instead of implying a complete export. +- Profile fields are real graph nodes with original IDs; no invented relation edges. The unchanged frontend lacks the ProfileMemory translation and uses a different type-label grouping than the list tabs. The profile-template page remains ComingSoon despite working APIs. Recovery/retry receipts use APIs because the frontend has no controls for them. +- Archived-current-project table state may require refresh. Knowledge-base navigation remains disabled. These UI limitations are preserved under the issue's src/ zero-change requirement. +- State and audit indexing use local SQLite and filesystem coordination; this is not a distributed database/HA deployment. Historical upstream-wide testing had 18 reproduced baseline failures; no claim is made that this PR fixes them. + +Task reports retain detailed historical measurements. Private local evidence is intentionally not committed; the committed tests, patch and commands allow independent review. + +Final packaging checks: the complete support patch passed a clean-baseline temporary-index apply check and current-worktree reverse check. The portable smoke command passed against real services (six tabs, 14 graph nodes, two same-ID native REST comparisons); its new tests and affected contract/authorization checks passed 12 tests. OpenAPI license and same-origin server metadata were completed; Redocly now reports zero errors and four warnings, all for the four HTML form operations whose actual success is a 303 redirect. No fake 200 response was added. diff --git a/server/backend/GATEWAY-RELOAD-RESULT.md b/server/backend/GATEWAY-RELOAD-RESULT.md new file mode 100644 index 000000000..ab9019d5d --- /dev/null +++ b/server/backend/GATEWAY-RELOAD-RESULT.md @@ -0,0 +1,46 @@ +# 网关密钥自动重载 + +2026-10-27,用户授权新增“检测配置变化并自动重载”。仅主 agent 实现;没有修改前端、数据库结构或上游记忆服务 API,没有新增依赖,也没有提交代码。 + +## 行为 + +网关已升级并受控重启一次。之后控制台密钥新增/撤销成功发布后,下一次 HTTP 鉴权请求自动生效,不需要重启网关。改名不改变凭据或配置字节,不会中断鉴权。MEMOS_TENANTS 环境变量、网关代码或启动配置的变化仍需重启。 + +请求级检测读取文件身份、大小、纳秒修改/变更时间和 pending 标记;没有变化时不重新解析。变化时复用 Dashboard 发布锁,以非阻塞方式读取确认文件,比较读取前后指纹并一次替换完整映射。鉴权中的文件操作在线程池执行,不阻塞异步事件循环,不启动额外后台监听进程。 + +文件损坏、丢失、无读取权限、与环境映射冲突、发布锁忙或 pending 时,拒绝文件来源的密钥,避免继续接受可能已撤销的旧密钥。环境来源的独立身份保持可用,后续确认发布自动恢复。空文件不关闭鉴权。删除文件里的重复映射不能撤销环境变量中的同一凭据,环境变量仍独立有效。 + +已经完成鉴权的请求允许结束,不承诺取消正在执行的工具。公开 health/落地页不触发重载,租户数量反映最近一次鉴权快照。多 worker 各自在下一次请求检查共享文件。 + +## 交付 + +- 上游 gateway/domain/tenants.py:配置来源、指纹检测、发布校验、失败拒绝及自动恢复。 +- 上游 gateway/live_auth.py:动态鉴权,复用同一个 registry 供审计归属查询。 +- 上游 gateway/app.py:替换启动时静态 verifier;空文件仍保持鉴权开启。 +- test_gateway_live_reload.py:11项新增回归;原有文件加载、工具、契约测试保留。 +- acceptance/task14_driver.py:更新为新增200、撤销401均无需重启网关,最终断言网关PID不变;仍验证后端重启后的持久性。 +- README、人工验收步骤、OpenAPI描述与历史报告注释已同步。 +- patches/task14-gateway-tenants-file.patch 更新为对上游基线的完整组合补丁,包含此前Task14修复和本次增强,已通过 git apply --reverse --check。不要将新版完整补丁重复应用在已应用旧版的树上;先反向旧版或使用对应工作区源码升级。 + +## 已执行验证 + +| 检查 | 结果 | +|---|---| +| 网关自动重载/配置/契约/工具测试 | 56 passed,3.46秒;1条既有FastMCP stateless_http参数弃用警告 | +| Dashboard密钥与无body浏览器写回归 | 27 passed,15.07秒 | +| 真实独立验收 | 19条记录,complete.passed=true,证据不含密钥前缀 | +| 网关PID | 整段密钥验收保持57308不变 | +| 新增/撤销 | 新增后直接200且真实检索成功;撤销后直接401 | +| 原有凭据 | 环境凭据及当前项目的1把活跃文件凭据均继续200 | +| 清理 | 仅撤销本轮临时密钥、归档本轮测试项目;配置恢复为测试前逐字节内容 | +| Ruff | 修改代码check与format通过;按Makefile中的Ruff命令限定修改范围执行,未全仓自动格式化 | +| Redocly | 0错误、6条既有警告 | +| 差异检查 | 双仓git diff --check通过;src/shared/server/mock/.env.local对03e10145无变化 | + +证据:.data/gateway-reload-live.json、gateway-reload-tests.log、gateway-reload-dashboard-tests.log、gateway-reload-redocly.log。新增测试在实现前失败,证据gateway-reload-red.log。本轮没有为此重跑此前已通过的整个后端或上游全仓套件。 + +真实验收使用全新项目与临时密钥,仅执行只读检索,不增加记忆,不调用对话模型。最后恢复原配置字节;没有批量撤销现有密钥。首次部署升级重启与之后无需重启的密钥验收是两个阶段,不能混为一谈。 + +## 人工操作 + +在接口密钥页新建一把测试密钥,直接用其调用网关即可,不运行 Restart-Gateway.ps1;撤销后下一次请求应401。改名后同一密钥仍可调用。不要把401的撤销测试预期误当成网关故障。故障配置请先恢复Dashboard已提交状态,再重试鉴权,勿通过取消pending或关闭鉴权绕过发布确认。 diff --git a/server/backend/IMPLEMENTATION-NOTES-AUDIT.md b/server/backend/IMPLEMENTATION-NOTES-AUDIT.md new file mode 100644 index 000000000..2b0ec5a3e --- /dev/null +++ b/server/backend/IMPLEMENTATION-NOTES-AUDIT.md @@ -0,0 +1,48 @@ +# Issue 3 第三节独立复核 + +> 后续结论(2026-10-27):用户已明确选择分层授权,原文提到的权限解释歧义已在本实现范围内澄清。具体规则见 AUTHORIZATION.md 与 OpenAPI 的逐接口权限元数据;下文保留作原始审查记录。不声称上游维护者已经确认这一解释。 + +复核日期:2026-10-27。读取最新 GitHub Issue 正文,检查当前工作区代码,执行真实只读 HTTP 与隔离测试;本轮没有修改业务代码、权限策略或前端。 + +结论:响应体积、字段映射、记忆状态/恢复、历史日志兼容均有通过证据。鉴权采用平台管理员与项目角色两层授权;如果严格解释 Issue 的“/admin/* 需要管理员身份”为全部路由仅平台管理员可访问,则当前实现存在偏差,不能宣称该节逐字全部满足。 + +## 1. 响应体积 + +memories.project_node 使用 MemoryItem 白名单投影;不返回 metadata.embedding、sources、history。列表正文最多 500 字后加省略号,详情独立保留全文。列表还有 200 KiB 数据体积保护。 + +真实 issue3-project-a 的 text_mem 一页 10 条,总数 10,完整 HTTP JSON 响应 3688 字节;profile_mem 一页 4 条,1555 字节;另外四类为空,各 90 字节。均小于验收的 200 KB。合成测试还包含 1024 维向量与大 sources/history,验证没有透传。 + +## 2. 字段映射 + +列表和详情共同使用 memories.py 的 project_node:id→memoryId、memory→memoryContent、created_at→createTime。画像行的扁平字段和普通记忆 metadata 在这一处归一;六标签映射在 memory_models.TAB_TYPES;展示时间由 timefmt 统一转换。图谱有独立的 GraphNode 投影,因为其契约就是另一种形状。 + +## 3. 时间与状态 + +记忆列表/详情使用 createTime,真实结果为 YYYY-MM-DD HH:MM:SS(固定 UTC+8),存储/查询机器时间不被展示转换覆盖。列表上游查询限定 activated,投影再次检查状态;图谱同样只返回激活节点。删除/恢复具备幂等回执、同步确认及失败重试,普通记忆检查图向量,画像字段走字段状态转换。 + +已有真实合成画像字段生命周期证据为 8→7→8、删除时全文404、恢复后同 ID/正文一致,见 .data/profile-graph-repair/live.json。本轮没有再次改变真实数据。 + +Issue 对时间字段的概括与其接口定义有例外:图谱为 createdAt,账号/成员为 created_at,邀请为 createdAt/expiresAt 等。当前按权威 spec 保留这些字段,不能把它们全部改名为 createTime。原始日志无时间戳时返回空串,不伪造时间。 + +## 4. 历史 JSONL + +audit_normalize 集中做默认值与兼容:缺 input/output 时输出合法 JSON 字符串 null;缺耗时为 null、缺 token 为0、缺时间为空串、无成功/失败证据时为 UNKNOWN。缺失/无效租户的记录隔离,不猜测归属;坏 JSON 记录拒收但不使整页失败。隔离与拒收不是“所有旧记录都一定显示”,而是在不串租户前提下兼容可归属记录。 + +## 5. 账号与鉴权 + +/api/auth/me 来自真实持久会话,匿名401;/admin/accounts 列表/更新/删除均要求 platform_admin。禁用、删除、过期会话失效。账号、成员、邀请均落 SQLite 控制面。 + +但 /admin/tenants/{tenantId}/members 允许该项目 viewer 读取;成员变更和邀请管理要求项目 owner,leave 允许当前成员退出。隔离测试实测非平台管理员 viewer:成员列表200,账号列表403。这是有项目边界的角色策略,不是匿名开放,但与“全部 /admin/* 都仅平台管理员”不一致。需要明确采用哪种解释后再修改;本轮不擅自改变权限。 + +密钥签发写配置、重启网关生效的既有真实验收记录:.data/task14-keys/task14-live.json 共20项,complete.passed=true。本轮之前刚通过现有有效项目密钥完成5次真实MCP写入、形成6条记忆,同ID REST一致,见.data/graph-demo/result.json;不能把“既有密钥当前可用”冒充“本轮新建密钥再次验收”。 + +## 本轮验证 + +- 日志、时间、账号、成员、邀请、生命周期及密钥相关测试:95 passed,33.50秒。 +- 记忆列表、详情、图谱与画像图谱相关测试:63 passed,18.62秒。 +- 共158项通过;这是本轮针对性回归,不是声称重跑全量318项。 +- 真实只读证据:.data/implementation-notes/live.json。 +- 日志:.data/implementation-notes-tests.log、.data/implementation-notes-memory-tests.log。 +- src/ 相对基线03e10145仍为空diff。 + +列表与图谱的中文类型命名不一致是已记录的前端限制,属于其他展示问题,不应通过更改上游真实类型掩盖,也不能因本节的后端检查通过就宣称它已解决。 diff --git a/server/backend/ISSUE3-COMPLIANCE-REVIEW-2026-10-27.md b/server/backend/ISSUE3-COMPLIANCE-REVIEW-2026-10-27.md new file mode 100644 index 000000000..14f094455 --- /dev/null +++ b/server/backend/ISSUE3-COMPLIANCE-REVIEW-2026-10-27.md @@ -0,0 +1,67 @@ +# Issue3 原文独立合规复核 + +> 后续修复:用户已要求补齐前三项。三个固定接口现已实现并通过主站200/code0契约验证;本报告下文保留修复前审查记录。知识库继续冻结。最终结果见 ISSUE3-FIXED-ENDPOINTS-RESULT.md。 + +2026-10-27。仅审查和记录,未修改实现。结论:当前不能认定完整达到原Issue全部要求;P0/P1主要业务链路已有验收支持,但三个固定返回接口尚未实现,原始全部接口对齐也存在冻结知识库缺口。 + +## 权威来源 + +- https://github.com/hugegraph/MemOS/issues/3 (本次通过GitHub读取正文;updated_at=2026-10-25T10:06:43Z,0评论) +- dashboard-v2/openapi.json,blob 04eea16bb8ecd603c43f7546da06260d72b7b2bc,57路径/60操作。 +- dashboard-v2/src/lib/api.ts,blob 4ab6701c598a63b6df6897bc288635cf92e33969,与本机src/lib/api.ts一致。 +- dashboard-v2/server/mock/server.ts;deployment-v2/docker/.env.example、src/memos/api/server_api.py、deploy/mcp-gateway/README.md。 + +原始OpenAPI与本机基线03e10145a4dd8bc3e50b6e2a50ed788d64111945中的openapi.json blob相同。新增恢复/回执/HTML入口后的本机69操作不能替代对原60操作逐项对齐。 + +## 已确认缺口 + +### 1. 三个“无需真实业务”的接口仍必须返回固定成功数据 + +Issue §2(3) 允许固定数据,并未允许接口缺失;§4要求这些接口的形状同样正确。Task16将其标成“明确豁免无路由”不准确,必须以本次复核纠正。没有必要实现计费或订阅系统,只需实现固定返回。 + +在已登录的主站5274实际请求,三个接口均HTTP404/code40001: + +| Method | Path | 原文要求 | +|---|---|---| +| GET | /api/user/quota/summary | 成功信封,data为额度对象 | +| POST | /api/user/subscription/details | 成功信封,data为订阅周期对象 | +| POST | /api/dashboard/skill/skillMD | 成功信封,data为空字符串 | + +因此,即便业务测试302条通过、Redocly通过,也不能认为§4“所有调用按定义响应”已满足。缺失接口没有被此前完整性检查判为失败,是验收规则本身的遗漏。 + +### 2. 知识库记忆列表未实现 + +POST /api/dashboard/knowledgebase/memory/list 在src/lib/api.ts中有调用、原始spec中有定义,当前无处理路由,已登录有效形状请求返回404/code40001。应当在权限和知识库归属校验后返回真实 records/total。 + +用户此前明确冻结知识库开发,所以此处不是未经授权继续补写的理由;但该冻结不能视为GitHub维护者对原Issue要求的豁免。知识库本身属于加分模块,完整接口对齐却明确要求api.ts里的每条调用正确。应在提交说明中如实披露,不能宣称全接口完成。 + +### 3. 仍保留的范围限制 + +- 非空usage.apiKeyId目前明确400,已在本机spec和README说明;当前用量页没有实际使用此筛选。不能宣称支持完整的按密钥统计能力。这是已声明的功能限制,不应混同前三个必须存在的固定接口。 +- 知识库任务10/11未按用户冻结后的范围重新完成整套真实文件链路验收,不能仅由现有单元测试宣布知识库加分全项通过。 + +## 本次重新核实通过的项目 + +- 主站5274经真实5281,meta live;原文要求的src/零改动成立,保护目录对上述基线无diff。 +- 同一项目、activated统计范围下,事实3、偏好0、事件0、技能0、工具0、画像字段4,六标签BFF条数与原生REST完全相同。 +- 本次六标签10条上限请求分别1254/90/90/90/90/1555字节,均小于200KiB。空标签的非空类型映射能力由既有专项测试和各任务合成记录证据支持,不把空态说成当前每类都有真实数据。 +- 17个只读请求用原始spec检查,16个零结构错误;日志因新增console事件不在原enum中而有差异。该扩展已同步本机OpenAPI,符合Issue允许“有出入同步更新spec”,不作为新增缺陷。当前契约验收见Task16证据。 +- 之前Task16的新鲜证据:唯一合成记忆同ID/同正文REST对照、软删除不可读与恢复、独立图向量确认、真实网关JSONL/索引/页面同一标记、真实上游停机时错误提示及恢复、1万行三轮冷启动779–805ms。 +- 项目/账号/成员/邀请权限、最后管理员/owner约束、历史缺字段、筛选分页及错误信封由302条完整后端测试及对应真链路报告支持;此轮没有再次改动所有真实管理资源。 +- Task16曾实际运行pnpm verify通过、Redocly0错误5警告、相关上游104条和网关45条通过。本次是审查,不将这些称为本轮重新运行。 +- 密钥、Prompt、模板、图谱与模型已有真实专项证据,分别见Task12/13修复、Task14、Task15报告。 + +## 不应算作缺陷的原前端限制 + +- ProfileTemplates.tsx本身为ComingSoon,菜单禁用;Task15已实现API列表/详情。前端不能改,不能把解禁页面列成必须修复项。 +- 恢复/重试/操作查询是额外后端能力,原前端无按钮不构成原Issue新增缺口。 +- 登录/登出美化只改后端生成的HTML,不违反src/零改动。 +- Compose仅是原文提供的起服务方法之一;当前Windows主机运行上游和后端、Docker运行Neo4j/Qdrant,只要真实链路正确,不因未把所有服务装进同一个compose而判失败。 + +## 修复顺序与结论 + +优先补三个固定返回接口及覆盖原始60操作的完整性断言,避免再把“无需真实业务”误判为“无须接口”。然后按用户对知识库的范围决定,补齐memory/list或提交时明确说明不申请知识库完整交付。保留前端零改动。 + +本次没有修改实现或之前Task16验收规则;仅新增本审查文档和脱敏证据。此前“按调整范围全部完成”的结论应加上上述三个固定接口缺口,不能据此前表述提交“全部达标”。 + +证据目录:.data/issue3-review-20261027/baseline-contracts.json、counts-native.json、missing-endpoints.json。 diff --git a/server/backend/ISSUE3-FIXED-ENDPOINTS-RESULT.md b/server/backend/ISSUE3-FIXED-ENDPOINTS-RESULT.md new file mode 100644 index 000000000..d7bf3c588 --- /dev/null +++ b/server/backend/ISSUE3-FIXED-ENDPOINTS-RESULT.md @@ -0,0 +1,30 @@ +# Issue3 固定接口补齐 + +2026-10-27。按用户授权,仅补齐前三个固定接口;知识库实现保持不变。未提交代码。 + +## 行为 + +| 接口 | 成功data | +|---|---| +| GET /api/user/quota/summary | memoryCount=0、knowledgebaseCount=0、knowledgebaseCapacity=1000000、allowedKnowledgebaseCount=10 | +| POST /api/user/subscription/details | cycles=[] | +| POST /api/dashboard/skill/skillMD | 空字符串 | + +均使用HTTP200与code=0/data/message信封,Cache-Control:no-store。额度数值是Issue允许的固定展示数据,不是实际容量或记忆统计。订阅无有效周期,因此不伪造currentCycle或账期日期。knowledgebaseId/cycleId参数不触发知识库查询或计费业务;skillMD不下载oss_url。用量统计逻辑未改动。 + +保留既有鉴权:skillMD属于/api/dashboard/受会话及同源中间件保护;额度/订阅只返回公开固定值,沿用其原始OpenAPI未要求认证的定义。请求体由严格类型验证,非法输入仍返回统一错误信封。 + +## 实现与验收修正 + +- compatibility.py独立管理三个固定响应,app.py注册路由;没有改变OpenAPI、src/、Mock或知识库代码。 +- 两个专项测试先复现404与缺失路由,再通过:固定返回/JSON Schema/空值及类型边界/鉴权/不访问上游,以及全spec接口存在性。 +- 完整性断言现在只允许用户明确冻结的POST /api/dashboard/knowledgebase/memory/list无路由;三个固定接口不再豁免。Task16重演脚本同时加入三个固定响应的真实契约检查。 +- 当前69个操作中68个有处理路由;54个业务实现、3个固定接口、12个冻结知识库操作(11个已有路由)。原始60操作中的固定接口缺口已经关闭。 + +## 实际验证 + +- 后端已受控重启;通过主站5274实际请求三个接口,全部HTTP200/code0,并通过openapi.json对应响应JSON Schema。 +- 证据:.data/issue3-review-20261027/fixed-endpoints-live.json。 +- 专项2 passed;完整后端304 passed,92.03秒,见同目录pytest-fixed.log。Ruff全量和本轮4个Python文件格式检查通过;保护目录对03e10145a4dd8bc3e50b6e2a50ed788d64111945无差异。 + +知识库冻结项及之前已说明的其他功能限制仍保留,不把本次三个固定接口补齐称为知识库完整交付。 diff --git a/server/backend/MANUAL-ACCEPTANCE.md b/server/backend/MANUAL-ACCEPTANCE.md new file mode 100644 index 000000000..d278dc9c8 --- /dev/null +++ b/server/backend/MANUAL-ACCEPTANCE.md @@ -0,0 +1,322 @@ +# Issue3 已实现内容与人工验收手册 + +更新日期:2026-10-27。当前有效入口为主站5274;早期任务报告里的“5274仍是Mock/5276单独验收”是历史状态,不能作为当前操作指引。 + +## 1. 当前环境与入口 + +| 用途 | 地址/位置 | +|---|---| +| 登录 | http://127.0.0.1:5274/__login | +| 主控制台 | http://127.0.0.1:5274/cn/memoryList | +| 退出确认 | http://127.0.0.1:5274/__logout | +| 真实模式与上游健康 | http://127.0.0.1:5274/api/meta | +| 控制台后端Swagger,供手工执行API | http://127.0.0.1:5281/docs | +| 上游原生Swagger | http://127.0.0.1:8000/docs | +| 上游健康 | http://127.0.0.1:8000/health | +| 网关健康 | http://127.0.0.1:8002/health | +| 管理员用户名 | memos-admin | +| 管理员密码 | 仅在本机 D:/MemOS/.local/dashboard-admin.json 中查看 | + +统一使用127.0.0.1,不与localhost混用,避免Cookie来源不同。登录页已改为白色居中卡片、紫色按钮;退出页有确认和返回入口。登录表单仍使用CSRF,密码和会话不写入本手册。 + +写本手册时:5281/8000/8002健康均200,主站meta.mode=live,Mock5280不监听;当前界面选中Task10-13-a-0420c18a。 + +## 2. 16项工作的准确交付范围 + +| 任务 | 已实现内容 | 人工验收入口 | +|---|---|---| +| 1 P0运行信息 | 独立后端、统一成功/错误信封、真实上游连接检查、meta live及故障状态 | meta、真实页面、故障测试 | +| 2 P0记忆只读 | 六标签类型映射、列表/详情、项目范围、搜索/筛选/分页、字段白名单与体积限制 | 记忆列表;细筛选通过API | +| 3 P0记忆删除 | 普通记忆及画像字段软删除、恢复、回执、部分失败重试、图/向量确认 | 删除可点页面;恢复/重试通过API | +| 4 P0日志 | 同源审计JSONL增量索引、列表/详情、历史缺字段、来源/查询筛选、异步关联与1万行性能 | 调用日志、JSONL对照 | +| 5 P1用量 | 复用同一审计索引、按北京时间天聚合、日期范围、授权项目范围、缺token默认0 | 用量监控、Network | +| 6 P1项目 | 真实列表/创建/更新/切换/停用/启用/归档/恢复、重启持久化 | 项目与成员 | +| 7 P1账号 | 登录会话、当前账号、管理员账号列表/更新/启停/删除、最后有效管理员约束 | 登录及项目页“用户”标签 | +| 8 P1成员 | viewer/editor/owner、候选搜索、添加/改角色/移除/退出、最后有效owner约束 | 团队项目“成员” | +| 9 P1邀请 | 创建/角色/撤销/有效期、单次使用、登录/注册接受、过期与重用拒绝 | 成员弹窗→邀请链接;独立浏览器会话接受 | +| 10 P2知识库实体 | 保留已有实现;按用户指令冻结,不作为完整交付 | 菜单仍禁用,直达路由仅可查看已有页面 | +| 11 P2知识库文件 | 保留已有实现;按用户指令冻结,不再开发 | 不纳入本次完整人工交付签字 | +| 12 P2 Prompt | 读取/保存/真实AI生成/恢复默认/调试、真实运行时抽取生效、项目隔离 | 自定义抽取;运行时效果还需原生add验证 | +| 13 P2图谱/模型 | 真实只读来源、节点/关系投影、截断和全文、项目权限、模型配置脱敏 | 图谱、模型列表 | +| 14 P3密钥 | 持久列表/创建/改名/复制/撤销、网关文件发布、失败补偿、重启后真实可用/失效 | 接口密钥+网关探针 | +| 15 P3模板 | 复用上游CRUD,列表/详情、字段树/分页/项目权限、持久化 | API;原前端仍为ComingSoon | +| 16汇总验收 | 停Mock主站live、页面、契约矩阵、构建/静态检查、性能、真实故障/恢复、保护目录差异 | Task16报告及本手册 | + +另外补齐3个Issue允许固定返回的接口:额度、订阅、skillMD;不能把它们当作真实计费、真实配额统计或真实技能包下载。 + +当前本机OpenAPI有69个HTTP操作,68个有处理路由;只剩用户冻结的POST /api/dashboard/knowledgebase/memory/list无路由。“有路由”本身不是逐条业务验证结论。 + +## 3. 建议先完成这组只读检查 + +### 3.1 证明页面连接真实服务 + +1. 打开主站登录页,使用本机管理员凭据登录。 +2. 新标签打开主站/api/meta,检查code=0、data.mode=live,以及真实上游health内容。 +3. 回到控制台按F12,Network选择Fetch/XHR,勾选保留日志,刷新页面。 +4. 查看/api/meta、project/dict、project/memory/list的实际Response;不能只凭页面外观判断。 +5. PowerShell执行 `Get-NetTCPConnection -State Listen -LocalPort 5280 -ErrorAction SilentlyContinue`,应无监听记录。 + +主站响应经代理来自5281,后端再从8000查询;前端看起来可能仍像原示例页面,但数据来源已变。 + +### 3.2 记忆列表、搜索、详情、同ID原生对照 + +页面:http://127.0.0.1:5274/cn/memoryList + +1. 左上角项目选择Task10-13-a-0420c18a。 +2. 选“事实记忆”,在搜索框输入 `REPAIR_RULE_785229fb8e3c`,按回车提交,不能只输入而不提交。 +3. 应查到真实抽取记录;打开“查看详情”,核对主体、完整正文、创建时间和记忆ID。 +4. 本条验收ID为 `db0949e1-7610-4176-aa75-b5a5510f5291`。 +5. 新标签打开下面原生REST地址,比较data.id及data.memory与控制台详情。原生仍为上游信封,成功码可能是200,控制台信封成功码为0。 + +http://127.0.0.1:8000/product/get_memory/db0949e1-7610-4176-aa75-b5a5510f5291?mem_cube_id=proj_50e0980ab13a4f1b96560b016e34c96e + +正文应含项目部署南京、审计保留37天、周日03:00备份、两名owner审批等合成验收信息。可比较全文,不仅比较列表里可能已截断的预览。 + +6. 切换Task10-13-b-0420c18a,再搜索同一标记;不应查到A项目的这条记录。B项目可以有其他记忆,不要求B完全为空。 +7. 切回A,清空搜索并按回车。逐个点击事实/偏好/事件/用户/技能/工具六个标签,观察请求里的memoryDetailType和结果。 + +| 页面标签 | API类型 | 来源口径 | +|---|---|---| +| 事实记忆 | text_mem | Working/LongTerm/User/OuterMemory | +| 偏好记忆 | pref_mem | PreferenceMemory | +| 事件记忆 | event_mem | EventMemory/Context/InsightMemory | +| 用户记忆 | profile_mem | 有效绑定画像中非空、未弃用、未删除字段 | +| 技能 | skill_mem | SkillMemory | +| 工具记忆 | tool_mem | ToolSchemaMemory/ToolTrajectoryMemory | + +空标签是合法结果。目前没有为展示而强行给每个类型灌数据;空标签不能单独证明其非空映射正确,需结合专项测试或自己写入该类型的独立合成记录。最近一次issue3-project-a统计为事实3、用户字段4、其余0,与原生逐项一致;这些是快照,不是未来必须保持的固定数字。 + +8. Network中查看memory/list:数据键为memListItemResponseList,包含total/current;没有embedding/sources/history大字段。单页应小于200KiB。 +9. 记忆页现有UI只有关键词、类型和分页等入口,后端还支持日期/主体/知识库元数据筛选;没有展示出来的筛选通过Swagger验证,不要寻找不存在的前端控件。 +10. 当前示例项目可能少于一页,页按钮禁用合理。可在Swagger把pageSize设为1验证分页;日志页现有数据足够直接翻页。 + +### 3.3 调用日志与真实网关来源 + +页面:http://127.0.0.1:5274/cn/requests + +1. 切换项目issue3-project-a。 +2. 搜索 `task16-gateway-e991c8536c4c` 并按回车。 +3. 应出现searchMemory/SUCCESS记录;查看详情,请求JSON包含该唯一标记。该次耗时为3109ms,这是一次模型检索耗时,不是日志列表查询耗时。 +4. 点击响应详情查看原始结果。缺失Token显示0,不能据此推断模型没有成本。 +5. Network打开log/detail的Response:requestBody与responseBody必须是JSON字符串;页面解码成JSON展示属于正常行为。 +6. 这次记录索引ID:`log_b85aa48099e5201275d92e762ecdd4132761becf675f9a1937cffdd2659520e1`。 +7. 在D:/MemOS/.local/audit下搜索上述标记,对照同一行的tool/tenant/ts/ms/ok/input;页面时间转换UTC+8,应按同一时刻核对,不要求与UTC原字符串逐字相等。来源记录位置见.data/task16/models-gateway.json。 +8. 清空搜索并按回车;翻页,再打开漏斗筛选“网关/控制台/全部”、日期、主体、事件,点击应用筛选;结果应随条件改变。 +9. 控制台查询、删除/恢复及故障测试本身会留下console事件;FAILURE记录不自动表示当前服务仍故障,要看时间和请求详情。 + +### 3.4 用量监控 + +页面:http://127.0.0.1:5274/cn/usage + +1. 切换页面已有7/14/30天等范围按钮,查看图与日表同步变化。 +2. 切换项目,确认Network请求projectId和统计范围同步变化。 +3. 选一个有网关调用的日期,在日志页仅筛“网关”并使用相同项目/日期,核对addMessage与searchMemory次数。 +4. 口径:北京时间自然日,只统计gateway来源;console不进入用量。成功、失败、pending等逻辑调用均计数,异步同callId合并一次。 +5. 当前页面主要展示写/读次数;未知事件与缺时间行有明确排除口径。缺token为0,有可信token则保留真实值。 +6. 非空apiKeyId筛选当前明确400;现有页面未使用该筛选,不能把它列成已支持能力。 + +### 3.5 图谱与模型 + +图谱:http://127.0.0.1:5274/cn/memoryGraph + +选择Task10-13-a-0420c18a,点击节点,找到前面的REPAIR_RULE记录;标签可能截断,点击“展开全文”后与记忆详情同ID正文一致。切换项目图谱内容随之变化。节点时间统一UTC+8。 + +图谱并非无限全量:上限/预算触发时stats.truncated=true。可在Swagger调用GET graph/memories并设limit=1,观察截断标记;不要以“图上节点比数据库少”直接判断丢数据。 + +模型:http://127.0.0.1:5274/cn/models + +应显示实际Haiku与BAAI/bge-m3相关配置;同一模型供多个角色复用时可能合并展示。Network models响应不应带API Key、URL用户名密码或敏感查询串。页面是只读清单,不是配置模型的编辑器。 + +## 4. 可在页面做的写操作验收 + +建议创建自己的MANUAL-日期前缀测试项目和账号;下述步骤由人工执行,本手册生成过程没有替你改这些资源。 + +### 4.1 项目生命周期 + +页面:http://127.0.0.1:5274/cn/projects + +1. 新建MANUAL-A-日期和MANUAL-B-日期团队项目,记录不可变项目ID。 +2. 编辑A名称/描述,保存后F5,值仍在。 +3. 设A为当前,左上角和当前项目徽标同步;新项目记忆为空正常,不自动复制旧项目内容。 +4. 停用A后,它从活跃项目下拉中消失;重新启用恢复可用。启用和设为当前是两个动作,按页面实际按钮分别验证。 +5. 归档A,列表显示已归档;恢复后原ID不变。归档不是清空上游记忆。 +6. 归档当前项目时,既有前端表格可能短暂缓存旧状态;F5后以真实列表及/api/auth/me.currentProjectId判断,这是已记录限制。 +7. 持久化可在完成操作后运行 `D:/MemOS/setup/Restart-Backend.ps1`,重新确认项目与会话仍在。登录状态不应仅靠前端缓存维持。 + +### 4.2 邀请、账号、成员 + +1. 管理员在自己的MANUAL团队项目点击“成员”→“邀请链接”,选择viewer和有效期,生成并复制链接。 +2. 使用独立浏览器配置或隐私窗口打开该链接;普通新标签页共享Cookie,不算独立账号。 +3. 按邀请落地页注册一个MANUAL测试账号并接受。回到管理员窗口,应看到新成员和已使用邀请。 +4. 再次打开同一链接,不能再次接受。另生成一条邀请并撤销,撤销后也不能接受。页面最短有效期是1天;秒级过期场景用Swagger的expiresIn,例如2s,等待后验证410。 +5. 项目页“用户”标签可查看账号、修改显示名称、启用/禁用、平台管理员标记和删除;账号可从登录页“注册账号”入口独立注册,也可在邀请落地页注册;注册为普通账号,其他团队需另行邀请。 +6. 禁用自己刚创建的测试账号,独立会话下一次API请求应401;重新启用后再登录可访问。 +7. 在成员弹窗搜索已有测试账号,添加、切换viewer/editor/owner、移除。viewer可读、不可删记忆;editor可写记忆,但不能管理成员/密钥;owner管理项目资源。 +8. 移除成员后,其下一次访问项目应被拒绝;退出同样撤回范围。个人空间不作为团队邀请共享。 +9. 观察最后有效管理员、最后有效owner的限制:按钮可能禁用,服务端也应拒绝绕过。不要把原管理员当一次性测试账号删除。 + +### 4.3 密钥创建、改名、真实生效与撤销 + +页面:http://127.0.0.1:5274/cn/apikeys + +1. 先选择自己新建的MANUAL项目,创建一把MANUAL测试密钥。 +2. 页面显示掩码,Network列表按原Issue下发完整apiKey;复制功能取得完整值。 +3. 改名后名称变化,凭据本身不变。F5后仍保存。 +4. 新建/撤销成功发布后,已升级网关在下一次鉴权前自动重载,无需重启。首次部署自动重载代码仍需重启一次;配置发布异常会明确失败,修复后重试。 +5. 在PowerShell中临时设置PROBE_TOKEN为自己创建的值,不把值写入代码或聊天。于dashboard目录执行: + +```powershell +$env:PROBE_TOKEN = Read-Host '粘贴本次测试密钥' +D:/MemOS/.venv/Scripts/python.exe -m server.backend.acceptance.probe_gateway env +Remove-Item Env:PROBE_TOKEN +``` + +创建后直接探测应http_status=200并列出工具。撤销这把测试密钥后直接重试,同一凭据应401(env探针将以非零退出,此处正是撤销测试的预期)。无需重启网关;只清理本次创建的测试密钥,不批量撤销旧项目密钥。完整自动重演见GATEWAY-RELOAD-RESULT.md。 + +### 4.4 Prompt保存与调试 + +页面:http://127.0.0.1:5274/cn/customExtraction + +1. 选择自己的MANUAL项目,复制保存当前Prompt原文。 +2. 点击编辑,保留 `${conversation}` 占位符,加入明确规则,例如“只保留用户明确陈述的长期项目事实”。保存修改,F5后规则仍在。 +3. 右侧输入“我的长期项目代号是MANUAL-PROJECT,部署区域固定为杭州”,运行抽取。结果来自真实配置模型,不是固定演示内容;候选可能为空,需结合Prompt与原始输出判断。 +4. 返回记忆页,对比调试前后数量;调试只生成候选,不自动写入记忆。 +5. AI修改会调用模型,生成后先查看,再保存。恢复默认会调用上游重置接口,并非纯本地撤销;即使随后取消编辑,也不能假定服务器规则已恢复旧值。 +6. 最后把保存的原文重新保存,完成清理。 +7. 要验证“保存规则影响正式入库”,还必须通过上游Swagger的POST /product/add在同一mem_cube_id写入合成对话,再到列表同ID核对;不能仅凭调试框有结果就判定正式写入链路成功。已有REPAIR_RULE记录就是该链路的实际验收证据。 + +## 5. 删除与恢复:页面/API联合验收 + +优先使用已指定的合成记录,不用前面作为长期只读演示的REPAIR_RULE记录做删除。 + +1. 在项目issue3-project-a的事实标签搜索 `Task03-1a180ab65165` 并回车。 +2. 指定合成记录ID为 `70693895-6d13-4fd7-b55f-6faf5bc4b7f0`。先记下详情全文、ID和当前total。 +3. 页面点击该记录“删除”,确认后列表不再显示;只删这一条。 +4. 原生REST检查同ID: + +http://127.0.0.1:8000/product/get_memory/70693895-6d13-4fd7-b55f-6faf5bc4b7f0?mem_cube_id=issue3-project-a + +原生可能HTTP200但data=null;BFF详情应404。不要只检查HTTP状态码就认为仍存在。 + +5. 现有前端没有“恢复”按钮。打开后端Swagger的POST /api/dashboard/project/memory/restore,Try it out,输入: + +```json +{ + "projectId": "issue3-project-a", + "memory_ids": ["70693895-6d13-4fd7-b55f-6faf5bc4b7f0"], + "memType": "text_mem" +} +``` + +6. Execute应返回code=0、state=confirmed、restored=1。回到正确项目的记忆页刷新,同一ID和正文恢复。 +7. 若操作为pending/非零code,记录返回的operationId,使用GET operation查看及POST retry重试原操作;不能把pending视为成功。重试使用原ID,新一轮删除/恢复不要反复指定旧幂等ID;省略operationId由服务生成即可。 +8. 用户画像字段同理,但memType=profile_mem、ID取该字段的真实memoryId;删除一字段不应移除同画像其他字段。恢复后值应相同。 + +普通页面及REST检查无法独立证明向量已同步;Network回执应有vectorState确认。独立Neo4j/Qdrant读回的既有证据见.data/task16/live.json;需要重演时使用acceptance.task16_driver,而不是以“行消失”作为全部证明。 + +## 6. 模板和固定接口:用Swagger验收 + +登录主站后,同一浏览器打开http://127.0.0.1:5281/docs,Cookie可沿用。展开接口、Try it out、填写body、Execute,检查Response body。 + +模板前端灰色/ComingSoon是原始前端限制,不能改src/解禁。API验证步骤: + +1. POST profileTemplate/list:`{"projectId":"issue3-project-a","pageNum":1,"pageSize":10}`;返回list/total/current/size/pages,可看到现存测试模板。 +2. 复制列表templateId,GET profileTemplate/detail填projectId/templateId;应返回字段树和bindingCount。 +3. 如需写入,使用自己的MANUAL项目创建一个模板,内容例如: + +```json +{ + "projectId": "替换为自己的MANUAL项目ID", + "templateName": "MANUAL-template", + "工作信息": { + "职责": {"value": "后端开发", "algorithm_updatable": false} + } +} +``` + +4. 创建返回templateId;用同ID读详情。更新时带完整当前字段树、templateName与templateId;删除时带projectId/templateId。另一项目读取/更新/删除同ID都应404。 +5. 上游保留deprecated历史字段,BFF只展示有效字段;原生null默认值投影为省略可选value,不是接口失败。 + +三个固定接口: + +| 请求 | 预期data | +|---|---| +| GET /api/user/quota/summary | 0条记忆、0个知识库、1000000容量、10个知识库额度的固定对象 | +| POST /api/user/subscription/details,body为{"cycleId":"","includeAllCycles":true} | {"cycles":[]} | +| POST /api/dashboard/skill/skillMD,body为{"oss_url":"https://example.invalid/test"} | 空字符串 | + +均HTTP200/code0。不要将固定memoryCount=0与真实记忆列表3条比较后认为数据丢失:额度接口按Issue允许固定值,不是实际统计。 + +## 7. 性能、故障与工程质量 + +### 7.1 性能 + +- Network核对memory/list单页小于200KiB。 +- 1万行JSONL首屏小于1秒,须使用专门语料测试;看当前几十行的页面速度不能替代该门槛。 +- 本机三轮新索引记录805.350/779.142/795.091ms,见.data/task16/performance-faults.json。 +- 可在PowerShell独立复测,不改变主站数据: + +```powershell +Set-Location D:/MemOS/dashboard +@' +import sys, httpx +sys.path.insert(0, 'D:/MemOS/setup') +from verify_task04 import performance +with httpx.Client(trust_env=False, timeout=40) as client: + print(performance(client)) +'@ | D:/MemOS/.venv/Scripts/python.exe - +``` + +### 7.2 真实上游故障提示 + +仅在你准备好立即恢复的本机验收窗口操作: + +1. 执行 `powershell -NoProfile -ExecutionPolicy Bypass -File D:/MemOS/setup/Restart-Upstream.ps1 -StopOnly`。 +2. 刷新记忆页,预期“无法连接上游记忆服务”及重试入口,不能白屏;meta为503。 +3. 执行 `powershell -NoProfile -ExecutionPolicy Bypass -File D:/MemOS/setup/Restart-Upstream.ps1`。 +4. 等8000/health及主站meta恢复成功,再重试或刷新列表,数据恢复。 + +故障时生成的FAILURE审计记录应保留;不因恢复成功而改写旧日志。 + +### 7.3 工程检查 + +```powershell +Set-Location D:/MemOS/dashboard +D:/MemOS/.venv/Scripts/python.exe -m pytest server/backend/tests -q +D:/MemOS/.venv/Scripts/python.exe -m ruff check server/backend +pnpm verify +pnpm --package=@redocly/cli dlx redocly lint openapi.json +git diff 03e10145a4dd8bc3e50b6e2a50ed788d64111945 -- src/ +``` + +当前最后完整后端测试304 passed。前端检查通过;Redocly允许已有5条warning但不应有error。src/差异应为空。上游只声明已运行的相关套件通过,不宣称整个上游仓库全绿。 + +## 8. 人工验收记录表 + +每项记录:项目ID、账号角色、操作时间、请求路径、code、预期/实际、必要截图;避免截图完整凭据或密码。 + +| 检查项 | 通过标准 | 人工结果 | +|---|---|---| +| live与停Mock | meta live;5280无监听;页面仍有真实数据 | 待填 | +| 同ID对照 | REPAIR_RULE详情与原生REST全文一致 | 待填 | +| 六标签 | 对应请求正确;有数据/空态均正常;total按范围一致 | 待填 | +| 项目隔离 | A标记在B不可见;详情不串项目 | 待填 | +| 删除恢复 | 合成记录不可见后恢复同ID同正文;回执确认 | 待填 | +| 日志 | 唯一网关标记、JSONL时间/耗时/事件对应;字符串正文 | 待填 | +| 用量 | 同项目同日网关读写次数对得上;范围切换生效 | 待填 | +| 项目 | CRUD状态、选中、归档恢复及重启持久化 | 待填 | +| 账号成员邀请 | 登录隔离、角色门禁、禁用撤权、单次邀请及最后owner/admin约束 | 待填 | +| 密钥 | 页面持久;重启后新key可用、撤销key401 | 待填 | +| Prompt | 保存后重读;真实调试不落库;正式入库另验 | 待填 | +| 图谱/模型 | 真节点全文与同ID一致;模型配置无凭据泄露 | 待填 | +| 模板API | list/detail及项目边界正确,前端仍占位 | 待填 | +| 三个固定接口 | 均200/code0、形状与固定值正确 | 待填 | +| 性能/故障 | 1万行专测达标;上游停机页面有提示,恢复后正常 | 待填 | +| 交付门槛 | verify/Redocly/测试/零src差异 | 待填 | + +知识库菜单禁用、知识库memory/list缺失、用量apiKeyId限制、恢复/模板无前端按钮,继续按范围记录。没有要求通过修改前端消除这些占位。人工未执行的格子保留“待填”,不要把自动化测试通过当成人工已经签字。 + + +## 新增:独立账号注册 + +登录页现有“注册账号”链接,地址http://127.0.0.1:5274/__login/register。只需账号、密码和确认密码;密码至少12字符。成功后回登录页,普通账号自动拥有个人空间,不自动加入既有团队。完整说明见REGISTRATION-RESULT.md。新增注册后最新完整后端测试为307条通过;OpenAPI新增同一路径GET/POST两个操作。 diff --git a/server/backend/PROFILE-GRAPH-REPAIR.md b/server/backend/PROFILE-GRAPH-REPAIR.md new file mode 100644 index 000000000..55b47b9d4 --- /dev/null +++ b/server/backend/PROFILE-GRAPH-REPAIR.md @@ -0,0 +1,36 @@ +# 画像字段图谱补全验收 + +日期:2026-10-27。仅修改后端与接口说明,前端 src/ 保持不变。 + +## 原因与修复 + +上游 get_memory_graph 读取普通记忆存储;画像字段由独立画像存储提供。原图谱只调用前者,导致同一项目列表中事实记忆 4 条、用户画像字段 4 条,图谱却只有 4 个节点。 + +后端现在按已授权项目合并普通图节点和 list_profile_memories 返回的绑定画像字段。画像保持原字段 ID 和 ProfileMemory 类型;分页每页最多 100 条,与普通节点共用 2000 节点和原有响应字节预算。普通节点先占预算,画像使用剩余配额;遗漏时明确标记 truncated。分页结构、项目归属、状态、字段 ID 和日期均校验,画像源失败不会伪装成成功的局部图。 + +画像全文通过同 ID 的 get_profile_memory 读取,保留统一响应、授权、404 和 1 MiB JSON 全文上限。兼容最长 1024 字符的字段 ID,OpenAPI 同步说明。敏感扩展字段不进入输出。 + +画像没有真实会话或关系时,sessionId/sourceIds 为空,不制造边。另修正所有孤点 clusterId 都为空字符串的问题:孤点以自己的 ID 分组,真实连通节点仍使用连通分组,避免前端把全部孤点画成同一个会话。 + +## 真实链路证明 + +项目 issue3-project-a: + +- 两个列表分别 4 条;图谱 8 个节点,ID 集合严格等于两个列表的并集,truncated=false。 +- 类型统计为 LongTermMemory=1、UserMemory=3、ProfileMemory=4。 +- 4 个画像全文逐条与上游同 ID REST 相等;换项目访问全部 404。 +- ProfileMemory 单独筛选为 4;limit=1 返回 1 且 truncated=true。 +- 仅对既有 Task03 合成测试画像字段执行可恢复删除:图谱 8 → 7,全文 404;finally 恢复后 7 → 8,同 ID/全文保持一致。未删除用户手工写入的记忆。 +- 后端重启后仍为同一组 8 个节点,8 个孤点分组各自独立。 + +证据:.data/profile-graph-repair/live.json、graph-eight.png。证据不保存正文、密钥或会话值。 + +## 人工检查 + +打开 http://127.0.0.1:5274/cn/memoryGraph,选择 issue3-project-a,刷新。左下角应为“记忆 8 · 可见 8”。点击节点检查 ID 与记忆列表一致;没有关系边的节点单独分布是正常现象。 + +当前前端没有 ProfileMemory 的中文图例词条,会显示 graph.type.ProfileMemory,颜色采用默认值。这是保留前端零变更的展示限制,不影响数量、字段 ID、筛选或全文。没有把画像伪装成 UserMemory 来回避该限制。 + +专项测试覆盖合并、205 字段跨页、类型过滤、截断、畸形/越界上游、删除恢复、长字段 ID、全文 JSON 字节预算与项目授权。质量门日志:.data/profile-graph-pytest.log、.data/profile-graph-pnpm.log、.data/profile-graph-redocly.log。 + +最终质量门:后端全量 318 passed(117.82 秒);Ruff check/修改文件 format check 通过;pnpm verify 通过;Redocly 0 错误、6 条既有警告;git diff --check 通过。src/shared/server/mock/.env.local 相对 03e10145 保持空 diff。真实浏览器取消画像类型后仅显示 4 个普通节点,再勾选恢复 8 个;筛选截图见 profile-filter-off.png。 diff --git a/server/backend/README.md b/server/backend/README.md new file mode 100644 index 000000000..b2fa9a6ed --- /dev/null +++ b/server/backend/README.md @@ -0,0 +1,263 @@ +# Dashboard 独立后端 + +当前可部署交付以 [DELIVERY.md](DELIVERY.md) 为入口,包含跨平台启动、完整上游补丁、范围与验收摘要。下方任务分阶段说明和 TASKxx 报告保留历史状态,早期 sample/端口和旧测试数量不代表当前交付状态。 + +权限采用平台与项目两层授权,详见 [AUTHORIZATION.md](AUTHORIZATION.md):账号管理仅平台管理员可用;项目 owner 管理本项目成员与邀请;viewer/editor 可查看本项目成员并退出自己。`/admin/` 路径前缀不表示所有接口都仅平台管理员可用。OpenAPI 的逐接口说明与 `x-authorization` 同步记录此规则。 + +已实现独立服务、统一响应与错误、MemOS 连接检查、`GET /api/meta`,以及六标签记忆列表、详情、软删除、恢复、操作回执和失败重试。调用日志、按日用量和任务06项目生命周期也已实现。项目列表、创建、更新、选中、停用、归档和恢复使用真实 SQLite 持久化;任务07账号、任务08成员、任务09邀请均已实现。详见 `TASK07-RESULT.md`、`TASK08-RESULT.md`、`TASK09-RESULT.md`。 + +任务12 Prompt 与任务13图谱/模型已交付,详见 [TASK12-RESULT.md](TASK12-RESULT.md)、[TASK13-RESULT.md](TASK13-RESULT.md)。真实抽取需要部署 `patches/task12-upstream-prompt-runtime.patch` 中的上游内部修复并重启上游;本机已应用和验证。知识库任务10、11按用户要求冻结当前实现,不宣称补齐全部原验收项。 + +主前端5274继续连接原始 Mock,辅助5276用于真实服务联调。请不要为显示 `live` 标记修改 `.env.local` 或停止 Mock。 + +## 启动 + +要求 Python 3.11 或更新版本。在仓库根目录运行。使用独立 Python 环境安装: + +```sh +python -m pip install -r server/backend/requirements-dev.txt +python -m server.backend --host 127.0.0.1 --port 5281 +``` + +依赖已经安装时可以直接启动。`pnpm backend` 和 `pnpm backend:test` 使用当前 PATH 中的 Python,需要先激活相应虚拟环境。 + +当前 Windows 环境的直接启动命令: + +```powershell +cd D:\MemOS\dashboard +$env:DASHBOARD_PROJECTS = 'issue3-project-a:issue3-project-a;issue3-project-b:issue3-project-b' +$env:DASHBOARD_DEFAULT_PROJECT_ID = 'issue3-project-a' +$env:DASHBOARD_UPSTREAM_TIMEOUT_SECONDS = '10' +& D:\MemOS\.venv\Scripts\python.exe -m server.backend --port 5281 +``` + +该命令在前台运行,按 Ctrl+C 停止。当前项目还提供后台启停脚本: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File D:\MemOS\setup\Start-Backend.ps1 +powershell -NoProfile -ExecutionPolicy Bypass -File D:\MemOS\setup\Stop-Backend.ps1 +``` + +本地辅助脚本位于 Dashboard 仓库外,属于当前工作区环境;可移植的交付入口是 `python -m server.backend`。默认只绑定本机,不启用自动 reload。后端启动不要求 MemOS 已在线;连接失败会在元信息接口中明确返回。 + +## 配置 + +配置来自进程环境变量,示例见同目录 `.env.example`;程序不自动加载模型密钥文件。 + +| 变量 | 默认值 | 说明 | +|---|---|---| +| `DASHBOARD_UPSTREAM_URL` | `http://127.0.0.1:8000/` | MemOS 根地址,可带部署路径前缀;禁止用户名、密码、查询参数和片段 | +| `DASHBOARD_MODEL_TIMEOUT_SECONDS` | `120` | Prompt生成与调试的总截止秒数,大于0且不超过180秒;后端不持有模型密钥 | +| `DASHBOARD_UPSTREAM_TIMEOUT_SECONDS` | `3` | 本次健康请求的总截止时间,同时配置 HTTP 阶段超时;大于 0 且不超过 60 秒 | +| `DASHBOARD_PROJECTS` | 空 | `projectId:cubeId`,多个用分号分隔;种子访问范围;另允许本地操作员通过接口创建的持久项目 | +| `DASHBOARD_DEFAULT_PROJECT_ID` | 空 | 配置项目时必填;无有效项目偏好时的初始选择;详情按持久 cookie 上下文中的当前项目查询 | +| `DASHBOARD_DATA_DIR` | `.data` | 项目及偏好上下文 SQLite 保存目录;无种子配置也可创建首个项目 | + +任务 01 不需要 Haiku、Embedding 或数据库密钥。内部 HTTP 客户端不读取系统代理变量,不跟随重定向,不做隐式重试;远端 MemOS 需要网络直达。 + +## 接口和含义 + +| 接口 | 用途 | +|---|---| +| `GET /health` | 后端进程存活检查,不访问上游,仍使用统一响应 | +| `GET /api/meta` | 每次真实访问 MemOS `/health`,校验服务身份和契约后返回运行信息 | +| `GET /api/dashboard/project/dict` | 当前实例可访问的活跃项目;仅本上下文当前项目标为 ENABLED | +| `POST /api/dashboard/project/memory/list` | 六标签、项目范围、组合筛选与原生分页 | +| `POST /api/dashboard/project/memory/detail` | 当前上下文项目内按同一 memoryId 读取详情 | +| `GET /docs` | 本阶段实际注册接口的调试文档;完整 Dashboard 规格仍以仓库根 `openapi.json` 为准 | + +正常元信息响应示例: + +```json +{ + "code": 0, + "data": { + "mode": "live", + "service": "memos-dashboard-backend", + "health": { + "status": "healthy", + "service": "memos", + "version": "1.0.1", + "contract_version": 2 + }, + "keyPrefix": "sk-", + "tools": [ + { "name": "add_message", "title": "写入记忆" }, + { "name": "search_memory", "title": "检索记忆" }, + { "name": "get_user_profile", "title": "读取画像" }, + { "name": "delete_memory", "title": "删除记忆" }, + { "name": "add_feedback", "title": "提交反馈" } + ], + "authEnabled": true + }, + "message": "success" +} +``` + +`live` 表示这个后端真实探测了 MemOS,**不代表整个 Issue 已经完成**。`tools` 是既有 MCP 网关协议的工具目录,`keyPrefix` 是控制台密钥格式约定;本阶段未实现密钥管理或网关工具代理。默认 `authEnabled=true`,业务接口要求真实登录会话,并按平台管理员或项目成员角色校验。`live` 本身仍不代表权限配置或全部模块已验收。只有显式设置 `DASHBOARD_AUTH_ENABLED=false` 才启用历史本机操作员模式;隔离的旧模块夹具也明确指定此模式。 + +健康信息仅保留上述四个字段;不透传上游配置、密钥、异常详情和任意扩展字段。健康响应体最多读取 64 KiB,响应不缓存。请求指定 identity 编码,读取前拒绝压缩响应,避免先解压再检查大小。探测只证明 HTTP 可达和当前上游自报的进程状态,不能将其解读为 Neo4j、Qdrant、模型全部经过实时深度检查。 + +## 错误 + +错误均为 `{ "code": 非零值, "data": null, "message": "说明" }`,并返回对应 HTTP 状态。不会切换为假数据或复用上一次成功健康状态。 + +| 情况 | HTTP | code | +|---|---|---| +| 参数校验失败 | 400 | 40000 | +| 接口不存在(含未实现业务接口) | 404 | 40001 | +| 方法不支持 | 405 | 40000 | +| 上游 HTTP 异常、非 JSON、响应格式或契约不匹配 | 502 | 50200 | +| 上游连接失败或报告不健康 | 503 | 50300 | +| 上游超时 | 504 | 50400 | +| 未预期的内部异常 | 500 | 50000 | + +仅接受 `service=memos`、`status=healthy`、非空版本和整数 `contract_version=2`。契约版本不兼容时不会报告成功;恢复上游后下一次请求即可恢复,无须重启后端。 + +## 测试与人工验证 + +```sh +python -m pytest server/backend/tests -q +python -m ruff check server/backend +python -m ruff format --check server/backend +pnpm verify +pnpm --package=@redocly/cli dlx redocly lint openapi.json +``` + +单元测试使用 httpx MockTransport,验证成功与故障,不调用真实模型、不写记忆、不依赖 Docker。CLI 启动验证使用子进程。 + +当前环境可分别打开: + +- 后端元信息:http://127.0.0.1:5281/api/meta,应为 `code=0`、`mode=live`,包含实际 MemOS 健康信息。 +- 后端进程检查:http://127.0.0.1:5281/health。 +- 现有前端元信息:http://127.0.0.1:5274/api/meta,仍应为 `mode=sample`。 + +可以用单独端口启动一个指向不存在上游的后端实例来验证 503,不需要停止当前真实 MemOS 或 Mock。任务 01 的实际验收结果另见同目录 `TASK01-RESULT.md`。 + +## 任务 02 的查询口径 + +只读 activated 记忆;先筛选、计数,再分页。列表正文最多 500 字符加省略号,详情保留全文;不透传 embedding、sources、history。日期按 UTC 自然日处理,时间戳须带时区。图记忆搜索遵循上游大小写敏感子串语义,画像搜索不区分大小写。 + +`knowledgeBaseId` 匹配真实图元数据的 `knowledgebase_id`、`knowledge_base_id`、`knowledgebase_ids`,没有关联的记录不命中。画像按实例创建时间排序和过滤,每个有效字段是一条可定位的记忆。部署时需要同时安装本任务新增的上游画像只读接口与可选事件类型过滤参数。 + +真实请求示例: + +```powershell +$query = @{ projectId='issue3-project-a'; memoryDetailType='text_mem'; pageNum=1; pageSize=10 } +Invoke-RestMethod http://127.0.0.1:5281/api/dashboard/project/memory/list -Method Post -ContentType 'application/json' -Body ($query | ConvertTo-Json) +``` + +当前工作区的可重复真实验收:`D:\MemOS\.venv\Scripts\python.exe D:\MemOS\setup\verify_task02.py`。默认只读已保存的测试标识,`--seed` 才允许补建独立测试数据。完整结果见 `TASK02-RESULT.md`。主前端 5274 保持 Mock,独立验收前端使用 5276。 + +## 任务 03:软删除和恢复 + +`POST /api/dashboard/project/memory/delete`、`/restore` 接受 `projectId`、`memType`、`memory_ids` 和可选 `operationId`。先检查全部目标的项目、类型和状态,再逐项执行。删除保留正文和向量,画像只操作单个字段;普通记忆需图与向量状态双边确认。 + +全部确认才返回 code=0。部分失败返回 code=50301,data 内保留确认数量、operationId 和逐项结果。`GET /api/dashboard/project/memory/operation?projectId=...&operationId=...` 查询进度;查询成功仍须检查 data.state。`POST /api/dashboard/project/memory/retry` 接受 projectId、operationId,重试未确认项。恢复和重试目前使用 API,没有修改前端增加新界面。 + +部署需要同步上游生命周期路由、MetadataStore 锁、普通读取可见性修正和后台补偿。单独更新 Dashboard 不能提供这些语义。Dashboard 已接入账号会话与项目角色;上游控制面端口仍限可信本机/内网使用。浏览器写请求检查显式配置的控制台 Origin,不能绕过 Dashboard 直接公开无鉴权上游。 + +完整实现、真实进程中断与向量连接故障证据、已有基线测试失败说明见 `TASK03-RESULT.md`。仅本次独立测试记录经过删除与恢复,原有 Task01/02 数据保留。 + +## 任务 04:调用日志 + +已实现 `/api/dashboard/log/list` 和 `/api/dashboard/log/detail`。安装 `requirements.txt` 中新增的固定依赖 `orjson==3.11.4`。通过 `DASHBOARD_AUDIT_GATEWAY_DIR` 显式指定网关审计目录;控制台来源默认保存在 `DASHBOARD_DATA_DIR/audit`,可通过 `DASHBOARD_AUDIT_CONSOLE_DIR` 覆盖。当前本机启动脚本配置为 `D:/MemOS/.local/audit`。 + +仅导入 `calls-*.jsonl`、`console-*.jsonl` 及数字后缀轮转文件(如 `.jsonl.1`);协议、检索诊断、备份、临时和压缩文件不计为调用。增量索引位于 `DASHBOARD_DATA_DIR/audit.db`;索引支持重启与事务回滚,坏行诊断保存在 `audit_rejects`(仅代次、字节位置、原因,不存原始坏行),可按 reason 分组计数。 + +来源、主体、事件、状态、关键词、UTC 时间和分页筛选先于计数与分页执行。未知筛选参数返回 400;不可用来源返回 503,不能返回空列表假成功。详情使用当前持久项目上下文,列表刷新导入索引。日志查询本身不追加控制台业务日志。 + +历史 Token 缺失填 0、耗时缺失为 null;异步按来源、租户、callId 合并。当前真实网关没有异步关联记录,这部分经独立 JSONL/HTTP 夹具验证。完整配置、性能计时、真实链路与边界见 `TASK04-RESULT.md`。主前端 5274 保持 Mock,真实日志验收页为 http://127.0.0.1:5276/cn/requests 。任务 05 用量聚合的最新交付口径见下节。 + +## 任务 05:用量聚合 + +已交付 `POST /api/dashboard/usage/query`,复用日志同一 `AuditService` / `audit_calls` 索引,仅统计 gateway。北京时间固定UTC+8切天、空日补0、默认当月1日至今天、最多366天;日期参数仅YYYY-MM-DD。缺时间的历史行不进入日期统计,缺token为0。 + +`projectId`省略/null/空白/all只覆盖当前配置种子与本地操作员创建的活跃项目;配置撤销的数据库旧项目不再进入统计。具体未授权项目404、无范围或来源不可用503。认证模式还按当前账号的成员关系限制范围,平台管理员可访问配置允许及本地创建项目。非空apiKeyId明确400,不静默忽略。 + +页面次数与`summary.totalCalls`均仅addMessage+searchMemory;失败/pending等各逻辑调用算一次,异步终态更新token不重复计次。chat/extractMemory提供真实token,rerank提供次数和token;未知事件/console排除。summary.totalTokens涵盖五个已知分组。includeDailyDetails=false返回空dailyStats及同一summary。 + +专项测试与真实HTTP验收见[TASK05-RESULT.md](TASK05-RESULT.md),只读验收脚本`D:/MemOS/setup/verify_task05.py`。任务06—09已交付,知识库等模块继续依实施顺序推进。 + +## 任务06 项目工作区 + +- `POST /api/dashboard/project/list`:`pageNum/pageSize`,返回 `records/total`,包含停用与归档项目以便恢复。 +- `create`:`name/description`,可选 `isMultiView`;服务端生成不可变 ID 和对应上游 namespace。 +- `update`:`id` 加至少一个显式非 null 可写字段。活跃项目 `status=ENABLED` 为选中;停用项目同一请求只重新启用,不自动切换。`DISABLED` 为停用。 +- `delete/{id}`:可恢复归档;`restore/{id}` 恢复;`revoke/{id}` 为归档兼容别名。都不删除上游记忆。 +- 认证模式使用 `memos_session` 及其持久项目上下文;不同登录会话独立选择,重启保留。历史 `memos_project_context` 只在显式关闭鉴权的隔离模式中使用,不是登录凭证,也不能给认证会话指定其他项目。 +- 字典排除停用/归档项目,记忆、日志和用量显式项目查询同步拒绝;当前项目失活时回退到其他活跃项目。 +- 认证模式返回真实有效项目角色;平台管理员具有项目管理能力。网关密钥状态联动属于后续密钥任务,不能根据既有前端提示认定已实现。 +- 独立真实页面为 `http://127.0.0.1:5276/cn/projects`。既有前端在归档当前项目时曾出现表格缓存滞后,重新载入可显示真实状态;遵守 `src/` 零变更要求,未改动该前端行为。 + +## 任务07—09 登录、账号、成员与邀请 + +默认启动要求登录,先用本地 CLI 初始化唯一首个管理员(环境变量使用上述相同数据目录与项目映射): + +```powershell +& D:\MemOS\.venv\Scripts\python.exe -m server.backend.bootstrap memos-admin --bootstrap +``` + +命令交互式读取12—256字符密码,不把密码放进参数。需要自动随机密码时可追加 `--credential-file`,目标文件须不存在。后续本地新增账号使用相同命令去掉 `--bootstrap`,需要平台管理员时显式加 `--admin`;现有账号密码不会被此命令覆盖。Windows 部署须限制凭据文件 ACL,本机初始化文件已限制为当前用户。 + +本机已完成初始化。登录入口 http://127.0.0.1:5276/__login ,账号 `memos-admin`,随机密码只保存在 `D:/MemOS/.local/dashboard-admin.json`。使用真实控制台5276;5274保持Mock。登录后“项目与成员→用户”显示真实账号;团队项目“成员”进入成员/邀请管理。 + +| 配置 | 默认值 | 语义 | +|---|---|---| +| `DASHBOARD_AUTH_ENABLED` | `true` | 默认启用会话鉴权;false仅用于显式本机操作员隔离场景 | +| `DASHBOARD_SESSION_TTL_SECONDS` | `86400` | 会话秒数,60—2592000;退出、禁用和删除即时撤销,不通过重新启用复活 | +| `DASHBOARD_ALLOWED_ORIGINS` | localhost/127.0.0.1/[::1] 的5274、5276、5281 HTTP来源 | 可用分号配置明确HTTP/HTTPS来源,不含路径或凭据;HTTPS下Cookie带Secure | + +- `GET/POST /__login`、`GET/POST /__logout` 是后端HTML表单,沿用现有Vite代理。CSRF、Origin、严格同源重定向和登录失败限制均在服务端执行。页面Referrer-Policy为same-origin,避免no-referrer导致浏览器表单Origin:null;跨源不发送引用地址,CSP限定form-action self。 +- `/api/auth/me` 使用camelCase;`/admin/accounts` 与成员列表的账号字段使用snake_case。所有JSON业务响应保持统一信封;HTML表单成功303跳转,不虚构200。 +- 管理员维护账号;owner/管理员管理团队成员、邀请及项目生命周期;editor可执行记忆写操作;viewer只读,也可选中有权访问的活跃项目。权限变更在下一次请求即时生效,全项目用量也限制成员范围。 +- 每个账号拥有独立个人空间。团队成员可添加、改角色、移除及自行退出;个人空间不提供团队成员/邀请写操作。最后有效管理员、最后有效团队owner不能被降级、移除、禁用或删除;并发操作同样受事务约束。 +- 邀请通过 `POST /admin/tenants/{tenantId}/invites` 创建,角色viewer/editor/owner,有效期默认168h,最多30天。只在创建返回 `path`;库和列表不保存/返回明文token。落地 `/__login/invite/{token}` 支持登录账号接受或新账号注册接受;原子单次使用,已有成员返回409且不降级、不消费邀请。 +- 账号删除清理个人控制空间、成员和会话,团队项目保留;邀请创建者/使用者引用清空且已使用状态保留。没有P3密钥表时不声称已验证网关密钥联动;不会删除上游记忆。 +- `python -m pytest server/backend/tests -q` 覆盖这些功能及既有任务回归。真实环境验收脚本 `D:/MemOS/setup/verify_task07_09.py` 只操作明确命名的空测试资源,不写删真实记忆或调用模型。 + +## 任务 14:接口密钥(P3 密钥) + +「密钥即项目」:dashboard 界面签发的接口密钥代表项目本体,网关侧身份就是 `user_id = cube_id = <项目 id>`,调用与既有租户一样落在项目自己的记忆空间,审计照常归因。 + +密钥存于 control.db 的 `api_keys` 表(明文存 `token`、存 `token_hash`,软撤销保留行)。每次 create / revoke 在同一事务里把 active 密钥清单原子写入网关配置文件——**接口成功返回 ⟺ 文件已写入**,写文件失败随事务回滚并返回 503,密钥不落库(绝不假成功)。 + +配置与流程: + +| 配置 | 默认 | 语义 | +|---|---|---| +| `DASHBOARD_GATEWAY_KEYS_FILE` | 空 | 网关交付路径(tenants.conf);为空时 create/revoke 返回 503,list/copy/rename 不受影响 | + +- `setup/Start-Backend.ps1` / `setup/Restart-Backend.ps1` 已支持 `-GatewayKeysFile`(默认 `D:/MemOS/upstream/deploy/mcp-gateway/tenants.conf`),Restart 会透传旧记录值,旧记录缺该字段时回退默认。 +- 后端每次启动会按库内 active 集合**单向重写**该文件(自愈;写失败只告警不阻断启动,后续写操作如实报错)。手工放进去的 token 会被拭除——手工管理的密钥请走 `MEMOS_TENANTS`。 +- 已升级网关在每次鉴权前检测 `MEMOS_TENANTS_FILE` 的变化:创建后下一请求可用,撤销后下一请求401,无需重启。首次部署自动重载代码需要重启一次;环境变量变更仍需重启。损坏、冲突、待确认或不可读文件暂时禁用文件密钥,确认发布后自动恢复,独立环境变量密钥不受影响。 + +密钥明文保存在本地 control.db 与网关配置文件(gitignore 范围内,无 KMS 不加密);列表/复制仍按契约完整返回且仅 owner/platform admin 可见。自动重载不取消已经授权执行中的请求。当前验收见 `GATEWAY-RELOAD-RESULT.md`;`TASK14-RESULT.md` 中重启生效的描述保留为升级前历史记录。 + + + +## Task12/13最新专项验收(2026-10-27) + +复核发现的合法空数组误判raw、规范类型绕过筛选两项问题已修复。280项后端测试、108项上游相关测试及新的真实模型/图谱同ID验证通过。上游内部补丁已更新,不能只部署Dashboard适配器。当前5276用于真实服务验证,5274仍为sample;详见[TASK12-13-REPAIR-2026-10-27.md](TASK12-13-REPAIR-2026-10-27.md)。知识库保持既有实现,未推进任务16。 + + +## Task14独立复核修复完成(2026-10-27) + +5项复核问题已关闭,当前以[TASK14-RESULT.md](TASK14-RESULT.md)的新验收为准。287项后端测试和单轮隔离真链路通过。网关补丁新增发布锁/pending协议,需与Dashboard一同部署;共享配置目录要支持协调锁。证据旧25条已归档,当前task14-live.json为20条单轮完整成功记录。安全重演入口为`python -m server.backend.acceptance.task14_driver --root D:/MemOS`,每轮新建项目、只清理本轮key,并真实重启后端/网关。知识库和任务15/16不在本轮变更范围。 + + +## 2026-10-27 最终环境与验收 + +主站 http://127.0.0.1:5274 现在连接真实后端5281(live),Mock5280已停止,5276辅助真实前端保留。不要用历史Start-Dev输出中的sample提示判断当前连接。已有基础服务运行后,可执行 `powershell -NoProfile -ExecutionPolicy Bypass -File server/backend/acceptance/start_live.ps1` 切换主站,无需改.env.local。登录入口改用 http://127.0.0.1:5274/__login;管理员密码继续只在本机私有文件中读取。 + +Task15模板接口及前端限制见TASK15-RESULT.md;最终质量门、69操作矩阵、性能与故障证据及已知未实现项见TASK16-RESULT.md和TASK16-OPERATIONS.md。知识库保持用户冻结范围,模板页面仍为占位。 + + +### Issue3固定接口补齐 + +额度概览、订阅周期、skillMD现已按Issue §2(3)提供固定成功返回,不再是404。额度0条计数/1000000容量/10知识库额度仅为展示默认值;订阅cycles为空,技能Markdown为空字符串,不实现真实计费或URL下载。知识库继续冻结。详见ISSUE3-FIXED-ENDPOINTS-RESULT.md。 + + +### 普通账号注册 + +登录页可点击“注册账号”,或访问 /__login/register;输入账号、密码和确认密码,成功后回登录页。账号规则与现有身份系统一致,密码至少12字符;普通账号只拥有个人空间,加入其他项目需邀请。该页面由后端生成,不修改src/。验证及证据见REGISTRATION-RESULT.md。 diff --git a/server/backend/REGISTRATION-RESULT.md b/server/backend/REGISTRATION-RESULT.md new file mode 100644 index 000000000..b418839f6 --- /dev/null +++ b/server/backend/REGISTRATION-RESULT.md @@ -0,0 +1,15 @@ +# 简单账号注册 + +2026-10-27。注册入口GET/POST /__login/register,位于现有Vite /__login代理之下,无需修改src/或代理配置。登录页新增注册链接;样式沿用登录卡片。 + +只有账号、密码、确认密码。账号沿用现有1至64位字母数字及._-规则,密码沿用现有12至256字符规则。两次密码不一致、账号重复或输入无效时原页提示,密码不回填。成功303回登录页并显示注册成功,由用户登录。 + +复用IdentityStore事务、scrypt密码摘要和个人空间创建逻辑。新账号为普通用户,不授予平台管理员或既有项目权限;未知注册字段被拒绝。CSRF和Origin沿用现有表单校验。知识库、Mock与前端源码未修改,未提交代码。 + +验证:307条完整后端测试通过(101.74秒),包含3条新增注册专项,覆盖注册后登录、普通用户权限、个人空间、重启持久化、重复账号、密码不匹配、非法字段、CSRF与跨源。pnpm verify、Ruff及diff检查通过;Redocly0错误6警告,其中新增1条为注册POST成功303而非2xx,按实际行为保留。 + +真实主站注册/登录均303,auth/me确认非管理员及个人空间,管理接口403;本轮自建测试账号已定向删除,不保留额外凭据。浏览器注册页已确认3个输入框并保存截图。 + +证据:.data/registration-live.json、registration-tests.log、registration-pnpm.log、registration-redocly.log、registration-page.png。 + +手工使用:http://127.0.0.1:5274/__login/register (5276同路径也可用)。新注册账号需要被邀请才可访问其他团队项目。 diff --git a/server/backend/TASK01-RESULT.md b/server/backend/TASK01-RESULT.md new file mode 100644 index 000000000..ac8628b77 --- /dev/null +++ b/server/backend/TASK01-RESULT.md @@ -0,0 +1,57 @@ +# 任务 01 验收记录 + +状态:已完成。基础接口、独立代码检查及实际 HTTP 验证均已完成。仅涵盖设计文档实施顺序第 1 项。 + +## 交付内容 + +- 独立 FastAPI 后端,默认监听 127.0.0.1:5281;不导入 MemOS 模型、数据库或 Mock 模块。 +- GET /health:本进程健康信息,使用 code/data/message 信封,不依赖上游在线。 +- GET /api/meta:实时读取 MemOS /health,严格验证 memos / healthy / contract_version=2,白名单返回健康字段、live 来源、工具目录和密钥前缀。 +- 统一处理业务异常、参数校验、404、405、上游故障和未预期异常;错误 data=null,不返回原始敏感错误。 +- 总截止时间、HTTP 超时、64 KiB 健康响应上限、禁止重定向和隐式代理,生命周期关闭连接池。 +- 可移植 Python CLI、依赖文件、环境变量示例、单元测试与使用文档。 +- 当前工作区另提供 setup/Start-Backend.ps1 和 Stop-Backend.ps1,验证进程归属,重复启动不会替换已有服务。 + +## 修改范围 + +- 新建 server/backend/ 的实现、测试及文档。 +- package.json 增加 backend 与 backend:test 两个命令,原命令不变。 +- .gitignore 增加 Python 编译/测试缓存和虚拟环境排除项。 +- openapi.json 增补元信息的 502/503/504/default 错误响应、统一错误 schema 和 authEnabled 说明。此前已有的纯排版差异仍在;本轮不把整个 diff 当成新增接口语义。 +- src/、shared/、server/mock/ 与原始基线完全一致;未更改前端 .env.local 或代理目标。 + +## 已运行的检查 + +| 检查 | 结果 | +|---|---| +| 初始测试 RED | 后端实现不存在,测试收集明确失败 | +| 边界测试 RED | 总截止时间、响应体上限、启动配置脱敏三项先失败,再修复 | +| 后端 pytest | 56 passed | +| Ruff lint 与 format --check | 通过 | +| 真实 HTTP 验证 | 8 项通过,见下文 | +| 后台进程启停 | 启动、重复启动不重复创建、停止后重启均通过 | +| pnpm verify | 通过;保留原有大 chunk 提示 | +| Redocly lint | 通过;仍有缺 license、本地 server URL 两条既有 warning | +| git diff --check | 通过 | +| 对基线检查 src/、shared/、server/mock/ | 无改动 | + +独立代码检查发现压缩响应先解压后限额的问题;已增加流式 gzip 等编码的读取前拒绝测试,先观察失败再修复。同时补齐 HTTP 协议异常到 502 的映射。新增 7 项回归最终全部通过;无遗留阻塞问题。压缩体测试验证其流根本未被消费,而不只是最终返回错误。 + +真实 HTTP 验证包括:真实上游健康一致、后端存活、未实现接口明确 404、真实连接失败 503 且本地存活仍正常、真实超时 504、前端与 Mock 仍为 sample、验证过程中前端配置未变、前端源码无差异。通过临时端口注入连接失败和慢响应,没有停止当前 MemOS、Mock 或网关。 + +当前环境的原始记录:D:/MemOS/.local/task01-runtime-verification.json;执行日志位于 Dashboard 的忽略目录 .data/task01-runtime-meta/。不将本地数据库、配置或真实审计文件提交到仓库。 + +## 亲自验证 + +1. 打开 http://127.0.0.1:5281/api/meta,应看到 code=0、mode=live,以及上游健康状态。 +2. 打开 http://127.0.0.1:5281/health,应看到后端本身健康。 +3. 打开 http://127.0.0.1:5274/api/meta,仍应是 sample;控制台仍使用 Mock。 +4. 按 README 命令运行后端单元测试,或使用本地 setup/verify_task01.py 重跑真实 HTTP 验证。 + +## 边界 + +- live 只表示该后端使用真实 MemOS 探测,不能据此宣称记忆/日志功能完成。 +- authEnabled=false;本阶段只有运行信息,未建立账号、项目或访问记忆数据的业务接口。 +- MemOS /health 当前是进程自检,本任务不将它包装成数据库或模型的实时深度自检。 +- 未调用付费模型,没有新增或删除记忆。 +- 本轮结束后停在任务 01,不自动进入任务 02。 diff --git a/server/backend/TASK02-RESULT.md b/server/backend/TASK02-RESULT.md new file mode 100644 index 000000000..425445756 --- /dev/null +++ b/server/backend/TASK02-RESULT.md @@ -0,0 +1,105 @@ +# 任务 02:P0 记忆只读验收结果 + +交付范围:六标签记忆列表、详情、字段映射、项目范围、筛选分页、响应投影和真实链路对照。任务 03 的删除、任务 04 的日志,以及后续模块没有在本任务中实现。主前端 5274 继续使用 Mock;独立验收前端 5276 指向后端 5281。 + +## 实现与映射 + +Dashboard 新增 `server/backend/control.py`、`memory_models.py`、`memory_upstream.py`、`memories.py` 及对应测试;扩展 `settings.py`、`app.py` 与根 `openapi.json`。原任务 01 的未实现接口测试改用尚未实现的日志接口,原检查语义保持。没有改动 `src/`、`shared/`、`server/mock/`。 + +| 标签 | 真实来源 | +|---|---| +| text_mem | WorkingMemory、LongTermMemory、UserMemory、OuterMemory | +| pref_mem | PreferenceMemory | +| event_mem | EventMemory、Context、InsightMemory;不包含 DreamDiary | +| profile_mem | 有效绑定画像实例中的非空、未弃用、未删除字段 | +| skill_mem | SkillMemory | +| tool_mem | ToolSchemaMemory、ToolTrajectoryMemory | + +五个图记忆标签使用上游 `/product/get_memory` 原生查询和 `/product/get_memory/{id}` 详情。上游增加可选 `status` 和 `event_memory_types` 参数;省略时保留原有行为。画像使用新增的 `/product/list_profile_memories`、`/product/get_profile_memory`,字段 ID 为稳定的实例 ID 加字段路径,支持下划线和中文,不通过语义召回代替全量列表。 + +上游修改涉及 `src/memos/api/profile.py`、`profile_memory_routes.py`、`product_models.py`、`handlers/memory_handler.py`、`routers/server_router.py`、相关测试与再生成的 `docs/openapi.json`。已有的 `universal_api.py` 兼容补丁属于环境准备,不能计入本任务实现。 + +## 查询与隔离口径 + +- 只返回 activated 状态;筛选之后计算 total,再按页返回,空页保留正确 total。图记忆沿用创建时间倒序、ID 倒序,画像按创建时间倒序、实例 ID 和路径稳定排序。 +- `projectId` 通过配置与 SQLite 持久映射解析为 cube,旧数据库中未出现在当前配置的项目不获得访问权限。详情请求按规格没有 projectId,因此使用实例启动时固定的默认项目,不由其他列表请求改变。 +- 同时支持搜索、创建时间、主体类型、主体 ID、知识库范围。日期按 UTC 自然日解释;带时区的时间戳转换为 UTC;日期结束值覆盖整天,时间戳结束值包含该时刻。非法参数返回 40000。 +- 图记忆搜索遵循原生大小写敏感子串匹配;画像搜索使用 casefold。知识库筛选匹配已有图元数据 `knowledgebase_id`、`knowledge_base_id` 或数组 `knowledgebase_ids`;无关联画像不命中,不虚构知识库关系。 +- 投影只输出接口字段,列表正文上限 500 字符加省略号、详情全文;不返回 embedding、sources、history 或整个 metadata。未知主体类型不冒充 user,只有可靠的遗留 user_id/agent_id 才补充主体。 +- 画像 createTime 是实例 created_at,不把 updated_at 当创建时间。画像是字段视图,不是新的业务数据副本。 +- 当前项目映射是本机操作员白名单,不是账号权限体系。`authEnabled=false`;完整项目生命周期和登录授权属于后续任务。不要将当前实例直接用作生产多用户服务。 + +## 真实验收证据 + +2026-10-26 使用真实 MemOS 8000、Dashboard 后端 5281、Neo4j 与画像存储验收。只调用一次真实 `/product/add` 抽取,新增独立测试标识,不删除已有记忆。未做额外 12—15 次付费抽取;真实分页用已有记录加新记录、每页 1 条验证,多页 25 条覆盖由单测完成。 + +| 检查 | 实测结果 | +|---|---| +| 唯一标记 | `Task02-43f4051aafd7` | +| 测试主体 | `task02-user-0d64856f96` | +| 列表发现的 memoryId | `e49e7bcc-279e-4264-ad3e-56dacffaeb10` | +| 同 ID REST 对照 | Dashboard 详情正文与上游原始 memory 逐字一致,类型为 UserMemory | +| 组合筛选 | 搜索 + 主体类型 + 主体 ID + 知识库 ID + 当天时间范围同时命中同一 ID | +| 知识库筛选数据 | 仅给本次新增测试记忆用既有更新接口添加测试知识库标签;不代表 P2 知识库功能已完成 | +| 真实分页 | total=2,第一页与第二页无重复,第三页空且 total 仍为 2 | +| 画像同 ID 对照 | 两个字段的列表、详情和上游同 ID 值相等,包含 basic.user_name 与 basic.城市 | +| 跨项目隔离 | B 项目列表不含 A 标记;另一个默认 B 的后端实例读取 A 的事实及画像 ID 均为 404/非零 code | +| 保留原数据 | 原环境验证记忆仍在事实列表中,未做清理或删除 | +| 浏览器 | 原前端源码在独立 5276 端口成功显示真实事实、画像字段和详情 | + +画像字段 ID: + +```text +profile_2894b0b247b74d9a861a63876a74836e_basic.user_name +profile_2894b0b247b74d9a861a63876a74836e_basic.城市 +``` + +六标签均真实请求成功,事实 total=2、画像 total=2;偏好、事件、技能、工具目前真实 total=0。后四类通过真实空列表和合成数据的列表/详情/映射测试,不能称为真实有数据详情验证。 + +响应体积:真实事实列表 897 bytes,画像列表 848 bytes;空列表 90 bytes。10 行合成数据每行携带 1024 维 embedding 和大型 sources/history,上游投影后响应仅 1750 bytes,低于 200 KiB。上游响应有 32 MiB 读取上限,列表另有投影大小保护。 + +本机报告:`D:/MemOS/.local/task02-runtime-verification.json`;可重复只读脚本:`D:/MemOS/setup/verify_task02.py`,测试标识持久保存在 `.local/task02-seed.json`。这些是工作区环境文件,不随 Git 自动交付;仓库内的自动化回归测试不依赖它们或真实模型。 + +## 质量门 + +- Dashboard:`python -m pytest server/backend/tests -q`,110 passed;包含原任务 01 回归。 +- 上游:画像、请求模型、原生六视图、读取范围、cloud routes、事件与图筛选相关测试,共 70 passed。 +- Dashboard Ruff check 与 format --check 通过;上游本任务改动文件 Ruff check 通过。 +- 独立审查未发现阻塞性缺陷。唯一日期溢出边界已修复:先复现 3 个失败用例,再修复并全量 110 项通过,极端 UTC 边界现在返回 400/40000。 +- `pnpm verify`、Redocly、两仓库 diff 检查和最终运行验证结果见本文件末尾的最终检查记录。 + +失败用例覆盖上游 HTTP 错误、非 JSON、业务失败、畸形负载、超时、连接失败、错误项目/类型/ID、无项目配置、过大与压缩响应。错误保留非零 code,不用空列表冒充成功。 + +## 人工复验 + +打开 `http://127.0.0.1:5276/cn/memoryList`,事实标签应显示标记,点击“查看详情”可以看到上述真实 ID。用户记忆标签是画像字段视图,可看到 `basic.user_name` 与 `basic.城市`。删除按钮尚无本任务对应后端实现。 + +```powershell +$body = @{ projectId='issue3-project-a'; memoryDetailType='text_mem'; pageNum=1; pageSize=10; memSearchContent='Task02-43f4051aafd7' } +Invoke-RestMethod http://127.0.0.1:5281/api/dashboard/project/memory/list -Method Post -ContentType 'application/json' -Body ($body | ConvertTo-Json) +$detail = @{ memoryId='e49e7bcc-279e-4264-ad3e-56dacffaeb10'; memType='text_mem' } +Invoke-RestMethod http://127.0.0.1:5281/api/dashboard/project/memory/detail -Method Post -ContentType 'application/json' -Body ($detail | ConvertTo-Json) +``` + +## 对原实施计划的裁定 + +1. 事件统一走原生 get_memory,加可选事件类型过滤,保留旧默认;避免旧事件接口丢失组合筛选与主体元数据。代价是部署需同步上游补丁。 +2. knowledgeBaseId 按真实元数据匹配,替代原计划的一律拒绝;成本是无关联数据不会命中,不意味着已实现知识库管理。 +3. 未知主体不伪造为 user,保留可靠遗留字段回退;成本是部分旧记录主体可能为空。 +4. 画像按 created_at,而非 updated_at;成本是编辑字段不会被伪装成新创建的记忆。 +5. 明确采用本机白名单和固定默认项目,不提前做完整 P1;成本是当前不能提供多用户授权或通过页面切换活动项目。 +6. 真实付费抽取缩减为一次,真实 2 条分页加合成 25 条分页;成本是四个空类型的有数据详情仍只有合成覆盖,已明确披露。 +7. 将审查发现的日期溢出按参数错误契约修复;成本仅增加三个回归用例,不扩大功能范围。 + +本轮仅完成任务 02,代码尚未提交、推送或创建 PR;Issue #3 整体验收仍须后续任务完成。 + +## 最终检查记录 + +2026-10-26 03:23 UTC,在修复极端日期问题并重启后端后,重复运行真实验收脚本(不带 --seed)通过全部检查,未新增模型调用;任务 01 的 8 项真实 HTTP 检查全部通过。 + +- `pnpm verify`:typecheck、ESLint、Prettier、生产构建全部通过。构建存在原有大 chunk 提示,不影响结果,未为消除提示修改前端。 +- `pnpm --package=@redocly/cli dlx redocly lint openapi.json`:0 errors、2 warnings(原有 info-license 和 localhost servers)。 +- 两仓库 `git diff --check` 均通过;Dashboard 对基线 `03e10145a4dd8bc3e50b6e2a50ed788d64111945` 的 `src/ shared/ server/mock/` 差异为空。 +- `.env.local` SHA256 前后均为 `05f6015a024755f20bf6a71233a0f1fd5bb13d2bf67f06e4c03a63d911dfe4d6`;5274 `/api/meta` 仍为 sample。 +- 上游 docs/openapi.json 已由实际应用重新导出,新增 285 行,无删除;包含新只读画像路由、请求模型和可选过滤参数。 +- 独立浏览器证据保存于 `D:/MemOS/.local/task02-memory-detail.png`。 diff --git a/server/backend/TASK03-RESULT.md b/server/backend/TASK03-RESULT.md new file mode 100644 index 000000000..fe429ce51 --- /dev/null +++ b/server/backend/TASK03-RESULT.md @@ -0,0 +1,132 @@ +# 任务 03:P0 记忆删除、恢复与一致性验收 + +本任务交付普通记忆和画像字段软删除、恢复、图向量确认、持久操作回执、失败重试和故障测试。仅推进至任务 03;日志及后续任务未实现。原前端 5274 保持 Mock,真实验收前端仍为 5276,src/shared/server/mock 未修改。 + +主要代码入口:Dashboard 的 `server/backend/memory_lifecycle.py`、`operation_store.py` 及 `memory_upstream.py`/`app.py` 接入;上游的 `src/memos/api/memory_lifecycle.py`、`memory_lifecycle_routes.py`、`control_lock.py`、`memory_state.py`、`memory_visibility.py`,以及 ProfileService、读取/搜索处理器、server_router 和 server_api 生命周期接入。新增回归集中在两仓库对应的 test_memory_lifecycle,以及上游 test_profile_lifecycle、test_lifecycle_routes、test_lifecycle_visibility;原搜索测试仅补齐新的权威状态读取依赖。 + +## 实现行为 + +普通记忆在上游保留 Memory 节点、正文和向量点,删除将 status 改为 deleted,保存 previous_status、deleted_at 和生命周期操作 ID。只允许删除 activated 条目,冻结和其他状态拒绝。恢复从上游 previous_status 恢复为 activated,不接受任意客户端状态覆盖。 + +上游新增可信控制面接口: + +| 路径 | 用途 | +|---|---| +| POST /product/inspect_memory_lifecycle | 按 cube 检查真实类型和状态,包含墓碑;不输出正文/向量 | +| POST /product/transition_memory | 普通记忆受控软删除/恢复 | +| POST /product/transition_profile_memory | 单个画像字段软删除/恢复 | +| POST /product/get_memory_operation | 按 cube、operation_id 查询持久意图结果 | + +这些接口沿用上游成功 code=200;pending 时 data.success=false,不能只看 HTTP 或 code 判定完成。它们仅供可信内网控制面使用,不构成新增账号权限体系。 + +上游先在现有 MetadataStore 中保存 memory_lifecycle 意图,再写图、写向量 payload、分别读回状态。不能依赖原 update_node 的 best-effort 同步:本实现直接限定租户更新图,向量异常不吞掉、不伪装成功。缺少已索引向量点或索引状态不明确时也保持 pending;只有真实明确 vector_sync=not_indexed 且确认点不存在,才允许按未索引记录完成。 + +Neo4j 使用 CloudEntity 锁记录和唯一约束,在持有数据库事务锁时串行处理同一记忆,进程退出后锁自动释放。文件后备存储采用跨进程文件锁。相反操作先完成前一未完成意图;确认过的旧操作 ID 重放只返回历史结果,不重新执行。启动时和每 5 秒运行补偿,失败持续保留待重试意图,不盲目回滚覆盖后续操作。 + +普通单条、批量、六视图、事件和图谱读取排除 deleted。搜索在返回前按图库当前状态再次校验,图库不可用时返回错误,不能放行旧向量 payload。旧 `/product/get_all` 的搜索子图也过滤已删除邻居和关联边。Neo4j Community 解析器会移除租户字段,因此控制状态通过带租户条件的原生查询读取,不能用解析后缺失字段推断归属。 + +画像以实例 ID 与字段路径定位,只改变一个字段状态、保留原 value 和算法设置,不删除整个实例,不写 Qdrant。项目列表、字段详情、普通 get_profile 和 recall 隐藏已删除字段。模板同步、手工编辑、算法抽取均保留墓碑且不覆盖删除前值。所有画像写入共享控制锁,避免整份文档并发覆盖。若实例已解除绑定或字段已经弃用,操作明确拒绝,不强制恢复不再有效的字段。 + +## Dashboard 接口和回执 + +| 路径 | 行为 | +|---|---| +| POST /api/dashboard/project/memory/delete | 批量软删除;成功保留 data.deleted | +| POST /api/dashboard/project/memory/restore | 恢复;返回 data.restored | +| GET /api/dashboard/project/memory/operation | 按 projectId、operationId 查询回执;查询成功不等于操作已完成,须检查 data.state | +| POST /api/dashboard/project/memory/retry | 仅重试未确认项,沿用上游幂等 ID | + +删除/恢复请求必须包含 projectId、memory_ids、memType,最多 100 个互不重复的 ID。可传 operationId,省略由服务端生成。先校验全部目标的项目、标签和状态,再开始写入;部分失败不撤销已确认项,但整体返回 HTTP 503、code=50301,以及操作 ID、确认数、逐项结果。只有全部确认才 code=0。同一个 operationId 用于不同动作或目标返回 409。 + +SQLite operation_records 在请求上游前保存计划和稳定的逐项操作 ID。网络超时即使无法判断上游是否已执行,也能在后端重启后用同一 ID 查询/重试,已确认项不会再次执行。确认结果单调合并,不被迟到失败回执覆盖。 + +写请求若携带 Origin,仅接受本机既有控制台端口;不携带 Origin 的可信本机 CLI 可调用。项目范围继续采用任务 02 的显式配置白名单,authEnabled 仍为 false。当前并非生产多用户授权,不应公开本机服务端口。 + +## 真实数据与故障证据 + +只新增一个带唯一标记的事实和一个两字段画像,使用一次真实抽取调用。没有删除 Task01/02 原有记忆,也未删除用户记录。所有本次测试记录最终已恢复。 + +```text +marker: Task03-1a180ab65165 +cube: issue3-project-a +subject: task03-user-be7c92cab3 +memoryId: 70693895-6d13-4fd7-b55f-6faf5bc4b7f0 +profile field: profile_7c5ae18b4e844e4fbd28927954b5198e_basic.user_name +``` + +| 场景 | 实测结果 | +|---|---| +| 普通软删除 | Dashboard 详情 404,列表 total=0;普通 REST 单条 data=null、批量为空;真实搜索不返回该 ID | +| 独立双边读回 | 直接查询真实 Neo4j 和 Qdrant,均为 deleted;保留原节点与向量点 | +| 恢复 | 同一 ID 恢复为 activated,正文与删除前逐字一致 | +| 画像 | 删除 basic.user_name 后该字段在普通画像接口不可见,另一字段仍在;经过真实服务重启仍不可见;恢复后原字段值和 ID 不变 | +| 图写入后进程退出 | 独立故障进程在真实图提交后以 47 退出;当时图 deleted、向量 activated;最终状态过滤将旧向量命中降为 0 | +| 重启补偿 | 主上游重启后处理持久意图,状态 confirmed;随后恢复成功 | +| 向量连接故障 | 故障进程向不可达本机端口发出真实 TCP 请求;操作 pending、success=false、retryable=true,图 deleted/向量 activated;没有报告成功 | +| 故障恢复 | 使用正常连接的上游重启后后台重试,attempts=2、双边确认;随后恢复 | +| 旧 ID 重放 | 恢复后重放此前删除 operation_id,返回历史 confirmed 回执;记忆仍 activated,没有再次被删除 | +| HTTP 回执丢失与后端重启 | 真实代理先转发并完成删除,再断开响应连接;Dashboard 返回非零 code 和 pending,持久回执在新后端实例中重试后确认;重复重试,上游 attempts 始终为 1;最后恢复原值 | + +上述进程中断与向量故障仅针对独立 Task03 测试 ID。故障期间暂停的是项目自有上游进程;共享 Neo4j、Qdrant、网关、Mock 和前端一直保留。故障连接只影响注入进程,不停止共享 Qdrant。 + +本机原始证据位于: + +- `D:/MemOS/.local/task03-runtime-verification.json` +- `D:/MemOS/.local/task03-fault-outage.json` +- `D:/MemOS/.local/task03-deleted-double-readback.json`(独立双边读取均为 deleted) +- `D:/MemOS/.local/task03-fault-read.json`(最终恢复后独立双边读取均为 activated) +- `D:/MemOS/.local/task03-seed.json`(独立测试 ID) +- `D:/MemOS/dashboard/.data/task03-memory-lifecycle/progress.md`(执行与审查记录) + +本机辅助工具 `D:/MemOS/setup/verify_task03.py` 和 `task03_fault_probe.py` 是受控的有副作用验收工具,只处理自己的 Task03 种子;不能用作普通无副作用健康检查。仓库内回归测试不调用真实模型、不依赖这些本机文件。 + +`D:/MemOS/setup/verify_task03_retry.py` 另外验证真实 HTTP 回执丢失、后端实例重启、SQLite 回执恢复与幂等重试,不触碰主后端配置。 + +## 自动化测试与基线限制 + +Dashboard 全量测试结果及最终质量检查见下文最终记录。覆盖正常批量、全量预校验、跨项目/错类型、Origin、参数、部分超时、重启后的回执、同 ID 重试、未确认向量不能成功、画像专用路径。 + +上游新增生命周期故障、画像生命周期、可见性和路由回归,并更新搜索测试的真实状态查询依赖。覆盖图后中断、意图写入失败、向量不可用/丢失/读回不一致、幂等冲突、相反并发、运行中自动重试、画像更新不复活、租户隔离、已删除邻居及关联边过滤。 + +全量 `tests/api` 确实执行过,并非全部通过:一次运行是 535 passed、18 failed。为判断是否本次引入,另将未修改的 HEAD 源码导出到隔离目录运行,复现完全相同的 18 个失败 ID(513 passed、18 failed)。其中 13 项涉及当前 checkout 缺失的 deploy/soc/tools 维护脚本,4 项为既有硬删除/技能删除测试,1 项为整套运行时 Prompt Mock 类型问题。未把这些原有问题改为 skip/xfail,也没有将它们宣称为通过。 + +以这 18 个已逐一比对的基线失败作为明确 deselect 后,包含本任务与其他 API 回归的最终运行是 **537 passed、18 deselected**。日志分别保留为 baseline-api-full.log、upstream-api-tests-final.log、upstream-api-baseline-excluded.log。后续任务仍应单独解决这些基线问题。 + +独立审查发现一个 P1:旧 get_all 子图可返回已删除邻居。已先复现失败断言,再补权威状态和关联边过滤,7 项可见性回归通过;没有遗留审查阻塞项。 + +## 请求示例 + +以下示例会更改本次独立测试数据,删除后请执行恢复。 + +```powershell +$body = @{ projectId='issue3-project-a'; memType='text_mem'; memory_ids=@('70693895-6d13-4fd7-b55f-6faf5bc4b7f0'); operationId='manual-delete-unique-id' } +Invoke-RestMethod http://127.0.0.1:5281/api/dashboard/project/memory/delete -Method Post -ContentType 'application/json' -Body ($body | ConvertTo-Json) +Invoke-RestMethod 'http://127.0.0.1:5281/api/dashboard/project/memory/operation?projectId=issue3-project-a&operationId=manual-delete-unique-id' +$body.operationId = 'manual-restore-unique-id' +Invoke-RestMethod http://127.0.0.1:5281/api/dashboard/project/memory/restore -Method Post -ContentType 'application/json' -Body ($body | ConvertTo-Json) +``` + +## 实施裁定与代价 + +1. 保留上游原硬删除接口,新增受控生命周期接口;代价是部署必须同步上游补丁,不能只更新 Dashboard。 +2. 用现有 MetadataStore 和数据库/文件控制锁持久化意图,不复制记忆正文或向量;代价是当前生产确认链路依赖部署中的 Neo4j 和 Qdrant,其他 provider 未获得实测认证。 +3. 补偿期间普通读取以图状态为准,搜索和旧子图增加权威查询;代价是增加读取开销,图库故障时明确失败,不降级放行陈旧向量。 +4. 当前只支持 activated 的删除与恢复;代价是冻结、已弃用或解绑画像明确拒绝,不绕过其生命周期。 +5. 回执记录部分成功,重试剩余项,不做盲目全批回滚;代价是调用方必须识别非零 code、operationId 和 pending 状态。 +6. 不展开 P1 账号授权,也不修改前端源码增加恢复界面;代价是恢复和重试目前通过 API 操作,主前端继续 Mock。 +7. 18 项原有 API 失败经 HEAD 对照证实后保留原样,单列报告;代价是上游全量套件目前仍非全绿,不能把本任务通过等同于全仓库验收通过。 + +本任务不包含任务 04 日志,不提交、推送或创建 PR。 + +## 最终检查记录 + +- Dashboard:`python -m pytest server/backend/tests -q`,**119 passed**。 +- 上游 API:包含本任务的最终基线排除运行 **537 passed、18 deselected**。18 项失败已用未修改 HEAD 源码逐一对照复现,原测试没有改成 skip/xfail;全量套件仍存在这些基线失败。 +- Dashboard Ruff check/format 与上游本任务 17 个改动文件 Ruff check/format 均通过。 +- `pnpm verify`:TypeScript、ESLint、Prettier、生产构建通过;保留原有大 chunk 提示。 +- Redocly:0 errors,2 条原有警告(license 缺失、localhost servers)。保留并复用 DeleteResult 组件,未新增未使用组件警告。 +- 上游 OpenAPI 已通过实际应用导出;Dashboard 当前定义 60 条路径、63 个 HTTP 操作,包含恢复、重试、操作查询三个补充接口。 +- 两仓库 diff --check 通过;src/shared/server/mock 对 Dashboard 基线零变更。 +- Task01 的 8 项真实 HTTP 回归和 Task02 真实只读回归均通过;5274 仍为 sample,.env.local 哈希不变。 +- 本任务所有真实测试项最终恢复:事实同 ID 同正文,画像两字段同 ID 同值;独立 Neo4j/Qdrant 读回均 activated。未提交、推送或创建 PR。 +- 最后一次真实搜索已再次召回恢复后的事实 ID;旧 Task01、Task02 两条原始验证记忆均仍存在。 diff --git a/server/backend/TASK04-RESULT.md b/server/backend/TASK04-RESULT.md new file mode 100644 index 000000000..b29a31fdd --- /dev/null +++ b/server/backend/TASK04-RESULT.md @@ -0,0 +1,70 @@ +# 任务 04:P0 调用日志 + +范围:JSONL 增量导入、列表/详情、历史缺字段、来源与组合筛选、异步关联和 1 万行性能。当前任务不包含任务 05 的用量聚合。 + +## 实现 + +- `POST /api/dashboard/log/list` 返回 `{ code, data: { logs, total }, message }`;`POST /api/dashboard/log/detail` 返回同一日志 ID 的详情。失败统一返回非零业务码。 +- 网关读取显式配置目录的 `calls-*.jsonl` 及数字后缀轮转文件(如 `.jsonl.1`);备份、临时、压缩、协议 `requests-*`、检索诊断 `searches-*` 不作为调用计数。控制台仅对已交付的记忆列表、详情、删除、恢复、重试追加自己的 `console-*.jsonl`;查询日志不产生新调用日志。 +- SQLite 保存文件物理标识、代次、已提交字节偏移及内容锚点;半行等待后续补全,坏行跳过并在 `audit_rejects` 保存代次/位置/原因(不保存原始坏行),文件记录与偏移在同一事务提交。支持追加、重命名轮转、同名替换、截断后重写超过旧偏移、进程重启和并发扫描。 +- 文件是追加式审计源;内容锚点用于检测常见截断重写,不承诺识别任意原位修改。导入索引是可重建缓存,原 JSONL 才是权威数据。WAL `synchronous=NORMAL` 保持事务原子性,断电可能回退整个最近事务,随后重放;不能只提交偏移。 +- 项目必须存在于受控映射。无租户的旧行不归入默认项目;用户 ID 不作为租户。详情契约只有 `logId`,因此使用实例固定默认项目,其他项目的 ID 返回 404。详情读已导入索引,列表请求刷新索引。 +- `scope=all/gateway/console`,支持主体类型/ID、事件、状态、关键词、UTC 日期/带偏移时间戳和分页组合。关键词是脱敏后的事件、关联 ID、主体 ID、请求/响应正文的大小写无关字面子串;`%` 不是通配符。无主体 ID 时清空主体类型。未知事件原名展示,未知事件筛选值返回 400。 +- 历史耗时缺失为 `null`、Token 缺失为 `0`、主体不猜测、时间缺失为空串、状态证据缺失为 `UNKNOWN`;正文缺失为 JSON 字符串 `null`。原本已编码的 JSON 字符串不二次编码;密钥脱敏,大正文标记截断。 +- 异步按 `source + tenant + callId` 合并,支持终态先到、请求晚到、重复终态和跨项目/来源同名 ID。Pending 耗时为 `null`,一个逻辑调用只占一条列表记录。 +- 矛盾字段中明确的业务失败优先于外层成功。终态缺少的信息不会覆盖已经确认的状态、耗时、Token 和正文;真实终态发生冲突时,较晚时间的明确证据优先,相同时间优先失败。明确报告的 0 Token 保留为 0。 +- 审计正文捕获上限为 64 KiB;实际接口在 ASGI scope 独立记录已确认的项目和业务码,所以大请求不会丢失归属,大响应也不会因截断而把成功写成失败。凭据字段支持 snake_case、camelCase 和嵌套/已编码 JSON。 + +## 真实验收与边界 + +实际执行一次只读 MCP `search_memory`,用唯一标识在真实 `calls` JSONL 找到一行;列表和同 ID 详情的请求、响应正文、状态与耗时均与该原行相符。另一个项目查询此标识为 0 条。随后真实执行控制台记忆查询,产生独立 console 行,全部来源恰好两条。 + +验收页面为 [独立真实前端调用日志](http://127.0.0.1:5276/cn/requests):关键词筛选两条、记忆调用来源筛选真实网关日志、详情显示唯一标识均通过。主前端 5274 保持 Mock;`src/`、`shared/`、`server/mock/` 和 `.env.local` 不改动。 + +当前网关本身只记录同步调用,没有 `callId` 和异步终态记录。异步合并使用独立 JSONL 夹具,经真实 HTTP 验证;没有把这部分宣称成生产网关异步联调。后续上游输出异步关联行时,需要显式 `callId`、请求/终态 phase 和 tenant。历史无关联 ID 的行不会被猜测合并。 + +真实验收脚本:`D:/MemOS/.venv/Scripts/python.exe D:/MemOS/setup/verify_task04.py`。只读现有记忆,调用一次真实检索;会消耗少量 embedding Token。报告 `D:/MemOS/.local/task04-runtime-verification.json` 只保存标识、ID、原文件定位、检查结果和计时,不保存密钥或完整真实正文。 + +## 性能 + +验收使用约 5.38 MB 的独立 1 万行 JSONL,每行包含主体、状态、耗时、Token 和约 300 字符响应内容。每轮使用全新 SQLite 数据库,首次真实 HTTP 请求包含建库、全部导入、SQL 查询和首屏序列化。不是预导入后的查询计时,也不代表冷操作系统文件缓存。 + +初次实现约 2.40 秒;性能分析后增加固定版本 `orjson==3.11.4`、SQLite 32 MiB 缓存,并减少无关联 ID 记录的重复存储。最终计时见本地报告与下表。 + +| 轮次 | 冷启动首屏 HTTP | 热查询 P50 / P95 | 重启后首屏 | 增量追加一行 | +|---|---:|---:|---:|---:| +| 1 | 903.312 ms | 27.431 / 31.335 ms | 53.558 ms | 37.823 ms | +| 2 | 859.519 ms | 26.070 / 31.443 ms | 37.083 ms | 34.467 ms | +| 3 | 893.486 ms | 27.619 / 31.166 ms | 39.996 ms | 43.239 ms | + +冷导入原始文件为 **5,378,890 字节 / 10,000 行**,首屏 10 条响应约 **2,916 字节**。三轮均小于 1 秒。补充未负载运行的三轮为 805.607 / 793.371 / 755.036 ms;正式报告以上表为准。该指标受本机负载、磁盘和实际正文大小影响,不承诺任意大小的 1 万条正文均能在一秒内导入。 + +## 最终验证 + +- 后端全套 **162 passed**,包含新增 **43 项**审计测试和原有 119 项回归;列表/详情响应也按根 OpenAPI 的 JSON Schema 校验。 +- Ruff check / format 通过;`pnpm verify` 的 TypeScript、ESLint、Prettier 和生产构建通过,保留原有 chunk 大小提示。 +- Redocly 0 errors、2 条既有警告(license 缺失、localhost server);仍为 60 条路径、63 个 HTTP 操作。 +- Task01 的 8 项真实 HTTP 回归和 Task02 真实只读回归通过;原 Task01/02 记忆保留。审计测试没有删除或恢复真实记忆。 +- 新鲜只读审查发现 4 类重要问题,均以失败测试复现后修复:大正文元数据、矛盾状态优先级、稀疏终态覆盖、驼峰密钥脱敏。另将坏行诊断丢失与备份重复计数按实际影响提升为本次必须修复项,同样完成 RED/GREEN;无遗留审查项。 +- `.data/task04-call-logs/` 保存本地执行记录、失败/通过输出和复验结果;不纳入提交。未重新执行无关上游全套测试,也未把其已知基线失败描述成通过。 + +## 配置与复现 + +```powershell +$env:DASHBOARD_AUDIT_GATEWAY_DIR = 'D:/MemOS/.local/audit' +# 默认控制台目录为 DASHBOARD_DATA_DIR/audit;可选覆盖: +$env:DASHBOARD_AUDIT_CONSOLE_DIR = 'D:/MemOS/dashboard/.data/audit' +``` + +网关来源未配置、目录不存在或不可读时,查询该来源返回 503;明确存在且为空的目录才返回空列表成功。`console` 可独立查询。路径只接受进程配置,不接受 HTTP 参数。运行实例的启动配置变更必须重启自有后端进程,不替换其他占用端口的进程。 + +接口验证示例: + +```powershell +$body = @{ projectId='issue3-project-a'; scope='gateway'; pageNum=1; pageSize=10 } +Invoke-RestMethod http://127.0.0.1:5281/api/dashboard/log/list -Method Post -ContentType 'application/json' -Body ($body | ConvertTo-Json) +``` + +控制台日志在业务响应后追加,写失败只记录异常类型,不把已经执行的操作变成可重试假失败。日志目录和索引应按审计数据设置本地权限。当前仍是单机 SQLite 方案;账号鉴权和多副本集中摄取不属于本任务。 + +没有提交、推送或创建 PR,没有推进任务 05。上游沿用任务 03 的既有测试结论:537 通过、18 项已对照 HEAD 复现的基线失败,不宣称这些失败已修复。 diff --git a/server/backend/TASK05-RESULT.md b/server/backend/TASK05-RESULT.md new file mode 100644 index 000000000..541a8d4f8 --- /dev/null +++ b/server/backend/TASK05-RESULT.md @@ -0,0 +1,56 @@ +# Task05 — P1 用量交付结果 + +## 实现范围 + +`POST /api/dashboard/usage/query` 已交付。它通过与日志接口相同的 `AuditService` 做增量同步,再读取同一 SQLite `audit_calls` 逻辑记录;没有第二套 JSONL 统计器、逐次重新全读或人为生成业务次数。Task06 项目生命周期及账号鉴权没有提前实现。 + +主要代码:`usage_models.py`(参数和日期边界)、`usage_routes.py`(授权后同步及统一响应)、`AuditIndex.usage`(同索引聚合)、`ControlStore.authorized_cubes`(当前配置允许范围)。根 `openapi.json` 补齐原有 projectId 必填与 all/空串描述歧义,以及 summary/元数据和错误契约。前端保护目录不变。 + +## 固定统计口径 + +- 仅 `gateway` 来源;console 记忆页读取、删除及审计查询自身不计入用量。无法访问配置日志来源时明确503,不能假装0。 +- `addMessage` / `searchMemory` / `rerank` 提供 `calls` 和 `totalTokens`;`chat` / `extractMemory` 提供请求、响应、总 token。所有五种分组每天存在,空日为0;不因为分组对象存在而虚构一次调用。未知事件不凑入已知事件。 +- `summary.totalCalls` = `addMessage.calls + searchMemory.calls`,与当前前端总请求卡片口径一致,不声称等于所有网关事件次数。`summary.totalTokens` 是五个已知事件的 token 之和;每条缺失 token 按0,不按文本长度推算,也不是账单。 +- SUCCESS / FAILURE / PENDING / UNKNOWN 都是已观察到的调用,各逻辑行计一次。异步请求、重复终态按 Task04 的来源+cube+callId 合并;终态到达会更新原调用 token,不增加次数。日期优先请求时间,终态先到且缺请求时暂用终态时间,后续请求到达可纠正归属日期。 +- `projectId` 省略、null、空白或 `all`:只遍历当前进程配置 allowlist;具体项目不在范围返回404。数据库残留但本次配置已移除的项目不进入全项目统计。无配置范围返回503。先校验范围,再扫描审计文件。 +- 以上是当前本地操作员配置授权边界;`authEnabled=false`,尚未交付 P1 账号会话/成员角色,不能称生产多用户鉴权完成。 +- 日界线与前端一致:北京时间固定 UTC+8,响应 `timezone=Asia/Shanghai`。`startDate/endDate` 仅接受 YYYY-MM-DD(空白/null等同未传),不接受 timestamp;默认当月1日至今天,最多366天,首尾日期包含。实际索引条件为 `[开始日00:00, 结束日次日00:00)`。每个请求验证时固定默认日期,跨月不会重新取今天导致500。无合法时间的历史行仍在日志中,但不归入任何日用量。UTC+8 为当前 UI 的固定时区口径,不处理历史上海夏令时。 +- `includeDailyDetails=false` 返回 `dailyStats=[]` 及完全相同的 `summary`,避免静默忽略。非空 `apiKeyId` 目前明确400,理由是审计没有可信密钥 ID;不能忽略此筛选返回更宽范围,也不取完整密钥作为身份。 + +## 验证证据 + +行为测试包含:UTC+8 两侧微秒边界、失败/pending、空日、历史无时间、未知事件、来源隔离、真实/缺失 token、异步更新只计一次、项目all与移除配置残留、授权先于摄取、apiKeyId不支持、366天/闰日、无效日期/上界溢出、默认当月/跨月请求一致性。首轮18项RED后实现GREEN;跨月500问题另补RED再修复。 + +- Task05 专项:25项通过。 +- 全后端187项通过(9.45秒);Ruff检查通过;Task01真实运行信息/故障/Mock保护回归全部通过。 +- `D:/MemOS/setup/verify_task05.py`:独立真实HTTP夹具验证边界、异步、重启与配置撤权,通过;2个逻辑调用、16个真实夹具token。 +- 5281真实HTTP与同一生产审计索引逐日对照通过:当前项目4次add/search、26个自然日,50.129ms(此为已持久索引上的当次查询,不冒称10k冷启动性能)。无新模型调用,无真实记忆写入或删除。 +- Task01 未实现接口负例从已经实现的 usage/query 改为尚未实现的 project/create;其余既有验证保留。 + +验收仅覆盖 Task05 以及必要回归。主5274保持Mock,独立5276可查看真实用量。未执行Task16停止Mock后的全模块验收,原有上游基线失败仍按Task03报告说明。 + +## 用户验证 + +打开 `http://127.0.0.1:5276/cn/usage`,选择 `issue3-project-a`,查看7/14/30天次数与每日明细。接口示例: + +```json +{"projectId":"issue3-project-a","startDate":"2026-10-01","endDate":"2026-10-26","includeDailyDetails":true} +``` + +向 `http://127.0.0.1:5281/api/dashboard/usage/query` 发送 POST;成功统一信封 `code=0`,`data.dailyStats` 是逐日数据,`data.summary` 是区间汇总。日期按实际验证日期调整。 + +最终只读审查提出的日期契约差异已修复:根日期schema使用null/纯空白/严格日期三分支,与实现一致;非空日期两侧空白明确400。新增带FormatChecker的根请求契约测试和4个HTTP参数回归,先RED后GREEN。主控独立随机oracle、原始JSONL对照及前端最终验收另由主控记录。 + + +## 主控最终独立验收 + +- 子 agent 实现和只读审查均继承主 agent 的模型与思考强度,本任务未使用 Astra;主控另行独立验收。 +- 主控重跑后端全套:187 passed(9.07秒);Ruff check / format 全部通过(31个Python文件)。日期请求schema差异已由实现子agent按RED/GREEN修复,限定只读复核25项再次通过,无遗留审查项。 +- 独立240行随机夹具,含多时区、不同租户、失败状态、未知事件和真实/缺失Token,以另一份Python日历算法逐日核对:7天、70次调用、796Token,全部一致。 +- 只读原始2份 `calls-*.jsonl` 独立计数,与真实接口逐日对照:本月26天、4次add/search调用,全部归属北京时间2026-10-26。这里的原始行计数仅用于当前没有callId的真实记录对照;异步去重另由带关联ID的隔离夹具验证,不冒称生产异步投递已验收。 +- 独立真实前端 http://127.0.0.1:5276/cn/usage 的近7/14/30天、每日明细和零日期展示均通过,显示4次检索、0次写入。控制台记忆操作日志不膨胀此处工具用量。 +- `pnpm verify`:TypeScript、ESLint、Prettier及生产构建通过,保留既有chunk大小提示。Redocly:0errors、2条既有警告;未新增HTTP操作(仍60路径、63操作)。 +- `src/`、`shared/`、`server/mock/` 对基线零差异;`.env.local` 哈希一致,主5274仍为sample。git diff --check通过。 +- 独立验收输出位于 `.data/task05-maincheck/`,实现者HTTP输出位于 `.data/task05-usage/acceptance.json`。这些文件只保存检查结果与汇总,不存密钥或完整真实正文;不纳入提交。 + +仍采用既定边界:固定UTC+8日历、不处理历史DST;全项目仅当前本地操作员配置allowlist,完整账号/成员鉴权属于后续任务;非空apiKeyId明确不支持;Token按已记录值统计,缺失为0,不能当作模型账单。断电、多机、生产异步网关投递及Task06以后功能不属于本次验收。没有调用付费模型,没有写删真实记忆,没有推进任务06或执行任务16全模块验收。 diff --git a/server/backend/TASK06-RESULT.md b/server/backend/TASK06-RESULT.md new file mode 100644 index 000000000..16232074e --- /dev/null +++ b/server/backend/TASK06-RESULT.md @@ -0,0 +1,45 @@ +# Task06 — P1 项目工作区交付结果 + +## 交付范围 + +已实现真实项目列表、创建、更新、选中、停用、可恢复归档及恢复,以及服务重启后的持久化。数据写入 `D:/MemOS/dashboard/.data/control.db`,不再使用固定项目字典冒充完整项目管理。本任务由主 agent 实施和复核,没有新增子 agent;未推进任务07,也未提交或推送代码。 + +接口:`GET project/dict`;`POST project/list`、`project/create`、`project/update`、`project/delete/{id}`、`project/restore/{id}`、`project/revoke/{id}`,共同前缀 `/api/dashboard/`。列表返回 `records/total`,统一信封 `code/data/message`。根 `openapi.json` 记录可写字段、分页上限、状态语义和错误响应;归档/恢复沿用现有规格操作,未增加额外路径。 + +## 设计和实现 + +- `control.py` 事务迁移旧四列表,保留原项目 ID、名称、创建时间及上游映射。种子启动插入不覆盖已修改名称、停用或归档状态;配置映射冲突拒绝启动。新建项目分配不可变 UUID ID,上游 namespace 与该 ID 对应,客户端不能指定其他租户、所有者或归档元数据。 +- 种子访问范围来自本进程配置;本地操作员通过接口创建的项目有持久管理标记,重启仍可访问。配置移除的旧种子不因数据库残留而重新获得访问范围。 +- `project_models.py` 限制可写字段和大小,严格分页,拒绝 null 更新与空更新;名称采用 NFKC/casefold 唯一键。事务保护并发同名创建、部分更新回滚和状态变化。 +- `project_context.py` 使用30天 HttpOnly、SameSite=Lax cookie 保存不透明偏好标识,数据库仅存 SHA256 摘要;HTTPS 下使用 Secure。两个浏览器上下文独立选择,同一上下文重启后保留。该 cookie 是项目偏好,不是登录凭证。 +- `status=ENABLED` 仅表示本上下文的当前活跃项目;非当前项目也可能 `status=DISABLED, disabled=false`,不能与真正停用混淆。对停用项目发送 ENABLED 只重新启用,再次发送才选中;归档恢复不自动切换,且保留原停用状态。 +- 停用/归档后移出活跃字典,记忆、日志、用量显式项目接口拒绝读取;只对选中目标的上下文回退。记忆详情、日志详情及对应控制台审计关联改用当前上下文,不再固定配置默认项目。无活跃项目时字典为空,详情明确不可用。 +- 归档为控制面软删除,revoke 是兼容别名,不删除 MemOS、图、向量或记忆正文。新项目 namespace 通过真实上游六种读取视图验证为空;未调用属于另一套 owner 注册协议的 `/create_cube`,也未生成假记忆。 +- `projects.py` 复用写操作 Origin 检查,数据库故障统一503,非法参数400、重名/归档更新409、未知或不可访问项目404,不返回假成功。 + +主要文件:`control.py`、`project_models.py`、`project_context.py`、`projects.py`、`app.py`、`memories.py`、`audit_routes.py`、`audit_writer.py`、`tests/test_projects.py`。测试 conftest 隔离工作目录,防止默认配置污染真实 `.data`。 + +## 已执行验证 + +- 全后端 **204 passed,13.97秒**,其中17项项目专项测试;包含旧库迁移、重启、独立上下文、停用/归档门禁、空项目、并发重名、回滚、Origin、字段/分页及实际响应对根 OpenAPI 的结构校验。 +- Ruff check 和 format check:36个 Python 文件通过。 +- `pnpm verify`:TypeScript、ESLint、Prettier及生产构建通过;保留原有大 chunk 提示。 +- `pnpm --package=@redocly/cli dlx redocly lint openapi.json`:0错误,2条既有警告(license、localhost server)。 +- `D:/MemOS/setup/verify_task06.py`:同一个临时 SQLite 库上三次真实 HTTP 服务器实例验证完整生命周期;这两次实例重建不冒称操作系统进程重启。真实5281服务另做 **三次操作系统进程重启**,逐次验证当前选择、停用状态、归档状态持久化,且旧项目与原标识记忆仍存在。 +- 实际验收项目 `proj_d819620bb02441958981be2ade083248` 最终保留为可恢复归档。新 namespace 六视图为空,原项目 A 的标识记忆 `61ca5bdd-65bc-4c5d-9fcc-115e7cea396c` 全程保持可读。 +- 修正验收脚本必须存在上次测试残留的错误假设。Windows 启动器输出改用普通临时文件,避免长驻后端继承管道导致等待 EOF 超时;保留启动器退出码和健康检查。重启改用已校验所有权的 Stop/Start,停止 Python 父子进程树。单独重跑修正后的 Restart-Backend.ps1 通过,进程替换完成,用时4.72秒。 +- Task01真实健康/连接失败/超时/Mock保护8项回归通过;Task02六标签、同ID REST、分页、画像及外项目详情拒绝回归通过;Task05隔离边界/异步/重启及真实同索引逐日对照通过(当前4次调用、26天,查询75.202ms)。本次未调用付费模型,未写删真实记忆。 +- 独立真实前端5276完成新建、编辑、设为当前、停用、启用、归档、恢复。浏览器测试项目 `Task06-ui-20261026-edited` / `proj_bdf912eab65347ee87eaa0c94ad0f01b` 恢复为正常,当前选择回到 `issue3-project-a`。 +- 主5274及Mock5280仍为sample,`.env.local` 校验一致;`src/`、`shared/`、`server/mock/` 相对基线零差异。无上游代码改动。 + +本机验收记录:`.data/task06-projects/acceptance.json`、`pnpm-verify.log`、`redocly.log` 和 `browser-projects-restored.png`;这些本地证据不包含密钥或完整记忆正文,不纳入提交。Task01与Task02既有验收记录、Task05 acceptance 已刷新。 + +## 用户验证与明确边界 + +打开 http://127.0.0.1:5276/cn/projects 操作测试项目;5274仍展示原Mock。可新建、编辑、切换、停用、启用、归档和恢复,重启后再次读取列表核对。直接接口使用 project/list 的 `records`,不能用其他模块的 `list/logs` 键。 + +既有前端在归档当前项目时曾显示成功提示且侧栏已回退,但表格仍保留旧状态;重新载入后显示“已归档”,恢复操作立即显示“正常”。后端独立读取及重启证据均正确。遵守本任务 `src/` 零变更约束,未修改此表格缓存行为,也不宣称无刷新的页面状态同步已完全通过。创建表单中“名称同时作为ID”的既有提示也与服务端生成ID不一致,真实返回的ID应以接口为准。 + +`authEnabled=false`,`role=owner` 是本机受信操作员能力,账号、成员、邀请与登录会话属于任务07。前端归档提示中“成员/API Key立即失效、恢复原样可用”超出本任务:目前仅控制台数据接口受项目状态约束,网关密钥配置/停用联动尚未交付,不能据此声称网关调用已被阻断。新建项目也不会自动创建网关密钥。 + +未执行任务16停Mock后的全模块验收。上游既有18项基线失败仍见TASK03报告,不宣称其已修复。 diff --git a/server/backend/TASK07-RESULT.md b/server/backend/TASK07-RESULT.md new file mode 100644 index 000000000..c4ef23fff --- /dev/null +++ b/server/backend/TASK07-RESULT.md @@ -0,0 +1,35 @@ +# Task07 — P1 账号、登录会话与管理员交付结果 + +## 已交付 + +- 后端 `GET/POST /__login`、`GET/POST /__logout`,以及真实 `/api/auth/me`。现有Vite已代理这些路径,前端源码保持不变。 +- 管理员账号分页/关键词搜索、更新显示名/管理员标记、禁用/启用、删除。JSON字段按规格保留snake_case;me为camelCase,完整响应由根OpenAPI校验。 +- 密码采用16字节随机盐与scrypt;会话为32字节随机token,库中仅保存SHA256摘要、账号、到期时间和CSRF随机值。Cookie为HttpOnly/SameSite=Lax,HTTPS为Secure,默认24小时,范围60秒至30天。不同会话有独立持久当前项目,匿名偏好cookie不能替代登录。 +- 每次请求重新检查会话和账号状态。退出撤销当前会话;禁用、删除撤销该账号所有会话,重新启用不会复活旧会话。默认开启鉴权,无任何公共默认密码或匿名管理员。 +- 最后一个有效(未禁用)平台管理员不能被降级、禁用或删除。账号也是团队最后有效owner时,禁用/删除同样被拒;先转移所有权。BEGIN IMMEDIATE保护并发约束。 +- 账号删除清理个人控制空间、成员及会话;团队项目不删除,邀请创建者/使用者引用置空,已消费状态保留。上游记忆不因账号删除被清空。P3密钥模块未实现,不宣称网关密钥清理或状态联动已经验收。 + +## 主要实现与故障修复 + +`identity_store.py`(账号/会话及约束)、`auth.py`(认证中间件、HTML与管理员API)、`bootstrap.py`(本地一次性初始化/账号创建)、`security.py`(显式Origin)、`settings.py`、`app.py`、`control.py`、`project_context.py`,以及记忆/日志/用量的请求级权限接线。 + +登录表单为8192字节上限、CSRF保护和同源跳转;数据库故障统一503且不透传详情。错误CSRF含Unicode时曾触发500,已先RED后修为403。真实浏览器发现no-referrer使表单Origin变成null,现改为same-origin并用浏览器证实;仍拒绝Origin:null,保留CSP form-action self和跨源来源限制。仅允许现有本机来源,或通过DASHBOARD_ALLOWED_ORIGINS显式配置HTTPS控制台来源。登录失败按来源限速,10次失败后等待5分钟;这不是外部身份提供商或完整企业SSO。 + +## 本机使用 + +打开 http://127.0.0.1:5276/__login ,账号 `memos-admin`;随机密码只保存在 `D:/MemOS/.local/dashboard-admin.json`,该文件已设置当前用户专有ACL。初始化前已做一致SQLite备份,位于 `D:/MemOS/.local/backups/before-task07-identity-*.sqlite`。登录后进入“项目与成员→用户”。主5274继续是Mock。 + +新部署使用 `python -m server.backend.bootstrap 用户名 --bootstrap` 交互设置首个管理员;本地新增账号去掉--bootstrap,需要管理员时加--admin;也可通过有效邀请注册。未提供公共随意注册或修改现有密码的接口。账号初始化仅能在空账号库执行一次。 + +## 共同验收证据(最终版本) + +- 全后端 **229 passed,57.56秒**;其中任务07—09新增25项测试,保留任务01—06的204项回归,并显式标注旧隔离夹具的非认证模式。Settings与CLI默认均开启鉴权。 +- Ruff check / format通过,47个Python文件;`pnpm verify`(类型、Lint、格式、构建)通过,保留原有chunk大小提示。 +- Redocly:0错误、5条警告。原有license与localhost两条;新增3条因为HTML POST成功仅303跳转,没有2xx,按真实行为保留,不虚构200响应。根规格现为63条路径、69个HTTP操作,新增的是登录/退出/邀请落地各GET/POST。 +- 一次继承主模型与思考强度的只读安全复核(无Astra、无额外实现agent),没有具体未解决问题。复核者独立运行当时22项身份测试通过;后续新增3项异常存储/HTTPS/浏览器表单策略回归由主控完成。 +- `D:/MemOS/setup/verify_task07_09.py` 通过真实5281 HTTP执行登录、账号启停/删除、角色权限、成员增改移除及邀请创建/角色/注册接受/单次/撤销/过期,实际操作系统进程重启后保留会话、选中项目与已用邀请状态。最终合成项目 `proj_7ead9c61ba7a4775a41748cef8200ad7` 已可恢复归档,空的合成账号已删除;不写删真实记忆,不调用付费模型。 +- Task01真实健康/连接故障/超时/保护回归8项通过;Task02六标签、同ID REST、画像字段、分页和外项目详情拒绝通过;Task05边界/异步/重启及真实同索引对照通过(4次调用、26日,48.862ms)。旧Task02/05本机验收工具已增加从私有初始化文件登录的兼容入口,不绕过鉴权。 +- 真实浏览器5276登录、账号列表、成员列表、候选搜索,以及邀请生成/改角色/撤销通过;关闭后重开邀请弹窗无明文令牌。截图 `.data/task07-09-identity/browser-invites-revoked.png` 不含有效邀请令牌或密码。 +- `.env.local` 校验不变,主5274与5280仍为sample;`src/`、`shared/`、`server/mock/` 相对基线零差异。无上游代码改动;`git diff --check`通过。未提交/推送,未推进任务10或执行任务16;上游既有18项基线失败未在本任务修复。 + +本机证据位于 `.data/task07-09-identity/`(live-acceptance.json、backend-tests.log、pnpm-verify.log、redocly.log、task02-regression.log及截图)。不包含密码、会话令牌或完整真实正文,不纳入提交。早期任务报告是当时状态快照,当前认证配置以本报告和README为准。 diff --git a/server/backend/TASK08-RESULT.md b/server/backend/TASK08-RESULT.md new file mode 100644 index 000000000..4a833181c --- /dev/null +++ b/server/backend/TASK08-RESULT.md @@ -0,0 +1,37 @@ +# Task08 — P1 成员、角色与最后owner交付结果 + +## 已交付 + +`/admin/tenants/{tenantId}/members` GET/POST、`member-candidates` GET、`members/{accountId}/remove` POST、`members/leave` POST。候选按用户名/显示名搜索,带已有成员/禁用标记,最多100项并返回真实匹配总数。添加按account_id或username二选一;非法角色、显式null及同时指定两个目标拒绝400。 + +| 身份 | 项目读取/选中 | 记忆写操作 | 成员/邀请/项目管理 | 平台账号管理 | +|---|---|---|---|---| +| viewer | 本人有权项目 | 禁止 | 禁止,可自行退出 | 禁止 | +| editor | 本人有权项目 | 允许 | 禁止,可自行退出 | 禁止 | +| owner | 本人有权项目 | 允许 | 本项目允许 | 禁止 | +| 平台管理员 | 配置允许及本地创建范围 | 允许 | 允许 | 允许 | + +范围检查同时覆盖显式projectId、当前项目详情、操作回执、日志与全项目用量。创建团队项目在同一事务中加入创建者owner。项目列表中的role是有效权限,管理员可能有管理能力而成员表里不是owner;最后owner约束只数真实、有效的成员。 + +最后有效owner不可降级/移除/退出,账号禁用/删除也不可绕开。禁用owner不计入有效数;并发降级两个owner最终只有一次成功。成员移除立即撤回后续请求范围和当前选择,不全局改变其他会话。不同账号和会话互不串项目。 + +个人空间随账号管理,不作为可邀请共享的团队;团队成员API对此明确409,不假返回成功。既有前端仍可能展示通用成员入口,但不能据此绕过该边界。 + +## 证据与入口 + +5项最初成员专项先RED后GREEN;额外边界测试验证全项目用量只统计成员范围、viewer可读回执、非管理员owner可以管理候选/邀请、实时撤权、账号禁用/删除不能绕开最后owner。真实HTTP另验证增改查移除、viewer写拒绝及最后owner退出拒绝。 + +浏览器 http://127.0.0.1:5276/cn/projects 进入团队“成员”:真实owner显示,候选搜索正确标记已加入,最后owner退出按钮被禁用;服务端约束不依赖按钮是否显示。主要实现为 `members.py` 和请求绑定的 `ControlStore.with_actor/visibility/require_role`。 + +## 共同验收证据(最终版本) + +- 全后端 **229 passed,57.56秒**;其中任务07—09新增25项测试,保留任务01—06的204项回归,并显式标注旧隔离夹具的非认证模式。Settings与CLI默认均开启鉴权。 +- Ruff check / format通过,47个Python文件;`pnpm verify`(类型、Lint、格式、构建)通过,保留原有chunk大小提示。 +- Redocly:0错误、5条警告。原有license与localhost两条;新增3条因为HTML POST成功仅303跳转,没有2xx,按真实行为保留,不虚构200响应。根规格现为63条路径、69个HTTP操作,新增的是登录/退出/邀请落地各GET/POST。 +- 一次继承主模型与思考强度的只读安全复核(无Astra、无额外实现agent),没有具体未解决问题。复核者独立运行当时22项身份测试通过;后续新增3项异常存储/HTTPS/浏览器表单策略回归由主控完成。 +- `D:/MemOS/setup/verify_task07_09.py` 通过真实5281 HTTP执行登录、账号启停/删除、角色权限、成员增改移除及邀请创建/角色/注册接受/单次/撤销/过期,实际操作系统进程重启后保留会话、选中项目与已用邀请状态。最终合成项目 `proj_7ead9c61ba7a4775a41748cef8200ad7` 已可恢复归档,空的合成账号已删除;不写删真实记忆,不调用付费模型。 +- Task01真实健康/连接故障/超时/保护回归8项通过;Task02六标签、同ID REST、画像字段、分页和外项目详情拒绝通过;Task05边界/异步/重启及真实同索引对照通过(4次调用、26日,48.862ms)。旧Task02/05本机验收工具已增加从私有初始化文件登录的兼容入口,不绕过鉴权。 +- 真实浏览器5276登录、账号列表、成员列表、候选搜索,以及邀请生成/改角色/撤销通过;关闭后重开邀请弹窗无明文令牌。截图 `.data/task07-09-identity/browser-invites-revoked.png` 不含有效邀请令牌或密码。 +- `.env.local` 校验不变,主5274与5280仍为sample;`src/`、`shared/`、`server/mock/` 相对基线零差异。无上游代码改动;`git diff --check`通过。未提交/推送,未推进任务10或执行任务16;上游既有18项基线失败未在本任务修复。 + +本机证据位于 `.data/task07-09-identity/`(live-acceptance.json、backend-tests.log、pnpm-verify.log、redocly.log、task02-regression.log及截图)。不包含密码、会话令牌或完整真实正文,不纳入提交。早期任务报告是当时状态快照,当前认证配置以本报告和README为准。 diff --git a/server/backend/TASK09-RESULT.md b/server/backend/TASK09-RESULT.md new file mode 100644 index 000000000..d296bd624 --- /dev/null +++ b/server/backend/TASK09-RESULT.md @@ -0,0 +1,31 @@ +# Task09 — P1 单次邀请与接受流程交付结果 + +## 已交付 + +- 团队邀请列表/创建、角色修改、撤销。角色严格为viewer/editor/owner,默认有效168h;expiresIn接受正整数s/m/h,最长30天,与现有UI的1/7/14/30天选择兼容。 +- 明文令牌仅创建时在`path`返回一次;库保存随机token摘要,列表不返回路径或令牌。默认Uvicorn入口关闭原始URL访问日志,避免邀请路径落盘。 +- `/__login/invite/{token}` 后端落地页支持当前账号接受;未登录者可注册新账号并接受,已有账号可跳转登录后回到该邀请。使用现有Vite `/__login` 代理,不改React。 +- 创建、修改、撤销仅owner/管理员;接受是有效邀请能力授权,提交仍需CSRF及Origin校验。密码与账号创建沿用Task07规则,注册失败与邀请消费同事务回滚。 +- 接受时重新检查令牌摘要、到期、撤销、已用及项目活跃状态。BEGIN IMMEDIATE原子写入成员及已使用状态;并发两个账号只能一个成功。成功会选中新加入项目。 +- 已有成员接受返回409、邀请不消耗、原角色不变,防止被viewer邀请静默降级。已使用/撤销409、过期410、未知或不可访问404。创建者账号删除保留团队邀请但清空引用;已使用者删除不让邀请重新有效。 + +## 测试与真实使用 + +5项邀请专项先RED后GREEN,含角色、摘要、单次、撤销、过期、停用项目、外项目操作、注册接受、重复成员和并发接受;额外边界覆盖创建者删除和本地登录续跳,所有JSON响应对根OpenAPI校验通过。 + +真实5281注册接受、改角色、重用拒绝、撤销、短有效期过期和重启保留已通过。真实5276团队“成员→邀请链接”完成生成、改为编辑者及撤销;关闭重开后仅显示“编辑者/已撤销”,不再显示明文链接。有效测试链接未发送给第三方,无邮件发送或外部账号操作。 + +打开 http://127.0.0.1:5276/__login 登录后,进入 http://127.0.0.1:5276/cn/projects 的团队成员弹窗。邀请路径须在同一控制台来源打开;访问POST成功返回303跳回项目页,不能把它当作JSON业务接口。 + +## 共同验收证据(最终版本) + +- 全后端 **229 passed,57.56秒**;其中任务07—09新增25项测试,保留任务01—06的204项回归,并显式标注旧隔离夹具的非认证模式。Settings与CLI默认均开启鉴权。 +- Ruff check / format通过,47个Python文件;`pnpm verify`(类型、Lint、格式、构建)通过,保留原有chunk大小提示。 +- Redocly:0错误、5条警告。原有license与localhost两条;新增3条因为HTML POST成功仅303跳转,没有2xx,按真实行为保留,不虚构200响应。根规格现为63条路径、69个HTTP操作,新增的是登录/退出/邀请落地各GET/POST。 +- 一次继承主模型与思考强度的只读安全复核(无Astra、无额外实现agent),没有具体未解决问题。复核者独立运行当时22项身份测试通过;后续新增3项异常存储/HTTPS/浏览器表单策略回归由主控完成。 +- `D:/MemOS/setup/verify_task07_09.py` 通过真实5281 HTTP执行登录、账号启停/删除、角色权限、成员增改移除及邀请创建/角色/注册接受/单次/撤销/过期,实际操作系统进程重启后保留会话、选中项目与已用邀请状态。最终合成项目 `proj_7ead9c61ba7a4775a41748cef8200ad7` 已可恢复归档,空的合成账号已删除;不写删真实记忆,不调用付费模型。 +- Task01真实健康/连接故障/超时/保护回归8项通过;Task02六标签、同ID REST、画像字段、分页和外项目详情拒绝通过;Task05边界/异步/重启及真实同索引对照通过(4次调用、26日,48.862ms)。旧Task02/05本机验收工具已增加从私有初始化文件登录的兼容入口,不绕过鉴权。 +- 真实浏览器5276登录、账号列表、成员列表、候选搜索,以及邀请生成/改角色/撤销通过;关闭后重开邀请弹窗无明文令牌。截图 `.data/task07-09-identity/browser-invites-revoked.png` 不含有效邀请令牌或密码。 +- `.env.local` 校验不变,主5274与5280仍为sample;`src/`、`shared/`、`server/mock/` 相对基线零差异。无上游代码改动;`git diff --check`通过。未提交/推送,未推进任务10或执行任务16;上游既有18项基线失败未在本任务修复。 + +本机证据位于 `.data/task07-09-identity/`(live-acceptance.json、backend-tests.log、pnpm-verify.log、redocly.log、task02-regression.log及截图)。不包含密码、会话令牌或完整真实正文,不纳入提交。早期任务报告是当时状态快照,当前认证配置以本报告和README为准。 diff --git a/server/backend/TASK12-13-REPAIR-2026-10-27.md b/server/backend/TASK12-13-REPAIR-2026-10-27.md new file mode 100644 index 000000000..eec18b800 --- /dev/null +++ b/server/backend/TASK12-13-REPAIR-2026-10-27.md @@ -0,0 +1,39 @@ +# Task12/13修复与最终专项复验(2026-10-27) + +任务12和13本轮专项验收通过。2026-10-27复核报告中的两个P2问题均已关闭,服务未齐的环境限制也已解除。由主agent完成,没有子agent;没有修改知识库实现、原有业务Prompt或受保护前端,也未提交Git、发布或推进任务16。 + +## 已关闭问题 + +1. 上游prompt_handler.py原先用布尔or合并memory list与memory_list,误将合法空数组当作缺失。现在按键存在性选取,明确主键优先;两种空数组均返回空results,非法结构仍明确raw。新增6项测试,其中原空数组和主键优先测试先实际失败,再修复通过。 +2. Dashboard prompts.py原先只过滤原生记忆类型。现在先统一原生/规范类型,再对所有已知类型按选择集合过滤,非结构结果单独保留raw。新增text_mem/pref_mem/event_mem三个选择集交叉用例,三个用例均先失败再修复通过。 + +部署补丁已同步到server/backend/patches/task12-upstream-prompt-runtime.patch,包含此前的运行时模板、键名兼容、multi-cube隔离修复,以及本次上游debug空结果修复与测试;git apply --reverse --check通过。上游源码和Dashboard适配器需要一起交付,本机均已生效。 + +## 本次新的真实链路证据 + +复验标记REPAIR_RULE_785229fb8e3c,仅存在于保存的Prompt,不在对话输入。使用原先专用隔离项目,生成候选不自动保存;真实模型空抽取返回零条候选、不落记忆;保存后真实上游进程重启仍能读回配置。真实调试返回所选text_mem。随后实际/product/add产生新记忆db0949e1-7610-4176-aa75-b5a5510f5291,新ID不在本次写入前的记忆集合中。 + +Dashboard按新标记查到该记忆,并与原生REST同ID逐字对照。图谱标签120字、全文220字,全文SHA256为80019e816ce3fdc59455945d41e0b49a53bca9b99eac6301340252909897c7b8;地点南京、保留37天、周日03:00备份、两名owner审批四项信息均保留。limit=1的真实图返回truncated=true,换另一项目读取同一ID返回404。 + +模型清单对照真实/product/models共3行,角色/模型/来源正确,只投影白名单字段;凭据、URL查询/片段不上报。匿名读取模型接口401;viewer权限、停用范围、异常结构、响应预算、模型凭据注入等负例由专项回归验证。 + +另一个项目的Prompt保持不变;验收项目Prompt在finally中恢复为开始前配置。本次合成记忆保留供复核,未删除或修改原业务数据。中间一次未提供具体JSON例子的测试模板返回非结构文本,服务正确标记raw且未伪装成功;测试模板明确JSON结构后才完成最终真实抽取对照。 + +## 页面复验与当前服务 + +辅助前端5276:真实Prompt调试输入“你好”后显示“暂无数据”,不显示raw;图谱中点击本次新ID并展开220字全文成功;模型页显示Haiku和bge-m3。截图位于.data/task12-13-repair-20261027/prompt-empty.png、graph-fulltext.png、models.png。 + +Docker原先未运行,已启动D盘现有Docker并按已有脚本启动依赖及服务。最终8000健康正常,5281元信息200/live,辅助5276为live;主5274依旧sample,Mock保留。.env.local及src/shared/server/mock相对03e10145a4dd8bc3e50b6e2a50ed788d64111945零变化。 + +## 最终检查 + +- Dashboard全量280 passed(82.22秒),本次Task12/13专项18 passed。 +- 上游相关108 passed;上游三项Prompt/图谱/运行时模块专项23 passed。 +- Dashboard本任务4个文件、上游本任务5个文件Ruff check及format检查通过。 +- pnpm verify通过,保留既有大bundle提示。 +- Redocly 0错误、5条既有警告。 +- 上游补丁可逆应用检查通过。 + +本机证据目录.data/task12-13-repair-20261027含live.json、verification.json、redocly.log及三张截图,均不包含密钥、会话令牌或原业务记忆正文。带真实模型调用的本机脚本是D:/MemOS/setup/verify_task12_13_repair.py(有收费调用、独立测试状态,默认单元测试不会执行);既有已完成回执会阻止重复整轮执行,避免误把缓存当成新的一轮通过。 + +本结论只覆盖任务12/13;知识库保持用户指定现状,P3与任务16仍按各自实施状态单独验收。 diff --git a/server/backend/TASK12-13-REVIEW-2026-10-27.md b/server/backend/TASK12-13-REVIEW-2026-10-27.md new file mode 100644 index 000000000..c1dcf80f1 --- /dev/null +++ b/server/backend/TASK12-13-REVIEW-2026-10-27.md @@ -0,0 +1,42 @@ +# Task12/13复核(2026-10-27) + +**状态更新:本报告所列两项问题及环境限制已修复并重新验收,见 [修复与最终专项复验](TASK12-13-REPAIR-2026-10-27.md)。下文保留发现时的历史记录。** + +结论:主体实现存在,现有自动化检查通过;发现2项可复现Task12边界问题,本轮不修改实现代码。Task13代码与专项检查未发现新的阻断问题,但当前实际服务不齐,不能把旧验收截图当成本次现场联调通过。 + +## P2:合法空抽取结果被误判为raw + +位置:D:/MemOS/upstream/src/memos/api/handlers/prompt_handler.py:216。 + +当模型返回`{"memory list": [], "summary": "No durable facts"}`,`memory_list = parsed.get("memory list") or parsed.get("memory_list")`把空数组当成缺失,最终响应为`results:[{"type":"raw","content":"原JSON字符串"}]`。这应是正常的零条记忆,而非模型格式异常。对一次性任务、寒暄等应当不记忆的输入,会出现错误的预览结果。该问题位于上游,当前BFF继承此行为。 + +复现:给PromptHandler.debug_extract的llm.generate设置上述固定返回值,调用真实方法;已经复现,无真实付费模型调用。 + +建议:区分键不存在和合法空数组;两个别名都按显式存在性选择,并增加空数组、非空数组、非法结构回归用例。不要用布尔or合并合法可空列表。 + +## P2:调试类型筛选遗漏规范类型名 + +位置:D:/MemOS/dashboard/server/backend/prompts.py:158。 + +请求extractionTypes=["text_mem"],上游返回results=[{"type":"pref_mem","content":"synthetic preference"}]时,接口返回HTTP200并原样包含pref_mem,与响应extractionTypes矛盾。当前只在原生类型名映射时过滤,规范类型名绕过过滤。 + +复现:使用临时SQLite目录及httpx.MockTransport,经真实登录会话调用POST /api/dashboard/prompt-config/debug/extract。返回`{"extractionTypes":["text_mem"],"results":[{"type":"pref_mem","content":"synthetic preference"}]}`。未写正式项目数据。 + +建议:先统一映射成规范type,再对所有已知记忆类型执行选择集过滤;raw按明确约定单独处理。补充原生/规范两套类型名与选择集合交叉测试。 + +## 本次实际检查 + +- Dashboard全量:277 passed,84.36秒。 +- Task12/13专项:15 passed。 +- 上游实际模板传播、Prompt接口和图谱相关测试:17 passed。 +- 本任务4个Dashboard实现/测试文件Ruff check和format通过。 +- pnpm verify通过;构建仍有既有大bundle提示。 +- Redocly校验通过:0错误、5条已有警告。 +- src/shared/server/mock/.env.local相对03e10145a4dd8bc3e50b6e2a50ed788d64111945无diff。 +- 上游Task12补丁git apply --reverse --check通过,当前源代码包含模板生效、别名及multi-cube隔离修复。 + +## 当前现场限制 + +5281/health正常,但5281/api/meta返回HTTP503、code50300(无法连接上游MemOS)。8000、5274、5276连接失败。因此本次不能复跑真实上游同ID全文对照或浏览器完整链路;既有.data/task10-13-p2中的成功证据是2026-10-26历史验收记录。没有为复核启动/停止服务,没有再次调用付费模型,也没有修改Prompt、知识库或实现源码。 + +建议关闭上述2个问题后,启动完整测试环境,重新跑Prompt空结果/筛选用例,以及真实抽取、同ID图谱全文、跨项目404和模型脱敏验证,再给出最终通过结论。 diff --git a/server/backend/TASK12-RESULT.md b/server/backend/TASK12-RESULT.md new file mode 100644 index 000000000..c47ffa190 --- /dev/null +++ b/server/backend/TASK12-RESULT.md @@ -0,0 +1,36 @@ +# Task12:P2 Prompt 验收结果 + +**最新状态(2026-10-27):两项复核问题已修复,真实链路和页面已重新验收。当前最终结果为280项后端测试、108项上游相关回归通过;详见 [最终复验](TASK12-13-REPAIR-2026-10-27.md)。下文2026-10-26数字为首次交付快照。** +2026-10-26。任务12已交付;本轮由主agent直接实现,无子agent。知识库实体与文件按用户要求冻结现状。本报告不宣称整个Issue3或任务16已经完成。 + +## 行为与权限 + +| 接口(前缀 `/api/dashboard/prompt-config`) | 行为 | 最低权限 | +|---|---|---| +| POST `/detail` | 从真实上游读取项目已保存模板或默认模板 | viewer | +| POST `/save` | 保存整个模板,必须包含 `${conversation}`,最长20000字 | owner | +| POST `/generate` | 使用真实模型按描述生成候选模板,不自动保存 | editor | +| POST `/reset-default` | 删除项目自定义配置并返回上游默认模板 | owner | +| POST `/debug/extract` | 用项目当前模板调用真实模型,返回候选,不写记忆 | editor | + +所有接口要求真实登录会话和可访问、未停用/归档的项目。请求禁止额外字段;生成描述最长2000字;调试消息1–30条,每条最长20000字。extractionTypes仅接受text_mem/pref_mem/event_mem,空数组使用默认三类。返回类型映射原生记忆类型;非结构模型输出明确标记raw。普通读取与保存使用上游请求超时,生成/调试使用独立120秒总截止时间,可通过DASHBOARD_MODEL_TIMEOUT_SECONDS配置,最大180秒。模型异常、HTTP200业务失败和模板占位符丢失返回明确失败,不回显服务商错误或密钥,不隐式重试收费调用。 + +## 真实抽取生效的修复与证据 + +验收发现两处实际断点:多模态读取器覆盖父类方法后只选内置模板;模型返回memory_list时调试器能识别、真实读取器却只识别memory list。现在真实chat抽取读取项目ContextVar模板,并兼容两种键名。多项目fan-out还会逐cube载入各自模板,避免把第一个项目的规则串到后续项目。文档抽取仍使用原有文档模板。 + +改动仅涉及上游内部multi_modal_struct.py和composite_cube.py及新增回归测试,没有新增上游公共API或数据库迁移。上游补丁保存在patches/task12-upstream-prompt-runtime.patch,针对deployment-v2本机基线8d6e231d7d95ea29b6a2716c9c34837644d3e7f6;已通过git apply --reverse --check。本任务部署需要该补丁生效并重启上游,仅复制Dashboard适配器不能修复上游真实抽取。 + +隔离项目proj_50e0980ab13a4f1b96560b016e34c96e中,RULE_PREFIX_0420c18a只写在保存的Prompt,不在对话输入。真实/product/add同步fine抽取后,Dashboard按该标记查到记忆ef3c3567-e47e-4c50-9e2f-c58943f8ccba;再按同一ID读取上游REST,正文确实包含规则前缀。生成候选没有改写原配置,另一个项目的Prompt哈希未变,完成后隔离项目已恢复默认。 + +辅助前端5276实际完成编辑、保存、恢复默认与重新读取;另用中文合成发布约定触发真实调试,页面显示4条text_mem候选。调试前后隔离项目的活动记忆均为2条,证明调试未落库。原有业务项目与Prompt未修改。隔离项目保留2条明确标记的合成记忆供复核,其中1条为早期失败探针、1条为最终规则生效证据。 + +## 测试与复核 + +新增回归先观察失败再修复,覆盖模板传播、工作线程复用不串规则、未配置项目回退、文档模板独立、multi-cube隔离及键名兼容。Dashboard专项还覆盖读取/保存/生成/重置/调试、角色和跨项目、停用项目、占位符、上游故障与脱敏。 + +最终Dashboard全量249 passed;任务12/13专项15 passed;上游相关102 passed。本任务Dashboard七个文件与上游三个文件Ruff check/format均通过;按改动范围执行上游make format中的Ruff命令,避免全仓自动修改无关文件。pnpm verify通过;Redocly为0错误、5条既有警告(license、localhost、3条HTML成功303);src/shared/server/mock/.env.local相对基线03e10145a4dd8bc3e50b6e2a50ed788d64111945零变化。主5274保持sample,辅助5276为live。 + +本机证据:.data/task10-13-p2/task12-live.json、task12-prompt-debug.png、verification.json。命令:python -m pytest server/backend/tests -q。真实模型验收脚本为本机D:/MemOS/setup/verify_task12.py,执行会修改隔离测试项目Prompt并可能产生收费调用,不属于默认单元测试。 + +浏览器复核:http://127.0.0.1:5276/cn/customExtraction,使用现有账号登录,选择Task10-13-a-0420c18a。生成候选仍需点保存才改变真实抽取;快速调试使用服务端已保存模板,编辑区未保存文本不生效,这是当前前端既有交互。 diff --git a/server/backend/TASK13-RESULT.md b/server/backend/TASK13-RESULT.md new file mode 100644 index 000000000..cf65606b1 --- /dev/null +++ b/server/backend/TASK13-RESULT.md @@ -0,0 +1,32 @@ +# Task13:P2 图谱与模型验收结果 + +**最新状态(2026-10-27):两项复核问题已修复,真实链路和页面已重新验收。当前最终结果为280项后端测试、108项上游相关回归通过;详见 [最终复验](TASK12-13-REPAIR-2026-10-27.md)。下文2026-10-26数字为首次交付快照。** +2026-10-26。任务13已交付;由主agent直接实现,无子agent。不包含P3密钥、属性树模板和任务16总验收;知识库保持用户要求的现状。 + +## 实现 + +GET /api/dashboard/graph/memories从真实/product/get_memory_graph读取激活记忆,tenant只能是当前账号可访问的Dashboard项目ID,省略时使用持久会话选择,不能传任意上游cube绕过授权。types为原生类型逗号列表,不支持的类型400;limit默认2000、范围1–2000。停用、归档、无权项目不可读。所有响应只投影约定字段,不下发embedding、sources正文、原始配置等扩展字段。 + +节点标签最多120个Unicode字符;后端截断或上游已截断都会正确设置labelTruncated。节点预算1MiB,连线预算750KiB且最多8000条,达到预算后stats.truncated=true;专项测试验证整体JSON小于2MiB。仅保留有效端点,映射parent/merged关系,并计算同会话星状连线、来源派生连线、连通簇与孤点统计。sourceIds保留已授权源节点携带的来源引用,不因当前页缺少来源节点篡改来源数量;绘图连线仍过滤悬空端点。 + +GET /api/dashboard/graph/memory按同一项目查询/product/get_memory_by_ids,mem_cube_id通过原生接口要求的query传递,正文来源与图谱一致,兼容InsightMemory等图节点。排除删除、归档与非激活节点,跨项目同ID404;全文JSON预算1MiB,超过显式413,不拿截断标签冒充全文。 + +GET /api/dashboard/models需要登录,允许普通账号只读部署级模型清单。数据来自真实/product/models;只允许已知4种配置来源,role/group由服务端固定映射,不信任上游额外字段。模型名禁止携带URL、查询参数或明显密钥形态;供应商只保留hostname/port,去除userinfo、路径、query和fragment;向量维度/重排后端分别按白名单投影。未知配置来源不返回,重复或畸形有效行明确502。当前真实配置为3行:Haiku记忆读取、Haiku对话、BAAI/bge-m3向量化;没有虚构未配置的重排模型。 + +所有接口只读、Cache-Control:no-store;身份、成员撤权沿用真实会话中间件,统一使用{code,data,message}。上游超时/业务失败返回明确错误且不回显服务商错误正文。 + +## 真实验收 + +隔离项目proj_50e0980ab13a4f1b96560b016e34c96e,limit=1时返回真实截断标记。记忆ef3c3567-e47e-4c50-9e2f-c58943f8ccba的标签120字、全文228字,Dashboard全文与同ID原生REST逐字相等,SHA256=3320a4224ea6e101ca17676ad14c78fc8e227fa24dce9010f1428623c61151e8。换另一个项目读取该ID得到404。辅助前端5276已显示真实图谱,点击节点并展开全文成功;模型页面显示实际Haiku与bge-m3清单。最终Backend重启后相同验证再次通过。 + +专项覆盖未知类型、limit边界、任意cube注入、匿名401、viewer跨项目、异常上游结构、删除节点、来源引用、响应体预算、只读方法、模型URL/密钥注入与配置脱敏。 + +最终Dashboard全量249 passed;任务12/13专项15 passed;上游相关102 passed。任务范围内Ruff check/format通过;pnpm verify通过;Redocly0错误、5条已有警告。保护目录src/shared/server/mock/.env.local相对03e10145a4dd8bc3e50b6e2a50ed788d64111945零变化,5274仍sample,5276为live。 + +证据位于本机.data/task10-13-p2/task13-live.json、task13-graph-fulltext.png、task13-models.png、verification.json、redocly.log;没有输出密钥、会话令牌和原业务记忆正文。真实只读复核命令:D:/MemOS/.venv/Scripts/python.exe D:/MemOS/setup/verify_task13.py。该本机脚本仅适用于已创建的隔离验收项目。 + +浏览器:http://127.0.0.1:5276/cn/memoryGraph 和 /cn/models。接口实现不是全库无限导出,显示统计对应返回投影,不代表截断后仍涵盖完整图。当前知识库入口仍保持既有前端状态,本任务未修改或启用它。 + +## 2026-10-27 图谱范围修复 + +早期验收只验证了普通记忆图,遗漏了独立存储的画像字段。现已补全画像合并、同 ID 全文和孤点分组,真实项目 4 条事实 + 4 条画像对应 8 个节点。详细修复与当前前端图例限制见 PROFILE-GRAPH-REPAIR.md;旧统计、端口状态和测试数量属于当时快照。 diff --git a/server/backend/TASK14-RESULT.md b/server/backend/TASK14-RESULT.md new file mode 100644 index 000000000..90e4969c4 --- /dev/null +++ b/server/backend/TASK14-RESULT.md @@ -0,0 +1,53 @@ +# TASK14-RESULT:P3接口密钥(2026-10-27最终复验) + +> 后续增强:网关已支持配置变化检测与自动重载,当前创建/撤销无需重启,见 GATEWAY-RELOAD-RESULT.md。下文重启生效记录是升级前的真实历史验收;重演脚本已更新为无需重启网关并检查PID不变。 + +任务14已修复独立复核发现的5项问题并通过本轮专项验收。仅主agent执行,无子agent、无Git提交;知识库、前端、Mock及任务15/16保持原范围。 + +## 实现与关闭问题 + +| 项目 | 最终行为 | +|---|---| +| SQLite与网关文件一致性 | 所有配置发布和网关加载共用跨进程advisory lock;事务提交前设置pending。提交失败立即按已提交数据库恢复文件,补偿失败保留pending并返回明确503;网关拒绝加载pending配置。启动或后续成功变更可按数据库自愈。不是跨介质原子事务。 | +| 凭据错误脱敏 | env/file解析错误使用固定无凭据消息,屏蔽原异常上下文;冲突仅带掩码。兼容原有缺省/null cube回退user语义。 | +| 安全清理 | 每轮创建新隔离项目,只处理本轮获得的key ID;finally撤销本轮key并归档本轮项目。不再枚举或撤销issue3-project-a的现有密钥。 | +| 自动验收门禁 | 所有状态码、真实检索、审计租户、重启PID、配置字节均为必须通过的require;失败非0,记录明确failed/complete状态。每轮run_id唯一,禁止覆盖旧证据;序列化扫描通过后才落盘和输出。 | +| 本机直连 | 所有探针/驱动HTTP客户端trust_env=False,避免Windows系统代理干扰。恶意token模式以401为成功预期。 | + +5条keys路由契约不变:owner/platform_admin授权,完整密钥下发;分页records/total;copy.data为完整字符串;改名不改网关字节;撤销行保留可审计。明文存储依原契约保留,没有宣称KMS或一次可见。 + +发布协调路径为tenants.conf.lock与tenants.conf.pending,Dashboard和网关必须使用同一个配置路径与共享协调目录。锁文件首次创建需要目录写权限。该协议覆盖并发读取、提交失败、补偿失败及进程崩溃恢复,不承诺断电跨文件系统原子性。已运行网关继续使用启动快照,签发/撤销仍需重启生效。 + +Restart-Gateway.ps1在停止已有网关前检查已知pending状态;正式加载时仍受发布锁和pending校验保护。敏感临时文件使用唯一名称、写入刷新、原子替换、finally清理;锁、pending及崩溃残留临时文件均已加入上游ignore。 + +## 新的真实验收 + +当前task14-live.json为一次全新独立运行的20条记录,单一run_id,末条complete.passed=true;不是把旧25条混合重试记录当作全通过。旧记录、旧verification与原报告已原样归档到.data/task14-keys/history/。 + +- 旧密钥200、恶意密钥401。 +- 在全新项目proj_c8a0974d3e7e4e1bb9c314a208d2b4a9创建密钥、copy逐字相同、rename后manifest字节不变。 +- 操作系统进程真实重启Dashboard,密钥和会话仍持久可读。 +- 新key网关重启前401;真实重启后200,tools/list含search_memory。 +- 真实只读search_memory成功;JSONL唯一run marker命中1条,tenant等于本轮新项目ID。 +- revoke从配置移除;再次真实重启网关,同key401;旧密钥仍200。 +- finally仅清理本轮资源;manifest回到测试前逐字节相同状态,已有凭据未被清扫。 +- 两个当前验收JSON序列化后均不含凭据前缀,源码Recorder也强制拒绝不安全记录。最后一次加载最终网关源码后,旧200/恶意401再次通过。 + +## 检查结果 + +Dashboard全量287 passed(84.35秒)。密钥相关原20项加本次一致性/安全驱动7项,共27项覆盖;网关文件10项、既有契约17项、工具18项全部通过。Ruff check/format通过,pnpm verify通过;Redocly0错误、5条已有警告。前端src/shared/server/mock/.env.local相对03e10145a4dd8bc3e50b6e2a50ed788d64111945无变化;5274为sample,5276/5281为live。 + +## 交付与重演 + +版本化源码为server/backend/acceptance/probe_gateway.py与task14_driver.py;.data同名文件是安全兼容入口,旧危险脚本仅作为.pre-review.txt历史文本留档。网关补丁server/backend/patches/task14-gateway-tenants-file.patch已更新并通过reverse-check;Windows运维脚本位于本机D:/MemOS/setup/Restart-Gateway.ps1。 + +```powershell +cd D:\MemOS\dashboard +D:\MemOS\.venv\Scripts\python.exe -m pytest server/backend/tests -q +D:\MemOS\.venv\Scripts\python.exe -m server.backend.acceptance.probe_gateway old +D:\MemOS\.venv\Scripts\python.exe -m server.backend.acceptance.probe_gateway malicious +# 每次自动生成新run_id和新输出路径;会操作本轮隔离项目,并真实重启backend/gateway。 +D:\MemOS\.venv\Scripts\python.exe -m server.backend.acceptance.task14_driver --root D:/MemOS +``` + +当前证据为.data/task14-keys/task14-live.json、verification.json、ledger.md;计划为docs/superpowers/plans/2026-10-27-task14-repair.md。本结论为任务14专项,不把Task15/16总验收提前视为完成。 diff --git a/server/backend/TASK14-REVIEW-2026-10-27.md b/server/backend/TASK14-REVIEW-2026-10-27.md new file mode 100644 index 000000000..20cdbeb2e --- /dev/null +++ b/server/backend/TASK14-REVIEW-2026-10-27.md @@ -0,0 +1,43 @@ +# Task14独立复核(2026-10-27) + +**状态更新:下列5项问题已修复并通过新的隔离真实重演,见 [最终结果](TASK14-RESULT.md)。下文保留原始发现记录。** + +结论:已交付主体能力,常规专项检查通过,但发现以下问题,暂不予最终验收通过。本轮未修改实现,未运行会清扫现有密钥的原driver,未启动子agent。原verification.json和25条历史live记录保持不变。 + +## P1:数据库提交失败后配置残留,不满足设计的补偿约定 + +位置server/backend/keys.py:114、134。create/revoke在SQLite提交之前已替换tenants.conf;数据库提交抛错后仅通用返回503,未按专项设计§5.2重新从已提交数据库恢复manifest。 + +在临时数据库、临时网关文件、真实TestClient登录会话中,用SQLite authorizer只拒绝COMMIT:create返回503/code50300,api_keys为0行,manifest却为1条。下一次仅重启网关即可加载库中不存在的孤儿凭据。Dashboard下次启动自愈不能代替本次失败后的补偿;结果文档“文件与库同事务原子”不成立。需要覆盖提交失败,而不只是写文件失败;补偿失败也必须显式标识与阻断错误配置发布。 + +## P1:损坏的配置条目会把完整token带进异常 + +位置upstream/deploy/mcp-gateway/gateway/domain/tenants.py:30、60。load_tenants_file直接调用parse_tenants;若JSON条目缺user_id,ValueError包含完整token。用合成token和临时文件已复现contains_complete_token=True,输出中没有打印token。通过build_app启动传播时会进入未捕获启动异常/日志,不符合计划“任何日志不得有完整token”。冲突分支的mask_token保护不足以覆盖全部解析错误。需要对文件/env所有凭据相关解析错误统一脱敏。 + +## P1:重演脚本会撤销现有项目的非测试密钥 + +位置.data/task14-keys/task14_driver.py:145–163。PROJECT固定issue3-project-a,入口先列出前100条active key并逐一撤销,没有限制到本次run的key ID或明确隔离项目。按报告指令重演会破坏现有凭据;名字叫幂等清扫不改变这个副作用。本次没有执行它。应使用独立临时项目,只清理本轮创建并记录的key,失败时finally清理本轮资源。 + +## P2:driver并非完整的自动验收门禁 + +位置.data/task14-keys/task14_driver.py:191、215、232、250。重启前401、检索isError/tenant归因、撤销后401等结果只record为布尔值;不匹配仍可能return0。record本身也没有宣称的“无sk-断言”。25条历史JSON实际含多轮driver-start/login/cleanup,不是一次干净单轮;早期startup_hits_ok=False也在其中,末轮关键记录符合预期。建议每轮run_id、必需步骤、预期状态严格断言、失败非0、最终passed以及写证据前的秘密扫描。 + +## P2:探针默认读取系统代理,当前无法直接重演 + +位置.data/task14-keys/probe_gateway.py:75、79,及driver的默认httpx.Client。当前直接执行old和malicious探针均为502;显式trust_env=False直连,旧密钥200、非法密钥401、health200。stdlib检测到Windows http/https/ftp系统代理,尽管没有HTTP_PROXY环境变量。探针默认行为受系统代理影响,需像现有后端一样显式使用trust_env=False或明确loopback bypass,不能把502当作网关鉴权结论。 + +## 已验证通过 + +- 密钥仓储和路由20项测试通过;权限、完整密钥契约、普通持久化/文件失败回滚、改名与软撤销正常路径有覆盖。 +- 网关文件合并6项、既有网关契约17项通过;上游补丁reverse-check通过。 +- 密钥本任务文件Ruff通过。 +- 交付verification.json、task14-live.json可解析;live为25条,两个JSON均没有sk-字符串;ledger含7任务实施记录及Ruling。 +- 当前网关health200;避开系统代理的只读验证为存量token200、恶意token401,没有生成或撤销实际密钥。 + +## 计划位置 + +总体实施顺序:docs/superpowers/specs/2026-10-26-issue3-dashboard-backend-design.md。 +Task14实施计划:docs/superpowers/plans/2026-10-26-issue3-task14-keys-plan.md。 +Task14设计:docs/superpowers/specs/2026-10-26-issue3-task14-keys-design.md。 + +本次安全复核证据:.data/task14-keys/review-verification-20261027.json,仅计数/布尔/状态码,不含实际密钥、cookie或原始配置正文。修复后还需要新的隔离401→200→401真链路重演,旧25条不能替代新的完成证据。 diff --git a/server/backend/TASK15-RESULT.md b/server/backend/TASK15-RESULT.md new file mode 100644 index 000000000..406cf3fb7 --- /dev/null +++ b/server/backend/TASK15-RESULT.md @@ -0,0 +1,29 @@ +# Task15 属性树模板交付 + +2026-10-27。主 agent 实现,无子 agent,未提交代码。 + +## 已完成 + +五个约定接口均复用上游真实 profile 服务:POST list/create/update/delete,GET detail。项目映射由控制存储解析;viewer 可读,owner 可写;停用、归档、未授权项目不可访问。 + +列表按契约输出 list/current/size/total/pages;详情将 profile_template_id/name/metadata/bound_count/updated_at 投影为 templateId/templateName/动态字段树/bindingCount/updatedAt。更新时间统一 UTC+8。未知上游字段不下发。创建返回 templateId,更新/删除返回空对象成功信封。 + +上游更新保留 deprecated 历史字段,适配层仅投影当前字段,不改历史数据;删除沿用上游语义,已绑定画像变为 detached,不销毁其历史值。 + +## 验证 + +- 12 条专项测试通过:契约、空态、分页、CRUD、跨项目三种操作拒绝、viewer 写拒绝、未知项目、输入校验、非法上游响应、deprecated 投影、原生 null 默认值兼容及源数据不变。 +- 实际后端5281→上游8000,8个检查全部通过,证据 `.data/task15/706769ba885e.json`。 +- 独有 TASK15 标记创建;BFF与原生REST同一模板ID和字段树;分页中可找到;另一项目详情/更新/删除均404;更新移除字段后原生保留deprecated、BFF不再展示。 +- 后端与上游分别真正重启后仍能读回同ID更新内容;之后删除并验证两侧均不可见。最终通过运行的测试模板已删除。 +- 可重演入口:`python -m server.backend.acceptance.task15_driver --project --other-project --cube --restart`。使用本机已有管理员文件,不输出凭据。 + +## 限制 + +`src/pages/ProfileTemplates.tsx` 仍是 ComingSoon,菜单仍禁用,没有列表编辑 UI;本任务交付 API 能力,不把占位页称为可操作页面。未修改 src/、shared/、Mock或OpenAPI。 + +Dashboard契约为两层“组→字段→字符串/布尔叶子”,不开放原生三层或任意JSON值。限制50组、500字段、单值4000字符、规范化树128KiB;禁止点号路径和与上游叶子标记冲突的字段名。create不接受templateId/bindingCount/updatedAt伪造字段;update要求完整当前树与名称。未增加绑定接口或模板编辑前端。 + +原生历史模板允许 value:null 表示未设置默认值,投影时省略契约可选的字符串 value 字段,保留 algorithm_updatable;不写回、不把 null 文本化。issue3-project-a 的历史模板列表已实测通过。先前一次重启就绪等待失败留下的本次自建模板也已按记录 ID 定向清理。 + +前置登录/退出界面同时完成:居中白卡片、控制台紫色按钮、显式标签、返回入口;样式用CSP哈希授权,脚本仍禁止。19条既有认证/邀请回归通过,实际浏览器截图见 `.data/auth-ui/login.png` 与 `logout.png`。 diff --git a/server/backend/TASK16-OPERATIONS.md b/server/backend/TASK16-OPERATIONS.md new file mode 100644 index 000000000..c4d36c458 --- /dev/null +++ b/server/backend/TASK16-OPERATIONS.md @@ -0,0 +1,75 @@ +# Task16 操作状态矩阵(固定接口修复后) + +知识库按用户指示保持冻结。路由存在性不是逐条真实写入验收。 + +| Method | Path | 状态 | 处理路由 | +|---|---|---|---| +| GET | `/api/dashboard/project/dict` | 已实现 | 有 | +| POST | `/api/dashboard/project/list` | 已实现 | 有 | +| POST | `/api/dashboard/project/create` | 已实现 | 有 | +| POST | `/api/dashboard/project/update` | 已实现 | 有 | +| POST | `/api/dashboard/project/revoke/{id}` | 已实现 | 有 | +| POST | `/api/dashboard/keys/list` | 已实现 | 有 | +| POST | `/api/dashboard/keys/create` | 已实现 | 有 | +| POST | `/api/dashboard/keys/revoke/{id}` | 已实现 | 有 | +| POST | `/api/dashboard/keys/copy` | 已实现 | 有 | +| POST | `/api/dashboard/log/list` | 已实现 | 有 | +| POST | `/api/dashboard/log/detail` | 已实现 | 有 | +| POST | `/api/dashboard/project/memory/list` | 已实现 | 有 | +| POST | `/api/dashboard/project/memory/detail` | 已实现 | 有 | +| POST | `/api/dashboard/project/memory/delete` | 已实现 | 有 | +| POST | `/api/dashboard/knowledgebase/list` | 知识库冻结 | 有 | +| POST | `/api/dashboard/knowledgebase/create` | 知识库冻结 | 有 | +| POST | `/api/dashboard/knowledgebase/update` | 知识库冻结 | 有 | +| POST | `/api/dashboard/knowledgebase/detail` | 知识库冻结 | 有 | +| POST | `/api/dashboard/knowledgebase/file/list` | 知识库冻结 | 有 | +| POST | `/api/dashboard/knowledgebase/memory/list` | 知识库冻结 | 无 | +| POST | `/api/dashboard/knowledgebase/project/associate` | 知识库冻结 | 有 | +| POST | `/api/dashboard/knowledgebase/project/remove` | 知识库冻结 | 有 | +| POST | `/api/dashboard/profileTemplate/list` | 已实现 | 有 | +| GET | `/api/dashboard/profileTemplate/detail` | 已实现 | 有 | +| POST | `/api/dashboard/profileTemplate/create` | 已实现 | 有 | +| POST | `/api/dashboard/profileTemplate/update` | 已实现 | 有 | +| POST | `/api/dashboard/profileTemplate/delete` | 已实现 | 有 | +| POST | `/api/dashboard/prompt-config/detail` | 已实现 | 有 | +| POST | `/api/dashboard/prompt-config/save` | 已实现 | 有 | +| POST | `/api/dashboard/prompt-config/generate` | 已实现 | 有 | +| POST | `/api/dashboard/prompt-config/reset-default` | 已实现 | 有 | +| POST | `/api/dashboard/prompt-config/debug/extract` | 已实现 | 有 | +| POST | `/api/dashboard/usage/query` | 已实现 | 有 | +| POST | `/api/dashboard/skill/skillMD` | 按Issue固定成功返回 | 有 | +| GET | `/api/user/quota/summary` | 按Issue固定成功返回 | 有 | +| POST | `/api/user/subscription/details` | 按Issue固定成功返回 | 有 | +| GET | `/api/meta` | 已实现 | 有 | +| GET | `/api/auth/me` | 已实现 | 有 | +| POST | `/api/dashboard/project/delete/{id}` | 已实现 | 有 | +| POST | `/api/dashboard/project/restore/{id}` | 已实现 | 有 | +| POST | `/api/dashboard/keys/rename/{id}` | 已实现 | 有 | +| POST | `/api/dashboard/knowledgebase/delete` | 知识库冻结 | 有 | +| POST | `/api/dashboard/knowledgebase/file/add` | 知识库冻结 | 有 | +| POST | `/api/dashboard/knowledgebase/file/delete` | 知识库冻结 | 有 | +| GET | `/api/dashboard/knowledgebase/file/download` | 知识库冻结 | 有 | +| GET | `/api/dashboard/graph/memories` | 已实现 | 有 | +| GET | `/api/dashboard/graph/memory` | 已实现 | 有 | +| GET | `/api/dashboard/models` | 已实现 | 有 | +| GET | `/admin/accounts` | 已实现 | 有 | +| POST | `/admin/accounts/{id}` | 已实现 | 有 | +| DELETE | `/admin/accounts/{id}` | 已实现 | 有 | +| GET | `/admin/tenants/{tenantId}/members` | 已实现 | 有 | +| POST | `/admin/tenants/{tenantId}/members` | 已实现 | 有 | +| POST | `/admin/tenants/{tenantId}/members/{accountId}/remove` | 已实现 | 有 | +| POST | `/admin/tenants/{tenantId}/members/leave` | 已实现 | 有 | +| GET | `/admin/tenants/{tenantId}/member-candidates` | 已实现 | 有 | +| GET | `/admin/tenants/{tenantId}/invites` | 已实现 | 有 | +| POST | `/admin/tenants/{tenantId}/invites` | 已实现 | 有 | +| POST | `/admin/tenants/{tenantId}/invites/{inviteId}/revoke` | 已实现 | 有 | +| POST | `/admin/tenants/{tenantId}/invites/{inviteId}/role` | 已实现 | 有 | +| POST | `/api/dashboard/project/memory/restore` | 已实现 | 有 | +| POST | `/api/dashboard/project/memory/retry` | 已实现 | 有 | +| GET | `/api/dashboard/project/memory/operation` | 已实现 | 有 | +| GET | `/__login` | 已实现 | 有 | +| POST | `/__login` | 已实现 | 有 | +| GET | `/__logout` | 已实现 | 有 | +| POST | `/__logout` | 已实现 | 有 | +| GET | `/__login/invite/{token}` | 已实现 | 有 | +| POST | `/__login/invite/{token}` | 已实现 | 有 | diff --git a/server/backend/TASK16-RESULT.md b/server/backend/TASK16-RESULT.md new file mode 100644 index 000000000..fbccdcd0e --- /dev/null +++ b/server/backend/TASK16-RESULT.md @@ -0,0 +1,73 @@ +# Task16 汇总验收结果 + +> 权限约定补充(2026-10-27):采用平台管理员与项目角色分层授权,见 AUTHORIZATION.md。账号管理要求平台管理员;项目成员和邀请按目标项目角色授权。此选择已同步 OpenAPI,运行时权限未放宽。 + +> 后续修正:三个固定接口已补齐并通过主站200/code0契约验证;不再允许豁免缺路由。原始质量门数字为Task16当次记录,补齐后的回归见ISSUE3-FIXED-ENDPOINTS-RESULT.md。 + +2026-10-27。用户要求 Task15 后继续总验收;主 agent 全程执行,无子 agent。用户冻结的知识库范围、明确豁免项及原有前端限制继续有效,未提交或推送。 + +## 当前可用环境 + +- 主控制台 http://127.0.0.1:5274/cn/memoryList 已通过启动环境变量连接真实5281,`/api/meta` 为 live。 +- Mock5280已停止;辅助真实控制台5276保留;后端5281、上游8000、网关8002正常。 +- `.env.local` 不变。可重演切换:`powershell -NoProfile -ExecutionPolicy Bypass -File server/backend/acceptance/start_live.ps1`。脚本仅停止已记录且进程指纹匹配的本项目 Mock/Vite,不停止数据库或网关。 +- Mock自身仍可独立启动:隔离5290/临时数据库验证 sample 后退出,不参与主站验收。 + +## 新鲜质量门 + +| 检查 | 本次结果 | +|---|---| +| 后端完整测试 | 302 passed,95.27秒,`.data/task16/pytest-final.log` | +| 上游画像/生命周期相关 | 35 passed | +| 上游 Prompt/运行时抽取相关 | 69 passed | +| 网关配置/契约/工具 | 45 passed | +| pnpm verify | TypeScript、ESLint、Prettier、生产构建全部通过 | +| Redocly | 0 errors、5 warnings(license、localhost、3个HTML表单303无2xx,原有) | +| Ruff | 后端全量通过;本轮6个Python变更文件format检查通过 | +| git diff --check | Dashboard、上游均通过 | +| 前端保护面 | src/shared/server/mock/.env.local 对03e10145a4dd8bc3e50b6e2a50ed788d64111945无差异 | + +上游测试为明确列出的相关套件,不声称整个上游仓库全绿;Task03已记录18项既有基线失败,本次未重跑全仓套件。生产构建保留既有大chunk提示。 + +## 页面与真实链路 + +主5274在Mock停止期间检查了全部13个React路由:快速开始、密钥、日志、记忆、图谱、模型、自定义抽取、项目、用量、模板、知识库直达页、文档、关于。六记忆标签均实际点击并等待加载,记忆详情与日志详情均实际打开。正常检查中页面无请求失败提示;模板页明确显示建设中。 + +真实HTTP脚本完成17项检查:主站live、匿名401、项目/账号/会话契约、六标签投影与每页10条小于200KiB、唯一合成记忆同ID/同正文原生REST对照、普通记忆及画像字段软删除/恢复、日志详情的JSON字符串、用量/Prompt/模板/图谱/模型契约及跨项目唯一标记不可见。 + +删除仅操作Task03既有合成验收记录;finally恢复原ID和原正文。普通记忆删除后BFF和原生读取不可见,另用独立探针读回图库/向量均deleted、陈旧向量召回0;恢复回执confirmed。未删除业务记忆。 + +本次真实MCP只读查询标记为task16-gateway-e991c8536c4c,JSONL与同一索引日志的请求、状态、耗时对照一致,并在主站搜索后打开该条详情。真实模型debug返回0条候选,调试前后事实记忆数量相同。没有改写现有业务Prompt。 + +证据:`.data/task16/live.json`、`models-gateway.json`、`browser-pages-ready.json`、`browser-six-tabs.json`、`screenshots/`。可重演主要链路:`python -m server.backend.acceptance.task16_driver`,要求当前本机已配置的服务和Task03合成种子。 + +## 性能与故障 + +1万行/5.38MB JSONL三次全新索引冷启动首屏HTTP为805.350 / 779.142 / 795.091ms,均小于1秒;温查询p95为26.295 / 26.471 / 27.203ms;追加一条31.583 / 32.057 / 34.358ms;每页2856字节。每次重启后10001条仍可读,历史详情ID保持可查。 + +隔离HTTP故障回归验证异步合并、历史字段、追加/文件系统异常;完整测试包括超时、上游异常、不完整向量确认、部分失败重试、跨项目权限、最后管理员/owner约束、邀请过期/单次使用、时间边界及密钥补偿失败。 + +另实际停止本项目上游进程:主站meta返回503/code50300;浏览器记忆页显示“无法连接上游记忆服务”和重试入口,未白屏。通过finally恢复上游后meta健康;页面自动重取恢复3条数据。未声称手动点击重试——恢复时自动刷新已让按钮消失。故障产生的真实失败日志保留供审计。 + +证据:`.data/task16/performance-faults.json`、`outage.json`、故障及恢复截图。性能数字为本机隔离语料下的验收测量,不是任意规模或多机并发SLA。 + +## 69个操作的准确范围 + +`.data/task16/operation-matrix.json`逐条分类,`TASK16-OPERATIONS.md`便于查阅。共69个HTTP操作,68个存在处理路由: + +- 54个有效开发范围操作均有实现,覆盖P0/P1、Prompt、图谱/模型、密钥和Task15模板。 +- 12个知识库操作按用户指示冻结;其中11个已有处理路由,`POST /api/dashboard/knowledgebase/memory/list`未实现,未补写、未伪造成功。本次仅做已有知识库自动化回归和直达页查看,不宣称知识库任务10/11重新完整交付。 +- 3个允许固定返回的接口现已补齐:skill/skillMD返回空字符串,quota/summary返回固定额度,subscription/details返回空周期列表。此前把这三项作为缺路由豁免的判断已纠正。 + +路由存在性不是逐条真实写入验收。涉及账号、成员、邀请、项目生命周期和密钥的破坏性/配置写入以本次完整自动化套件和各任务已验收真链路证据支持,没有为凑69条而再次修改所有业务资源。 + +## 保留限制 + +- 属性树模板菜单禁用、页面ComingSoon;CRUD可用API验证,没有编辑UI。知识库菜单也保持禁用,直达路由可查看已有页面。 +- 恢复、重试、操作回执目前通过API,现有React没有对应按钮。归档当前项目的表格刷新滞后按Task06限制保留。 +- 用量不支持缺可信审计来源的apiKeyId筛选,明确400;无token来源的记录按0,不推测实际模型收费。 +- 密钥配置已支持后续自动重载增强:成功发布后下一次鉴权生效,无需重启网关,见 GATEWAY-RELOAD-RESULT.md。补偿失败需先恢复文件/数据库一致性,不能把待修复状态当成功。 +- 上游内部补丁与控制台一起部署,见patches/;图与向量的真实确认仅对当前Neo4j/Qdrant组合有实测证据。 +- 登录UI已桌面浏览器验收并加入窄屏CSS,未做真实手机/移动视口验收。 + +这些限制不因“汇总验收完成”而消失,不能将本报告描述为所有前端功能均已开放或整个上游测试套件全绿。 diff --git a/server/backend/TIME-DISPLAY-REPAIR-2026-10-27.md b/server/backend/TIME-DISPLAY-REPAIR-2026-10-27.md new file mode 100644 index 000000000..1f707b64e --- /dev/null +++ b/server/backend/TIME-DISPLAY-REPAIR-2026-10-27.md @@ -0,0 +1,24 @@ +# 时间展示独立验收修复(2026-10-27) + +## 结果 + +已修复独立验收发现的两处问题。仅修复知识库已有文件筛选的回归,不新增知识库功能。 + +1. 文件列表保留上游原始 `created_at`,使用带时区的原始时刻做筛选;响应仍由 `file_view` 转换为固定 UTC+8 展示格式。禁止反向解析无时区的展示字符串参与筛选。 +2. 图谱节点 `createdAt` 通过 `timefmt.display()` 输出。空字符串保持为空;非法上游时间转为受控上游数据错误。 + +库存 DB 时间、JSONL、时间筛选输入规则、机器模型接口、OpenAPI、前端源码均未修改。本次排查未发现其他将展示时间送回 `time_boundary` 的调用。Prompt 的 `updatedAt` 当前未被前端展示,未扩大修改范围。 + +## 新鲜验证 + +- 修改前:新增的文件筛选用例返回 500;图谱 UTC+8 断言失败,两个问题均被自动化测试复现。 +- 修改后:时间转换、文件和图谱定向回归 18 passed。 +- 全量后端:288 passed,84.97 秒。该次测试收集后又补充两个图谱时间输入用例,因此不将其计入全量数字。 +- 最终图谱测试文件:9 passed,包含后补的空时间与非法时间两个用例。当前所有用例均已执行通过。 +- 文件测试覆盖日期范围、精确 UTC 边界、排除边界、+08:00 等价时刻,并确认上游原始时间不变。 +- Ruff 全量检查通过;git diff --check 通过。 +- src/shared/server/mock/.env.local 对 03e10145a4dd8bc3e50b6e2a50ed788d64111945 无差异。 +- 通过现有受控重启脚本更新 5281 后端,health 200;真实图谱接口返回 200,3 个节点的非空时间均符合 19 字符展示格式。 +- Live 脱敏证据:`.data/time-display-fix/repair-live-20261027.json`。 + +未提交或推送代码,未改动网关配置或切换前端 Mock。 diff --git a/server/backend/__init__.py b/server/backend/__init__.py new file mode 100644 index 000000000..ddc8f4be9 --- /dev/null +++ b/server/backend/__init__.py @@ -0,0 +1 @@ +"""Independent HTTP backend for the MemOS Dashboard.""" diff --git a/server/backend/__main__.py b/server/backend/__main__.py new file mode 100644 index 000000000..286aa3794 --- /dev/null +++ b/server/backend/__main__.py @@ -0,0 +1,26 @@ +"""Run with python -m server.backend from the Dashboard repository root.""" + +import argparse + +import uvicorn +from pydantic import ValidationError + +from .app import create_app +from .settings import Settings + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run the independent MemOS Dashboard backend") + parser.add_argument("--host", default="127.0.0.1", help="Bind address (default: loopback)") + parser.add_argument("--port", type=int, default=5281, help="Bind port (default: 5281)") + args = parser.parse_args() + try: + settings = Settings.from_env() + except ValidationError: + parser.error("Invalid backend configuration; check DASHBOARD_UPSTREAM_URL and timeout") + # Invitation URLs contain a capability token; never persist raw request URLs. + uvicorn.run(create_app(settings), host=args.host, port=args.port, access_log=False) + + +if __name__ == "__main__": + main() diff --git a/server/backend/acceptance/__init__.py b/server/backend/acceptance/__init__.py new file mode 100644 index 000000000..643052583 --- /dev/null +++ b/server/backend/acceptance/__init__.py @@ -0,0 +1 @@ +"""Explicit live acceptance tools; never run automatically by pytest.""" diff --git a/server/backend/acceptance/probe_gateway.py b/server/backend/acceptance/probe_gateway.py new file mode 100644 index 000000000..c74698e0e --- /dev/null +++ b/server/backend/acceptance/probe_gateway.py @@ -0,0 +1,106 @@ +"""Direct loopback MCP probes. Credentials remain in memory, never evidence.""" + +import json +import os +from pathlib import Path + +import httpx + +ACCEPT = "application/json, text/event-stream" + + +def gateway_client(**kwargs): + return httpx.Client(trust_env=False, timeout=120, **kwargs) + + +def old_token(root: Path, project="issue3-project-a"): + for line in (root / ".local/gateway.env").read_text(encoding="utf-8-sig").splitlines(): + if not line.strip().startswith("MEMOS_TENANTS="): + continue + raw = line.split("=", 1)[1].strip() + if raw.startswith("{"): + for token, spec in json.loads(raw).items(): + uid = spec if isinstance(spec, str) else spec.get("user_id") + if uid == project: + return token + else: + for pair in raw.split(","): + token, _, uid = pair.rpartition(":") + if uid.strip() == project: + return token.strip() + raise ValueError("Existing regression credential not configured") + + +def result(response): + for frame in [response.text] + [ + s[5:] for s in response.text.splitlines() if s.startswith("data:") + ]: + try: + data = json.loads(frame) + except ValueError: + continue + if isinstance(data, dict) and isinstance(data.get("result"), dict): + return data["result"] + return {} + + +def rpc(client, gateway, token, method, params, number=1): + return client.post( + gateway + "/mcp", + headers={"Authorization": "Bearer " + token, "Accept": ACCEPT}, + json={"jsonrpc": "2.0", "id": number, "method": method, "params": params}, + ) + + +def probe(client, gateway, token): + response = rpc( + client, + gateway, + token, + "initialize", + { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "task14-isolated", "version": "2"}, + }, + ) + if response.status_code != 200: + return {"http_status": response.status_code, "tools": []} + response = rpc(client, gateway, token, "tools/list", {}, 2) + tools = result(response).get("tools", []) + return { + "http_status": response.status_code, + "tools": [ + item["name"] + for item in tools + if isinstance(item, dict) and isinstance(item.get("name"), str) + ], + } + + +def main(): + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument("mode", choices=["old", "malicious", "env"], nargs="?", default="env") + parser.add_argument("--root", type=Path, default=Path("D:/MemOS")) + parser.add_argument("--gateway", default="http://127.0.0.1:8002") + args = parser.parse_args() + token = ( + old_token(args.root) + if args.mode == "old" + else "sk-" + "0" * 48 + if args.mode == "malicious" + else os.environ.get("PROBE_TOKEN", "") + ) + with gateway_client() as client: + data = probe(client, args.gateway, token) + safe = json.dumps({"step": args.mode, **data}) + if "sk-" in safe: + raise ValueError("Unsafe probe output rejected") + print(safe) + return 0 if data["http_status"] == (401 if args.mode == "malicious" else 200) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/server/backend/acceptance/smoke.py b/server/backend/acceptance/smoke.py new file mode 100644 index 000000000..fa352a5bd --- /dev/null +++ b/server/backend/acceptance/smoke.py @@ -0,0 +1,141 @@ +"""Portable, read-only live-service checks; no private fixtures or local setup imports.""" + +import argparse +import getpass +import json +import os +import re +from urllib.parse import quote, urlsplit + +import httpx + +TABS = ("text_mem", "pref_mem", "event_mem", "profile_mem", "skill_mem", "tool_mem") + + +def envelope(response, code=0): + response.raise_for_status() + body = response.json() + if body.get("code") != code: + raise ValueError("Unexpected response envelope") + return body["data"] + + +def origin(value): + parsed = urlsplit(value) + if ( + parsed.scheme not in {"http", "https"} + or not parsed.hostname + or (parsed.username or parsed.password or parsed.query or parsed.fragment) + ): + raise argparse.ArgumentTypeError("Use an HTTP(S) service URL without credentials") + return value.rstrip("/") + + +def run_checks(web, base, project, native=None, cube=None): + meta = envelope(web.get(base + "/api/meta")) + assert meta["mode"] == "live" + envelope(web.get(base + "/api/auth/me")) + envelope(web.get(base + "/api/dashboard/project/dict")) + proof = {"live": True, "project": project, "pages": [], "sameIdNativeMatches": 0} + for tab in TABS: + response = web.post( + base + "/api/dashboard/project/memory/list", + json={"projectId": project, "memoryDetailType": tab, "pageNum": 1, "pageSize": 10}, + ) + data = envelope(response) + assert len(response.content) < 200000 + rows = data["memListItemResponseList"] + for row in rows: + assert {"memoryId", "memoryContent", "createTime"} <= row.keys() + assert not {"embedding", "sources", "history", "metadata"} & row.keys() + proof["pages"].append({"tab": tab, "total": data["total"], "bytes": len(response.content)}) + if native and rows: + identifier = rows[0]["memoryId"] + full = envelope( + web.get( + base + "/api/dashboard/graph/memory", + params={ + "tenant": project, + "id": identifier, + }, + ) + ) + if tab == "profile_mem": + remote = envelope( + web.post( + native + "/product/get_profile_memory", + json={ + "mem_cube_id": cube, + "memory_id": identifier, + }, + ), + code=200, + ) + else: + remote = envelope( + web.get( + native + "/product/get_memory/" + quote(identifier, safe=""), + params={"mem_cube_id": cube}, + ), + code=200, + ) + assert remote["id"] == identifier == full["id"] + assert remote["memory"] == full["memory"] + proof["sameIdNativeMatches"] += 1 + graph = envelope(web.get(base + "/api/dashboard/graph/memories", params={"tenant": project})) + proof["graphNodes"] = len(graph["nodes"]) + proof["graphTruncated"] = graph["stats"]["truncated"] + proof["models"] = envelope(web.get(base + "/api/dashboard/models"))["total"] + logs = envelope( + web.post( + base + "/api/dashboard/log/list", + json={ + "projectId": project, + "pageNum": 1, + "pageSize": 10, + }, + ) + ) + proof["logTotal"] = logs["total"] + envelope(web.post(base + "/api/dashboard/usage/query", json={"projectId": project})) + envelope(web.post(base + "/api/dashboard/prompt-config/detail", json={"projectId": project})) + proof["passed"] = True + return proof + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base", type=origin, default="http://127.0.0.1:5281") + parser.add_argument("--project", required=True) + parser.add_argument("--native", type=origin) + parser.add_argument("--cube") + args = parser.parse_args() + if bool(args.native) != bool(args.cube): + parser.error("--native and --cube must be supplied together") + username = os.getenv("DASHBOARD_TEST_USERNAME") or input("Username: ") + password = os.getenv("DASHBOARD_TEST_PASSWORD") or getpass.getpass("Password: ") + try: + with httpx.Client(trust_env=False, timeout=45, follow_redirects=False) as web: + page = web.get(args.base + "/__login") + page.raise_for_status() + token = re.search(r'name="csrf" value="([^\"]+)"', page.text) + assert token is not None + response = web.post( + args.base + "/__login", + data={ + "username": username, + "password": password, + "csrf": token[1], + }, + ) + assert response.status_code == 303 + print(json.dumps(run_checks(web, args.base, args.project, args.native, args.cube))) + return 0 + except Exception as error: + # Do not echo service bodies, auth headers, credentials or business memories. + print(json.dumps({"passed": False, "errorType": type(error).__name__})) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/server/backend/acceptance/start_live.ps1 b/server/backend/acceptance/start_live.ps1 new file mode 100644 index 000000000..b349f6537 --- /dev/null +++ b/server/backend/acceptance/start_live.ps1 @@ -0,0 +1,34 @@ +# Launch the existing frontend against the real backend; never edit .env.local. +$ErrorActionPreference = 'Stop' +$taskRoot = 'D:/MemOS' +$dashboard = Join-Path $taskRoot 'dashboard' +$statePath = Join-Path $taskRoot '.local/processes.json' +$state = Get-Content -Raw -LiteralPath $statePath | ConvertFrom-Json + +function Stop-OwnedListener([int]$Port, [string]$Name, [string]$Marker) { + $listeners = @(Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue) + foreach ($listener in $listeners) { + $service = Get-CimInstance Win32_Process -Filter "ProcessId = $($listener.OwningProcess)" + $entry = $state.PSObject.Properties[$Name] + if (-not $entry -or $entry.Value.id -ne $service.ProcessId -or + $service.ExecutablePath -ne $entry.Value.executable -or + -not $service.CommandLine.Replace('/', '\').Contains($Marker)) { + throw "Port $Port is not the recorded owned $Name process; refusing to stop it" + } + Stop-Process -Id $service.ProcessId -ErrorAction Stop + } +} + +$health = Invoke-RestMethod -Uri 'http://127.0.0.1:5281/health' +if ($health.code -ne 0) { throw 'Real backend is not healthy' } +Stop-OwnedListener 5280 'mock' 'server\mock\server.ts' +Stop-OwnedListener 5274 'web' 'vite\bin\vite.js' +$env:MOCK_ORIGIN = 'http://127.0.0.1:5281' +$node = 'E:/nodejs/node.exe' +$vite = Join-Path $dashboard 'node_modules/vite/bin/vite.js' +$service = Start-Process -FilePath $node -ArgumentList @($vite, '--host', '127.0.0.1', '--port', '5274', '--strictPort') -WorkingDirectory $dashboard -WindowStyle Hidden -RedirectStandardOutput (Join-Path $taskRoot '.local/logs/web-live.out.log') -RedirectStandardError (Join-Path $taskRoot '.local/logs/web-live.err.log') -PassThru +$entry = [pscustomobject]@{id=$service.Id; executable=$node.Replace('/', '\'); marker='vite\bin\vite.js'; port=5274; mode='live'} +$state | Add-Member -MemberType NoteProperty -Name web -Value $entry -Force +$state.PSObject.Properties.Remove('mock') +$state | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $statePath -Encoding UTF8 +Write-Host 'Mock stopped. Main frontend 5274 now proxies real backend 5281.' diff --git a/server/backend/acceptance/task14_driver.py b/server/backend/acceptance/task14_driver.py new file mode 100644 index 000000000..2055efb8f --- /dev/null +++ b/server/backend/acceptance/task14_driver.py @@ -0,0 +1,250 @@ +"""Explicit isolated Task14 acceptance; issues/revokes only this run's credential.""" + +import argparse +import hashlib +import json +import re +import subprocess +import tempfile +import time +import uuid +from dataclasses import dataclass +from pathlib import Path + +from .probe_gateway import gateway_client, old_token, probe, result, rpc + + +def require(condition, message): + if not condition: + raise AssertionError(message) + + +class Recorder: + def __init__(self, path, run_id): + self.path, self.run_id, self.rows = Path(path), run_id, [] + + def record(self, step, **data): + rows = self.rows + [{"run_id": self.run_id, "step": step, **data}] + encoded = json.dumps(rows, indent=2) + if "sk-" in encoded: + raise ValueError("Unsafe credential evidence rejected") + self.path.parent.mkdir(parents=True, exist_ok=True) + self.path.write_text(encoded + "\n", encoding="utf-8") + self.rows = rows + print(json.dumps({"step": step, **data}), flush=True) + + +@dataclass +class RunResources: + project_id: str | None = None + key_id: str | None = None + revoked: bool = False + + def cleanup_ids(self): + return [self.key_id] if self.key_id and not self.revoked else [] + + +def restart(root, script, name, recorder): + state = root / ".local/processes.json" + before = json.loads(state.read_text(encoding="utf-8-sig"))[name]["id"] + with tempfile.TemporaryFile() as stdout, tempfile.TemporaryFile() as stderr: + process = subprocess.run( + [ + "powershell", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(root / "setup" / script), + ], + stdout=stdout, + stderr=stderr, + timeout=100, + ) + require(process.returncode == 0, "Owned service restart failed") + after = json.loads(state.read_text(encoding="utf-8-sig"))[name]["id"] + require(before != after, "Service PID did not change") + recorder.record(name + "_restart", passed=True, pid_changed=True) + + +def main(argv=None): + parser = argparse.ArgumentParser() + parser.add_argument("--root", type=Path, default=Path("D:/MemOS")) + parser.add_argument("--output", type=Path) + args = parser.parse_args(argv) + root = args.root + run_id = "task14-" + uuid.uuid4().hex[:12] + path = args.output or root / ("dashboard/.data/task14-keys/runs/" + run_id + ".json") + require(not path.exists(), "Fresh output path required; historical evidence is immutable") + recorder = Recorder(path, run_id) + resources = RunResources() + manifest = root / "upstream/deploy/mcp-gateway/tenants.conf" + base, gateway = "http://127.0.0.1:5281", "http://127.0.0.1:8002" + start_hash = hashlib.sha256(manifest.read_bytes()).hexdigest() + process_state = root / ".local/processes.json" + gateway_pid = json.loads(process_state.read_text(encoding="utf-8-sig"))["gateway"]["id"] + failed = False + with gateway_client() as client: + + def post(route, body): + response = client.post(base + route, json=body) + require(response.status_code == 200, "Dashboard operation failed") + data = response.json() + require(data.get("code") == 0, "Dashboard business failure") + return data["data"] + + try: + account = json.loads((root / ".local/dashboard-admin.json").read_text(encoding="utf-8")) + page = client.get(base + "/__login") + csrf = re.search(r'name="csrf" value="([^" ]+)"', page.text) + require(csrf is not None, "Missing login CSRF") + login = client.post( + base + "/__login", + data={ + "username": account["username"], + "password": account["password"], + "csrf": csrf[1], + }, + follow_redirects=False, + ) + require(login.status_code == 303, "Login rejected") + recorder.record("login", passed=True) + existing = old_token(root) + require( + probe(client, gateway, existing)["http_status"] == 200, + "Existing key rejected before run", + ) + recorder.record("existing_key_before", status=200) + require( + probe(client, gateway, "sk-" + "0" * 48)["http_status"] == 401, + "Invalid key accepted", + ) + recorder.record("invalid_key", status=401) + project = post( + "/api/dashboard/project/create", + {"name": run_id, "description": "Isolated Task14 credential acceptance"}, + ) + require( + project.get("name") == run_id and project["id"].startswith("proj_"), + "Unexpected project identity", + ) + resources.project_id = project["id"] + recorder.record("isolated_project", project_id=resources.project_id) + created = post( + "/api/dashboard/keys/create", {"projectId": resources.project_id, "keyName": run_id} + ) + resources.key_id = created["id"] + token = created["apiKey"] + require( + bool(re.fullmatch(r"sk-[0-9a-f]{48}", token)), "Invalid generated credential shape" + ) + require( + token in json.loads(manifest.read_text(encoding="utf-8")), + "Missing published credential", + ) + recorder.record("create", passed=True, file_matches=True) + copied = post("/api/dashboard/keys/copy", {"apiKeyId": resources.key_id}) + require(copied == token, "Copy response mismatch") + recorder.record("copy", passed=True) + before_rename = manifest.read_bytes() + post("/api/dashboard/keys/rename/" + resources.key_id, {"keyName": run_id + "-renamed"}) + require(manifest.read_bytes() == before_rename, "Rename changed gateway bytes") + recorder.record("rename", file_unchanged=True) + restart(root, "Restart-Backend.ps1", "backend", recorder) + persisted = post("/api/dashboard/keys/copy", {"apiKeyId": resources.key_id}) + require(persisted == token, "Credential/session did not persist") + recorder.record("backend_persistence", passed=True) + require( + probe(client, gateway, token)["http_status"] == 200, + "New key did not become live without gateway restart", + ) + recorder.record("new_key_without_gateway_restart", status=200) + live = probe(client, gateway, token) + require( + live["http_status"] == 200 and "search_memory" in live["tools"], + "New credential not usable", + ) + recorder.record("new_key_live", status=200, has_search=True) + response = rpc( + client, + gateway, + token, + "tools/call", + {"name": "search_memory", "arguments": {"query": run_id}}, + 3, + ) + content = result(response) + require( + response.status_code == 200 + and isinstance(content.get("content"), list) + and not content.get("isError"), + "Real search failed", + ) + recorder.record("real_search", status=200, success=True) + hits = [] + for _ in range(15): + hits = [] + for audit in (root / ".local/audit").glob("calls-*.jsonl"): + for line in audit.read_text(encoding="utf-8").splitlines(): + if run_id not in line: + continue + row = json.loads(line) + if row.get("tool") == "search_memory" and run_id in json.dumps( + row.get("input") + ): + hits.append(row) + if hits: + break + time.sleep(0.2) + require( + len(hits) == 1 and hits[0].get("tenant") == resources.project_id, + "Audit tenant/count mismatch", + ) + recorder.record("audit_attribution", hits=1, tenant_matches=True) + post("/api/dashboard/keys/revoke/" + resources.key_id, {}) + resources.revoked = True + require( + token not in json.loads(manifest.read_text(encoding="utf-8")), + "Revoked key still in configuration", + ) + recorder.record("revoke", file_absent=True) + require( + probe(client, gateway, token)["http_status"] == 401, "Revoked key still accepted" + ) + recorder.record("revoked_key_without_gateway_restart", status=401) + require( + probe(client, gateway, existing)["http_status"] == 200, "Existing key regressed" + ) + recorder.record("existing_key_after", status=200) + except Exception as error: + failed = True + recorder.record("failed", passed=False, error_type=type(error).__name__) + finally: + try: + for identifier in resources.cleanup_ids(): + post("/api/dashboard/keys/revoke/" + identifier, {}) + resources.revoked = True + if resources.project_id: + post("/api/dashboard/project/revoke/" + resources.project_id, {}) + require( + hashlib.sha256(manifest.read_bytes()).hexdigest() == start_hash, + "Preexisting manifest bytes changed", + ) + recorder.record( + "cleanup", own_resources_only=True, original_manifest_unchanged=True + ) + require( + json.loads(process_state.read_text(encoding="utf-8-sig"))["gateway"]["id"] + == gateway_pid, + "Gateway process changed during live credential acceptance", + ) + recorder.record("gateway_pid_unchanged", pid=gateway_pid) + except Exception as error: + failed = True + recorder.record("cleanup_failed", passed=False, error_type=type(error).__name__) + recorder.record("complete", passed=not failed, secret_scan_passed=True) + return int(failed) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/server/backend/acceptance/task15_driver.py b/server/backend/acceptance/task15_driver.py new file mode 100644 index 000000000..26ec61e7b --- /dev/null +++ b/server/backend/acceptance/task15_driver.py @@ -0,0 +1,178 @@ +"""Replay Task15 against local real services; create and delete only our own template.""" + +import argparse +import hashlib +import json +import re +import sys +import time +import uuid +from pathlib import Path + +import httpx + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--root", type=Path, default=Path("D:/MemOS")) + parser.add_argument("--project", required=True) + parser.add_argument("--other-project", required=True) + parser.add_argument("--cube", required=True) + parser.add_argument("--restart", action="store_true") + args = parser.parse_args() + sys.path.insert(0, str(args.root / "setup")) + from verify_task01 import authenticate_local_backend + from verify_task06 import run_ps + + base = "http://127.0.0.1:5281/api/dashboard/profileTemplate/" + native = "http://127.0.0.1:8000/product/" + run = uuid.uuid4().hex[:12] + output = args.root / "dashboard/.data/task15" / (run + ".json") + output.parent.mkdir(parents=True, exist_ok=True) + proof = {"run_id": run, "passed": False, "checks": []} + identifier = None + + def record(name, **details): + proof["checks"].append({"name": name, "passed": True, **details}) + + def envelope(response, code=0): + assert response.status_code == 200, ("HTTP", response.status_code) + body = response.json() + assert body["code"] == code, ("envelope", body["code"]) + return body["data"] + + with httpx.Client(trust_env=False, timeout=45) as web: + try: + assert web.post(base + "list", json={"projectId": args.project}).status_code == 401 + record("anonymous_denied") + authenticate_local_backend(web) + body = { + "projectId": args.project, + "templateName": "TASK15_" + run, + "Work": { + "role": {"value": "engineer", "algorithm_updatable": False}, + "location": {"value": "test-only", "algorithm_updatable": True}, + }, + } + identifier = envelope(web.post(base + "create", json=body))["templateId"] + params = {"projectId": args.project, "templateId": identifier} + record("create_real_upstream", template_id=identifier) + upstream = envelope( + web.post( + native + "get_profile_template", + json={"mem_cube_id": args.cube, "profile_template_id": identifier}, + ) + ) + detail = envelope(web.get(base + "detail", params=params)) + assert upstream["profile_template_id"] == detail["templateId"] == identifier + assert upstream["metadata"] == {"Work": detail["Work"]} + assert detail["bindingCount"] == upstream["bound_count"] == 0 + assert re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", detail["updatedAt"]) + record( + "same_id_rest_and_tree", + tree_sha256=hashlib.sha256( + json.dumps(detail["Work"], sort_keys=True).encode() + ).hexdigest(), + ) + page = 1 + found = False + while not found: + listing = envelope( + web.post( + base + "list", + json={"projectId": args.project, "pageNum": page, "pageSize": 2}, + ) + ) + assert listing["current"] == page and listing["size"] == 2 + assert listing["pages"] == (listing["total"] + 1) // 2 + found = any(row["templateId"] == identifier for row in listing["list"]) + assert found or page < min(listing["pages"], 100) + page += 1 + record("paged_list_contains_same_id") + foreign = {**params, "projectId": args.other_project} + for operation in ["detail", "update", "delete"]: + response = ( + web.get(base + operation, params=foreign) + if operation == "detail" + else web.post( + base + operation, + json=(foreign if operation == "delete" else {**body, **foreign}), + ) + ) + assert response.status_code == 404 + record("foreign_detail_update_delete_denied") + # Remove a field to validate the native retained/deprecated-field semantics. + updated = { + **body, + **params, + "templateName": "TASK15_UPDATED_" + run, + "Work": {"role": {"value": "reviewer", "algorithm_updatable": False}}, + } + envelope(web.post(base + "update", json=updated)) + detail = envelope(web.get(base + "detail", params=params)) + assert detail["Work"] == updated["Work"] + upstream = envelope( + web.post( + native + "get_profile_template", + json={"mem_cube_id": args.cube, "profile_template_id": identifier}, + ) + ) + assert "Work.location" in upstream["deprecated_paths"] + record("update_active_tree_and_native_deprecation") + if args.restart: + for service in ["Backend", "Upstream"]: + result = run_ps(args.root / "setup" / ("Restart-" + service + ".ps1")) + assert result.returncode == 0, "Owned service restart failed: " + service + deadline = time.monotonic() + 45 + while True: + ready = web.get("http://127.0.0.1:5281/api/meta") + if ready.status_code == 200 and ready.json().get("code") == 0: + break + assert time.monotonic() < deadline, "Upstream readiness timeout" + time.sleep(0.5) + authenticate_local_backend(web) + detail = envelope(web.get(base + "detail", params=params)) + assert detail["templateName"] == updated["templateName"] + assert detail["Work"] == updated["Work"] + record("backend_and_upstream_restart_persistence") + envelope(web.post(base + "delete", json=params)) + assert web.get(base + "detail", params=params).status_code == 404 + assert ( + web.post( + native + "get_profile_template", + json={"mem_cube_id": args.cube, "profile_template_id": identifier}, + ).json()["code"] + == 404 + ) + record("delete_native_and_bff_missing") + identifier = None + proof["passed"] = True + except Exception as error: + proof["error_type"] = type(error).__name__ + raise + finally: + if identifier is not None: + try: + authenticate_local_backend(web) + cleaned = web.post( + base + "delete", json={"projectId": args.project, "templateId": identifier} + ) + proof["cleanup_status"] = cleaned.status_code + except Exception as error: + proof["cleanup_error_type"] = type(error).__name__ + serialized = json.dumps(proof, indent=2) + assert "sk-" not in serialized + output.write_text(serialized, encoding="utf-8") + print( + json.dumps( + { + "passed": proof["passed"], + "checks": len(proof["checks"]), + "evidence": str(output), + } + ) + ) + + +if __name__ == "__main__": + main() diff --git a/server/backend/acceptance/task16_driver.py b/server/backend/acceptance/task16_driver.py new file mode 100644 index 000000000..f62ce440e --- /dev/null +++ b/server/backend/acceptance/task16_driver.py @@ -0,0 +1,295 @@ +"""Final local HTTP/contract and reversible synthetic lifecycle acceptance, with Mock stopped.""" + +import hashlib +import json +import socket +import subprocess +import sys +import uuid +from pathlib import Path + +import httpx +from jsonschema import Draft202012Validator +from server.backend.app import create_app + +ROOT = Path("D:/MemOS") +BASE = "http://127.0.0.1:5274" +NATIVE = "http://127.0.0.1:8000" + + +def main(): + sys.path.insert(0, str(ROOT / "setup")) + from verify_task01 import authenticate_local_backend + + spec = json.loads((ROOT / "dashboard/openapi.json").read_text(encoding="utf-8")) + evidence = ROOT / "dashboard/.data/task16" + evidence.mkdir(exist_ok=True) + run = uuid.uuid4().hex[:12] + checks = [] + + def record(name, **details): + checks.append({"name": name, "passed": True, **details}) + + def validate(response, component): + assert response.status_code == 200, (component, response.status_code) + payload = response.json() + assert payload["code"] == 0 + if component in spec["components"]["responses"]: + schema = spec["components"]["responses"][component]["content"]["application/json"][ + "schema" + ] + else: + schema = { + "allOf": [ + {"$ref": "#/components/schemas/Envelope"}, + {"properties": {"data": {"$ref": "#/components/schemas/" + component}}}, + ] + } + Draft202012Validator({**schema, "components": spec["components"]}).validate(payload) + return payload["data"] + + def post(web, path, body, component): + return validate(web.post(BASE + path, json=body), component) + + try: + with socket.socket() as probe: + assert probe.connect_ex(("127.0.0.1", 5280)) != 0, "Mock must be stopped" + record("mock_port_closed") + app = create_app() + routes = { + (method.lower(), route.path) + for route in app.routes + for method in getattr(route, "methods", []) + } + fixed = { + "/api/dashboard/skill/skillMD", + "/api/user/quota/summary", + "/api/user/subscription/details", + } + matrix = [] + for path, operations in spec["paths"].items(): + for method in operations: + if method not in {"get", "post", "put", "patch", "delete", "head", "options"}: + continue + implemented = (method, path) in routes + status = ( + "fixed_compatibility" + if path in fixed + else "frozen_knowledge" + if "/knowledgebase/" in path + else "implemented" + ) + assert implemented or (method, path) == ( + "post", + "/api/dashboard/knowledgebase/memory/list", + ), (method, path) + matrix.append( + { + "method": method.upper(), + "path": path, + "status": status, + "handler_registered": implemented, + } + ) + (evidence / "operation-matrix.json").write_text( + json.dumps(matrix, indent=2), encoding="utf-8" + ) + record( + "all_spec_operations_classified", + operations=len(matrix), + implemented=sum(row["handler_registered"] for row in matrix), + fixed_compatibility=sum(row["status"] == "fixed_compatibility" for row in matrix), + ) + with httpx.Client(trust_env=False, timeout=45) as web: + meta = validate(web.get(BASE + "/api/meta"), "Meta") + assert meta["mode"] == "live" + quota = validate(web.get(BASE + "/api/user/quota/summary"), "QuotaSummary") + assert quota["memoryCount"] == 0 + subscription = post( + web, + "/api/user/subscription/details", + { + "cycleId": "", + "includeAllCycles": True, + }, + "SubscriptionDetails", + ) + assert subscription == {"cycles": []} + assert web.post(BASE + "/api/dashboard/project/list", json={}).status_code == 401 + record("main_frontend_live_and_anonymous_denied") + authenticate_local_backend(web) + assert ( + post( + web, + "/api/dashboard/skill/skillMD", + { + "oss_url": "https://example.invalid/not-fetched", + }, + "StringData", + ) + == "" + ) + record("fixed_compatibility_contracts") + validate(web.get(BASE + "/api/auth/me"), "Account") + validate(web.get(BASE + "/api/dashboard/project/dict"), "ProjectDict") + post(web, "/api/dashboard/project/list", {"pageSize": 200}, "ProjectRecords") + validate(web.get(BASE + "/admin/accounts"), "AdminAccountRecords") + project = "issue3-project-a" + post( + web, + "/api/dashboard/project/update", + {"id": project, "status": "ENABLED"}, + "Project", + ) + record("project_account_session_contracts") + for tab in [ + "text_mem", + "pref_mem", + "event_mem", + "profile_mem", + "skill_mem", + "tool_mem", + ]: + response = web.post( + BASE + "/api/dashboard/project/memory/list", + json={"projectId": project, "memoryDetailType": tab, "pageSize": 10}, + ) + validate(response, "MemoryList") + assert len(response.content) < 200 * 1024 + record("six_tab_projection", tab=tab, bytes=len(response.content)) + seed = json.loads((ROOT / ".local/task03-seed.json").read_text(encoding="utf-8")) + assert seed["marker"].startswith("Task03-") + memory = seed["memory_id"] + before = post( + web, + "/api/dashboard/project/memory/detail", + {"memoryId": memory, "memType": "text_mem"}, + "Memory", + ) + native = web.get( + NATIVE + "/product/get_memory/" + memory, params={"mem_cube_id": project} + ).json()["data"] + assert native["id"] == before["memoryId"] == memory + assert before["memoryContent"] == native["memory"] + record( + "unique_synthetic_same_id_rest", + memory_id=memory, + content_sha256=hashlib.sha256(before["memoryContent"].encode()).hexdigest(), + ) + for identifier, tab in [ + (memory, "text_mem"), + (seed["profile_field_ids"][0], "profile_mem"), + ]: + original = post( + web, + "/api/dashboard/project/memory/detail", + {"memoryId": identifier, "memType": tab}, + "Memory", + ) + body = {"projectId": project, "memory_ids": [identifier], "memType": tab} + try: + deleted = post( + web, + "/api/dashboard/project/memory/delete", + {**body, "operationId": "task16-delete-" + run + tab}, + "MemoryOperation", + ) + assert deleted["state"] == "confirmed" and deleted["deleted"] == 1 + assert ( + web.post( + BASE + "/api/dashboard/project/memory/detail", + json={"memoryId": identifier, "memType": tab}, + ).status_code + == 404 + ) + if tab == "text_mem": + assert ( + web.get( + NATIVE + "/product/get_memory/" + identifier, + params={"mem_cube_id": project}, + ).json()["data"] + is None + ) + subprocess.run( + [sys.executable, str(ROOT / "setup/task03_fault_probe.py"), "read"], + check=True, + capture_output=True, + timeout=40, + ) + raw = json.loads( + (ROOT / ".local/task03-fault-read.json").read_text(encoding="utf-8") + ) + assert raw["graph_status"] == raw["vector_status"] == "deleted" + assert raw["stale_vector_result_count"] == 0 + record("independent_graph_vector_deleted", stale_vector_results=0) + finally: + restored = post( + web, + "/api/dashboard/project/memory/restore", + {**body, "operationId": "task16-restore-" + run + tab}, + "MemoryOperation", + ) + assert restored["state"] == "confirmed" and restored["restored"] == 1 + final = post( + web, + "/api/dashboard/project/memory/detail", + {"memoryId": identifier, "memType": tab}, + "Memory", + ) + assert final["memoryContent"] == original["memoryContent"] + record("real_soft_delete_restore", tab=tab, same_id=True, same_content=True) + logs = post( + web, "/api/dashboard/log/list", {"projectId": project, "scope": "all"}, "LogList" + ) + assert logs["logs"] + log = post( + web, "/api/dashboard/log/detail", {"logId": logs["logs"][0]["id"]}, "LogDetail" + ) + assert isinstance(log["requestBody"], str) and isinstance(log["responseBody"], str) + json.loads(log["requestBody"]) + json.loads(log["responseBody"]) + record("audit_list_detail_json_strings") + post(web, "/api/dashboard/usage/query", {"projectId": project}, "UsageQuery") + post(web, "/api/dashboard/prompt-config/detail", {"projectId": project}, "PromptConfig") + post( + web, + "/api/dashboard/profileTemplate/list", + {"projectId": project}, + "ProfileTemplateList", + ) + validate( + web.get(BASE + "/api/dashboard/graph/memories", params={"tenant": project}), + "GraphMemoryGraph", + ) + validate(web.get(BASE + "/api/dashboard/models"), "ModelRecords") + record("usage_prompt_templates_graph_models_contracts") + foreign = post( + web, + "/api/dashboard/project/memory/list", + { + "projectId": "issue3-project-b", + "memoryDetailType": "text_mem", + "memSearchContent": seed["marker"], + }, + "MemoryList", + ) + assert foreign["total"] == 0 + record("foreign_project_unique_marker_absent") + result = {"passed": True, "run_id": run, "checks": checks} + except Exception as error: + result = { + "passed": False, + "run_id": run, + "checks": checks, + "error_type": type(error).__name__, + } + raise + finally: + serialized = json.dumps(result, indent=2) + assert "sk-" not in serialized + (evidence / "live.json").write_text(serialized, encoding="utf-8") + print(json.dumps({"passed": result["passed"], "checks": len(checks)})) + + +if __name__ == "__main__": + main() diff --git a/server/backend/app.py b/server/backend/app.py new file mode 100644 index 000000000..7a86d8ac2 --- /dev/null +++ b/server/backend/app.py @@ -0,0 +1,113 @@ +"""Application factory: independent of model SDKs, databases, and Mock.""" + +import logging +import sqlite3 +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +import httpx +from fastapi import FastAPI, Request, Response + +from .audit_routes import AuditService +from .audit_routes import router as audit_router +from .audit_writer import ConsoleAuditMiddleware, ConsoleWriter +from .auth import SessionMiddleware +from .auth import router as auth_router +from .compatibility import router as compatibility_router +from .control import ControlStore +from .errors import install_error_handlers +from .identity_store import IdentityStore +from .invites import InviteStore +from .invites import router as invite_router +from .kb_store import KnowledgeStore +from .key_store import ApiKeyStore, sync_manifest +from .keys import router as keys_router +from .knowledge import router as knowledge_router +from .knowledge_files import router as kb_file_router +from .members import router as member_router +from .memories import router as memory_router +from .memory_lifecycle import router as lifecycle_router +from .memory_upstream import MemoryUpstream +from .models import SERVICE_NAME, Envelope, Meta +from .product_client import ProductClient +from .profile_templates import router as template_router +from .projects import router as project_router +from .prompts import router as prompt_router +from .read_views import router as read_router +from .settings import Settings +from .upstream import UpstreamClient +from .usage_routes import router as usage_router + + +def create_app( + settings: Settings | None = None, + *, + transport: httpx.AsyncBaseTransport | None = None, +) -> FastAPI: + config = settings if settings is not None else Settings.from_env() + + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncIterator[None]: + async with httpx.AsyncClient( + base_url=config.upstream_url, + timeout=config.upstream_timeout_seconds, + follow_redirects=False, + trust_env=False, + transport=transport, + headers={"Accept": "application/json", "Accept-Encoding": "identity"}, + ) as client: + app.state.upstream = UpstreamClient(client) + app.state.memories = MemoryUpstream(client) + app.state.product = ProductClient(client) + app.state.settings = config + app.state.control = ControlStore(config) + app.state.identity = IdentityStore(app.state.control, config) + app.state.knowledge = KnowledgeStore(app.state.control) + app.state.invites = InviteStore(app.state.identity) + app.state.audit = AuditService(config) + app.state.console_audit = ConsoleWriter( + config.audit_console_dir or config.data_dir / "audit" + ) + if config.gateway_keys_file: + try: + sync_manifest(ApiKeyStore(app.state.control), config.gateway_keys_file) + except (OSError, sqlite3.Error) as error: + logging.getLogger(__name__).warning( + "gateway keys manifest self-heal failed; later writes will " + "report the real error: %r", + error, + ) + yield + + app = FastAPI(title="MemOS Dashboard Backend", version="0.1.0", lifespan=lifespan) + install_error_handlers(app) + app.add_middleware(ConsoleAuditMiddleware) + app.add_middleware(SessionMiddleware) + app.include_router(memory_router) + app.include_router(lifecycle_router) + app.include_router(audit_router) + app.include_router(usage_router) + app.include_router(project_router) + app.include_router(auth_router) + app.include_router(member_router) + app.include_router(invite_router) + app.include_router(knowledge_router) + app.include_router(kb_file_router) + app.include_router(prompt_router) + app.include_router(keys_router) + app.include_router(read_router) + app.include_router(template_router) + app.include_router(compatibility_router) + + @app.get("/health", response_model=Envelope[dict[str, str]]) + async def liveness(response: Response) -> Envelope[dict[str, str]]: + response.headers["Cache-Control"] = "no-store" + return Envelope(data={"status": "healthy", "service": SERVICE_NAME}) + + @app.get("/api/meta", response_model=Envelope[Meta]) + async def meta(request: Request, response: Response) -> Envelope[Meta]: + health = await request.app.state.upstream.health() + response.headers["Cache-Control"] = "no-store" + return Envelope(data=Meta(health=health, authEnabled=config.auth_enabled)) + + return app diff --git a/server/backend/audit_index.py b/server/backend/audit_index.py new file mode 100644 index 000000000..fd1426095 --- /dev/null +++ b/server/backend/audit_index.py @@ -0,0 +1,447 @@ +"""Incremental, transactional JSONL index. Sources are operator configuration only.""" + +import hashlib +import json +import os +import re +import sqlite3 +import uuid +from contextlib import contextmanager +from datetime import timedelta +from pathlib import Path + +import orjson + +from .audit_normalize import UnscopedLine, normalize +from .errors import ApiError +from .memory_models import time_boundary +from .timefmt import display +from .usage_models import midnight + +MAX_LINE_BYTES = 1024 * 1024 + + +def compact(value): + return orjson.dumps(value).decode("utf-8") + + +def unavailable(): + return ApiError(50300, "审计日志来源暂不可用", status_code=503) + + +class AuditIndex: + def __init__(self, path: Path, sources: dict[str, Path]): + self.path, self.sources = path, sources + path.parent.mkdir(parents=True, exist_ok=True) + with self.connection() as db: + db.execute("PRAGMA journal_mode=WAL") + db.executescript(""" + CREATE TABLE IF NOT EXISTS audit_files ( + source TEXT, identity TEXT, generation TEXT, offset INTEGER, + prefix_len INTEGER, prefix_sha TEXT, tail_sha TEXT, + PRIMARY KEY(source,identity)); + CREATE TABLE IF NOT EXISTS audit_lines ( + generation TEXT, position INTEGER, call_key TEXT, + PRIMARY KEY(generation,position)); + CREATE TABLE IF NOT EXISTS audit_rejects ( + generation TEXT, position INTEGER, reason TEXT, + PRIMARY KEY(generation,position)); + CREATE TABLE IF NOT EXISTS audit_calls ( + id TEXT PRIMARY KEY, cube TEXT, source TEXT, event TEXT, status TEXT, + ts INTEGER, users TEXT, agents TEXT, search_text TEXT, + detail TEXT, request_record TEXT, terminal_record TEXT); + CREATE INDEX IF NOT EXISTS audit_tenant_time ON audit_calls(cube,ts DESC,id DESC); + CREATE INDEX IF NOT EXISTS audit_source_time + ON audit_calls(cube,source,ts DESC,id DESC); + CREATE INDEX IF NOT EXISTS audit_event_time + ON audit_calls(cube,event,ts DESC,id DESC); + CREATE INDEX IF NOT EXISTS audit_status_time + ON audit_calls(cube,status,ts DESC,id DESC); + """) + + @contextmanager + def connection(self): + db = sqlite3.connect(self.path, timeout=10) + db.row_factory = sqlite3.Row + # The authoritative JSONL can rebuild this cache. NORMAL preserves WAL + # transaction atomicity; after power loss both rows and cursor may replay. + db.execute("PRAGMA synchronous=NORMAL") + db.execute("PRAGMA cache_size=-32768") + try: + with db: + yield db + finally: + db.close() + + @staticmethod + def _anchors(file, offset): + length = min(offset, 4096) + file.seek(0) + prefix = hashlib.sha256(file.read(length)).hexdigest() + file.seek(offset - length) + tail = hashlib.sha256(file.read(length)).hexdigest() + return length, prefix, tail + + def sync(self, scope="all"): + totals = {"imported": 0, "invalid": 0, "unscoped": 0} + names = tuple(self.sources) if scope == "all" else (scope,) + if not names: + raise unavailable() + try: + # Validate every selected source before importing any of them. + files = [] + for source in names: + folder = self.sources.get(source) + if folder is None or not folder.is_dir(): + raise unavailable() + prefix = "calls-" if source == "gateway" else "console-" + for path in sorted(folder.iterdir()): + if not re.fullmatch( + re.escape(prefix) + r"[^/\\]+\.jsonl(?:\.[1-9][0-9]*)?", path.name + ): + continue + if path.is_symlink() or not path.is_file(): + raise unavailable() + files.append((source, path)) + for source, path in files: + counts = self._sync_file(source, path) + for key in totals: + totals[key] += counts[key] + except (OSError, sqlite3.Error) as exc: + raise unavailable() from exc + return totals + + def _sync_file(self, source, path): + counts = {"imported": 0, "invalid": 0, "unscoped": 0} + with path.open("rb") as file, self.connection() as db: + db.execute("BEGIN IMMEDIATE") + stat = os.fstat(file.fileno()) + identity = f"{stat.st_dev}:{stat.st_ino}" + if os.name == "nt": + identity += f":{stat.st_ctime_ns}" + cursor = db.execute( + "SELECT * FROM audit_files WHERE source=? AND identity=?", (source, identity) + ).fetchone() + offset = cursor["offset"] if cursor else 0 + generation = cursor["generation"] if cursor else uuid.uuid4().hex + if cursor and offset: + file.seek(0) + prefix = hashlib.sha256(file.read(cursor["prefix_len"])).hexdigest() + file.seek(max(0, offset - min(offset, 4096))) + tail = hashlib.sha256(file.read(min(offset, 4096))).hexdigest() + if ( + stat.st_size < offset + or prefix != cursor["prefix_sha"] + or tail != cursor["tail_sha"] + ): + offset, generation = 0, uuid.uuid4().hex + file.seek(offset) + while file.tell() < stat.st_size: + position = file.tell() + raw = file.readline(min(MAX_LINE_BYTES + 1, stat.st_size - position)) + oversized = len(raw) > MAX_LINE_BYTES + while oversized and not raw.endswith(b"\n") and file.tell() < stat.st_size: + raw = file.readline(min(MAX_LINE_BYTES + 1, stat.st_size - file.tell())) + if not raw.endswith(b"\n"): + break # The cursor stays at the start of a partial line. + offset = file.tell() + if oversized: + counts["invalid"] += 1 + db.execute( + "INSERT OR IGNORE INTO audit_rejects VALUES (?,?,?)", + (generation, position, "invalid"), + ) + continue + try: + entry = orjson.loads(raw) + record = normalize(entry, source) + counts["imported"] += self._store_line(db, generation, position, record) + except UnscopedLine: + counts["unscoped"] += 1 + db.execute( + "INSERT OR IGNORE INTO audit_rejects VALUES (?,?,?)", + (generation, position, "unscoped"), + ) + except (ValueError, TypeError, RecursionError): + counts["invalid"] += 1 + db.execute( + "INSERT OR IGNORE INTO audit_rejects VALUES (?,?,?)", + (generation, position, "invalid"), + ) + if os.fstat(file.fileno()).st_size < offset: + raise OSError("Audit file changed during import") + anchors = self._anchors(file, offset) + db.execute( + "INSERT OR REPLACE INTO audit_files VALUES (?,?,?,?,?,?,?)", + (source, identity, generation, offset, *anchors), + ) + return counts + + def _store_line(self, db, generation, position, record): + call_id = record["call_id"] + key = ( + "\0".join((record["source"], record["cube"], call_id)) + if call_id + else f"{generation}:{position}" + ) + identifier = "log_" + hashlib.sha256(key.encode()).hexdigest() + existing = ( + db.execute( + "SELECT request_record,terminal_record,event FROM audit_calls WHERE id=?", + (identifier,), + ).fetchone() + if call_id + else None + ) + if existing and record["event"] and existing["event"] not in (record["event"], "unknown"): + raise ValueError("Conflicting events for a call ID") + if not db.execute( + "INSERT OR IGNORE INTO audit_lines VALUES (?,?,?)", (generation, position, identifier) + ).rowcount: + return 0 + request = ( + json.loads(existing["request_record"]) + if existing and existing["request_record"] + else None + ) + terminal = ( + json.loads(existing["terminal_record"]) + if existing and existing["terminal_record"] + else None + ) + if record["phase"] == "terminal": + terminal = self._merge_terminal(terminal, record) + elif request is None or record["ts"] < request["ts"]: + request = record + base, final = request or terminal, terminal or request + users = sorted(set((request or {}).get("users", []) + (terminal or {}).get("users", []))) + agents = sorted(set((request or {}).get("agents", []) + (terminal or {}).get("agents", []))) + event = (request or {}).get("event") or (terminal or {}).get("event") or "unknown" + detail = { + "id": identifier, + "createTime": base["time"], + "eventType": event, + "status": final["status"], + "duration": final["duration"], + "requestTokens": final["request_tokens"], + "responseTokens": final["response_tokens"], + "requestBody": base["request_body"], + "responseBody": final["response_body"], + "source": record["source"], + } + if users: + detail["userId"] = ",".join(users) + if agents: + detail["agentId"] = ",".join(agents) + for output, field in ( + ("callId", "call_id"), + ("traceId", "trace_id"), + ("requestId", "request_id"), + ("requestCode", "request_code"), + ("responseCode", "response_code"), + ("llmCalls", "llm_calls"), + ): + value = final[field] if final[field] is not None else base[field] + if value is not None: + detail[output] = value + search = " ".join( + (event, call_id or "", *users, *agents, detail["requestBody"], detail["responseBody"]) + ).casefold() + db.execute( + "INSERT OR REPLACE INTO audit_calls VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", + ( + identifier, + record["cube"], + record["source"], + event, + final["status"], + base["ts"], + compact(users), + compact(agents), + search, + compact(detail), + compact(request) if request and call_id else None, + compact(terminal) if terminal and call_id else None, + ), + ) + return 1 + + @staticmethod + def _merge_terminal(previous, incoming): + if previous is None: + return incoming + # Later explicit evidence wins; an equal-time conflict prefers FAILURE. + newer, older = ( + (incoming, previous) + if (incoming["ts"], incoming["status"] == "FAILURE") + >= (previous["ts"], previous["status"] == "FAILURE") + else (previous, incoming) + ) + merged = dict(newer) + if newer["status"] == "UNKNOWN": + merged["status"] = older["status"] + for field in ( + "duration", + "request_tokens", + "response_tokens", + "request_body", + "response_body", + ): + if field not in newer.get("observed", []) and field in older.get("observed", []): + merged[field] = older[field] + merged["observed"] = sorted(set(newer.get("observed", []) + older.get("observed", []))) + for field in ( + "event", + "trace_id", + "request_id", + "request_code", + "response_code", + "llm_calls", + ): + if merged[field] is None: + merged[field] = older[field] + for field in ("users", "agents"): + merged[field] = sorted(set(newer[field] + older[field])) + return merged + + def usage(self, cubes, start, end, *, include_daily=True): + """Aggregate existing logical rows; no second reader or status-based billing guess.""" + events = ("addMessage", "searchMemory", "chat", "extractMemory", "rerank") + + def empty(): + return { + event: {"calls": 0, "totalTokens": 0} + if event in {"addMessage", "searchMemory", "rerank"} + else {"requestTokens": 0, "responseTokens": 0, "totalTokens": 0} + for event in events + } + + days = { + (start + timedelta(days=n)).isoformat(): empty() for n in range((end - start).days + 1) + } + summary = empty() + # Read all selected cubes in one SQLite snapshot. Date bounds are UTC + # microseconds, bucket labels follow the frontend's fixed UTC+8 calendar. + with self.connection() as db: + db.execute("BEGIN") + for cube in dict.fromkeys(cubes): + rows = db.execute( + "SELECT strftime('%Y-%m-%d'," + "(ts-CASE WHEN ts<0 THEN 999999 ELSE 0 END)/1000000," + "'unixepoch','+8 hours') AS day," + "event,json_extract(detail,'$.requestTokens') AS req," + "json_extract(detail,'$.responseTokens') AS res " + "FROM audit_calls WHERE cube=? AND source='gateway' AND ts>=? AND ts0") + values.append(query.query.casefold()) + if query.subjectId: + columns = ( + ("users",) + if query.subjectType == "user" + else ("agents",) + if query.subjectType == "agent" + else ("users", "agents") + ) + clauses.append( + "(" + + " OR ".join( + f"EXISTS(SELECT 1 FROM json_each({col}) WHERE value=?)" for col in columns + ) + + ")" + ) + values.extend([query.subjectId] * len(columns)) + for raw, end in ((query.startTime, False), (query.endTime, True)): + date, exclusive = time_boundary(raw, end=end) + if date is not None: + clauses.append("ts" + ("<" if exclusive else "<=" if end else ">=") + "?") + values.append(round(date.timestamp() * 1_000_000)) + where = " AND ".join(clauses) + with self.connection() as db: + db.execute("BEGIN") + total = db.execute( + "SELECT count(*) FROM audit_calls WHERE " + where, values + ).fetchone()[0] + rows = db.execute( + "SELECT detail,users,agents FROM audit_calls WHERE " + + where + + " ORDER BY ts DESC,id DESC LIMIT ? OFFSET ?", + (*values, query.pageSize, (query.pageNum - 1) * query.pageSize), + ).fetchall() + logs = [] + for row in rows: + detail = json.loads(row["detail"]) + served = { + key: value + for key, value in detail.items() + if key + not in { + "requestBody", + "responseBody", + "userId", + "agentId", + "traceId", + "requestId", + "requestCode", + "responseCode", + "llmCalls", + } + } | {"userIds": json.loads(row["users"]), "agentIds": json.loads(row["agents"])} + if served.get("createTime"): + served["createTime"] = display(served["createTime"]) + logs.append(served) + return {"logs": logs, "total": total} + + def detail(self, cube, identifier): + with self.connection() as db: + row = db.execute( + "SELECT detail FROM audit_calls WHERE cube=? AND id=?", (cube, identifier) + ).fetchone() + if row is None: + return None + served = json.loads(row[0]) + if served.get("createTime"): + served["createTime"] = display(served["createTime"]) + return served diff --git a/server/backend/audit_models.py b/server/backend/audit_models.py new file mode 100644 index 000000000..2a7513634 --- /dev/null +++ b/server/backend/audit_models.py @@ -0,0 +1,88 @@ +"""Call log query contracts; output unknown history is kept explicit.""" + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from .memory_models import time_boundary + +EVENT_TYPES = frozenset( + ( + "addMessage", + "searchMemory", + "getMemory", + "deleteMemory", + "addFeedback", + "manualAddMemory", + "updateMemory", + "bindProfileTemplate", + "editProfile", + "deleteProfile", + "getMessage", + "chat", + "extractMemory", + "rerank", + ) +) + + +class LogListRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + projectId: str = Field(min_length=1, max_length=128) + pageNum: int = Field(1, ge=1, le=2_147_483_647, strict=True) + pageSize: int = Field(50, ge=1, le=100, strict=True) + scope: Literal["all", "gateway", "console"] = "all" + subjectType: Literal["all", "user", "agent"] | None = None + subjectId: str | None = Field(None, max_length=256) + eventType: str | None = Field(None, max_length=256) + query: str | None = Field(None, max_length=1000) + status: Literal["SUCCESS", "FAILURE", "PENDING", "UNKNOWN"] | None = None + startTime: str | None = Field(None, max_length=64) + endTime: str | None = Field(None, max_length=64) + + @field_validator( + "subjectType", + "subjectId", + "eventType", + "query", + "status", + "startTime", + "endTime", + mode="before", + ) + @classmethod + def empty_is_missing(cls, value): + return value.strip() or None if isinstance(value, str) else value + + @field_validator("status", mode="before") + @classmethod + def status_alias(cls, value): + if not isinstance(value, str) or not value.strip(): + return None if value == "" else value + return {"FAILED": "FAILURE", "FAIL": "FAILURE"}.get(value.upper(), value.upper()) + + @field_validator("eventType") + @classmethod + def event_filter(cls, value): + value = "extractMemory" if value == "extractMemoryDirect" else value + if value is not None and value not in EVENT_TYPES: + raise ValueError("Unsupported event type filter") + return value + + @model_validator(mode="after") + def validate_filters(self): + if self.subjectId is None: + self.subjectType = None + try: + start, _ = time_boundary(self.startTime) + end, exclusive = time_boundary(self.endTime, end=True) + except OverflowError as exc: + raise ValueError("UTC date boundary out of range") from exc + if start and end and (start > end or (exclusive and start == end)): + raise ValueError("Invalid date range") + return self + + +class LogDetailRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + logId: str = Field(min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_.-]+$") diff --git a/server/backend/audit_normalize.py b/server/backend/audit_normalize.py new file mode 100644 index 000000000..e4d5fe506 --- /dev/null +++ b/server/backend/audit_normalize.py @@ -0,0 +1,242 @@ +"""Normalize only evidence present in audit lines; never guess tenant/subjects.""" + +import json +import math +import re +from datetime import datetime, timezone +from functools import lru_cache +from typing import Any + +TOOL_EVENTS = { + "add_message": "addMessage", + "search_memory": "searchMemory", + "get_user_profile": "getMemory", + "delete_memory": "deleteMemory", + "add_feedback": "addFeedback", +} +SECRET_KEY = re.compile(r"(?:apikey|authorization|password|secret|accesstoken|refreshtoken|token)$") +SECRET_VALUE = re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b") +BODY_LIMIT = 65536 + + +class UnscopedLine(ValueError): + pass + + +@lru_cache(maxsize=512) +def credential_key(key: str) -> bool: + """Cache common schema keys, never credential values or unbounded long keys.""" + return bool(SECRET_KEY.search(re.sub(r"[^a-z0-9]", "", key.casefold()))) + + +def redact(value: Any) -> Any: + if isinstance(value, dict): + return { + key: "[REDACTED]" + if ( + credential_key(key) + if len(key) <= 128 + else SECRET_KEY.search(re.sub(r"[^a-z0-9]", "", key.casefold())) + ) + else redact(item) + for key, item in value.items() + } + if isinstance(value, list): + return [redact(item) for item in value] + if isinstance(value, str): + return SECRET_VALUE.sub("[REDACTED]", value) + return value + + +def decoded(value: Any) -> Any: + if isinstance(value, str): + try: + return json.loads(value) + except (ValueError, RecursionError): + return value + return value + + +def body_json(value: Any) -> str: + parsed = decoded(value) + clean = redact(parsed) + if isinstance(value, str) and clean == parsed and not isinstance(parsed, str): + result = value + elif isinstance(value, str) and clean == parsed: + try: + json.loads(value) + result = value + except ValueError: + result = json.dumps(clean, ensure_ascii=False, separators=(",", ":"), allow_nan=False) + else: + result = json.dumps(clean, ensure_ascii=False, separators=(",", ":"), allow_nan=False) + if len(result) > BODY_LIMIT: + result = json.dumps( + {"truncated": True, "chars": len(result), "text": result[:BODY_LIMIT]}, + ensure_ascii=False, + separators=(",", ":"), + ) + return result + + +def text(value: Any, limit: int = 256) -> str | None: + return value[:limit] if isinstance(value, str) and value else None + + +def integer(value: Any) -> int | None: + return value if type(value) is int and value >= 0 else None + + +def timestamp(value: Any) -> tuple[str, int]: + try: + if isinstance(value, str): + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.utcoffset() is None: + return "", 0 + elif type(value) in (int, float) and math.isfinite(value): + parsed = datetime.fromtimestamp(value, timezone.utc) + else: + return "", 0 + parsed = parsed.astimezone(timezone.utc) + return parsed.isoformat(), round(parsed.timestamp() * 1_000_000) + except (ValueError, OverflowError, OSError): + return "", 0 + + +def identifiers(entry: dict, request: dict, kind: str) -> list[str]: + values = [] + for container in (entry, request): + for key in (kind + "Ids", kind + "_ids", kind + "Id", kind + "_id"): + value = container.get(key) + if isinstance(value, str): + values.append(value) + elif isinstance(value, list): + values.extend(item for item in value if isinstance(item, str)) + return sorted({item[:256] for item in values if item})[:100] + + +def normalize(entry: dict, source: str) -> dict: + if not isinstance(entry, dict): + raise ValueError("Audit line must be an object") + raw_cube = entry.get("tenant") or entry.get("mem_cube_id") or entry.get("cube_id") + if isinstance(raw_cube, str) and len(raw_cube) > 128: + raise ValueError("Tenant identifier is too long") + cube = text(raw_cube, 128) + if cube is None: + raise UnscopedLine("Audit tenant is absent") + request = decoded(entry.get("input", entry.get("requestBody"))) + output = decoded(entry.get("output", entry.get("responseBody"))) + input_dict = request if isinstance(request, dict) else {} + output_dict = output if isinstance(output, dict) else {} + data = output_dict.get("data") + data = data if isinstance(data, dict) else {} + event = text(entry.get("eventType") or entry.get("event_type") or entry.get("tool")) + event = TOOL_EVENTS.get(event, event) + event = "extractMemory" if event == "extractMemoryDirect" else event + raw_call_id = entry.get("callId") or entry.get("call_id") + if isinstance(raw_call_id, str) and len(raw_call_id) > 256: + raise ValueError("Call identifier is too long") + call_id = text(raw_call_id) + raw_phase = str(entry.get("phase") or "").lower() + phase = ( + "terminal" + if raw_phase in {"completed", "complete", "completion", "final", "terminal", "failed"} + else "request" + if raw_phase in {"request", "start", "accepted", "pending"} + else "sync" + ) + statuses = { + str(value).lower() + for value in (entry.get("status"), data.get("status"), output_dict.get("status")) + if value is not None + } + codes = [ + integer(value) + for value in ( + entry.get("responseCode"), + entry.get("response_code"), + output_dict.get("code"), + ) + ] + response_code = integer( + entry.get("responseCode", entry.get("response_code", output_dict.get("code"))) + ) + failed = ( + entry.get("ok") is False + or entry.get("err") not in (None, "", False) + or output_dict.get("success") is False + or data.get("success") is False + or bool(statuses & {"failed", "failure", "error"}) + or raw_phase == "failed" + or any(value is not None and value not in (0, 200) for value in codes) + ) + pending = bool(statuses & {"pending", "running", "in_progress", "queued"}) or ( + phase == "request" and bool(call_id) + ) + status = ( + "FAILURE" + if failed + else "PENDING" + if pending + else "SUCCESS" + if entry.get("ok") is True + or bool(statuses & {"success", "succeeded", "completed", "done"}) + or any(value in (0, 200) for value in codes) + else "UNKNOWN" + ) + if call_id and status == "PENDING": + phase = "request" + elif call_id and phase == "sync" and status in {"SUCCESS", "FAILURE"}: + phase = "terminal" + duration = entry.get("ms", entry.get("duration")) + duration = ( + duration + if type(duration) in (int, float) + and math.isfinite(duration) + and duration >= 0 + and status != "PENDING" + else None + ) + usage = output_dict.get("usage") or data.get("usage") or entry.get("usage") or {} + usage = usage if isinstance(usage, dict) else {} + request_tokens = integer( + entry.get("requestTokens", usage.get("prompt_tokens", usage.get("input_tokens"))) + ) + response_tokens = integer( + entry.get("responseTokens", usage.get("completion_tokens", usage.get("output_tokens"))) + ) + observed = [ + key + for key, value in ( + ("duration", duration), + ("request_tokens", request_tokens), + ("response_tokens", response_tokens), + ("request_body", entry.get("input", entry.get("requestBody"))), + ("response_body", entry.get("output", entry.get("responseBody"))), + ) + if value is not None + ] + time, ts = timestamp(entry.get("ts", entry.get("createTime"))) + return { + "cube": cube, + "source": source, + "event": event, + "call_id": call_id, + "phase": phase, + "status": status, + "duration": duration, + "time": time, + "ts": ts, + "users": identifiers(entry, input_dict, "user"), + "agents": identifiers(entry, input_dict, "agent"), + "request_tokens": request_tokens or 0, + "response_tokens": response_tokens or 0, + "observed": observed, + "request_body": body_json(entry.get("input", entry.get("requestBody"))), + "response_body": body_json(entry.get("output", entry.get("responseBody"))), + "trace_id": text(entry.get("traceId") or entry.get("trace_id")), + "request_id": text(entry.get("requestId") or entry.get("request_id")), + "request_code": integer(entry.get("requestCode", entry.get("request_code"))), + "response_code": response_code, + "llm_calls": integer(entry.get("llmCalls", data.get("llm_calls"))), + } diff --git a/server/backend/audit_routes.py b/server/backend/audit_routes.py new file mode 100644 index 000000000..999066f7c --- /dev/null +++ b/server/backend/audit_routes.py @@ -0,0 +1,77 @@ +"""Audit queries run in worker threads: importing JSONL cannot block the event loop.""" + +import sqlite3 +import threading + +from fastapi import APIRouter, Request, Response + +from .audit_index import AuditIndex, unavailable +from .audit_models import LogDetailRequest, LogListRequest +from .auth import control_for +from .errors import ApiError +from .models import Envelope +from .project_context import selected_project + +router = APIRouter() + + +class AuditService: + """Lazily initialize so the first-query benchmark includes database creation.""" + + def __init__(self, config): + self.config, self.index = config, None + self.lock = threading.Lock() + + def get(self): + with self.lock: + if self.index is None: + sources = { + "console": self.config.audit_console_dir or self.config.data_dir / "audit" + } + if self.config.audit_gateway_dir is not None: + sources["gateway"] = self.config.audit_gateway_dir + self.index = AuditIndex(self.config.data_dir / "audit.db", sources) + return self.index + + def sync(self, scope="all"): + if scope in {"all", "gateway"} and self.config.audit_gateway_dir is None: + raise unavailable() + try: + index = self.get() + index.sync(scope) + return index + except (OSError, sqlite3.Error) as exc: + raise unavailable() from exc + + +@router.post("/api/dashboard/log/list", response_model=Envelope[dict]) +def log_list(body: LogListRequest, request: Request, response: Response): + control = control_for(request) + cube = control.resolve_cube(body.projectId) if control else None + if cube is None: + raise ApiError(40001, "项目不存在或未配置访问范围", status_code=404) + index = request.app.state.audit.sync(body.scope) + try: + result = index.page(cube, body) + except (OSError, sqlite3.Error) as exc: + raise unavailable() from exc + response.headers["Cache-Control"] = "no-store" + return Envelope(data=result) + + +@router.post("/api/dashboard/log/detail", response_model=Envelope[dict]) +def log_detail(body: LogDetailRequest, request: Request, response: Response): + control = control_for(request) + cube = control.resolve_cube(selected_project(request)) if control else None + if cube is None: + raise ApiError(50300, "尚未配置当前项目 DASHBOARD_DEFAULT_PROJECT_ID", status_code=503) + # An indexed console detail does not depend on gateway availability. + try: + index = request.app.state.audit.get() + detail = index.detail(cube, body.logId) + except (OSError, sqlite3.Error) as exc: + raise unavailable() from exc + if detail is None: + raise ApiError(40001, "日志不存在", status_code=404) + response.headers["Cache-Control"] = "no-store" + return Envelope(data=detail) diff --git a/server/backend/audit_writer.py b/server/backend/audit_writer.py new file mode 100644 index 000000000..17d1160db --- /dev/null +++ b/server/backend/audit_writer.py @@ -0,0 +1,128 @@ +"""Bounded console audit capture. Log queries never generate business call rows.""" + +import json +import logging +import threading +import time +import uuid +from datetime import datetime, timezone + +from starlette.concurrency import run_in_threadpool +from starlette.requests import Request + +from .audit_normalize import body_json +from .auth import control_for +from .project_context import selected_project + +logger = logging.getLogger(__name__) +LIMIT = 65536 +PREFIX = "/api/dashboard/project/memory/" +EVENTS = { + "list": "console.memory.list", + "detail": "getMemory", + "delete": "deleteMemory", + "restore": "console.memory.restore", + "retry": "console.memory.retry", +} + + +class ConsoleWriter: + def __init__(self, folder): + self.folder, self.lock = folder, threading.Lock() + folder.mkdir(parents=True, exist_ok=True) + + def append(self, record): + line = json.dumps(record, ensure_ascii=False, separators=(",", ":"), allow_nan=False) + "\n" + filename = "console-" + datetime.now(timezone.utc).strftime("%Y%m%d") + ".jsonl" + # One append syscall; serialization protects simultaneous worker threads. + with self.lock, (self.folder / filename).open("ab", buffering=0) as file: + file.write(line.encode("utf-8")) + + +def captured_body(raw, size): + if size > LIMIT: + # Do not retain a sliced secret or invalid JSON from an oversized payload. + return {"truncated": True, "bytes": size} + try: + return json.loads(raw) + except (ValueError, UnicodeError): + return {"invalidJson": True, "bytes": size} + + +class ConsoleAuditMiddleware: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + path = scope.get("path", "") + event = EVENTS.get(path.removeprefix(PREFIX)) if path.startswith(PREFIX) else None + if scope["type"] != "http" or scope.get("method") != "POST" or event is None: + await self.app(scope, receive, send) + return + started = time.perf_counter() + request_parts, response_parts = bytearray(), bytearray() + request_size = response_size = 0 + http_status = 500 + + async def capture_receive(): + nonlocal request_size + message = await receive() + if message["type"] == "http.request": + body = message.get("body", b"") + request_size += len(body) + request_parts.extend(body[: max(0, LIMIT - len(request_parts))]) + return message + + async def capture_send(message): + nonlocal response_size, http_status + if message["type"] == "http.response.start": + http_status = message["status"] + elif message["type"] == "http.response.body": + body = message.get("body", b"") + response_size += len(body) + response_parts.extend(body[: max(0, LIMIT - len(response_parts))]) + await send(message) + + await self.app(scope, capture_receive, capture_send) + try: + state = scope["app"].state + input_body = captured_body(request_parts, request_size) + output = captured_body(response_parts, response_size) + control = control_for(Request(scope)) + # Only detail's contract lacks projectId. An invalid explicit project never falls back. + project = ( + selected_project(Request(scope)) + if path == PREFIX + "detail" + else input_body.get("projectId") + if isinstance(input_body, dict) + else None + ) + cube = scope.get("audit_cube") + if cube is None and control and project: + cube = await run_in_threadpool(control.resolve_cube, project) + if cube is not None: + code = scope.get( + "audit_code", output.get("code") if isinstance(output, dict) else None + ) + success = ( + (http_status < 400 and code == 0) + if code is not None + else False + if http_status >= 400 + else None + ) + record = { + "ts": datetime.now(timezone.utc).isoformat(), + "tenant": cube, + "eventType": event, + "callId": uuid.uuid4().hex, + "ok": success, + "ms": round((time.perf_counter() - started) * 1000, 3), + "responseCode": code, + "input": body_json(input_body), + "output": body_json(output), + } + await run_in_threadpool(state.console_audit.append, record) + except Exception as exc: + # Audit I/O failure cannot undo an already delivered mutation or leak its payload. + logger.error("Console audit write failed (%s)", type(exc).__name__) diff --git a/server/backend/auth.py b/server/backend/auth.py new file mode 100644 index 000000000..2e8f41369 --- /dev/null +++ b/server/backend/auth.py @@ -0,0 +1,342 @@ +"""Session enforcement and backend-owned HTML forms, without React changes.""" + +import hmac +import re +import secrets +import sqlite3 +from html import escape +from urllib.parse import parse_qs + +from fastapi import APIRouter, Query, Request, Response +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from pydantic import BaseModel, ConfigDict, Field, model_validator +from starlette.concurrency import run_in_threadpool + +from .auth_ui import STYLE_SOURCE, render +from .errors import ApiError +from .identity_store import SESSION_COOKIE, forbidden +from .models import Envelope +from .security import browser_write_receive, check_origin + +router = APIRouter() + + +def control_for(request): + actor = request.scope.get("identity") + store = request.app.state.control + return store.with_actor(actor) if actor else store + + +def actor_for(request, *, admin=False): + actor = request.scope.get("identity") + if actor is None: + raise ApiError(40100, "请先登录", status_code=401) + if admin and not actor["platform_admin"]: + raise forbidden() + return actor + + +class SessionMiddleware: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] != "http" or not scope["app"].state.settings.auth_enabled: + await self.app(scope, receive, send) + return + request = Request(scope) + try: + actor = await run_in_threadpool( + scope["app"].state.identity.identity, request.cookies.get(SESSION_COOKIE) + ) + scope["identity"] = actor + protected = ( + scope["path"].startswith(("/api/dashboard/", "/admin/")) + or scope["path"] == "/api/auth/me" + ) + if protected and actor is None: + raise ApiError(40100, "请先登录", status_code=401) + if protected and scope["method"] not in {"GET", "HEAD", "OPTIONS"}: + check_origin(request) + if request.headers.get("origin") is not None: + receive = await browser_write_receive(request, receive) + except ApiError as exc: + response = JSONResponse( + {"code": exc.code, "data": None, "message": exc.message}, + status_code=exc.status_code, + headers={"Cache-Control": "no-store"}, + ) + await response(scope, receive, send) + return + except sqlite3.Error: + await JSONResponse( + {"code": 50300, "data": None, "message": "身份存储暂不可用"}, status_code=503 + )(scope, receive, send) + return + await self.app(scope, receive, send) + + +def page(title, content, status=200): + return HTMLResponse( + render(title, content), + status_code=status, + headers={ + "Cache-Control": "no-store", + "Referrer-Policy": "same-origin", + "Content-Security-Policy": ( + "default-src 'none'; form-action 'self'; frame-ancestors 'none'; " + "style-src " + STYLE_SOURCE + ), + "X-Content-Type-Options": "nosniff", + }, + ) + + +def form_fields(token): + return '' + + +async def form(request): + check_origin(request) + if request.headers.get("content-type", "").split(";")[0] != "application/x-www-form-urlencoded": + raise ApiError(40000, "表单格式错误", status_code=400) + raw = bytearray() + async for chunk in request.stream(): + raw.extend(chunk) + if len(raw) > 8192: + raise ApiError(40000, "表单过大", status_code=400) + try: + values = parse_qs(raw.decode("utf-8"), keep_blank_values=True, max_num_fields=10) + if any(len(v) != 1 for v in values.values()): + raise ValueError() + return {k: v[0] for k, v in values.items()} + except (ValueError, UnicodeError) as exc: + raise ApiError(40000, "表单格式错误", status_code=400) from exc + + +def check_csrf(request, body, expected=None): + expected = expected or request.cookies.get("memos_login_csrf", "") + supplied = body.get("csrf", "") + if ( + not re.fullmatch(r"[A-Za-z0-9_-]{43}", supplied) + or not expected + or len(expected) != 43 + or not hmac.compare_digest(supplied, expected) + ): + raise forbidden() + + +@router.get("/__login", response_class=HTMLResponse) +def login_page(request: Request): + token = secrets.token_urlsafe(32) + response = page( + "登录 MemOS", + '
' + + form_fields(token) + + ( + '