docs(impl): PR5 implementation note + STATUS update

11 PR5 commits: alembic 0002 (nullable workspace_id + FK + composite
index on 4 business tables) -> backfill_workspace_id.py (3-step
idempotent) -> alembic 0003 (pre-flight refuse + NOT NULL + UNIQUE).
18 new tests (9 alembic 0002+0003 + 9 backfill, including dry-run and
no-users error). 3150 passed + 30 skipped + 16 pre-existing caplog
flake (unchanged from PR4).

T5.11 (ORM nullable=False) deferred to PR6 — flipping the ORM-side
NOT NULL would break 6 INSERT sites whose repository signatures get
workspace_id wiring in PR6 anyway. DB-level invariant is already
enforced by alembic 0003 along the production path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1445043649
2026-05-13 09:22:19 +08:00
parent 30f2bd0084
commit 430f4a1132
2 changed files with 130 additions and 6 deletions
+8 -6
View File
@@ -2,11 +2,11 @@
> **每完成 1 个 PR 后必更新**。本文是 Stage 0 唯一的"现在到哪了"权威来源——其它文件(plan、ADR、各 PR impl note)都是静态的,不反映执行进度。 > **每完成 1 个 PR 后必更新**。本文是 Stage 0 唯一的"现在到哪了"权威来源——其它文件(plan、ADR、各 PR impl note)都是静态的,不反映执行进度。
> >
> 上次更新:2026-05-12PR4 merge 进 docs branch 后 > 上次更新:2026-05-13PR5 merge 进 docs branch 后
## 一句话状态 ## 一句话状态
PR1 + PR2 + PR3 + **PR4** 已 merge + **live 验证通过**PR3 时 RDS 上 11 张表+ **7 项 LOCK 决策 sign-off** + **docs branch 已 push originSSH-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 无关)。**下一个:PR5alembic 0002 ALTER 4 表 + workspace_id 回填 + 改 NOT NULL**。 PR1 + PR2 + PR3 + PR4 + **PR5** 已 mergePR3 时 RDS 上 11 张表 live 验证通过 + 7 项 LOCK 决策 sign-off + docs branch 已 push origin。**PR5 (2026-05-13)** 落地:alembic 00024 表 + nullable workspace_id + FK + 复合索引)+ `scripts/backfill_workspace_id.py`3 step 幂等:建 workspace per user → UPDATE 业务表 → legacy_workspace 兜底)+ alembic 0003pre-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 状态表 ## 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) | | **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) | | **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) | | **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 | — | — | | **PR6** | 🟡 pending | 0 | — | — |
| **PR7** | 🟡 pending | 0 | — | — | | **PR7** | 🟡 pending | 0 | — | — |
| **PR8** | 🟡 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 + 25PR2 末 3087。**16 个 caplog 排序 flake 持续存在**test_jina_client / test_lead_agent_prompt / test_summarization_middleware 等)→ isolate 跑全 PASSPR4 时 stash 验证stage 无关;预存 issue,集中清理推迟到 follow-up。
## 用户必须跟进的事(live verification / 决策) ## 用户必须跟进的事(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 自己能做,不阻塞) | | 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 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 | 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 处理) ## 即将遇到的开放问题(plan 末尾列的,下个 session 处理)
@@ -58,7 +60,7 @@ PR1 + PR2 + PR3 + **PR4** 已 merge + **live 验证通过**PR3 时 RDS 上 11
## 下一步建议 ## 下一步建议
**PR5alembic 0002 ALTER 4 表 + workspace_id 回填 + 改 NOT NULL**。plan 推荐 Inline 模式(migration script + 回填 + ALTER 改 NOT NULL 三阶段强耦合)。 **PR6(入口路由强校验 + Paths workspace 化 + 仓储 workspace_id 哨兵 + 文件迁移)**。同时接入 PR5 推迟的 T5.11ORM `nullable=False`)—— ORM 翻转必须与仓储 `create()``workspace_id` 参数 + 6 个 INSERT 站点 + contextvar 接入一起做,PR6 是天然落点。plan 推荐 Inline 模式(路由 + 仓储 + Paths 强耦合,难拆 subagent)。
历史模式回顾: 历史模式回顾:
@@ -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) PR5T5.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 2correlated 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 已是 PKshadow 了 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=Falsecommit"
**为什么推迟**
- 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 path6 个 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 测试 fixturePG 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` | 95 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。