4c0b4fab48
Adds `pr6-routes-paths-workspace.md`: - Scope summary (routes + repos + Paths + middleware + migration script + lifespan + T5.11) - Per-task commit table (T5.11 + T6.1..T6.15) - Acceptance checklist with the 64 net-new test count and 17 flake breakdown - Architecture decisions (404 not 403, check_access signature, three-state semantics, path shape, fallback workspace) - Live smoke command list for the migration follow-up - File structure index (new vs modified) Updates STATUS.md: - One-line status moves to PR6 merged, T5.11 cleared as PR6 deliverable - 8-PR status table: PR6 row marked merged with commit range and impl note link - Skipped/deferred rows: T5.11 struck as completed, new PR6 T6.15 live-smoke row added - Next-step suggestion switches to PR7 (CI boundary static scan); PR8 noted parallelisable
12 KiB
12 KiB
PR6 · 路由强校验 + Paths workspace 化 + 仓储 workspace_id 哨兵 + 文件迁移
实现笔记。对应 docs/superpowers/plans/2026-05-10-stage-0-multi-tenant-foundation.md PR6(T5.11 + T6.1-T6.15)。
状态:已落地,13 个 feat/test commits 提交到
docs/multi-tenant-redesign(PR6 起始361e653d..结束87ea715c)。
范围
PR6 把 PR5 落到 4 张业务表里的 workspace_id 列真正接到路由 → 仓储 → 文件系统:
- 仓储层 —
ThreadMetaRepository+RunRepository+FeedbackRepository+DbRunEventStore的 30+ 个方法全部新增workspace_id: str | None | _AutoSentinel = AUTO哨兵参数,AUTO走 contextvar,显式None旁路(迁移 / CLI),显式 str 覆盖。读路径加WHERE workspace_id = :wid;写路径写入workspace_id。 ThreadMetaStore.check_access升级三参数(thread_id, user_id, workspace_id, *, require_existing)。跨 workspace 一律 False,即使 user_id 匹配也 False — 上层装饰器把 False 翻成 404 不是 403,绝不泄露跨租户的 thread 存在性。@require_permission(owner_check=True)从get_effective_workspace_id()拉 workspace(PR4 AuthMiddleware 已注入;no-auth dev fallback 到"default")传给check_access。Paths切 workspace 维度:新顶层{base_dir}/workspaces/{wid}/...,per-thread 路径{base_dir}/workspaces/{wid}/threads/{tid}/user-data/{workspace,uploads,outputs}/,per-user state 嵌套在 workspace 下{base_dir}/workspaces/{wid}/users/{uid}/memory.json。thread_dir(thread_id, *, workspace_id=None, user_id=None)三档优先级(workspace > user > 完全 legacy)。- ThreadDataMiddleware
before_agent从get_effective_user_id()切到get_effective_workspace_id()+ 把user_id/workspace_id透传到thread_datastate。 - 文件系统迁移
scripts/migrate_paths_to_workspace.py(带--dry-run--default-workspace):扫{base}/users/{uid}/...→{base}/workspaces/{wid}/...,wid从users.default_workspace_id读,缺失时 fallback 到legacy_workspace(对齐 PR5 backfill 的孤儿 bucket);冲突分流到{base}/migration-conflicts/workspace-migration/。 - lifespan + Makefile:lifespan 探测
{base}/users/残留时 log warning 引导跑make migrate-paths;Makefile 加migrate-pathstarget(DRY_RUN=1/DEFAULT_WORKSPACE=<wid>两个 env 旋钮)。 - T5.11(PR5 推迟到 PR6):4 个业务 ORM 翻
Mapped[str] workspace_id+nullable=False,对齐 alembic 0003 的 DB 不变式到 ORMcreate_all()路径。
不在范围(Stage 1+):
- frontend workspace picker / switching UI
- service_accounts / api_keys / external_users schema(PR8)
tenant_*→workspace_*大规模 rename(ADR 用语保留)
Tasks 完成清单
| Task | Commit | 关键改动 |
|---|---|---|
| T6.1 | 361e653d |
ThreadMetaRepository.create workspace_id 哨兵 + autouse fixture + Base.metadata.after_create seed listener |
| T6.2 | 296a4f19 |
ThreadMetaRepository.get 加 SQL WHERE workspace_id |
| T6.3 | 28ad6c2b |
search/update_display_name/update_status/update_metadata/delete 全加 workspace_id |
| T6.4 | 05be7f9a |
check_access 升级 3 参数 + authz 装饰器接入 get_effective_workspace_id() |
| T6.5 | b4fa3bf1 |
RunRepository + FeedbackRepository + DbRunEventStore 同款改造 |
| T6.6 | 0456606d |
5 个 require_permission probe 测试(cross-workspace 404 / default fallback / 第三参透传) |
| T6.7 | f6a92292 |
POST /api/threads 集成测试:TestClient + 两个 workspace 各落不同 thread 行 |
| T6.8 | a7ecd76e |
4 case cross-workspace 404 e2e(GET / DELETE / PATCH + 同 workspace 正例) |
| T6.9 + T6.10 | f013fc1a |
Paths.workspace_dir / thread_dir(*, workspace_id, user_id) / 所有 sandbox_/host_/acp_workspace 都加 workspace_id 维度 |
| T6.11 | 2d3b546b |
ThreadDataMiddleware 切 workspace + thread_data 透传 user_id/workspace_id |
| T6.12 + T6.13 | 56f2c8d8 |
scripts/migrate_paths_to_workspace.py + 9 个测试(thread/memory/agent 迁移、dry-run、冲突分流、empty users/ 清理、DB 缺失降级) |
| T6.14 | c5c66ccb |
_check_path_migration_pending lifespan warning + Makefile migrate-paths target |
| T5.11 | 87ea715c |
4 个 ORM nullable=False + conftest 一致性 seed + backfill test 临时 nullable fixture |
验收
- 27 个新单测全过:3 thread_meta workspace create + 3 get + 5 search/update/delete + 2 check_access workspace + 7 run/feedback/run_event + 5 require_permission probes + 2 POST /threads + 4 boundary e2e + 15 paths workspace + 4 ThreadDataMiddleware workspace + 9 path migration + 3 lifespan warning
- 全套
make test3214 passed + 30 skipped + 17 caplog flake(PR5 末 3150 + 30 + 16;+64 passed / +1 flake,新 flake 是test_path_migration_pending_warning::test_warns...,solo 跑 PASS,与本 stage 无关,归并到 follow-up) - 跨 workspace 必 404:GET / DELETE / PATCH 任一从 workspace B 访问 workspace A 的 thread 都返 404;same workspace 正例返 200。E2e 通过
MemoryThreadMetaStore(真实 impl,不是 mock)穿过 require_permission 装饰器 - Paths 三档优先级:workspace_id > user_id > 完全 legacy;新形态
workspaces/{wid}/threads/{tid}/user-data/...,per-user 状态嵌套workspaces/{wid}/users/{uid}/memory.json;path traversal 防御覆盖 - 迁移脚本
--dry-run不写:snapshot 源/目标 inode → 跑 migrate(dry_run=True) → 源不变 + 目标不存在 - lifespan 探测:
{base}/users/有内容时 log WARNING 引导make migrate-paths;目录缺失或为空时静默 - lint:
make lint全过(ruff check + format) - 真机
make devsmoke +make migrate-paths --dry-run— 待用户(agent 起不了 dev 服务)。命令清单见下文"Live smoke 命令(用户跟进)"
关键架构决策
| 项 | 选择 | 理由 |
|---|---|---|
| 跨 workspace 错误码 | 404 not 403 | 防 enumeration leak — 跨租户请求不能区分"thread 在别的 tenant"和"thread 不存在" |
check_access 签名顺序 |
(thread_id, user_id, workspace_id, *, require_existing) |
LOCK 项。workspace 是位置参数(非 keyword-only)—— 调用方必须显式传,鼓励"先想 workspace、再想 user" |
workspace_id 三态语义 |
AUTO / explicit str / explicit None,沿用 user_id 模式 |
减少新概念,迁移脚本沿用 None 旁路即可 |
| Path 新形态 | {base}/workspaces/{wid}/threads/{tid}/user-data/... —— 不嵌 user_id |
thread 在 workspace 内 UNIQUE(alembic 0003 已保证),user_id 是元数据不是分区 |
| Path per-user state | {base}/workspaces/{wid}/users/{uid}/memory.json |
memory / 自定义 agent 是 per-user,但仍嵌在 workspace 下 —— 同 user 在不同 workspace 的 memory 互相隔离 |
| 迁移脚本 fallback workspace | legacy_workspace(同 PR5 backfill 的 LEGACY_WORKSPACE_ID) |
文件系统迁移和数据库回填的孤儿桶一致,方便用户事后定位 |
| ORM nullable=False 时机 | PR6 末(T5.11) | 单翻 ORM 会让所有 INSERT 站点炸 NOT NULL;必须等仓储 workspace_id 哨兵 + 路由 contextvar 接入完才能翻 |
测试基线增量
| PR5 末 | PR6 末 | 增量 | |
|---|---|---|---|
| passed | 3150 | 3214 | +64 |
| skipped | 30 | 30 | 0 |
| caplog flake | 16 | 17 | +1 |
新 flake test_path_migration_pending_warning::test_warns_when_legacy_users_dir_has_content —— solo 跑全 PASS,是 caplog level 传播 ordering 问题,与 PR4 时识别的 16 个同类 flake 一族,归并到 follow-up 集中清理。
迁移路径(live ops 文档)
生产环境(PR5 已 alembic 0003 上线)升级 PR6 后:
- DB 不变(PR5 alembic 0003 已落 NOT NULL;PR6 没新 alembic)
- 代码部署:路由 + 仓储 + ThreadDataMiddleware 自动开始写 workspace-scope path(
{base}/workspaces/{wid}/...) - lifespan 启动会 log WARNING:
Legacy per-user layout detected at {base}/users/. Run \make migrate-paths` ...` - 用户跑
make migrate-paths DRY_RUN=1看迁移计划 - 用户跑
make migrate-paths真迁移;冲突落{base}/migration-conflicts/workspace-migration/待人工处理 - 重启 gateway:lifespan WARNING 消失
reverse rollback:脚本不带 --reverse,但 migration-conflicts/ 保留原文件树,可手动还原。建议生产 make migrate-paths DRY_RUN=1 先跑一次。
Live smoke 命令(用户跟进)
agent 没法起 gateway,所以下列由用户在本地 / RDS 实跑:
# 1. 启动开发服务(应该看到 lifespan WARNING 如果有遗留 users/)
make dev
# 2. dry-run 迁移
make migrate-paths DRY_RUN=1
# 期望:列出每个被迁移的 user,源/目标路径,'action=moved -> ...' or 'conflict -> ...'
# 3. 真迁移
make migrate-paths
# 4. 重启 gateway,确认 lifespan 警告消失
make stop && make dev
# 5. 双账户 smoke
# - 注册 user A + user B(各自走默认 workspace)
# - A 创 thread t1 → 复制 t1 到 B 的 URL → 必 404
# - B 创 thread t2 → 复制到 A → 必 404
# - 各自路径检查:ls .deer-flow/workspaces/<wid_A>/threads/t1
# - 老路径不应再有新写入:watch .deer-flow/users/ → 应保持空
文件结构索引
新增:
backend/scripts/migrate_paths_to_workspace.py— workspace 文件迁移backend/tests/test_thread_meta_workspace_filter.py— thread_meta workspace_id 过滤 (11 tests)backend/tests/test_run_feedback_workspace_filter.py— Run/Feedback/RunEvent workspace_id 过滤 (7 tests)backend/tests/test_require_permission_workspace.py— @require_permission workspace 接入 (5 tests)backend/tests/test_workspace_isolation_boundary.py— 跨 workspace 404 e2e (4 tests)backend/tests/test_threads_post_workspace.py— POST /api/threads workspace 集成 (2 tests)backend/tests/test_paths_workspace.py— Paths workspace 形态 (15 tests)backend/tests/test_thread_data_middleware_workspace.py— ThreadDataMiddleware workspace (4 tests)backend/tests/test_migrate_paths_to_workspace.py— 迁移脚本 (9 tests)backend/tests/test_path_migration_pending_warning.py— lifespan warning (3 tests)
修改:
backend/packages/harness/deerflow/persistence/{thread_meta,run,feedback}/sql.py— workspace_id 哨兵 + WHEREbackend/packages/harness/deerflow/persistence/thread_meta/{base.py, memory.py}— abstract + memory impl 同款backend/packages/harness/deerflow/persistence/{thread_meta,run,feedback}/model.py+models/run_event.py— nullable=False (T5.11)backend/packages/harness/deerflow/runtime/events/store/db.py— workspace_id_from_context + WHEREbackend/packages/harness/deerflow/config/paths.py— workspace_dir / validate_workspace_id / thread_dir + sandbox* + host_* + ensure/delete/resolve_virtual_pathbackend/packages/harness/deerflow/agents/middlewares/thread_data_middleware.py— workspace 切换backend/app/gateway/authz.py— get_effective_workspace_id() 透传给 check_accessbackend/app/gateway/app.py—_check_path_migration_pendinglifespan 探测backend/tests/conftest.py—_auto_workspace_contextautouse +Base.metadata.after_createseed listenerbackend/tests/_router_auth_helpers.py—_StubAuthMiddleware加 workspace_factory + override_user_contextvarbackend/pyproject.toml— 新增no_auto_workspacemarkerMakefile—migrate-pathstarget + help 文案
阅读路径
- 跨 workspace 必 404 怎么实现的 → 看
check_access改造(commit05be7f9a)+@require_permission装饰器 - 路径新形态 →
Paths.thread_dir三档优先级(commitf013fc1a) - 仓储 workspace_id 哨兵模式 → 任一
*/sql.py看 create/get/search 签名 - 迁移脚本与 PR5 backfill 的边界 → 本文件 "迁移路径" 段,外加
pr5-business-workspace-id.md