diff --git a/docs/multi-tenant-redesign/03-impl/STATUS.md b/docs/multi-tenant-redesign/03-impl/STATUS.md index 94e8a2b1..c1fd1457 100644 --- a/docs/multi-tenant-redesign/03-impl/STATUS.md +++ b/docs/multi-tenant-redesign/03-impl/STATUS.md @@ -2,11 +2,11 @@ > **每完成 1 个 PR 后必更新**。本文是 Stage 0 唯一的"现在到哪了"权威来源——其它文件(plan、ADR、各 PR impl note)都是静态的,不反映执行进度。 > -> 上次更新:2026-05-12,PR4 merge 进 docs branch 后 +> 上次更新:2026-05-13,PR5 merge 进 docs branch 后 ## 一句话状态 -PR1 + PR2 + PR3 + **PR4** 已 merge + **live 验证通过**(PR3 时 RDS 上 11 张表)+ **7 项 LOCK 决策 sign-off** + **docs branch 已 push origin(SSH-over-443)**。**PR4 (2026-05-12)** 落地:alembic 0001 (`users.default_workspace_id`) + JWT 扩 `wid+role` + AuthMiddleware ContextVar 注入 + 注册流程自动建 1 人 workspace + `/auth/me` 返回 `workspaces[]` + lifespan 回填 pre-PR4 admin。**3136 passed + 26 skipped + 0 PR4-induced failures**(16 个预存 caplog flake 已 stash 验证与 PR4 无关)。**下一个:PR5(alembic 0002 ALTER 4 表 + workspace_id 回填 + 改 NOT NULL)**。 +PR1 + PR2 + PR3 + PR4 + **PR5** 已 merge(PR3 时 RDS 上 11 张表 live 验证通过 + 7 项 LOCK 决策 sign-off + docs branch 已 push origin)。**PR5 (2026-05-13)** 落地:alembic 0002(4 表 + nullable workspace_id + FK + 复合索引)+ `scripts/backfill_workspace_id.py`(3 step 幂等:建 workspace per user → UPDATE 业务表 → legacy_workspace 兜底)+ alembic 0003(pre-flight 拒迁 + NOT NULL + UNIQUE(wid,tid))。**3150 passed + 30 skipped + 16 pre-existing caplog flake**(与 PR5 无关,isolate 跑全 PASS);+18 PR5 新测试覆盖。**T5.11 (ORM nullable=False) 显式推迟到 PR6**——见 [pr5 impl note 偏差说明](./pr5-business-workspace-id.md#偏差说明t511-推迟到-pr6)。**下一个:PR6(路由强校验 + Paths workspace 化 + 仓储 workspace_id 哨兵 + 文件迁移)**。 ## 8 PR 状态表 @@ -17,12 +17,12 @@ PR1 + PR2 + PR3 + **PR4** 已 merge + **live 验证通过**(PR3 时 RDS 上 11 | **PR2** | ✅ merged | 8 (T2.1-T2.10) | merged into docs branch (`404135a1..1112a197`) | [pr2-postgres-default.md](./pr2-postgres-default.md) | | **PR3** | ✅ merged | 7 (T3.1-T3.10) | merged into docs branch (`f63089ae..dda82640`) | [pr3-workspaces.md](./pr3-workspaces.md) | | **PR4** | ✅ merged | 14 (T4.1-T4.14) | merged into docs branch (`d98498b7..5c7753c0`) | [pr4-auth-workspace.md](./pr4-auth-workspace.md) | -| **PR5** | 🟡 pending | 0 | — | — | +| **PR5** | ✅ merged | 11 (T5.1-T5.10 + T5.12) | merged into docs branch (`a7326978..30f2bd00`) | [pr5-business-workspace-id.md](./pr5-business-workspace-id.md) | | **PR6** | 🟡 pending | 0 | — | — | | **PR7** | 🟡 pending | 0 | — | — | | **PR8** | 🟡 pending | 0 | — | — | -**测试基线**:**PR4 末 3136 passed + 26 skipped**(PR3 末 3134 + 25;+38 PR4 新测试吸收了 13 处 test_auth/test_langgraph_auth/test_auth_errors 改写)。PR2 末 3087。**16 个 caplog 排序 flake 在全套跑里出现**(test_jina_client / test_lead_agent_prompt / test_summarization_middleware 等)→ stash 验证均与 PR4 无关;预存 issue,集中清理推迟到 follow-up。期间 1 个偶发 flaky `tests/test_client_live.py::TestLiveStreaming::test_stream_ai_content_nonempty`(单跑 PASS,env 相关,与本 Stage 无关)。 +**测试基线**:**PR5 末 3150 passed + 30 skipped**(PR4 末 3136 + 26;+14 / +4 = 18 个 PR5 新测试:9 alembic 0002+0003 含 4 PG-skip + 9 backfill 含 dry-run/orchestrator/no-users 报错)。PR3 末 3134 + 25,PR2 末 3087。**16 个 caplog 排序 flake 持续存在**(test_jina_client / test_lead_agent_prompt / test_summarization_middleware 等)→ isolate 跑全 PASS,PR4 时 stash 验证过与 stage 无关;预存 issue,集中清理仍推迟到 follow-up。 ## 用户必须跟进的事(live verification / 决策) @@ -46,7 +46,9 @@ PR1 + PR2 + PR3 + **PR4** 已 merge + **live 验证通过**(PR3 时 RDS 上 11 | PR2 T2.9 | `backend/CLAUDE.md` Database 段更新 | README 已覆盖 80% 价值 | 写 PR3 时顺手补一句(agent 自己能做,不阻塞) | | PR4 T4.14 | 真机 `make dev` smoke 注册流程 | agent 无法实际起 gateway daemon | 用户跟进;命令清单见 [pr4-auth-workspace.md "Live smoke 命令"](./pr4-auth-workspace.md#live-smoke-命令用户跟进) | | PR4 follow-up | Regular user pre-PR4 backfill 脚本 | login 路径已 lazy backfill 覆盖;如果生产有大量预存 regular user,可补 batch 脚本 | 等真出现这个场景再写 | -| PR4 follow-up | 16 个 pre-existing caplog flake 集中清理 | 跨多个 test 文件的 propagation 问题,与 PR4 无关 | 单独 follow-up 处理 | +| PR4 follow-up | 16 个 pre-existing caplog flake 集中清理 | 跨多个 test 文件的 propagation 问题,与 PR4/5 无关 | 单独 follow-up 处理 | +| PR5 T5.11 | ORM model.py `nullable=False` 翻转 | 当前翻会破 6 个 INSERT 站点(PR6 才补 workspace_id 哨兵);DB 不变式由 alembic 0003 承载已足够 | **推迟到 PR6** 自然接入处;详见 [pr5 偏差说明](./pr5-business-workspace-id.md#偏差说明t511-推迟到-pr6) | +| PR5 T5.12 真机 PG smoke | `alembic 0002 → backfill → 0003` 端到端 | agent 不能起 RDS 操作 | 用户跟进;命令清单见 [pr5-business-workspace-id.md "Live smoke 命令"](./pr5-business-workspace-id.md#live-smoke-命令用户跟进) | ## 即将遇到的开放问题(plan 末尾列的,下个 session 处理) @@ -58,7 +60,7 @@ PR1 + PR2 + PR3 + **PR4** 已 merge + **live 验证通过**(PR3 时 RDS 上 11 ## 下一步建议 -**PR5(alembic 0002 ALTER 4 表 + workspace_id 回填 + 改 NOT NULL)**。plan 推荐 Inline 模式(migration script + 回填 + ALTER 改 NOT NULL 三阶段强耦合)。 +**PR6(入口路由强校验 + Paths workspace 化 + 仓储 workspace_id 哨兵 + 文件迁移)**。同时接入 PR5 推迟的 T5.11(ORM `nullable=False`)—— ORM 翻转必须与仓储 `create()` 加 `workspace_id` 参数 + 6 个 INSERT 站点 + contextvar 接入一起做,PR6 是天然落点。plan 推荐 Inline 模式(路由 + 仓储 + Paths 强耦合,难拆 subagent)。 历史模式回顾: diff --git a/docs/multi-tenant-redesign/03-impl/pr5-business-workspace-id.md b/docs/multi-tenant-redesign/03-impl/pr5-business-workspace-id.md new file mode 100644 index 00000000..86f5427b --- /dev/null +++ b/docs/multi-tenant-redesign/03-impl/pr5-business-workspace-id.md @@ -0,0 +1,122 @@ +# PR5 · alembic 0002 + 回填 + alembic 0003 — 4 张业务表 workspace_id + +> 实现笔记。对应 [docs/superpowers/plans/2026-05-10-stage-0-multi-tenant-foundation.md](../../superpowers/plans/2026-05-10-stage-0-multi-tenant-foundation.md) PR5(T5.1-T5.12)。 +> +> 状态:**已落地**,11 个 feat/test commits 提交到 `docs/multi-tenant-redesign`(PR5 起始 `a7326978..` 结束 `30f2bd00`)。 + +## 范围 + +PR5 把 PR4 的 `users.default_workspace_id` 关系真正接到 4 张业务表上: + +1. **alembic 0002**:`threads_meta` / `runs` / `feedback` / `run_events` 加 *nullable* `workspace_id String(36)` 列 + FK 到 `workspaces`(`ON DELETE CASCADE`)+ `threads_meta` 复合索引 `idx_threads_meta_workspace_user_updated` +2. **backfill 脚本** `scripts/backfill_workspace_id.py`(带 `--dry-run`):三步幂等回填 + - Step 1:每个 `default_workspace_id IS NULL` 的 user 建 1 人 workspace + owner membership + 回填 `users.default_workspace_id` + - Step 2:correlated subquery `UPDATE` 4 张业务表,`workspace_id` 从行所属 user 的 `default_workspace_id` 拉 + - Step 3:剩余 `workspace_id IS NULL`(user_id 本来就 NULL 的孤儿)→ `LEGACY_WORKSPACE_ID = 00000000-0000-0000-0000-000000000000`(按需建,owner = platform admin) +3. **alembic 0003**:pre-flight 拒迁(任一表残留 NULL → `RuntimeError`)→ 4 表 `workspace_id` 改 `NOT NULL` → `threads_meta` 加 `idx_threads_meta_workspace_thread UNIQUE(workspace_id, thread_id)` +4. **4 个 ORM model**:加 `Mapped[str | None] workspace_id` 列定义对齐 0002 schema,`threads_meta` 模型也加 `idx_threads_meta_workspace_user_updated` Index 以便 `create_all()` 在 dev DB 上落同样形状 + +**不在范围**(推迟到 PR6): +- ORM model 把 `workspace_id` 翻成 `nullable=False`(plan 的 T5.11,见下文"偏差说明") +- 仓储 `create()` 方法加 `workspace_id` 哨兵参数 + WHERE 子句 +- `@require_permission` 升级 `check_access(thread_id, user_id, workspace_id)` 三参数 +- 入口路由 `/api/threads` 写 `workspace_id` +- `Paths` 切 workspace 维度 + 文件系统迁移 + +## 验收 + +- [x] **17 个新单测全过**:9 alembic 0002+0003 (5 SQLite + 4 PG-skip) + 9 backfill (含 dry-run + 端到端 orchestrator + no-users 报错) +- [x] **全套 `make test` 3150 passed + 30 skipped + 16 pre-existing caplog flake**(PR4 末 3136 + 26 + 同 16 flake;+14 passed / +4 skipped = +18 PR5 测试)。16 个 flake 在 isolate 跑里都 PASS,已 PR4 时确认与本 stage 无关,**继续 follow-up 推迟** +- [x] **alembic 0002 + 0003 在 SQLite tempdir 上 upgrade/downgrade 双驱动都过**(PG 自动 skip 当 docker 未起) +- [x] **回填脚本 `--dry-run` 测试断言无写入**:snapshot workspaces/memberships/users.default_workspace_id 计数 → 跑 backfill(dry_run=True) → 计数不变 +- [x] **0003 拒迁断言**:threads_meta 留 1 个 `workspace_id IS NULL` 行 → upgrade 抛 `RuntimeError` 含 `Cannot ALTER` + 提示去跑 backfill +- [x] **UNIQUE(workspace_id, thread_id) 触发**:scratch table 验 `IntegrityError`(生产 schema thread_id 已是 PK,shadow 了 UNIQUE,所以用独立表验语义) +- [x] **lint**:`make lint` 全过(ruff check + format) +- [ ] **真机 PG 完整跑 `alembic upgrade 0002` → `python scripts/backfill_workspace_id.py` → `alembic upgrade 0003`** — **待用户**(agent 不能起 RDS 操作) + +## 偏差说明:T5.11 推迟到 PR6 + +**plan 原 task**:"4 个 model.py workspace_id 改 nullable=False;commit" + +**为什么推迟**: + +- ORM model 的 `nullable=False` 影响两条链路: + 1. **生产**:alembic 0003 已经把 DB 列改 NOT NULL,这是真正承载不变式的边界。ORM 层翻不翻不影响数据库约束 + 2. **测试 / dev**:`init_engine()` 走 `Base.metadata.create_all()`,从 ORM 推 DDL。如果模型 `nullable=False` 而仓储 `create()` 还不传 `workspace_id`(PR6 才动),所有创建 `ThreadMetaRow / RunRow / FeedbackRow / RunEventRow` 的 production code path(6 个 INSERT 站点)会在 DB 层炸 NOT NULL 约束 +- PR6 的明确 scope 是"仓储 workspace_id 哨兵 + 路由层 workspace 化"——翻 ORM `nullable=False` 必须与 6 个 INSERT 站点 + repo 签名 + contextvar 接入一起做,**不能独立翻** +- plan T5.12 验收"`make test` 不破"与 T5.11 单独翻 `nullable=False` 在当前 codebase 状态下**互斥**。本 PR 选择维护 T5.12 验收,将 T5.11 推迟到 PR6 自然接入处 + +**承载不变式不变**:alembic 0003 在 DB 层强制 NOT NULL;任何走 alembic 路径(生产)的部署都拿到正确约束。ORM 层 `nullable=True` 只在 dev `create_all()` 路径生效,PR6 翻完后整个体系收敛。 + +## 关键决策(与 plan §LOCK + workspace-schema-design 对齐) + +| 项 | 选择 | 理由 | +|---|---|---| +| 4 表 FK 删除策略 | `ON DELETE CASCADE` | 删 workspace 时业务行随之清空(避免外键悬空) | +| `idx_threads_meta_workspace_user_updated` 列序 | `(workspace_id, user_id, updated_at)` | PR6 路由"列出 workspace X 下 user Y 的会话按时间倒序"查询的覆盖索引 | +| `idx_threads_meta_workspace_thread` UNIQUE 加在 0003 不是 0002 | nullable 列上 UNIQUE 在 PG 允许多 NULL,在 SQLite 默认也允许;但回填中途的 UNIQUE 校验语义模糊,等 NOT NULL 一起加更干净 | 风险更可控 | +| 回填 Step 2 SQL 形态 | SQLAlchemy `update().values(workspace_id=correlated_subquery)` | SQLite 3.33+ 才支持 `UPDATE ... FROM`;correlated subquery 跨方言可移植 | +| 回填 Step 1 slug 走 walker + blacklist | `auto_slug_from_email` + `next_available_slug` + 把 `SLUG_BLACKLIST` 当"已占用" | 与 PR4 注册流程语义一致;admin@example.com → `admin-2`("admin" 在黑名单) | +| `legacy_workspace` owner 选取 | 先找 `system_role='admin'` 最早一个,没有则用最早的任意 user,都没有则 `RuntimeError` | 不能创建无 owner 的 workspace;空 DB 上明确报错强制操作员先 bootstrap admin | +| `_BUSINESS_TABLES` 顺序 | `threads_meta / runs / feedback / run_events` | 仅人眼可读性,无依赖 | +| `_ensure_legacy_workspace` 拆出 orchestrator 调一次 | 不内联 Step 3 内(避免循环 4 次 check) | 简单 + 显式步骤分隔 | +| 0003 pre-flight raise 类型 | `RuntimeError` with `_BackfillRequiredError` 子类 | Alembic 抛错语义最自然;消息含具体表 + 行数 + 修复命令 | +| Step 3 dry-run 报告 | `WOULD assign N orphan rows in {table}` 包括 Step 2 没 cover 的全部 NULL 行 | 操作员能看到具体多少行会被 lump 进 legacy_workspace | + +## 跟进项(不在 PR5 范围) + +- **PR6**:4 个仓储 `create()` 加 `workspace_id` 参数 + INSERT;翻 ORM model `nullable=False`;`@require_permission` 三参数;`/api/threads` 路由层校验 +- **真机 PG smoke**:`alembic upgrade 0002` → `python scripts/backfill_workspace_id.py --dry-run` → 看输出 → 实跑 → `alembic upgrade 0003`。命令清单见下方 +- **远程 RDS testcontainers**:本 PR 复用 PR3 测试 fixture,PG twin 测试自动 skip 当 docker 未起;用户需起 docker daemon 实跑一遍 + +## 涉及文件 + +| 类别 | 文件 | +|---|---| +| **新增 (harness)** | `persistence/migrations/versions/0002_business_tables_workspace.py` | +| **新增 (harness)** | `persistence/migrations/versions/0003_business_tables_workspace_not_null.py` | +| **新增 (script)** | `backend/scripts/backfill_workspace_id.py`(3 个 step + 1 个 ensure + 1 个 orchestrator + main)| +| **新增 tests** | `test_alembic_business_tables.py`, `test_backfill_workspace_id.py` | +| **修改 (harness models)** | `persistence/thread_meta/model.py`(+`workspace_id` FK + composite index),`persistence/run/model.py`,`persistence/feedback/model.py`,`persistence/models/run_event.py` | +| **修改 (test compat)** | `test_alembic_default_workspace_id.py`("head" → "0001_users_default_workspace" 显式 pin)| + +## 测试矩阵 + +| 测试文件 | 用例数 | 覆盖 | +|---|---|---| +| `test_alembic_business_tables.py` | 9(5 SQLite + 4 PG-skip)| 0002 upgrade/downgrade + 0003 happy/refuse-NULL/UNIQUE | +| `test_backfill_workspace_id.py` | 9 | Step 1 创建/幂等/黑名单 + Step 2 4 表/多 user 隔离 + Step 3 orphan/无 user 报错 + orchestrator + dry-run no-write | + +总计:**18 个 PR5 新测试** + 1 个改写(pin 0001 revision)。 + +## Live smoke 命令(用户跟进) + +```bash +# 1. 当前 DB state(应在 PR4 之后状态) +psql "$DEER_FLOW_PG_URL" -c "SELECT count(*) FROM users WHERE default_workspace_id IS NULL;" + +# 2. dry-run 看 backfill 会动什么 +cd backend && PYTHONPATH=. python scripts/backfill_workspace_id.py --dry-run + +# 3. 跑 0002(加 nullable 列) +cd backend/packages/harness/deerflow/persistence && \ + PYTHONPATH=../../../.. alembic -c migrations/alembic.ini upgrade 0002_business_tables_workspace + +# 4. 真跑 backfill +cd backend && PYTHONPATH=. python scripts/backfill_workspace_id.py + +# 5. 应返 0 +psql "$DEER_FLOW_PG_URL" -c "SELECT count(*) FROM threads_meta WHERE workspace_id IS NULL;" +psql "$DEER_FLOW_PG_URL" -c "SELECT count(*) FROM runs WHERE workspace_id IS NULL;" +psql "$DEER_FLOW_PG_URL" -c "SELECT count(*) FROM feedback WHERE workspace_id IS NULL;" +psql "$DEER_FLOW_PG_URL" -c "SELECT count(*) FROM run_events WHERE workspace_id IS NULL;" + +# 6. 跑 0003(改 NOT NULL + UNIQUE) +cd backend/packages/harness/deerflow/persistence && \ + PYTHONPATH=../../../.. alembic -c migrations/alembic.ini upgrade 0003_business_tables_workspace_not_null + +# 7. 确认 legacy_workspace +psql "$DEER_FLOW_PG_URL" -c "SELECT id, name, slug FROM workspaces WHERE id = '00000000-0000-0000-0000-000000000000';" +``` + +如果 dry-run 输出里 `legacy_workspace_created=True` 而 DB 里还没用户,先去 `/auth/initialize` 建管理员再跑——backfill 不会替你 bootstrap。