Compare commits
96 Commits
main
...
30fa16ba7a
| Author | SHA1 | Date | |
|---|---|---|---|
| 30fa16ba7a | |||
| c77ee0dc06 | |||
| 45efba50e4 | |||
| dd1d40368d | |||
| 7dfc9968fe | |||
| 84de632b19 | |||
| 6090b9e5b1 | |||
| f803f393d3 | |||
| 6f806ff4a4 | |||
| 52e9999a61 | |||
| bb7289781e | |||
| 1fb07e48e6 | |||
| b0bf033f15 | |||
| d8b13afc59 | |||
| d0f1877070 | |||
| ba30d14041 | |||
| 1a6ccc9aaa | |||
| 4c0b4fab48 | |||
| 87ea715c2a | |||
| c5c66ccbfc | |||
| 56f2c8d873 | |||
| 2d3b546bf9 | |||
| f013fc1a65 | |||
| a7ecd76e0a | |||
| f6a922921b | |||
| 0456606dc1 | |||
| b4fa3bf12a | |||
| 05be7f9ad0 | |||
| 28ad6c2b0b | |||
| 296a4f1950 | |||
| 361e653d37 | |||
| 430f4a1132 | |||
| 30f2bd0084 | |||
| 73d0b7017b | |||
| 8a03abac75 | |||
| def45dd0c6 | |||
| 56f6572086 | |||
| e6bb220979 | |||
| ad322543d0 | |||
| d3361dba59 | |||
| 4e26ec9884 | |||
| a732697855 | |||
| 44f84800e9 | |||
| 5c7753c0b8 | |||
| 91846a201e | |||
| 634e5119e1 | |||
| 84701730da | |||
| a657d17995 | |||
| a06e88d58b | |||
| 2145d36744 | |||
| b4bef65079 | |||
| 54cb94c30f | |||
| 54762f491c | |||
| 917d8fbeaf | |||
| 8efbb2f9e5 | |||
| d98498b705 | |||
| c70c6594de | |||
| a592319e3c | |||
| dda8264057 | |||
| 3313047ff1 | |||
| 36ffe2713f | |||
| a40df03521 | |||
| d2d2d29c34 | |||
| 8323bf68d2 | |||
| f63089aea8 | |||
| cbbb83a706 | |||
| 39f8e117e8 | |||
| bdef6c6b9f | |||
| 7f17cb8fee | |||
| ad22242ecc | |||
| 1112a1971b | |||
| c53295dfee | |||
| 745a33e05d | |||
| 83b680b2ea | |||
| 3e62a0f6ef | |||
| d312bdf968 | |||
| 7d3d3560ad | |||
| 404135a16a | |||
| 85a14f4c05 | |||
| a33b46b4af | |||
| eae0190184 | |||
| e6f5ba53bd | |||
| 8f480cd76f | |||
| 31361e2dcf | |||
| ae4ea46be2 | |||
| fab85b14a6 | |||
| a74b88a45c | |||
| 89fe54cc07 | |||
| 9ff790554d | |||
| ecc9339ede | |||
| fd8d0d637d | |||
| 35ae97d0a6 | |||
| e78ed687a6 | |||
| 8bdd308ea0 | |||
| 27c4f14233 | |||
| dce5e9598b |
+5
-2
@@ -38,8 +38,11 @@ INFOQUEST_API_KEY=your-infoquest-api-key
|
||||
# GitHub API Token
|
||||
# GITHUB_TOKEN=your-github-token
|
||||
|
||||
# Database (only needed when config.yaml has database.backend: postgres)
|
||||
# DATABASE_URL=postgresql://deerflow:password@localhost:5432/deerflow
|
||||
# Database (Stage 0+ default; required when config.yaml has database.backend: postgres)
|
||||
# Local dev — start with: docker compose -f docker/docker-compose-dev.yaml up -d postgres
|
||||
DATABASE_URL=postgresql+asyncpg://deerflow:deerflow_dev@localhost:5432/deerflow
|
||||
# Remote RDS example:
|
||||
# DATABASE_URL=postgresql+asyncpg://USER:PASSWORD@HOST:5432/DBNAME
|
||||
#
|
||||
# WECOM_BOT_ID=your-wecom-bot-id
|
||||
# WECOM_BOT_SECRET=your-wecom-bot-secret
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
name: Postgres Tests
|
||||
|
||||
# Stage 0 PR1 · runs the @pytest.mark.postgres subset against a real
|
||||
# Postgres backend (testcontainers spawns postgres:16-alpine on the
|
||||
# GitHub runner's docker daemon).
|
||||
#
|
||||
# Kept as a separate workflow from `Unit Tests` so:
|
||||
# - the existing fast unit test loop is unchanged
|
||||
# - PG tests can fail-soft during early Stage 0 rollout if needed
|
||||
# (set continue-on-error: true on the run step)
|
||||
# - infra cost is opt-in for forks
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ 'main' ]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
concurrency:
|
||||
group: postgres-tests-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
backend-postgres-tests:
|
||||
if: github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Verify Docker daemon (testcontainers needs it)
|
||||
run: docker version
|
||||
|
||||
- name: Install backend dependencies (with postgres-test extra)
|
||||
working-directory: backend
|
||||
run: uv sync --group dev --extra postgres-test
|
||||
|
||||
- name: Run @pytest.mark.postgres tests
|
||||
working-directory: backend
|
||||
# -m postgres selects only postgres-tagged tests; testcontainers
|
||||
# spins up postgres:16-alpine as the fixture's session-scoped
|
||||
# container. ~5-10s container startup + per-test DB CREATE/DROP.
|
||||
run: PYTHONPATH=. uv run pytest -m postgres -v
|
||||
@@ -31,6 +31,7 @@ extensions_config.json
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
.qoder/
|
||||
|
||||
# Coverage report
|
||||
coverage.xml
|
||||
@@ -60,3 +61,7 @@ config.yaml.bak
|
||||
/frontend/playwright-report/
|
||||
.gstack/
|
||||
.worktrees
|
||||
skills/gstack
|
||||
skills/superpowers
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
|
||||
@@ -31,6 +31,7 @@ help:
|
||||
@echo " make start-daemon - Start prod services in background (daemon mode)"
|
||||
@echo " make stop - Stop all running services"
|
||||
@echo " make clean - Clean up processes and temporary files"
|
||||
@echo " make migrate-paths - Migrate legacy users/ tree into workspaces/ layout (DRY_RUN=1 to preview)"
|
||||
@echo ""
|
||||
@echo "Docker Production Commands:"
|
||||
@echo " make up - Build and start production Docker services (localhost:2026)"
|
||||
@@ -144,6 +145,14 @@ clean: stop
|
||||
@-rm -rf logs/*.log 2>/dev/null || true
|
||||
@echo "✓ Cleanup complete"
|
||||
|
||||
# Lift legacy per-user paths into the per-workspace layout (PR6).
|
||||
# Pass DRY_RUN=1 to log the migration plan without writing.
|
||||
# DEFAULT_WORKSPACE=<wid> claims un-assigned users (defaults to legacy_workspace).
|
||||
migrate-paths:
|
||||
@cd backend && PYTHONPATH=. uv run python scripts/migrate_paths_to_workspace.py \
|
||||
$(if $(filter 1 true,$(DRY_RUN)),--dry-run) \
|
||||
$(if $(DEFAULT_WORKSPACE),--default-workspace $(DEFAULT_WORKSPACE))
|
||||
|
||||
# ==========================================
|
||||
# Docker Development Commands
|
||||
# ==========================================
|
||||
|
||||
@@ -202,6 +202,38 @@ That prompt is intended for coding agents. It tells the agent to clone the repo
|
||||
|
||||
</details>
|
||||
|
||||
3. **Database backend (Stage 0+ defaults to Postgres)**
|
||||
|
||||
`config.example.yaml` ships with `database.backend: postgres` and `postgres_url: $DATABASE_URL`. Set `DATABASE_URL` in `.env`:
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgresql+asyncpg://deerflow:deerflow_dev@localhost:5432/deerflow
|
||||
```
|
||||
|
||||
Start the local Postgres dev container:
|
||||
|
||||
```bash
|
||||
docker compose -f docker/docker-compose-dev.yaml up -d postgres
|
||||
```
|
||||
|
||||
Or point `DATABASE_URL` at a remote RDS / Cloud SQL instance.
|
||||
|
||||
`make doctor` will report the configured backend, attempt an asyncpg connection, and surface actionable fix hints. `make dev` preflights Postgres reachability before starting services and aborts if `DATABASE_URL` is unreachable.
|
||||
|
||||
<details>
|
||||
<summary>Offline dev (SQLite fallback)</summary>
|
||||
|
||||
If you prefer no Postgres, edit `config.yaml`:
|
||||
|
||||
```yaml
|
||||
database:
|
||||
backend: sqlite
|
||||
sqlite_dir: .deer-flow/data
|
||||
```
|
||||
|
||||
SQLite is preserved as a valid backend for offline development. RLS / multi-node features (Stage 2+) require Postgres.
|
||||
</details>
|
||||
|
||||
### Running the Application
|
||||
|
||||
#### Deployment Sizing
|
||||
|
||||
+186
-74
@@ -1,20 +1,59 @@
|
||||
# 🦌 DeerFlow - 2.0
|
||||
# 🦌 DeerFlow - 2.0 · 多租户改造
|
||||
|
||||
[English](./README.md) | 中文 | [日本語](./README_ja.md) | [Français](./README_fr.md) | [Русский](./README_ru.md)
|
||||
|
||||
[](./backend/pyproject.toml)
|
||||
[](./Makefile)
|
||||
[](./config.example.yaml)
|
||||
[](./LICENSE)
|
||||
|
||||
<a href="https://trendshift.io/repositories/14699" target="_blank"><img src="https://trendshift.io/api/badge/repositories/14699" alt="bytedance%2Fdeer-flow | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
> 2026 年 2 月 28 日,DeerFlow 2 发布后登上 GitHub Trending 第 1 名。非常感谢社区的支持,这是大家一起做到的。
|
||||
DeerFlow(**D**eep **E**xploration and **E**fficient **R**esearch **Flow**)是一个开源的 **super agent harness**:它把 **sub-agents**、**memory**、**sandbox** 组织在一起,再配合可扩展的 **skills**,让 agent 可以完成几乎任何事情。
|
||||
|
||||
DeerFlow(**D**eep **E**xploration and **E**fficient **R**esearch **Flow**)是一个开源的 **super agent harness**。它把 **sub-agents**、**memory** 和 **sandbox** 组织在一起,再配合可扩展的 **skills**,让 agent 可以完成几乎任何事情。
|
||||
> [!IMPORTANT]
|
||||
> **本分支(`docs/multi-tenant-redesign`)是 DeerFlow 的多租户改造主线。** 在保留原有 super agent harness 全部能力的基础上,引入了 **workspace 租户模型、Postgres 为默认后端、带 `workspace_id` 的行级数据隔离、扩展后的 JWT(带 `wid`/`role`)、per-workspace 的文件系统布局、Headless API schema 底座,以及 `apps/` 上层应用脚手架**。下面的 [多租户改造](#多租户改造本分支主线) 一节是阅读本仓库的入口。
|
||||
|
||||
> [!NOTE]
|
||||
> **DeerFlow 2.0 是一次彻底重写。** 它和 v1 没有共用代码。如果你要找的是最初的 Deep Research 框架,可以前往 [`1.x` 分支](https://github.com/bytedance/deer-flow/tree/main-1.x)。
|
||||
|
||||
https://github.com/user-attachments/assets/a8bcadc4-e040-4cf2-8fda-dd768b999c18
|
||||
|
||||
> [!NOTE]
|
||||
> **DeerFlow 2.0 是一次彻底重写。** 它和 v1 没有共用代码。如果你要找的是最初的 Deep Research 框架,可以前往 [`1.x` 分支](https://github.com/bytedance/deer-flow/tree/main-1.x)。那里仍然欢迎贡献;当前的主要开发已经转向 2.0。
|
||||
## 多租户改造(本分支主线)
|
||||
|
||||
DeerFlow 原本面向"单机可信环境、单用户"。本分支按"以个人用户为主、少量小团队,统一只有 **workspace** 概念(个人 = 1 人 workspace),中心化 SaaS 为主线"的目标,把租户能力分 **Stage 0–4** 渐进落地。**目前 Stage 0(底座)工程层面已全部合入。**
|
||||
|
||||
### Stage 0 已落地的能力
|
||||
|
||||
| 能力 | 说明 | 落点 |
|
||||
|---|---|---|
|
||||
| **workspace 租户模型** | 新增 `workspaces` + `workspace_memberships` 两张表与仓储;每个用户注册时自动建 1 人 workspace(owner=自己);slug 唯一、黑名单校验。 | `persistence/workspace*` |
|
||||
| **Postgres 成为默认后端** | `config.example.yaml` / `.env.example` / `make dev` / `make doctor` 默认走 Postgres,与生产对齐;SQLite 保留为离线开发兜底。 | 见 [数据库后端](#数据库后端) |
|
||||
| **行级数据隔离** | 4 张业务表(`threads_meta` / `runs` / `run_events` / `feedback`)加 `workspace_id` 列(`NOT NULL` + `UNIQUE(workspace_id, thread_id)` 兜底),入口路由按 `(workspace_id, thread_id)` 强校验,跨 workspace 访问必 404。 | `persistence/*`、Gateway routers |
|
||||
| **扩展后的 JWT** | TokenPayload 一次到位为 `{sub, wid, role, exp, iat, ver}`;登录 / 改密 / `/auth/me` 全部带上 workspace 与角色;旧版 4 字段 JWT 被识别为 `WORKSPACE_MISSING` 并要求重登。 | `app/gateway/auth/` |
|
||||
| **per-workspace 文件系统** | 运行期状态从 `users/{uid}/...` 迁移到 `workspaces/{wid}/threads/{tid}/...`,提供 `make migrate-paths` 迁移脚本(支持 `DRY_RUN=1` 预览)。 | `config/paths.py`、`thread_data_middleware.py` |
|
||||
| **边界扫描围栏** | CI 静态扫描禁止任何路径绕过入口直连 LangGraph checkpoint/store,确保隔离不被旁路。 | `tests/boundary_allowlist.toml`、`test_workspace_boundary*.py` |
|
||||
| **Headless API schema 底座** | 预建 `service_accounts` / `api_keys`(`dfk_live_*` / `dfk_test_*`)/ `external_users` 三张表(schema-only),为 Stage 1 的无人值守接入做准备。 | `persistence/{service_account,api_key,external_user}` |
|
||||
| **Alembic 迁移 + 回填** | `0001`→`0003` 迁移链 + `backfill_workspace_id.py` 回填脚本,dev 用 `create_all()` 自愈、生产用迁移。 | `persistence/migrations/` |
|
||||
|
||||
> 数据库的事实参考(10 张表全字段 / 外键 / 索引)见 [`database-schema-as-built.zh-CN.md`](docs/multi-tenant-redesign/01-redesign/database-schema-as-built.zh-CN.md)。
|
||||
|
||||
### 路线图:Stage 0–4
|
||||
|
||||
| Stage | 目标 | 关键内容 | 状态 |
|
||||
|---|---|---|---|
|
||||
| **0** | workspace 模型立起来 + Postgres 切换 + auth 收紧 | 上表全部 | ✅ 工程层面已合入(业务门 / live 验证待跟进)|
|
||||
| **1** | 第一批付费客户 + 业务系统集成(双轨并行)| quota + 计费 + AioSandbox 轻量加固;Headless API(Pattern A/B)接通 PR8 三张表 | 🔜 已具备底座 |
|
||||
| **2** | 增长期,安全与隔离深化 | DeerFlow 自有表启用 RLS、KMS、ObjectStorage、完整 RBAC + invitation | 📋 规划中 |
|
||||
| **3** | 成熟期,K8s 隔离 + BYO | K8s namespace + NetworkPolicy、BYO LLM key、audit DB 拆分 | 📋 规划中 |
|
||||
| **4** | 企业化,按需开启 | SSO、自定义域名、per-tenant DB、gVisor/Kata、合规审计 | 📋 按合同 |
|
||||
|
||||
### 多租户文档入口
|
||||
|
||||
- **汇总索引(先读这个)**:[`docs/multi-tenant-redesign/README.zh-CN.md`](docs/multi-tenant-redesign/README.zh-CN.md)
|
||||
- **现状架构鸟瞰**:[`00-current-state/architecture-overview.zh-CN.md`](docs/multi-tenant-redesign/00-current-state/architecture-overview.zh-CN.md)
|
||||
- **决策(7 份 ADR + spike + 审计)**:[`01-redesign/`](docs/multi-tenant-redesign/01-redesign/)
|
||||
- **Stage 0 schema 锁定版 / 落地版**:[`workspace-schema-design`](docs/multi-tenant-redesign/01-redesign/workspace-schema-design.zh-CN.md) · [`database-schema-as-built`](docs/multi-tenant-redesign/01-redesign/database-schema-as-built.zh-CN.md)
|
||||
- **落地路线 + 集成轨道**:[`02-rollout/`](docs/multi-tenant-redesign/02-rollout/)
|
||||
- **Stage 0 进度面板(权威"现在到哪了")**:[`03-impl/STATUS.md`](docs/multi-tenant-redesign/03-impl/STATUS.md)
|
||||
|
||||
## 官网
|
||||
|
||||
@@ -32,13 +71,13 @@ https://github.com/user-attachments/assets/a8bcadc4-e040-4cf2-8fda-dd768b999c18
|
||||
|
||||
## 目录
|
||||
|
||||
- [🦌 DeerFlow - 2.0](#-deerflow---20)
|
||||
- [🦌 DeerFlow - 2.0 · 多租户改造](#-deerflow---20--多租户改造)
|
||||
- [多租户改造(本分支主线)](#多租户改造本分支主线)
|
||||
- [官网](#官网)
|
||||
- [InfoQuest](#infoquest)
|
||||
- [目录](#目录)
|
||||
- [一句话交给 Coding Agent 安装](#一句话交给-coding-agent-安装)
|
||||
- [字节跳动火山引擎方舟 Coding Plan](#字节跳动火山引擎方舟-coding-plan)
|
||||
- [快速开始](#快速开始)
|
||||
- [配置](#配置)
|
||||
- [数据库后端](#数据库后端)
|
||||
- [运行应用](#运行应用)
|
||||
- [部署建议与资源规划](#部署建议与资源规划)
|
||||
- [方式一:Docker(推荐)](#方式一docker推荐)
|
||||
@@ -48,7 +87,7 @@ https://github.com/user-attachments/assets/a8bcadc4-e040-4cf2-8fda-dd768b999c18
|
||||
- [MCP Server](#mcp-server)
|
||||
- [IM 渠道](#im-渠道)
|
||||
- [LangSmith 链路追踪](#langsmith-链路追踪)
|
||||
- [从 Deep Research 到 Super Agent Harness](#从-deep-research-到-super-agent-harness)
|
||||
- [多租户架构详解](#多租户架构详解)
|
||||
- [核心特性](#核心特性)
|
||||
- [Skills 与 Tools](#skills-与-tools)
|
||||
- [Claude Code 集成](#claude-code-集成)
|
||||
@@ -56,25 +95,14 @@ https://github.com/user-attachments/assets/a8bcadc4-e040-4cf2-8fda-dd768b999c18
|
||||
- [Sandbox 与文件系统](#sandbox-与文件系统)
|
||||
- [Context Engineering](#context-engineering)
|
||||
- [长期记忆](#长期记忆)
|
||||
- [推荐模型](#推荐模型)
|
||||
- [在 DeerFlow 之上构建应用(apps/)](#在-deerflow-之上构建应用apps)
|
||||
- [内嵌 Python Client](#内嵌-python-client)
|
||||
- [推荐模型](#推荐模型)
|
||||
- [文档](#文档)
|
||||
- [⚠️ 安全使用](#️-安全使用)
|
||||
- [参与贡献](#参与贡献)
|
||||
- [许可证](#许可证)
|
||||
- [致谢](#致谢)
|
||||
- [核心贡献者](#核心贡献者)
|
||||
- [Star History](#star-history)
|
||||
|
||||
## 一句话交给 Coding Agent 安装
|
||||
|
||||
如果你在用 Claude Code、Codex、Cursor、Windsurf 或其他 coding agent,可以直接把下面这句话发给它:
|
||||
|
||||
```text
|
||||
如果还没 clone DeerFlow,就先 clone,然后按照 https://raw.githubusercontent.com/bytedance/deer-flow/main/Install.md 把它的本地开发环境初始化好
|
||||
```
|
||||
|
||||
这条提示词是给 coding agent 用的。它会在需要时先 clone 仓库,优先选择 Docker,完成初始化,并在结束时告诉你下一条启动命令,以及还缺哪些配置需要你补充。
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -149,6 +177,48 @@ https://github.com/user-attachments/assets/a8bcadc4-e040-4cf2-8fda-dd768b999c18
|
||||
api_key: your-actual-api-key-here # 替换为真实 key
|
||||
```
|
||||
|
||||
### 数据库后端
|
||||
|
||||
多租户改造后,**Stage 0+ 默认后端是 Postgres**(与生产对齐,并为后续 RLS 留好空间)。`config.example.yaml` 默认带:
|
||||
|
||||
```yaml
|
||||
database:
|
||||
backend: postgres
|
||||
postgres_url: $DATABASE_URL
|
||||
```
|
||||
|
||||
在 `.env` 中设置 `DATABASE_URL`:
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgresql+asyncpg://deerflow:deerflow_dev@localhost:5432/deerflow
|
||||
# 远程 RDS / Cloud SQL 示例:
|
||||
# DATABASE_URL=postgresql+asyncpg://USER:PASSWORD@HOST:5432/DBNAME
|
||||
```
|
||||
|
||||
启动本地 Postgres 开发容器:
|
||||
|
||||
```bash
|
||||
docker compose -f docker/docker-compose-dev.yaml up -d postgres
|
||||
```
|
||||
|
||||
- `make doctor` 会报告当前配置的后端、尝试 asyncpg 连接,并给出可执行的修复建议。
|
||||
- `make dev` 在启动各服务前会先 preflight Postgres 可达性;`DATABASE_URL` 不可达时直接中止。
|
||||
- **dev** 启动时用 `Base.metadata.create_all()` 自动建缺失的表(不改已存在的表);**生产**用 Alembic 迁移(`backend/packages/harness/deerflow/persistence/migrations/`)。目标库不存在时会自动 `CREATE DATABASE` 后重试。
|
||||
|
||||
<details>
|
||||
<summary>离线开发(SQLite 兜底)</summary>
|
||||
|
||||
如果你不想起 Postgres,把 `config.yaml` 改成:
|
||||
|
||||
```yaml
|
||||
database:
|
||||
backend: sqlite
|
||||
sqlite_dir: .deer-flow/data
|
||||
```
|
||||
|
||||
SQLite 仍是合法的离线开发后端;但 RLS / 多节点等 Stage 2+ 能力需要 Postgres。
|
||||
</details>
|
||||
|
||||
### 运行应用
|
||||
|
||||
#### 部署建议与资源规划
|
||||
@@ -194,7 +264,7 @@ make down # 停止并移除容器
|
||||
|
||||
如果你更希望直接在本地启动各个服务:
|
||||
|
||||
前提:先完成上面的“配置”步骤(`make config` 和模型 API key 配置)。`make dev` 需要有效配置文件,默认读取项目根目录下的 `config.yaml`。可以用 `DEER_FLOW_PROJECT_ROOT` 显式指定项目根目录,也可以用 `DEER_FLOW_CONFIG_PATH` 指向某个具体配置文件。运行期状态默认写到项目根目录下的 `.deer-flow`,可用 `DEER_FLOW_HOME` 覆盖;skills 默认读取项目根目录下的 `skills/`,可用 `DEER_FLOW_SKILLS_PATH` 覆盖。
|
||||
前提:先完成上面的"配置"步骤(`make config`、模型 API key、`DATABASE_URL`)。`make dev` 需要有效配置文件,默认读取项目根目录下的 `config.yaml`。可以用 `DEER_FLOW_PROJECT_ROOT` 显式指定项目根目录,也可以用 `DEER_FLOW_CONFIG_PATH` 指向某个具体配置文件。运行期状态默认写到项目根目录下的 `.deer-flow`,可用 `DEER_FLOW_HOME` 覆盖;skills 默认读取项目根目录下的 `skills/`,可用 `DEER_FLOW_SKILLS_PATH` 覆盖。
|
||||
在 Windows 上,请使用 Git Bash 运行本地开发流程。基于 bash 的服务脚本不支持直接在原生 `cmd.exe` 或 PowerShell 中执行,且 WSL 也不保证可用,因为部分脚本依赖 Git for Windows 的 `cygpath` 等工具。
|
||||
|
||||
1. **检查依赖环境**:
|
||||
@@ -220,6 +290,16 @@ make down # 停止并移除容器
|
||||
|
||||
5. **访问地址**:http://localhost:2026
|
||||
|
||||
> [!TIP]
|
||||
> `make dev` 是前台阻塞运行。日常调试更顺手的是仓库根 `scripts/` 下两个生命周期脚本(子命令统一为 `start / stop / restart / status / logs / run`):
|
||||
> - `scripts/dev-gateway.sh` — 只起 Gateway(`http://localhost:8001`),起得快,适合调后端 API / 接入示例。
|
||||
> - `scripts/dev-full.sh` — Gateway + 前端 + nginx(`http://localhost:2026`),连前端一起调。
|
||||
>
|
||||
> 例如 `./scripts/dev-gateway.sh start`、`./scripts/dev-gateway.sh logs`、`SKIP_INSTALL=1 ./scripts/dev-full.sh start`。详见 [apps/README.md](apps/README.md)。
|
||||
|
||||
> [!NOTE]
|
||||
> 把历史的 `users/` 目录树迁移到新的 per-workspace 布局:`make migrate-paths`(加 `DRY_RUN=1` 仅预览,`DEFAULT_WORKSPACE=<wid>` 指定未分配用户的归属 workspace)。
|
||||
|
||||
### 进阶配置
|
||||
#### Sandbox 模式
|
||||
|
||||
@@ -405,23 +485,48 @@ LANGSMITH_PROJECT=xxx
|
||||
|
||||
Docker 部署时,追踪默认关闭。在 `.env` 中设置 `LANGSMITH_TRACING=true` 和 `LANGSMITH_API_KEY` 即可启用。
|
||||
|
||||
## 从 Deep Research 到 Super Agent Harness
|
||||
## 多租户架构详解
|
||||
|
||||
DeerFlow 最初是一个 Deep Research 框架,后来社区把它一路推到了更远的地方。上线之后,开发者拿它去做的事情早就不止研究:搭数据流水线、生成演示文稿、快速起 dashboard、自动化内容流程,很多方向一开始连我们自己都没想到。
|
||||
> 这一节展开 [多租户改造](#多租户改造本分支主线) 里 Stage 0 已落地的实现细节。完整决策与路线见 [`docs/multi-tenant-redesign/`](docs/multi-tenant-redesign/)。
|
||||
|
||||
这让我们意识到一件事:DeerFlow 不只是一个研究工具。它更像一个 **harness**,一个真正让 agents 把事情做完的运行时基础设施。
|
||||
**workspace 是唯一的隔离粒度。** 个人用户 = 1 人 workspace,小团队 = 多人 workspace。骨架是两条主线:
|
||||
|
||||
所以我们把它从头重做了一遍。
|
||||
1. **租户骨架**:`users` ↔ `workspaces`(多对多经 `workspace_memberships`)。每个用户注册时自动建 1 人 workspace(owner=自己),slug 唯一且过黑名单校验。
|
||||
2. **业务数据**:`threads_meta` → `runs` → `run_events` / `feedback`,全部挂 `workspace_id`(行级隔离),workspace 删除时级联清空。
|
||||
|
||||
DeerFlow 2.0 不再是一个需要你自己拼装的 framework。它是一个开箱即用、同时又足够可扩展的 super agent harness。基于 LangGraph 和 LangChain 构建,默认就带上了 agent 真正会用到的关键能力:文件系统、memory、skills、sandbox 执行环境,以及为复杂多步骤任务做规划、拉起 sub-agents 的能力。
|
||||
**数据隔离怎么做的。** DeerFlow 自有表走行级 `workspace_id` + `UNIQUE(workspace_id, thread_id)` 兜底;入口路由(`threads.py` / `thread_runs.py`)按 `(workspace_id, thread_id)` 强校验,跨 workspace 访问必返 404。LangGraph 自己的 checkpointer / store 表(`langgraph-checkpoint-postgres==3.0.5` 无 `connection_factory`,无法注入 RLS)则走**应用层强校验**,并由 CI 静态扫描(`tests/boundary_allowlist.toml`)禁止任何路径绕过入口直连这些表。
|
||||
|
||||
你可以直接拿来用,也可以拆开重组,改成你自己的样子。
|
||||
**身份与会话。** JWT TokenPayload 一次到位为 `{sub, wid, role, exp, iat, ver}`:
|
||||
|
||||
- `wid` — 当前 workspace;`role` — workspace 内角色(Stage 0 简化为 owner-only,Stage 2 扩到 owner/admin/member)。
|
||||
- `ver` — `token_version`,bump 后旧 token 全失效。
|
||||
- 旧版 4 字段 JWT 会被识别为 `WORKSPACE_MISSING` 并要求重登;登录 / 改密 / `/auth/me` 都会带上 workspace 与角色(`/auth/me` 返回 `workspaces[]`,含 id/name/slug/role)。
|
||||
|
||||
**文件系统布局。** 运行期状态按 workspace 分目录:
|
||||
|
||||
```text
|
||||
${DEER_FLOW_HOME:-./.deer-flow}/
|
||||
└── workspaces/
|
||||
└── {workspace_id}/
|
||||
├── threads/{thread_id}/... ← 每个 thread 的 sandbox / 产物
|
||||
└── users/{user_id}/... ← 用户级状态
|
||||
```
|
||||
|
||||
历史的 `users/{uid}/...` 布局用 `make migrate-paths`(`DRY_RUN=1` 预览)迁移过来。
|
||||
|
||||
**Headless API schema 底座(Stage 0 末预建,schema-only)。** 为 Stage 1 的无人值守 / 业务系统接入准备:
|
||||
|
||||
- `service_accounts` — workspace 内的非人身份,带 `identity_mode` 三态(`collapsed` / `external_passthrough` / `both`)。
|
||||
- `api_keys` — service account 的凭证,格式 `dfk_live_*` / `dfk_test_*`,`key_prefix` 全局唯一 + 部分索引 `WHERE revoked_at IS NULL`。
|
||||
- `external_users` — passthrough 终端身份,`(service_account_id, external_id)` 复合唯一。
|
||||
|
||||
> ⚠️ Stage 0 只建表,**API Key 鉴权中间件尚未接入**。外部系统当前只能走会话 cookie(见 [apps/README.md](apps/README.md) 的鉴权说明);等 `Authorization: Bearer dfk_live_...` 在 Stage 1 落地后再补无人值守接入。
|
||||
|
||||
## 核心特性
|
||||
|
||||
### Skills 与 Tools
|
||||
|
||||
Skills 是 DeerFlow 能做“几乎任何事”的关键。
|
||||
Skills 是 DeerFlow 能做"几乎任何事"的关键。
|
||||
|
||||
标准的 Agent Skill 是一种结构化能力模块,通常就是一个 Markdown 文件,里面定义了工作流、最佳实践,以及相关的参考资源。DeerFlow 自带一批内置 skills,覆盖研究、报告生成、演示文稿制作、网页生成、图像和视频生成等场景。真正有意思的地方在于它的扩展性:你可以加自己的 skills,替换内置 skills,或者把多个 skills 组合成复合工作流。
|
||||
|
||||
@@ -431,8 +536,6 @@ Skills 采用按需渐进加载,不会一次性把所有内容都塞进上下
|
||||
|
||||
Tools 也是同样的思路。DeerFlow 自带一组核心工具:网页搜索、网页抓取、文件操作、bash 执行;同时也支持通过 MCP Server 和 Python 函数扩展自定义工具。你可以替换任何一项,也可以继续往里加。
|
||||
|
||||
Gateway 生成后续建议时,现在会先把普通字符串输出和 block/list 风格的富文本内容统一归一化,再去解析 JSON 数组响应,因此不同 provider 的内容包装方式不会再悄悄把建议吞掉。
|
||||
|
||||
```text
|
||||
# sandbox 容器内的路径
|
||||
/mnt/skills/public
|
||||
@@ -456,24 +559,7 @@ Gateway 生成后续建议时,现在会先把普通字符串输出和 block/li
|
||||
npx skills add https://github.com/bytedance/deer-flow --skill claude-to-deerflow
|
||||
```
|
||||
|
||||
然后确认 DeerFlow 已经启动(默认地址是 `http://localhost:2026`),在 Claude Code 里使用 `/claude-to-deerflow` 命令即可。
|
||||
|
||||
**你可以做的事情包括:**
|
||||
- 给 DeerFlow 发送消息,并接收流式响应
|
||||
- 选择执行模式:flash(更快)、standard、pro(规划模式)、ultra(sub-agents 模式)
|
||||
- 检查 DeerFlow 健康状态,列出 models / skills / agents
|
||||
- 管理 threads 和会话历史
|
||||
- 上传文件做分析
|
||||
|
||||
**环境变量**(可选,用于自定义端点):
|
||||
|
||||
```bash
|
||||
DEERFLOW_URL=http://localhost:2026 # 统一代理基地址
|
||||
DEERFLOW_GATEWAY_URL=http://localhost:2026 # Gateway API
|
||||
DEERFLOW_LANGGRAPH_URL=http://localhost:2026/api/langgraph # LangGraph API
|
||||
```
|
||||
|
||||
完整 API 说明见 [`skills/public/claude-to-deerflow/SKILL.md`](skills/public/claude-to-deerflow/SKILL.md)。
|
||||
然后确认 DeerFlow 已经启动(默认地址是 `http://localhost:2026`),在 Claude Code 里使用 `/claude-to-deerflow` 命令即可。完整 API 说明见 [`skills/public/claude-to-deerflow/SKILL.md`](skills/public/claude-to-deerflow/SKILL.md)。
|
||||
|
||||
### Sub-Agents
|
||||
|
||||
@@ -485,12 +571,10 @@ lead agent 可以按需动态拉起 sub-agents。每个 sub-agent 都有自己
|
||||
|
||||
### Sandbox 与文件系统
|
||||
|
||||
DeerFlow 不只是“会说它能做”,它是真的有一台自己的“电脑”。
|
||||
DeerFlow 不只是"会说它能做",它是真的有一台自己的"电脑"。
|
||||
|
||||
每个任务都运行在隔离的 Docker 容器里,里面有完整的文件系统,包括 skills、workspace、uploads、outputs。agent 可以读写和编辑文件,可以执行 bash 命令和代码,也可以查看图片。整个过程都在 sandbox 内完成,可审计、会隔离,不会在不同 session 之间互相污染。
|
||||
|
||||
这就是“带工具的聊天机器人”和“真正有执行环境的 agent”之间的差别。
|
||||
|
||||
```text
|
||||
# sandbox 容器内的路径
|
||||
/mnt/user-data/
|
||||
@@ -509,16 +593,30 @@ DeerFlow 不只是“会说它能做”,它是真的有一台自己的“电
|
||||
|
||||
大多数 agents 会在对话结束后把一切都忘掉,DeerFlow 不一样。
|
||||
|
||||
跨 session 使用时,DeerFlow 会逐步积累关于你的持久 memory,包括你的个人偏好、知识背景,以及长期沉淀下来的工作习惯。你用得越多,它越了解你的写作风格、技术栈和重复出现的工作流。memory 保存在本地,控制权也始终在你手里。
|
||||
跨 session 使用时,DeerFlow 会逐步积累关于你的持久 memory,包括你的个人偏好、知识背景,以及长期沉淀下来的工作习惯。你用得越多,它越了解你的写作风格、技术栈和重复出现的工作流。
|
||||
|
||||
## 推荐模型
|
||||
## 在 DeerFlow 之上构建应用(apps/)
|
||||
|
||||
DeerFlow 对模型没有强绑定,只要实现了 OpenAI 兼容 API 的 LLM,理论上都可以接入。不过在下面这些能力上表现更强的模型,通常会更适合 DeerFlow:
|
||||
`apps/`(仓库根目录、与 `backend/` / `frontend/` 平级)用于存放**消费 DeerFlow 能力的上层应用**,遵循严格的依赖方向:**app 可以依赖 deerflow,deerflow 不能依赖 app / apps**。
|
||||
|
||||
- **长上下文窗口**(100k+ tokens),适合深度研究和多步骤任务
|
||||
- **推理能力**,适合自适应规划和复杂拆解
|
||||
- **多模态输入**,适合理解图片和视频
|
||||
- **稳定的 tool use 能力**,适合可靠的函数调用和结构化输出
|
||||
两种集成模式:
|
||||
|
||||
| 模式 | 适用场景 | 怎么连 | 示例 |
|
||||
|---|---|---|---|
|
||||
| **HTTP Gateway**(REST+SSE) | 上层是别的服务 / 多语言 | 调 `http://localhost:2026/api/*` | [`apps/examples/http-chat/`](apps/examples/http-chat/) |
|
||||
| **内嵌 DeerFlowClient** | 上层本身是 Python,进程内直接当 SDK 调 | `from deerflow.client import DeerFlowClient` | [`apps/examples/embedded-chat/`](apps/examples/embedded-chat/) |
|
||||
|
||||
每个示例自带 `run.sh`:
|
||||
|
||||
```bash
|
||||
# ① HTTP 模式:需要先起 Gateway(dev-gateway 或 dev-full 都行)
|
||||
./apps/examples/http-chat/run.sh
|
||||
|
||||
# ② 内嵌模式:不需要起任何服务,run.sh 自动进 backend uv 环境运行
|
||||
./apps/examples/embedded-chat/run.sh
|
||||
```
|
||||
|
||||
完整说明、鉴权流程与新建应用约定见 [apps/README.md](apps/README.md)。
|
||||
|
||||
## 内嵌 Python Client
|
||||
|
||||
@@ -546,12 +644,25 @@ client.upload_files("thread-1", ["./report.pdf"]) # {"success": True, "files":
|
||||
|
||||
所有返回 dict 的方法都会在 CI 中通过 Gateway 的 Pydantic 响应模型校验(`TestGatewayConformance`),以确保内嵌 client 始终和 HTTP API schema 保持同步。完整 API 说明见 `backend/packages/harness/deerflow/client.py`。
|
||||
|
||||
## 推荐模型
|
||||
|
||||
DeerFlow 对模型没有强绑定,只要实现了 OpenAI 兼容 API 的 LLM,理论上都可以接入。不过在下面这些能力上表现更强的模型,通常会更适合 DeerFlow:
|
||||
|
||||
- **长上下文窗口**(100k+ tokens),适合深度研究和多步骤任务
|
||||
- **推理能力**,适合自适应规划和复杂拆解
|
||||
- **多模态输入**,适合理解图片和视频
|
||||
- **稳定的 tool use 能力**,适合可靠的函数调用和结构化输出
|
||||
|
||||
## 文档
|
||||
|
||||
- [多租户改造汇总索引](docs/multi-tenant-redesign/README.zh-CN.md) - workspace / Postgres / RLS / Headless API 的决策与路线
|
||||
- [Stage 0 进度面板](docs/multi-tenant-redesign/03-impl/STATUS.md) - "现在到哪了"的权威来源
|
||||
- [数据库设计落地版](docs/multi-tenant-redesign/01-redesign/database-schema-as-built.zh-CN.md) - 10 张表全字段 / 外键 / 索引参考
|
||||
- [贡献指南](CONTRIBUTING.md) - 开发环境搭建与协作流程
|
||||
- [配置指南](backend/docs/CONFIGURATION.md) - 安装与配置说明
|
||||
- [架构概览](backend/CLAUDE.md) - 技术架构说明
|
||||
- [后端架构](backend/README.md) - 后端架构与 API 参考
|
||||
- [apps/ 上层应用](apps/README.md) - 在 DeerFlow 之上构建应用
|
||||
|
||||
## ⚠️ 安全使用
|
||||
|
||||
@@ -562,6 +673,9 @@ DeerFlow 具备**系统指令执行、资源操作、业务逻辑调用**等关
|
||||
- **未授权的非法调用**:agent 功能被未授权的第三方、公网恶意扫描程序探测到,进而发起批量非法调用请求,执行系统命令、文件读写等高危操作,可能导致安全后果。
|
||||
- **合规与法律风险**:若 agent 被非法调用用于实施网络攻击、信息窃取等违法违规行为,可能产生法律责任与合规风险。
|
||||
|
||||
> [!NOTE]
|
||||
> 多租户改造引入的 workspace 行级隔离 / 入口强校验 / 边界扫描,目标是**应用内**的租户隔离;它不替代上面的网络层 / 部署层防护。把 DeerFlow 曝光到不可信网络仍需配合下面的安全措施。多租户更强的 DB 层兜底(RLS / KMS / K8s)规划在 Stage 2–3。
|
||||
|
||||
### 安全使用建议
|
||||
|
||||
**注意:建议您将 DeerFlow 部署在本地可信的网络环境下。** 若您有跨设备、跨网络的部署需求,必须加入严格的安全措施。例如,采取如下手段:
|
||||
@@ -575,7 +689,12 @@ DeerFlow 具备**系统指令执行、资源操作、业务逻辑调用**等关
|
||||
|
||||
欢迎参与贡献。开发环境、工作流和相关规范见 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
||||
|
||||
目前回归测试已经覆盖 Docker sandbox 模式识别,以及 `backend/tests/` 中 provisioner kubeconfig-path 处理相关测试。
|
||||
提 PR 前请先在本地跑通校验(CI 会在每个 PR 上执行 backend lint + 测试,含 Postgres matrix):
|
||||
|
||||
```bash
|
||||
cd backend && make lint && make test # ruff + pytest
|
||||
cd frontend && pnpm lint && pnpm typecheck
|
||||
```
|
||||
|
||||
## 许可证
|
||||
|
||||
@@ -583,22 +702,15 @@ DeerFlow 具备**系统指令执行、资源操作、业务逻辑调用**等关
|
||||
|
||||
## 致谢
|
||||
|
||||
DeerFlow 建立在开源社区大量优秀工作的基础上。所有让 DeerFlow 成为可能的项目和贡献者,我们都心怀感谢。毫不夸张地说,我们是站在巨人的肩膀上继续往前走。
|
||||
DeerFlow 建立在开源社区大量优秀工作的基础上。所有让 DeerFlow 成为可能的项目和贡献者,我们都心怀感谢。
|
||||
|
||||
特别感谢以下项目带来的关键支持:
|
||||
|
||||
- **[LangChain](https://github.com/langchain-ai/langchain)**:它们提供的优秀框架支撑了我们的 LLM 交互与 chains,让整体集成和能力编排顺畅可用。
|
||||
- **[LangChain](https://github.com/langchain-ai/langchain)**:它们提供的优秀框架支撑了我们的 LLM 交互与 chains。
|
||||
- **[LangGraph](https://github.com/langchain-ai/langgraph)**:它们在多 agent 编排上的创新方式,是 DeerFlow 复杂工作流得以成立的重要基础。
|
||||
|
||||
这些项目体现了开源协作真正的力量,我们也很高兴能继续建立在这些基础之上。
|
||||
|
||||
### 核心贡献者
|
||||
|
||||
感谢 `DeerFlow` 的核心作者,是他们的判断、投入和持续推进,才让这个项目真正落地:
|
||||
|
||||
- **[Daniel Walnut](https://github.com/hetaoBackend/)**
|
||||
- **[Henry Li](https://github.com/magiccube/)**
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#bytedance/deer-flow&Date)
|
||||
</content>
|
||||
</invoke>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# 本地依赖 / 虚拟环境
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# 鉴权 cookie / 本地产物
|
||||
jar.txt
|
||||
*.local
|
||||
.env
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
# apps/ — 基于 DeerFlow 的应用
|
||||
|
||||
这个目录用于存放**消费 DeerFlow 智能体能力的上层应用**。每个应用一个子文件夹。
|
||||
|
||||
## 为什么放在这里
|
||||
|
||||
DeerFlow 的代码有一条严格的依赖方向(见根 `CLAUDE.md`):
|
||||
|
||||
```
|
||||
backend/packages/harness/deerflow/ ← 可发布的 Agent 框架(deerflow.*)
|
||||
backend/app/ ← Gateway / IM 通道(app.*)
|
||||
apps/ ← 你的应用(消费 deerflow,不反向依赖) ← 本目录
|
||||
```
|
||||
|
||||
规则:**app 可以依赖 deerflow,deerflow 不能依赖 app / apps**。本目录放在 `backend/` 之外、与 `frontend/` 平级,天然符合这条边界。
|
||||
|
||||
## 两种集成模式
|
||||
|
||||
| 模式 | 适用场景 | 怎么连 | 示例 |
|
||||
|---|---|---|---|
|
||||
| **HTTP Gateway**(REST+SSE) | 上层是别的服务 / 多语言 | 调 `http://localhost:2026/api/*` | [`examples/http-chat/`](examples/http-chat/) |
|
||||
| **内嵌 DeerFlowClient** | 上层本身是 Python,进程内直接当 SDK 调 | `from deerflow.client import DeerFlowClient` | [`examples/embedded-chat/`](examples/embedded-chat/) |
|
||||
|
||||
> 还有第三种:LangGraph SDK(`langgraph_sdk.get_client(url=".../api")`,graph id `lead_agent`),用于接入 LangGraph 生态工具链。需要的话照 HTTP 示例的鉴权流程拿 cookie 即可。
|
||||
|
||||
### 运行示例(每个示例自带 run.sh)
|
||||
|
||||
```bash
|
||||
# ① HTTP 模式:需要先起 Gateway(dev-gateway 或 dev-full 都行)
|
||||
# run.sh 会自动探测网关地址:优先 :2026,回退 :8001
|
||||
./apps/examples/http-chat/run.sh
|
||||
DF_BASE=http://localhost:8001 ./apps/examples/http-chat/run.sh # 也可手动指定
|
||||
|
||||
# ② 内嵌模式:不需要起任何服务,run.sh 自动进 backend uv 环境运行
|
||||
./apps/examples/embedded-chat/run.sh
|
||||
```
|
||||
|
||||
http-chat 的 `run.sh` 优先用 `uv run --no-project --with requests`(临时环境,不污染系统),没有 uv 才回退到本地 `.venv` + pip;可用 `DF_BASE` / `DF_EMAIL` / `DF_PASSWORD` 覆盖。embedded-chat 的 `run.sh` 自动定位 `backend/`、加载 `.env` 后用 `uv run` 启动,依赖 `config.yaml` 里有可用模型。
|
||||
|
||||
### 多租户并发验证(HTTP 模式)
|
||||
|
||||
[`examples/http-chat/multi_tenant.py`](examples/http-chat/multi_tenant.py) 是 `app.py` 的并发 / 多租户版:用 `/api/v1/auth/register` 并发创建多个租户(每个注册用户自带独立 workspace),各自一个 `requests.Session`(独立 cookie)同时跑 N 轮链式对话,并校验:① 真并发(对话时间窗重叠);② 多轮上下文按 `thread_id` 各自保持(第 2 轮起每轮都依赖上一轮结果);③ 租户隔离(`POST /api/threads/search` 仅见己有线程,跨租户 `GET /api/threads/{id}` 返回 404)。
|
||||
|
||||
```bash
|
||||
# 前提:已起 Gateway(dev-gateway 或 dev-full)
|
||||
DF_BASE=http://localhost:8001 DF_TENANTS=4 DF_TURNS=10 \
|
||||
uv run --no-project --with requests python apps/examples/http-chat/multi_tenant.py
|
||||
```
|
||||
|
||||
环境变量:`DF_BASE`(网关地址,默认 :8001)、`DF_TENANTS`(并发租户数,默认 3)、`DF_TURNS`(每租户轮数,默认 10)。每次运行用唯一邮箱新建租户,可重复跑,不撞 email、也不触发登录限流(`/register` 不限流;`setup-status` 全程只调一次以避开 60s/IP 限流)。
|
||||
|
||||
## 前置:先把 DeerFlow 跑起来
|
||||
|
||||
在**仓库根目录**:
|
||||
|
||||
```bash
|
||||
make dev # 起 Gateway(8001) + 前端(3000) + nginx(2026),统一入口 http://localhost:2026
|
||||
```
|
||||
|
||||
确保 `config.yaml` 里至少配了一个可用模型 + API key。
|
||||
|
||||
### 本地调试脚本(推荐)
|
||||
|
||||
`make dev` 是前台阻塞运行。日常调试更顺手的是仓库根 `scripts/` 下两个生命周期脚本,子命令统一为 `start / stop / restart / status / logs / run`:
|
||||
|
||||
| 脚本 | 起什么 | 入口 | 适合 |
|
||||
|---|---|---|---|
|
||||
| `scripts/dev-gateway.sh` | 只起 Gateway | `http://localhost:8001` | 调后端 API / 接入示例,起得快 |
|
||||
| `scripts/dev-full.sh` | Gateway + 前端 + nginx | `http://localhost:2026` | 连前端一起调,完整体验 |
|
||||
|
||||
```bash
|
||||
./scripts/dev-gateway.sh start # 后台启动,等就绪后返回
|
||||
./scripts/dev-gateway.sh status # PID / 端口 / HTTP 健康检查
|
||||
./scripts/dev-gateway.sh logs # tail -f 跟随日志(不影响服务)
|
||||
./scripts/dev-gateway.sh stop
|
||||
|
||||
./scripts/dev-full.sh start # 全量栈后台启动(首次装依赖)
|
||||
SKIP_INSTALL=1 ./scripts/dev-full.sh start # 跳过依赖安装,重启更快
|
||||
./scripts/dev-full.sh status # 三服务一览
|
||||
./scripts/dev-full.sh run # 前台运行(= make dev,gateway 带热重载)
|
||||
```
|
||||
|
||||
环境变量:`PORT=`(换端口)、`NO_RELOAD=1`(关热重载,断点更稳)、`SKIP_INSTALL=1`(全量栈跳过装依赖)。
|
||||
|
||||
## 鉴权(HTTP 模式必读)
|
||||
|
||||
Gateway 是 **fail-closed** 的——除少数公开路径外所有请求都要带会话 cookie:
|
||||
|
||||
1. `GET /api/v1/auth/setup-status` → 是否还没管理员
|
||||
2. 首次 `POST /api/v1/auth/initialize`(JSON `{email,password}`)建第一个管理员;之后 `POST /api/v1/auth/login/local`(**表单** `username`=邮箱 + `password`)
|
||||
3. 成功后 Session 里有 `access_token`(HttpOnly) + `csrf_token` 两个 cookie
|
||||
4. **所有写请求**(POST/PUT/DELETE/PATCH)必须带 `X-CSRF-Token` 头 = `csrf_token` 值
|
||||
|
||||
> 多租户:当前 `docs/multi-tenant-redesign` 分支的 API Key 鉴权中间件尚未接入,外部系统暂时只能走会话 cookie。等 `Authorization: Bearer dfk_live_...` 落地后再补无人值守接入。每个注册用户即一个独立租户(自带 workspace);并发与隔离行为可用上面的 [`multi_tenant.py`](examples/http-chat/multi_tenant.py) 验证。
|
||||
|
||||
## 新建一个应用
|
||||
|
||||
```bash
|
||||
mkdir apps/my-app
|
||||
# 放你的代码;HTTP 模式参照 examples/http-chat,内嵌模式参照 examples/embedded-chat
|
||||
```
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
内嵌模式示例:进程内直接把 DeerFlow 当 SDK 调,不起 HTTP。
|
||||
|
||||
必须在 backend 的 uv 环境里跑(这样才能 import deerflow.*):
|
||||
cd backend
|
||||
uv run python ../apps/examples/embedded-chat/app.py
|
||||
|
||||
依赖 config.yaml 里配好至少一个可用模型 + API key(路径解析见根 CLAUDE.md)。
|
||||
|
||||
API 对照:backend/packages/harness/deerflow/client.py
|
||||
"""
|
||||
|
||||
from deerflow.client import DeerFlowClient
|
||||
from deerflow.runtime.checkpointer.provider import get_checkpointer
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# checkpointer 提供跨轮状态持久化(sqlite/postgres 由 config.yaml 决定)
|
||||
client = DeerFlowClient(
|
||||
checkpointer=get_checkpointer(),
|
||||
thinking_enabled=True,
|
||||
)
|
||||
|
||||
thread_id = "embedded-demo-1"
|
||||
|
||||
# ① 流式:stream() 产出 StreamEvent
|
||||
print("👤 用一句话介绍你自己,然后心算 17 * 23。\n🤖 ", end="", flush=True)
|
||||
for ev in client.stream("用一句话介绍你自己,然后心算 17 * 23。", thread_id=thread_id):
|
||||
if ev.type == "messages-tuple" and ev.data.get("type") == "ai":
|
||||
print(ev.data.get("content", ""), end="", flush=True) # AI 文本增量
|
||||
elif ev.type == "end":
|
||||
print(f"\n[usage] {ev.data.get('usage')}")
|
||||
|
||||
# ② 阻塞式:chat() 直接返回完整 AI 文本(复用 thread_id 即多轮)
|
||||
print("\n👤 刚才结果再乘以 2 是多少?")
|
||||
answer = client.chat("刚才结果再乘以 2 是多少?", thread_id=thread_id)
|
||||
print(f"🤖 {answer}")
|
||||
|
||||
# 其它能力:list_models() / list_skills() / get_memory() / upload_files() ...
|
||||
models = client.list_models().get("models", [])
|
||||
print(f"\n[已配置模型] {[m.get('name') for m in models]}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# embedded-chat 示例启动脚本
|
||||
# ------------------------------------------------------------------
|
||||
# 内嵌 SDK 模式:进程内直接 import deerflow.*,不需要起任何服务。
|
||||
# 必须在 backend 的 uv 虚拟环境里跑(才能解析 deerflow-harness / app 包),
|
||||
# 本脚本自动 cd 到 backend 并用 uv run 启动。
|
||||
#
|
||||
# 用法:
|
||||
# ./run.sh
|
||||
#
|
||||
# 前提:
|
||||
# - 已 `cd backend && uv sync`(或跑过任意一个 dev 脚本,venv 已建好)
|
||||
# - config.yaml 里配好至少一个可用模型 + API key
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
APP="$SCRIPT_DIR/app.py"
|
||||
BACKEND="$REPO_ROOT/backend"
|
||||
|
||||
# ── 前置检查 ──────────────────────────────────────────────────────
|
||||
command -v uv >/dev/null 2>&1 || { echo "✗ 未找到 uv。安装:curl -LsSf https://astral.sh/uv/install.sh | sh" >&2; exit 1; }
|
||||
[ -f "$REPO_ROOT/config.yaml" ] || echo "⚠ 未找到 $REPO_ROOT/config.yaml —— 没有可用模型会启动失败" >&2
|
||||
|
||||
if [ ! -d "$BACKEND/.venv" ]; then
|
||||
echo "→ 未发现 backend/.venv,执行 uv sync"
|
||||
(cd "$BACKEND" && uv sync)
|
||||
fi
|
||||
|
||||
# ── 加载 .env(模型 key / 数据库等)──────────────────────────────
|
||||
if [ -f "$REPO_ROOT/.env" ]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
source "$REPO_ROOT/.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
# ── 在 backend uv 环境里运行(config.yaml 解析依赖运行目录为 backend/)──
|
||||
echo "→ 在 backend uv 环境中运行 embedded-chat"
|
||||
cd "$BACKEND"
|
||||
exec env PYTHONPATH=. uv run python "$APP"
|
||||
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
HTTP 模式示例:把 DeerFlow 当底层服务,通过 Gateway (REST + SSE) 对话。
|
||||
|
||||
运行:
|
||||
pip install -r requirements.txt
|
||||
python app.py # 前提:仓库根目录已 `make dev`
|
||||
|
||||
字段 / 事件名均已对照后端源码核对:
|
||||
鉴权 backend/app/gateway/routers/auth.py + auth_middleware.py + csrf_middleware.py
|
||||
线程/运行 backend/app/gateway/routers/threads.py + thread_runs.py
|
||||
SSE 事件名 backend/packages/harness/deerflow/runtime/runs/worker.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import requests
|
||||
|
||||
# 默认走 nginx(:2026);只起了 Gateway 时用 BASE=http://localhost:8001 覆盖
|
||||
BASE = os.environ.get("DF_BASE", "http://localhost:2026")
|
||||
EMAIL = os.environ.get("DF_EMAIL", "admin@example.com")
|
||||
PASSWORD = os.environ.get("DF_PASSWORD", "change-me-please-123") # 至少 8 位,避免弱口令
|
||||
|
||||
|
||||
def authenticate(s: requests.Session) -> None:
|
||||
"""首启则初始化管理员,否则登录。成功后 cookie 落在 session。"""
|
||||
status = s.get(f"{BASE}/api/v1/auth/setup-status").json()
|
||||
if status.get("needs_setup"):
|
||||
print("→ 首次启动,创建管理员账号")
|
||||
r = s.post(f"{BASE}/api/v1/auth/initialize",
|
||||
json={"email": EMAIL, "password": PASSWORD})
|
||||
else:
|
||||
print("→ 已有账号,登录")
|
||||
# login/local 是 OAuth2 表单:字段名 username(填邮箱)+ password
|
||||
r = s.post(f"{BASE}/api/v1/auth/login/local",
|
||||
data={"username": EMAIL, "password": PASSWORD})
|
||||
r.raise_for_status()
|
||||
print(" cookies:", list(s.cookies.keys()))
|
||||
|
||||
|
||||
def _csrf(s: requests.Session) -> dict:
|
||||
"""双提交 cookie 模式:csrf_token cookie 的值放进 X-CSRF-Token 头。"""
|
||||
token = s.cookies.get("csrf_token")
|
||||
if not token:
|
||||
raise RuntimeError("缺少 csrf_token cookie —— 鉴权可能失败")
|
||||
return {"X-CSRF-Token": token}
|
||||
|
||||
|
||||
def create_thread(s: requests.Session) -> str:
|
||||
r = s.post(f"{BASE}/api/threads", json={}, headers=_csrf(s))
|
||||
r.raise_for_status()
|
||||
tid = r.json()["thread_id"]
|
||||
print(f"→ 线程已创建: {tid}")
|
||||
return tid
|
||||
|
||||
|
||||
_seen_text = "" # 简单状态,按需扩展为按 message-id 维护
|
||||
|
||||
|
||||
def stream_chat(s: requests.Session, thread_id: str, message: str) -> None:
|
||||
body = {
|
||||
"assistant_id": "lead_agent", # 见 backend/langgraph.json
|
||||
"input": {"messages": [{"role": "user", "content": message}]},
|
||||
"stream_mode": ["messages-tuple", "values"], # 增量文本 + 全量状态
|
||||
}
|
||||
headers = {**_csrf(s), "Accept": "text/event-stream"}
|
||||
|
||||
with s.post(f"{BASE}/api/threads/{thread_id}/runs/stream",
|
||||
json=body, headers=headers, stream=True) as resp:
|
||||
resp.raise_for_status()
|
||||
print(f"\n👤 {message}\n🤖 ", end="", flush=True)
|
||||
|
||||
event, buf = None, []
|
||||
for raw in resp.iter_lines(decode_unicode=True):
|
||||
if raw is None:
|
||||
continue
|
||||
line = raw.strip()
|
||||
if line == "": # 一帧结束
|
||||
if event:
|
||||
_handle(event, "\n".join(buf))
|
||||
event, buf = None, []
|
||||
elif line.startswith(":"): # 心跳注释
|
||||
continue
|
||||
elif line.startswith("event:"):
|
||||
event = line[6:].strip()
|
||||
elif line.startswith("data:"):
|
||||
buf.append(line[5:].strip())
|
||||
print()
|
||||
|
||||
|
||||
def _handle(event: str, data: str) -> None:
|
||||
if event == "end" or not data:
|
||||
return
|
||||
try:
|
||||
payload = json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
return
|
||||
if event == "messages":
|
||||
# 形如 [chunk_dict, metadata_dict];AI 文本是增量
|
||||
chunk = payload[0] if isinstance(payload, list) and payload else {}
|
||||
if chunk.get("type") in ("ai", "AIMessageChunk"):
|
||||
content = chunk.get("content")
|
||||
text = content if isinstance(content, str) else _flatten(content)
|
||||
if text:
|
||||
print(text, end="", flush=True)
|
||||
# event == "metadata" → {run_id, thread_id}
|
||||
# event == "values" → 全量状态快照(title / messages / artifacts ...)
|
||||
|
||||
|
||||
def _flatten(content) -> str:
|
||||
if isinstance(content, list):
|
||||
return "".join(b.get("text", "") for b in content if isinstance(b, dict))
|
||||
return ""
|
||||
|
||||
|
||||
def main() -> None:
|
||||
s = requests.Session()
|
||||
authenticate(s)
|
||||
tid = create_thread(s)
|
||||
stream_chat(s, tid, "用一句话介绍你自己,然后心算 17 * 23。")
|
||||
stream_chat(s, tid, "刚才结果再乘以 2 是多少?") # 复用 thread_id 即多轮
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,297 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
多租户并发测试:以 http-chat 的方式(Gateway REST + SSE),多个租户同时对话,
|
||||
并验证租户隔离。app.py 的并发 / 多租户版。
|
||||
|
||||
运行(前提:仓库根已起 Gateway,如 `./scripts/dev-gateway.sh start`):
|
||||
DF_BASE=http://localhost:8001 DF_TENANTS=4 \
|
||||
uv run --no-project --with requests python multi_tenant.py
|
||||
# 没有 uv 时:pip install -r requirements.txt && python multi_tenant.py
|
||||
环境变量:DF_BASE(网关地址,默认 :8001)、DF_TENANTS(并发租户数,默认 3)。
|
||||
|
||||
每个注册用户 = 一个独立租户(自带独立 workspace)。并发 = 每租户一个
|
||||
requests.Session(独立 cookie),放进线程池同时跑。
|
||||
|
||||
字段/事件名沿用 apps/examples/http-chat/app.py(已对照后端源码):
|
||||
鉴权 /api/v1/auth/{setup-status,register,me}
|
||||
线程 POST /api/threads ;列举 POST /api/threads/search ;单查 GET /api/threads/{id}
|
||||
SSE POST /api/threads/{id}/runs/stream
|
||||
|
||||
约束(来自后端源码):
|
||||
- GET /auth/setup-status 限流 1 次/60s/IP → 整个测试只调用一次
|
||||
- POST /auth/login/local 限流 5 次/5min/IP → 本测试用 /register 建新租户,不走 login
|
||||
- 跨租户访问线程返回 404(不是 403)→ 即隔离信号
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
import requests
|
||||
|
||||
BASE = os.environ.get("DF_BASE", "http://localhost:8001")
|
||||
N_TENANTS = int(os.environ.get("DF_TENANTS", "3"))
|
||||
N_TURNS = int(os.environ.get("DF_TURNS", "10")) # 每租户的对话轮数
|
||||
# 同一批次唯一后缀,避免重复运行时 email 冲突
|
||||
RUN_ID = uuid.uuid4().hex[:8]
|
||||
PASSWORD = "DeerTenant-7x9q!" # ≥8 位且不在弱口令黑名单
|
||||
|
||||
_print_lock = threading.Lock()
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
with _print_lock:
|
||||
print(msg, flush=True)
|
||||
|
||||
|
||||
def _csrf(s: requests.Session) -> dict:
|
||||
token = s.cookies.get("csrf_token")
|
||||
if not token:
|
||||
raise RuntimeError("缺少 csrf_token cookie —— 鉴权可能失败")
|
||||
return {"X-CSRF-Token": token}
|
||||
|
||||
|
||||
def register(s: requests.Session, email: str) -> dict:
|
||||
"""注册并自动登录(register 会同时下发 access_token + csrf_token cookie)。"""
|
||||
r = s.post(f"{BASE}/api/v1/auth/register", json={"email": email, "password": PASSWORD})
|
||||
r.raise_for_status()
|
||||
return r.json() # {id, email, system_role}
|
||||
|
||||
|
||||
def whoami(s: requests.Session) -> dict:
|
||||
r = s.get(f"{BASE}/api/v1/auth/me")
|
||||
r.raise_for_status()
|
||||
return r.json() # {id, email, default_workspace_id, workspaces:[...]}
|
||||
|
||||
|
||||
def create_thread(s: requests.Session) -> str:
|
||||
r = s.post(f"{BASE}/api/threads", json={}, headers=_csrf(s))
|
||||
r.raise_for_status()
|
||||
return r.json()["thread_id"]
|
||||
|
||||
|
||||
def stream_answer(s: requests.Session, thread_id: str, message: str) -> dict:
|
||||
"""发一条消息,按 message-id 分组收集 AI 增量文本。
|
||||
|
||||
注意:TitleMiddleware 会另起一条 AI 消息生成线程标题,它和正文答复
|
||||
是不同的 message-id。必须按 id 分组,否则正文数字会和标题数字粘连
|
||||
(如 180 + "12乘15..." → "18012"),导致校验误判。
|
||||
"""
|
||||
body = {
|
||||
"assistant_id": "lead_agent",
|
||||
"input": {"messages": [{"role": "user", "content": message}]},
|
||||
"stream_mode": ["messages-tuple", "values"],
|
||||
}
|
||||
headers = {**_csrf(s), "Accept": "text/event-stream"}
|
||||
by_id: dict[str, str] = {}
|
||||
with s.post(f"{BASE}/api/threads/{thread_id}/runs/stream", json=body, headers=headers, stream=True) as resp:
|
||||
resp.raise_for_status()
|
||||
event, buf = None, []
|
||||
for raw in resp.iter_lines(decode_unicode=True):
|
||||
if raw is None:
|
||||
continue
|
||||
line = raw.strip()
|
||||
if line == "":
|
||||
if event == "messages" and buf:
|
||||
_collect(by_id, "\n".join(buf))
|
||||
event, buf = None, []
|
||||
elif line.startswith(":"):
|
||||
continue
|
||||
elif line.startswith("event:"):
|
||||
event = line[6:].strip()
|
||||
elif line.startswith("data:"):
|
||||
buf.append(line[5:].strip())
|
||||
return by_id # {message_id: text}
|
||||
|
||||
|
||||
def _collect(by_id: dict, data: str) -> None:
|
||||
try:
|
||||
payload = json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
return
|
||||
chunk = payload[0] if isinstance(payload, list) and payload else {}
|
||||
if chunk.get("type") in ("ai", "AIMessageChunk"):
|
||||
content = chunk.get("content")
|
||||
if isinstance(content, str):
|
||||
text = content
|
||||
elif isinstance(content, list):
|
||||
text = "".join(b.get("text", "") for b in content if isinstance(b, dict))
|
||||
else:
|
||||
text = ""
|
||||
mid = chunk.get("id") or "_"
|
||||
by_id[mid] = by_id.get(mid, "") + text
|
||||
|
||||
|
||||
def search_threads(s: requests.Session) -> list:
|
||||
r = s.post(f"{BASE}/api/threads/search", json={"limit": 100, "offset": 0}, headers=_csrf(s))
|
||||
r.raise_for_status()
|
||||
return r.json() # bare array of ThreadResponse
|
||||
|
||||
|
||||
def get_thread_status(s: requests.Session, thread_id: str) -> int:
|
||||
return s.get(f"{BASE}/api/threads/{thread_id}").status_code
|
||||
|
||||
|
||||
def _contains(by_id: dict, n: int) -> bool:
|
||||
"""某条 AI 消息里是否独立出现数字 n(按 message-id 分组比对,避免与标题数字粘连)。"""
|
||||
return any(str(n) in re.findall(r"\d+", text.replace(",", "")) for text in by_id.values())
|
||||
|
||||
|
||||
def _main_text(by_id: dict) -> str:
|
||||
"""取最长的一条 AI 消息当正文(标题通常更短)。"""
|
||||
return (max(by_id.values(), key=len) if by_id else "").strip()
|
||||
|
||||
|
||||
# ── 一个租户的完整链路(在独立线程里跑)────────────────────────────────
|
||||
def run_tenant(idx: int) -> dict:
|
||||
"""N_TURNS 轮链式对话,复用同一 thread:
|
||||
T1 = a×b;之后每轮「把上一条数字再加 d」(d 每租户不同)。
|
||||
每轮都必须记得上一轮结果,逐轮校验,验证多轮上下文在并发下各自保持。
|
||||
"""
|
||||
email = f"tenant-{RUN_ID}-{idx}@example.com"
|
||||
a, b = 11 + idx, 13 + idx * 2 # 每租户不同算式
|
||||
d = 2 + idx # 每租户不同步长,进一步坐实无串扰
|
||||
|
||||
# 预先算出每轮期望值
|
||||
expected = [a * b]
|
||||
for _ in range(1, N_TURNS):
|
||||
expected.append(expected[-1] + d)
|
||||
|
||||
s = requests.Session()
|
||||
rec = {"idx": idx, "email": email, "d": d, "expected": expected, "turns": [], "ok": False}
|
||||
t0 = time.time()
|
||||
try:
|
||||
user = register(s, email)
|
||||
me = whoami(s)
|
||||
rec["user_id"] = user["id"]
|
||||
rec["workspace_id"] = me.get("default_workspace_id")
|
||||
rec["t_start"] = t0
|
||||
log(f"[租户{idx}] 注册完成 user={user['id'][:8]} ws={str(rec['workspace_id'])[:8]} d={d} email={email}")
|
||||
|
||||
tid = create_thread(s)
|
||||
rec["thread_id"] = tid
|
||||
|
||||
for k in range(N_TURNS):
|
||||
if k == 0:
|
||||
q = f"只回答最终数字:{a} 乘以 {b} 等于多少?"
|
||||
else:
|
||||
q = f"把你上一条回答的那个数字再加 {d},只回答最终数字。"
|
||||
by_id = stream_answer(s, tid, q)
|
||||
hit = _contains(by_id, expected[k])
|
||||
rec["turns"].append({"k": k + 1, "expected": expected[k], "text": _main_text(by_id), "ok": hit})
|
||||
mark = "✓" if hit else "✗"
|
||||
log(f"[租户{idx}] T{k + 1:>2}/{N_TURNS} 期望 {expected[k]:>5} → {mark} {rec['turns'][-1]['text'][:24]!r}")
|
||||
|
||||
rec["turns_passed"] = sum(t["ok"] for t in rec["turns"])
|
||||
rec["all_turns_ok"] = rec["turns_passed"] == N_TURNS
|
||||
rec["context_ok"] = all(t["ok"] for t in rec["turns"][1:]) # 第 2 轮起依赖上下文
|
||||
rec["t_end"] = time.time()
|
||||
rec["session"] = s
|
||||
rec["ok"] = True
|
||||
log(f"[租户{idx}] ✓ 完成 {rec['turns_passed']}/{N_TURNS} 轮")
|
||||
except Exception as e: # noqa: BLE001
|
||||
rec["error"] = f"{type(e).__name__}: {e}"
|
||||
log(f"[租户{idx}] ✗ 失败:{rec['error']}")
|
||||
return rec
|
||||
|
||||
|
||||
def main() -> None:
|
||||
log(f"=== 多租户并发测试 BASE={BASE} 租户数={N_TENANTS} 轮数={N_TURNS} 批次={RUN_ID} ===\n")
|
||||
|
||||
# setup-status 只调用一次(60s 限流)
|
||||
try:
|
||||
st = requests.get(f"{BASE}/api/v1/auth/setup-status", timeout=5)
|
||||
if st.status_code == 200:
|
||||
log(f"setup-status: {st.json()}")
|
||||
if st.json().get("needs_setup"):
|
||||
log("⚠ 系统尚未初始化管理员。请先创建管理员(apps/examples/http-chat/app.py 首启会建),再跑本测试。")
|
||||
return
|
||||
else:
|
||||
log(f"setup-status: HTTP {st.status_code}(限流则忽略,按已初始化处理)")
|
||||
except Exception as e: # noqa: BLE001
|
||||
log(f"setup-status 请求失败:{e}")
|
||||
|
||||
# 并发跑所有租户
|
||||
log(f"\n── 并发启动 {N_TENANTS} 个租户 ──")
|
||||
results = []
|
||||
with ThreadPoolExecutor(max_workers=N_TENANTS) as ex:
|
||||
futs = [ex.submit(run_tenant, i) for i in range(N_TENANTS)]
|
||||
for f in as_completed(futs):
|
||||
results.append(f.result())
|
||||
results.sort(key=lambda r: r["idx"])
|
||||
|
||||
ok = [r for r in results if r.get("ok")]
|
||||
|
||||
# 并发证据:对话时间窗是否重叠
|
||||
log("\n── 并发证据(对话时间窗,相对秒)──")
|
||||
if ok:
|
||||
base_t = min(r["t_start"] for r in ok)
|
||||
for r in ok:
|
||||
s_off = r["t_start"] - base_t
|
||||
e_off = r["t_end"] - base_t
|
||||
bar = " " * int(s_off * 4) + "█" * max(1, int((e_off - s_off) * 4))
|
||||
log(f" 租户{r['idx']}: [{s_off:5.1f}s → {e_off:5.1f}s] {bar}")
|
||||
spans = [(r["t_start"], r["t_end"]) for r in ok]
|
||||
overlapped = any(
|
||||
a[0] < b[1] and b[0] < a[1] for i, a in enumerate(spans) for b in spans[i + 1 :]
|
||||
)
|
||||
log(f" → 存在时间窗重叠(真并发):{overlapped}")
|
||||
|
||||
# 隔离校验
|
||||
log("\n── 隔离校验 ──")
|
||||
iso_pass = True
|
||||
own_thread = {r["idx"]: r["thread_id"] for r in ok}
|
||||
for r in ok:
|
||||
s = r["session"]
|
||||
mine = {t["thread_id"] for t in search_threads(s)}
|
||||
# 1) search 只含自己的线程
|
||||
only_own = mine == {r["thread_id"]} if mine else False
|
||||
leaked = {own_thread[j] for j in own_thread if j != r["idx"]} & mine
|
||||
# 2) 直接 GET 别人的线程 → 期望 404
|
||||
cross_ok = True
|
||||
for j, tid in own_thread.items():
|
||||
if j == r["idx"]:
|
||||
continue
|
||||
code = get_thread_status(s, tid)
|
||||
if code != 404:
|
||||
cross_ok = False
|
||||
log(f" ✗ 租户{r['idx']} 访问 租户{j} 的线程返回 {code}(期望 404)")
|
||||
if leaked:
|
||||
iso_pass = False
|
||||
log(f" ✗ 租户{r['idx']} 的 search 里出现了别人的线程:{leaked}")
|
||||
if not cross_ok:
|
||||
iso_pass = False
|
||||
if only_own and cross_ok and not leaked:
|
||||
log(f" ✓ 租户{r['idx']}:search 仅见己有线程,跨租户 GET 均 404")
|
||||
|
||||
# 汇总
|
||||
log(f"\n── 汇总(每租户 {N_TURNS} 轮链式对话:T1=a×b,之后每轮 +d)──")
|
||||
log(f"{'租户':<6}{'user_id':<12}{'thread':<14}{'步长d':<8}{'通过轮数':<12}{'逐轮':<14}{'状态'}")
|
||||
for r in results:
|
||||
if r.get("ok"):
|
||||
seq = "".join("✓" if t["ok"] else "✗" for t in r["turns"])
|
||||
passed = f"{r['turns_passed']}/{N_TURNS}"
|
||||
log(f"{r['idx']:<6}{r['user_id'][:8]:<12}{r['thread_id'][:10]:<14}{r['d']:<8}{passed:<12}{seq:<14}OK")
|
||||
else:
|
||||
log(f"{r['idx']:<6}{'-':<12}{'-':<14}{'-':<8}{'-':<12}{'-':<14}FAIL: {r.get('error')}")
|
||||
|
||||
all_ok = len(ok) == N_TENANTS
|
||||
turn1_ok = all(r["turns"][0]["ok"] for r in ok)
|
||||
context_ok = all(r.get("context_ok") for r in ok) # 第 2 轮起全对
|
||||
all_turns_ok = all(r.get("all_turns_ok") for r in ok) # N 轮全对
|
||||
log("\n=== 结果 ===")
|
||||
log(f" 租户全部成功: {all_ok} ({len(ok)}/{N_TENANTS})")
|
||||
log(f" 首轮答复无串扰: {turn1_ok}")
|
||||
log(f" 多轮上下文保持: {context_ok} ← 第 2~{N_TURNS} 轮每轮都依赖上一轮结果")
|
||||
log(f" 全程 {N_TURNS} 轮全对: {all_turns_ok}")
|
||||
log(f" 租户隔离: {iso_pass}")
|
||||
verdict = all_ok and turn1_ok and context_ok and iso_pass
|
||||
log(f" >>> {'PASS ✅' if verdict else 'FAIL ❌'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
requests>=2.31
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# http-chat 示例启动脚本
|
||||
# ------------------------------------------------------------------
|
||||
# - 自动探测网关地址:优先 :2026(nginx 全量栈),回退 :8001(只起 Gateway)
|
||||
# - 优先用 uv 临时虚拟环境带上 requests(--no-project,不污染系统/项目)
|
||||
# 没有 uv 时回退到本地 .venv + pip
|
||||
#
|
||||
# 用法:
|
||||
# ./run.sh # 自动探测网关并运行
|
||||
# DF_BASE=http://localhost:8001 ./run.sh # 手动指定网关
|
||||
# DF_EMAIL=a@b.com DF_PASSWORD=xxxx ./run.sh
|
||||
#
|
||||
# 前提:先起好 Gateway
|
||||
# ../../../scripts/dev-gateway.sh start # → :8001
|
||||
# ../../../scripts/dev-full.sh start # → :2026
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# ── 探测可用网关 ──────────────────────────────────────────────────
|
||||
_alive() { curl -s -o /dev/null -w "%{http_code}" "$1/api/v1/auth/setup-status" 2>/dev/null | grep -qE "200|429"; }
|
||||
|
||||
if [ -z "${DF_BASE:-}" ]; then
|
||||
if _alive "http://localhost:2026"; then DF_BASE="http://localhost:2026"
|
||||
elif _alive "http://localhost:8001"; then DF_BASE="http://localhost:8001"
|
||||
else
|
||||
echo "✗ 没探测到运行中的网关(:2026 / :8001 都不通)。" >&2
|
||||
echo " 先启动:scripts/dev-gateway.sh start 或 scripts/dev-full.sh start" >&2
|
||||
echo " 或手动指定:DF_BASE=http://your-host:port ./run.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
export DF_BASE
|
||||
echo "→ 使用网关: $DF_BASE"
|
||||
|
||||
# ── 运行:优先 uv,回退 venv+pip ─────────────────────────────────
|
||||
if command -v uv >/dev/null 2>&1; then
|
||||
echo "→ uv 临时环境运行(--with requests)"
|
||||
exec uv run --no-project --with "requests>=2.31" python app.py
|
||||
else
|
||||
echo "→ 未找到 uv,使用本地 .venv + pip"
|
||||
if [ ! -d .venv ]; then
|
||||
python3 -m venv .venv
|
||||
./.venv/bin/pip install -q -r requirements.txt
|
||||
fi
|
||||
exec ./.venv/bin/python app.py
|
||||
fi
|
||||
@@ -102,6 +102,8 @@ Regression tests related to Docker/provisioner behavior:
|
||||
|
||||
Boundary check (harness → app import firewall):
|
||||
- `tests/test_harness_boundary.py` — ensures `packages/harness/deerflow/` never imports from `app.*`
|
||||
- `tests/test_workspace_boundary.py` — AST static scan that forbids direct imports of `langgraph.checkpoint.*` (and the third-party `langgraph_checkpoint_postgres` / `langgraph_checkpoint_sqlite` packages) outside the allowlist in `tests/boundary_allowlist.toml`. Everywhere else must obtain a checkpointer via `app.gateway.deps.get_checkpointer` or the harness `deerflow.runtime.checkpointer` factory. Imports inside `if TYPE_CHECKING:` blocks are exempt automatically (they do not enter runtime). When a legitimate new importer is genuinely needed, append its path to `boundary_allowlist.toml` in the same PR
|
||||
- `tests/test_workspace_boundary_self.py` — self-tests for the scanner above (9 cases over synthetic `.py` files) guarding against silent-empty regressions
|
||||
|
||||
CI runs these regression tests for every pull request via [.github/workflows/backend-unit-tests.yml](../.github/workflows/backend-unit-tests.yml).
|
||||
|
||||
|
||||
@@ -108,6 +108,23 @@ async def _ensure_admin_user(app: FastAPI) -> None:
|
||||
|
||||
admin_id = str(row.id)
|
||||
|
||||
# Stage 0 PR4 backfill: pre-PR4 admins have no default_workspace_id.
|
||||
# Create their personal workspace + owner membership on next boot so
|
||||
# they can log in and pass the workspace gate without hand-rolling
|
||||
# SQL. Idempotent — ensure_default_workspace short-circuits when the
|
||||
# column is already set.
|
||||
try:
|
||||
admin_user = await provider.get_user(admin_id)
|
||||
if admin_user is not None and not admin_user.default_workspace_id:
|
||||
from app.gateway.routers.auth import ensure_default_workspace
|
||||
|
||||
ws_id = await ensure_default_workspace(admin_user)
|
||||
logger.info("Backfilled default workspace %s for admin %s", ws_id, admin_id)
|
||||
except Exception:
|
||||
# Don't fail startup if backfill stumbles — the user can still
|
||||
# log in (login_local calls the same helper on its hot path).
|
||||
logger.exception("Admin workspace backfill failed (non-fatal)")
|
||||
|
||||
# LangGraph store orphan migration — non-fatal.
|
||||
# This covers the "no-auth → with-auth" upgrade path for users
|
||||
# whose existing LangGraph thread metadata has no user_id set.
|
||||
@@ -158,6 +175,34 @@ async def _migrate_orphaned_threads(store, admin_user_id: str) -> int:
|
||||
return migrated
|
||||
|
||||
|
||||
def _check_path_migration_pending(app: FastAPI) -> None:
|
||||
"""Warn the operator if the PR4 legacy user-isolation layout still has content.
|
||||
|
||||
PR6 routes every new write into ``{base_dir}/workspaces/{wid}/...`` via
|
||||
``Paths``. Pre-PR6 installations have data at
|
||||
``{base_dir}/users/{uid}/...`` that needs ``make migrate-paths`` to lift
|
||||
it under a workspace. We emit a warning at boot rather than crashing so
|
||||
the gateway keeps serving (reads from the legacy tree still work via the
|
||||
user_id branch of ``Paths.thread_dir``), but with a loud signal to run
|
||||
the migration script.
|
||||
"""
|
||||
from deerflow.config.paths import get_paths
|
||||
|
||||
legacy_users = get_paths().base_dir / "users"
|
||||
if not legacy_users.exists():
|
||||
return
|
||||
try:
|
||||
has_content = any(legacy_users.iterdir())
|
||||
except OSError:
|
||||
# Permission or transient FS issue — don't escalate; lifespan must succeed.
|
||||
return
|
||||
if has_content:
|
||||
logger.warning(
|
||||
"Legacy per-user layout detected at %s. Run `make migrate-paths` to lift it under the per-workspace layout (PR6).",
|
||||
legacy_users,
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Application lifespan handler."""
|
||||
@@ -174,6 +219,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
config = get_gateway_config()
|
||||
logger.info(f"Starting API Gateway on {config.host}:{config.port}")
|
||||
|
||||
_check_path_migration_pending(app)
|
||||
|
||||
# Initialize LangGraph runtime components (StreamBridge, RunManager, checkpointer, store)
|
||||
async with langgraph_runtime(app):
|
||||
logger.info("LangGraph runtime initialised")
|
||||
|
||||
@@ -21,6 +21,7 @@ class AuthErrorCode(StrEnum):
|
||||
PROVIDER_NOT_FOUND = "provider_not_found"
|
||||
NOT_AUTHENTICATED = "not_authenticated"
|
||||
SYSTEM_ALREADY_INITIALIZED = "system_already_initialized"
|
||||
WORKSPACE_REQUIRED = "workspace_required"
|
||||
|
||||
|
||||
class TokenError(StrEnum):
|
||||
@@ -29,6 +30,7 @@ class TokenError(StrEnum):
|
||||
EXPIRED = "expired"
|
||||
INVALID_SIGNATURE = "invalid_signature"
|
||||
MALFORMED = "malformed"
|
||||
WORKSPACE_MISSING = "workspace_missing"
|
||||
|
||||
|
||||
class AuthErrorResponse(BaseModel):
|
||||
@@ -42,4 +44,6 @@ def token_error_to_code(err: TokenError) -> AuthErrorCode:
|
||||
"""Map TokenError to AuthErrorCode — single source of truth."""
|
||||
if err == TokenError.EXPIRED:
|
||||
return AuthErrorCode.TOKEN_EXPIRED
|
||||
if err == TokenError.WORKSPACE_MISSING:
|
||||
return AuthErrorCode.WORKSPACE_REQUIRED
|
||||
return AuthErrorCode.TOKEN_INVALID
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""JWT token creation and verification."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
from pydantic import BaseModel
|
||||
@@ -10,30 +11,51 @@ from app.gateway.auth.errors import TokenError
|
||||
|
||||
|
||||
class TokenPayload(BaseModel):
|
||||
"""JWT token payload."""
|
||||
"""JWT token payload.
|
||||
|
||||
`wid` / `role` were added in Stage 0 PR4 and are optional at the
|
||||
model level so legacy 4-field tokens still parse into a value —
|
||||
callers (middleware, decode_token) decide what "missing wid" means.
|
||||
Post-PR4 production tokens always carry both fields.
|
||||
"""
|
||||
|
||||
sub: str # user_id
|
||||
wid: str | None = None # workspace_id (Stage 0 PR4)
|
||||
role: str | None = None # owner / admin / member (Stage 0 PR4)
|
||||
exp: datetime
|
||||
iat: datetime | None = None
|
||||
ver: int = 0 # token_version — must match User.token_version
|
||||
|
||||
|
||||
def create_access_token(user_id: str, expires_delta: timedelta | None = None, token_version: int = 0) -> str:
|
||||
def create_access_token(
|
||||
user_id: str,
|
||||
expires_delta: timedelta | None = None,
|
||||
token_version: int = 0,
|
||||
*,
|
||||
workspace_id: str | None = None,
|
||||
role: str | None = None,
|
||||
) -> str:
|
||||
"""Create a JWT access token.
|
||||
|
||||
Args:
|
||||
user_id: The user's UUID as string
|
||||
expires_delta: Optional custom expiry, defaults to 7 days
|
||||
token_version: User's current token_version for invalidation
|
||||
user_id: The user's UUID as string.
|
||||
expires_delta: Optional custom expiry, defaults to 7 days.
|
||||
token_version: User's current token_version for invalidation.
|
||||
workspace_id: Optional active workspace id; encoded as the ``wid`` claim.
|
||||
role: Optional workspace role; encoded as the ``role`` claim.
|
||||
|
||||
Returns:
|
||||
Encoded JWT string
|
||||
Encoded JWT string.
|
||||
"""
|
||||
config = get_auth_config()
|
||||
expiry = expires_delta or timedelta(days=config.token_expiry_days)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
payload = {"sub": user_id, "exp": now + expiry, "iat": now, "ver": token_version}
|
||||
payload: dict[str, Any] = {"sub": user_id, "exp": now + expiry, "iat": now, "ver": token_version}
|
||||
if workspace_id is not None:
|
||||
payload["wid"] = workspace_id
|
||||
if role is not None:
|
||||
payload["role"] = role
|
||||
return jwt.encode(payload, config.jwt_secret, algorithm="HS256")
|
||||
|
||||
|
||||
@@ -46,10 +68,20 @@ def decode_token(token: str) -> TokenPayload | TokenError:
|
||||
config = get_auth_config()
|
||||
try:
|
||||
payload = jwt.decode(token, config.jwt_secret, algorithms=["HS256"])
|
||||
return TokenPayload(**payload)
|
||||
except jwt.ExpiredSignatureError:
|
||||
return TokenError.EXPIRED
|
||||
except jwt.InvalidSignatureError:
|
||||
return TokenError.INVALID_SIGNATURE
|
||||
except jwt.PyJWTError:
|
||||
return TokenError.MALFORMED
|
||||
|
||||
# Reject legacy pre-PR4 tokens that lack the wid claim. Reported as
|
||||
# WORKSPACE_MISSING (not MALFORMED) so middleware can surface a
|
||||
# specific 401 telling the frontend to re-issue via /select-workspace.
|
||||
if "wid" not in payload or payload.get("wid") is None:
|
||||
return TokenError.WORKSPACE_MISSING
|
||||
|
||||
try:
|
||||
return TokenPayload(**payload)
|
||||
except Exception:
|
||||
return TokenError.MALFORMED
|
||||
|
||||
@@ -31,6 +31,12 @@ class User(BaseModel):
|
||||
needs_setup: bool = Field(default=False, description="True for auto-created admin until setup completes")
|
||||
token_version: int = Field(default=0, description="Incremented on password change to invalidate old JWTs")
|
||||
|
||||
# Workspace linkage (Stage 0 PR4)
|
||||
default_workspace_id: str | None = Field(
|
||||
default=None,
|
||||
description="The workspace the user lands in by default after login. NULL → /select-workspace.",
|
||||
)
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""Response model for user info endpoint."""
|
||||
@@ -39,3 +45,38 @@ class UserResponse(BaseModel):
|
||||
email: str
|
||||
system_role: Literal["admin", "user"]
|
||||
needs_setup: bool = False
|
||||
|
||||
|
||||
class UserMeWorkspace(BaseModel):
|
||||
"""One workspace entry in ``GET /auth/me`` (Stage 0 PR4)."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
slug: str
|
||||
role: str
|
||||
|
||||
|
||||
class UserMeResponse(BaseModel):
|
||||
"""Response model for ``GET /auth/me`` — extends UserResponse with workspaces."""
|
||||
|
||||
id: str
|
||||
email: str
|
||||
system_role: Literal["admin", "user"]
|
||||
needs_setup: bool = False
|
||||
default_workspace_id: str | None = None
|
||||
workspaces: list[UserMeWorkspace] = []
|
||||
|
||||
|
||||
class ActiveWorkspace(BaseModel):
|
||||
"""Lightweight workspace proxy injected into the request-scoped contextvar.
|
||||
|
||||
Implements the structural ``CurrentWorkspace`` protocol expected by
|
||||
``deerflow.runtime.workspace_context``: only ``.id`` (str) and
|
||||
``.role`` (str) are required. We intentionally do *not* embed the
|
||||
full ``WorkspaceRow`` here — the middleware needs to set the
|
||||
contextvar on every request and an extra DB lookup just to populate
|
||||
a name/slug we don't use yet would be wasted work.
|
||||
"""
|
||||
|
||||
id: str
|
||||
role: str
|
||||
|
||||
@@ -46,6 +46,7 @@ class SQLiteUserRepository(UserRepository):
|
||||
oauth_id=row.oauth_id,
|
||||
needs_setup=row.needs_setup,
|
||||
token_version=row.token_version,
|
||||
default_workspace_id=row.default_workspace_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -60,6 +61,7 @@ class SQLiteUserRepository(UserRepository):
|
||||
oauth_id=user.oauth_id,
|
||||
needs_setup=user.needs_setup,
|
||||
token_version=user.token_version,
|
||||
default_workspace_id=user.default_workspace_id,
|
||||
)
|
||||
|
||||
# ── CRUD ──────────────────────────────────────────────────────────
|
||||
@@ -106,6 +108,7 @@ class SQLiteUserRepository(UserRepository):
|
||||
row.oauth_id = user.oauth_id
|
||||
row.needs_setup = user.needs_setup
|
||||
row.token_version = user.token_version
|
||||
row.default_workspace_id = user.default_workspace_id
|
||||
await session.commit()
|
||||
return user
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Workspace slug helpers for the registration / initialize flow.
|
||||
|
||||
Stage 0 PR4 T4.10. Two responsibilities:
|
||||
|
||||
1. ``auto_slug_from_email(email)`` — pure transform from an email's
|
||||
local part to a base slug that matches the schema's
|
||||
``^[a-z0-9](-?[a-z0-9])*$`` pattern.
|
||||
2. ``next_available_slug(base, exists_check=...)`` — collision walker
|
||||
that appends ``-2``, ``-3``, … until ``exists_check`` reports the
|
||||
candidate is free. Kept separate from ``auto_slug_from_email`` so
|
||||
the pure function can be tested without a database.
|
||||
|
||||
Lives in the auth package (not in ``persistence``) because the input
|
||||
is the user's email — a registration-time concept that doesn't belong
|
||||
in a generic ``WorkspaceRepository``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import secrets
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
# Mirror the schema's slug rules from
|
||||
# ``deerflow.persistence.workspace.sql`` so callers of this module
|
||||
# never need to import private constants from persistence.
|
||||
_SLUG_MIN_LEN = 3
|
||||
_SLUG_MAX_LEN = 32
|
||||
|
||||
|
||||
def auto_slug_from_email(email: str) -> str:
|
||||
"""Map an email to a deterministic, schema-valid base slug.
|
||||
|
||||
Algorithm (from workspace-schema-design §3.1):
|
||||
|
||||
1. Take the local part (before ``@``).
|
||||
2. Replace ``+``, ``_``, ``.`` with ``-`` and lowercase.
|
||||
3. Strip everything that isn't ``[a-z0-9-]``.
|
||||
4. Collapse repeated ``-``; strip leading/trailing ``-``.
|
||||
5. Clamp to 32 chars.
|
||||
6. If the result is shorter than the schema minimum (3 chars) or
|
||||
empty, fall back to ``user-{token_hex(4)}`` so we always emit
|
||||
a valid slug.
|
||||
|
||||
The returned slug is the *base* — callers must run it through
|
||||
:func:`next_available_slug` before persisting to handle collisions.
|
||||
"""
|
||||
local = email.split("@", 1)[0]
|
||||
local = re.sub(r"[+_.]", "-", local).lower()
|
||||
local = re.sub(r"[^a-z0-9-]", "", local)
|
||||
local = re.sub(r"-+", "-", local).strip("-")
|
||||
slug = local[:_SLUG_MAX_LEN]
|
||||
if len(slug) < _SLUG_MIN_LEN:
|
||||
return f"user-{secrets.token_hex(4)}"
|
||||
return slug
|
||||
|
||||
|
||||
async def next_available_slug(
|
||||
base: str,
|
||||
*,
|
||||
exists_check: Callable[[str], Awaitable[bool]],
|
||||
) -> str:
|
||||
"""Return the first of ``base``, ``base-2``, ``base-3``, … that ``exists_check`` reports free.
|
||||
|
||||
Caller-supplied ``exists_check`` is awaited once per candidate so
|
||||
we can swap in a repository's ``get_by_slug`` without coupling
|
||||
this module to persistence imports.
|
||||
|
||||
When ``base + '-N'`` would exceed the 32-char schema limit, the
|
||||
base is truncated before the suffix is appended. The walker never
|
||||
returns an over-long slug.
|
||||
"""
|
||||
if not await exists_check(base):
|
||||
return base
|
||||
|
||||
n = 2
|
||||
while True:
|
||||
suffix = f"-{n}"
|
||||
max_base_len = _SLUG_MAX_LEN - len(suffix)
|
||||
candidate = f"{base[:max_base_len]}{suffix}"
|
||||
if not await exists_check(candidate):
|
||||
return candidate
|
||||
n += 1
|
||||
@@ -17,9 +17,11 @@ from starlette.responses import JSONResponse
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
|
||||
from app.gateway.auth.models import ActiveWorkspace
|
||||
from app.gateway.authz import _ALL_PERMISSIONS, AuthContext
|
||||
from app.gateway.internal_auth import INTERNAL_AUTH_HEADER_NAME, get_internal_user, is_valid_internal_auth_token
|
||||
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
||||
from deerflow.runtime.workspace_context import reset_current_workspace, set_current_workspace
|
||||
|
||||
# Paths that never require authentication.
|
||||
_PUBLIC_PATH_PREFIXES: tuple[str, ...] = (
|
||||
@@ -119,8 +121,22 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
# JWT-decode + DB-lookup pipeline a second time per request).
|
||||
request.state.user = user
|
||||
request.state.auth = AuthContext(user=user, permissions=_ALL_PERMISSIONS)
|
||||
token = set_current_user(user)
|
||||
user_token = set_current_user(user)
|
||||
|
||||
# Inject workspace contextvar from the JWT's wid/role claims.
|
||||
# decode_token has already rejected legacy no-wid tokens upstream,
|
||||
# so by the time we get here payload.wid is guaranteed non-None
|
||||
# for cookie-authenticated requests. Internal-auth requests skip
|
||||
# the workspace contextvar (they don't have a workspace scope —
|
||||
# the internal user is a system actor).
|
||||
ws_token = None
|
||||
payload = getattr(request.state, "auth_payload", None)
|
||||
if payload is not None and payload.wid is not None:
|
||||
ws_token = set_current_workspace(ActiveWorkspace(id=payload.wid, role=payload.role or "owner"))
|
||||
|
||||
try:
|
||||
return await call_next(request)
|
||||
finally:
|
||||
reset_current_user(token)
|
||||
if ws_token is not None:
|
||||
reset_current_workspace(ws_token)
|
||||
reset_current_user(user_token)
|
||||
|
||||
@@ -268,24 +268,27 @@ def require_permission(
|
||||
|
||||
# Owner check for thread-specific resources.
|
||||
#
|
||||
# 2.0-rc moved thread metadata into the SQL persistence layer
|
||||
# (``threads_meta`` table). We verify ownership via
|
||||
# ``ThreadMetaStore.check_access``: it returns True for
|
||||
# missing rows (untracked legacy thread) and for rows whose
|
||||
# ``user_id`` is NULL (shared / pre-auth data), so this is
|
||||
# strict-deny rather than strict-allow — only an *existing*
|
||||
# row with a *different* user_id triggers 404.
|
||||
# PR6: ``check_access`` now takes ``workspace_id`` as the third
|
||||
# positional argument; cross-workspace always denies regardless
|
||||
# of user_id match. We pull workspace_id from the contextvar
|
||||
# AuthMiddleware sets per request (and fall back to "default"
|
||||
# in no-auth dev mode so smoke flows keep working). Failures
|
||||
# convert to **404**, not 403, so the response never leaks the
|
||||
# existence of a thread that belongs to a different tenant.
|
||||
if owner_check:
|
||||
thread_id = kwargs.get("thread_id")
|
||||
if thread_id is None:
|
||||
raise ValueError("require_permission with owner_check=True requires 'thread_id' parameter")
|
||||
|
||||
from app.gateway.deps import get_thread_store
|
||||
from deerflow.runtime.workspace_context import get_effective_workspace_id
|
||||
|
||||
workspace_id = get_effective_workspace_id()
|
||||
thread_store = get_thread_store(request)
|
||||
allowed = await thread_store.check_access(
|
||||
thread_id,
|
||||
str(auth.user.id),
|
||||
workspace_id,
|
||||
require_existing=require_existing,
|
||||
)
|
||||
if not allowed:
|
||||
|
||||
@@ -220,6 +220,10 @@ async def get_current_user_from_request(request: Request):
|
||||
detail=AuthErrorResponse(code=AuthErrorCode.TOKEN_INVALID, message="Token revoked (password changed)").model_dump(),
|
||||
)
|
||||
|
||||
# Stash decoded payload on request.state so AuthMiddleware can read
|
||||
# wid/role for the workspace contextvar without a second decode.
|
||||
request.state.auth_payload = payload
|
||||
|
||||
return user
|
||||
|
||||
|
||||
|
||||
@@ -15,11 +15,60 @@ from app.gateway.auth import (
|
||||
)
|
||||
from app.gateway.auth.config import get_auth_config
|
||||
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
|
||||
from app.gateway.auth.models import UserMeResponse, UserMeWorkspace
|
||||
from app.gateway.auth.workspace_slug import auto_slug_from_email, next_available_slug
|
||||
from app.gateway.csrf_middleware import is_secure_request
|
||||
from app.gateway.deps import get_current_user_from_request, get_local_provider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def ensure_default_workspace(user) -> str:
|
||||
"""Create the user's personal workspace + owner membership, set default_workspace_id.
|
||||
|
||||
Returns the new workspace id. Idempotent for users who already
|
||||
have a default_workspace_id (used by both the registration flow
|
||||
and the lifespan backfill in app.py).
|
||||
"""
|
||||
from deerflow.persistence.engine import get_session_factory
|
||||
from deerflow.persistence.workspace import WorkspaceRepository
|
||||
from deerflow.persistence.workspace.sql import SLUG_BLACKLIST
|
||||
from deerflow.persistence.workspace_membership import WorkspaceMembershipRepository
|
||||
|
||||
if user.default_workspace_id:
|
||||
return user.default_workspace_id
|
||||
|
||||
sf = get_session_factory()
|
||||
ws_repo = WorkspaceRepository(sf)
|
||||
m_repo = WorkspaceMembershipRepository(sf)
|
||||
|
||||
base_slug = auto_slug_from_email(user.email)
|
||||
|
||||
async def slug_exists(s: str) -> bool:
|
||||
# Treat blacklisted slugs as "taken" so the walker skips them
|
||||
# instead of letting WorkspaceRepository.create raise after a
|
||||
# successful slug computation (the user picked a reserved name
|
||||
# like "admin@example.com" → base slug "admin").
|
||||
if s in SLUG_BLACKLIST:
|
||||
return True
|
||||
return (await ws_repo.get_by_slug(s)) is not None
|
||||
|
||||
unique_slug = await next_available_slug(base_slug, exists_check=slug_exists)
|
||||
|
||||
display_local = user.email.split("@", 1)[0]
|
||||
workspace = await ws_repo.create(
|
||||
name=f"{display_local}'s Workspace"[:64],
|
||||
slug=unique_slug,
|
||||
owner_id=str(user.id),
|
||||
)
|
||||
await m_repo.add(workspace_id=workspace["id"], user_id=str(user.id), role="owner")
|
||||
|
||||
user.default_workspace_id = workspace["id"]
|
||||
await get_local_provider().update_user(user)
|
||||
|
||||
return workspace["id"]
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
|
||||
|
||||
@@ -292,7 +341,15 @@ async def login_local(
|
||||
)
|
||||
|
||||
_record_login_success(client_ip)
|
||||
token = create_access_token(str(user.id), token_version=user.token_version)
|
||||
# Ensure the user has a workspace (covers pre-PR4 users still in DB
|
||||
# whose default_workspace_id was never backfilled by the lifespan hook).
|
||||
workspace_id = await ensure_default_workspace(user)
|
||||
token = create_access_token(
|
||||
str(user.id),
|
||||
token_version=user.token_version,
|
||||
workspace_id=workspace_id,
|
||||
role="owner",
|
||||
)
|
||||
_set_session_cookie(response, token, request)
|
||||
|
||||
return LoginResponse(
|
||||
@@ -316,7 +373,14 @@ async def register(request: Request, response: Response, body: RegisterRequest):
|
||||
detail=AuthErrorResponse(code=AuthErrorCode.EMAIL_ALREADY_EXISTS, message="Email already registered").model_dump(),
|
||||
)
|
||||
|
||||
token = create_access_token(str(user.id), token_version=user.token_version)
|
||||
workspace_id = await ensure_default_workspace(user)
|
||||
|
||||
token = create_access_token(
|
||||
str(user.id),
|
||||
token_version=user.token_version,
|
||||
workspace_id=workspace_id,
|
||||
role="owner",
|
||||
)
|
||||
_set_session_cookie(response, token, request)
|
||||
|
||||
return UserResponse(id=str(user.id), email=user.email, system_role=user.system_role)
|
||||
@@ -368,18 +432,57 @@ async def change_password(request: Request, response: Response, body: ChangePass
|
||||
|
||||
await provider.update_user(user)
|
||||
|
||||
# Re-issue cookie with new token_version
|
||||
token = create_access_token(str(user.id), token_version=user.token_version)
|
||||
# Re-issue cookie with new token_version. wid + role must be carried
|
||||
# forward so the re-signed JWT still passes the AuthMiddleware
|
||||
# workspace gate; ensure_default_workspace fills in for the (rare)
|
||||
# case where the user predates PR4 and has not been backfilled.
|
||||
workspace_id = await ensure_default_workspace(user)
|
||||
token = create_access_token(
|
||||
str(user.id),
|
||||
token_version=user.token_version,
|
||||
workspace_id=workspace_id,
|
||||
role="owner",
|
||||
)
|
||||
_set_session_cookie(response, token, request)
|
||||
|
||||
return MessageResponse(message="Password changed successfully")
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
@router.get("/me", response_model=UserMeResponse)
|
||||
async def get_me(request: Request):
|
||||
"""Get current authenticated user info."""
|
||||
"""Get current authenticated user info, including the workspaces they belong to."""
|
||||
from deerflow.persistence.engine import get_session_factory
|
||||
from deerflow.persistence.workspace import WorkspaceRepository
|
||||
from deerflow.persistence.workspace_membership import WorkspaceMembershipRepository
|
||||
|
||||
user = await get_current_user_from_request(request)
|
||||
return UserResponse(id=str(user.id), email=user.email, system_role=user.system_role, needs_setup=user.needs_setup)
|
||||
|
||||
sf = get_session_factory()
|
||||
workspaces: list[UserMeWorkspace] = []
|
||||
if sf is not None:
|
||||
ws_repo = WorkspaceRepository(sf)
|
||||
m_repo = WorkspaceMembershipRepository(sf)
|
||||
ws_rows = await ws_repo.list_by_user(user_id=str(user.id))
|
||||
memberships = await m_repo.list_by_user(user_id=str(user.id))
|
||||
role_by_ws = {m["workspace_id"]: m["role"] for m in memberships}
|
||||
workspaces = [
|
||||
UserMeWorkspace(
|
||||
id=w["id"],
|
||||
name=w["name"],
|
||||
slug=w["slug"],
|
||||
role=role_by_ws.get(w["id"], "member"),
|
||||
)
|
||||
for w in ws_rows
|
||||
]
|
||||
|
||||
return UserMeResponse(
|
||||
id=str(user.id),
|
||||
email=user.email,
|
||||
system_role=user.system_role,
|
||||
needs_setup=user.needs_setup,
|
||||
default_workspace_id=user.default_workspace_id,
|
||||
workspaces=workspaces,
|
||||
)
|
||||
|
||||
|
||||
_SETUP_STATUS_COOLDOWN: dict[str, float] = {}
|
||||
@@ -452,7 +555,14 @@ async def initialize_admin(request: Request, response: Response, body: Initializ
|
||||
detail=AuthErrorResponse(code=AuthErrorCode.SYSTEM_ALREADY_INITIALIZED, message="System already initialized").model_dump(),
|
||||
)
|
||||
|
||||
token = create_access_token(str(user.id), token_version=user.token_version)
|
||||
workspace_id = await ensure_default_workspace(user)
|
||||
|
||||
token = create_access_token(
|
||||
str(user.id),
|
||||
token_version=user.token_version,
|
||||
workspace_id=workspace_id,
|
||||
role="owner",
|
||||
)
|
||||
_set_session_cookie(response, token, request)
|
||||
|
||||
return UserResponse(id=str(user.id), email=user.email, system_role=user.system_role)
|
||||
|
||||
@@ -11,6 +11,7 @@ from langgraph.runtime import Runtime
|
||||
from deerflow.agents.thread_state import ThreadDataState
|
||||
from deerflow.config.paths import Paths, get_paths
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
from deerflow.runtime.workspace_context import get_effective_workspace_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -24,10 +25,15 @@ class ThreadDataMiddlewareState(AgentState):
|
||||
class ThreadDataMiddleware(AgentMiddleware[ThreadDataMiddlewareState]):
|
||||
"""Create thread data directories for each thread execution.
|
||||
|
||||
Creates the following directory structure:
|
||||
- {base_dir}/threads/{thread_id}/user-data/workspace
|
||||
- {base_dir}/threads/{thread_id}/user-data/uploads
|
||||
- {base_dir}/threads/{thread_id}/user-data/outputs
|
||||
PR6 routes thread storage through the workspace dimension. When a
|
||||
workspace contextvar is set (production via AuthMiddleware; tests via
|
||||
the autouse fixture), directories live at
|
||||
``{base_dir}/workspaces/{wid}/threads/{thread_id}/user-data/{workspace,uploads,outputs}``.
|
||||
In no-auth dev mode ``get_effective_workspace_id()`` returns
|
||||
``"default"`` so the layout stays valid; the ``user_id`` falls through
|
||||
to the same default constant. Either way thread state lives below a
|
||||
workspace bucket, never directly under ``{base_dir}/threads`` (legacy)
|
||||
or ``{base_dir}/users`` (PR4 layout).
|
||||
|
||||
Lifecycle Management:
|
||||
- With lazy_init=True (default): Only compute paths, directories created on-demand
|
||||
@@ -49,34 +55,18 @@ class ThreadDataMiddleware(AgentMiddleware[ThreadDataMiddlewareState]):
|
||||
self._paths = Paths(base_dir) if base_dir else get_paths()
|
||||
self._lazy_init = lazy_init
|
||||
|
||||
def _get_thread_paths(self, thread_id: str, user_id: str | None = None) -> dict[str, str]:
|
||||
"""Get the paths for a thread's data directories.
|
||||
|
||||
Args:
|
||||
thread_id: The thread ID.
|
||||
user_id: Optional user ID for per-user path isolation.
|
||||
|
||||
Returns:
|
||||
Dictionary with workspace_path, uploads_path, and outputs_path.
|
||||
"""
|
||||
def _get_thread_paths(self, thread_id: str, *, workspace_id: str, user_id: str) -> dict[str, str]:
|
||||
return {
|
||||
"workspace_path": str(self._paths.sandbox_work_dir(thread_id, user_id=user_id)),
|
||||
"uploads_path": str(self._paths.sandbox_uploads_dir(thread_id, user_id=user_id)),
|
||||
"outputs_path": str(self._paths.sandbox_outputs_dir(thread_id, user_id=user_id)),
|
||||
"workspace_path": str(self._paths.sandbox_work_dir(thread_id, workspace_id=workspace_id)),
|
||||
"uploads_path": str(self._paths.sandbox_uploads_dir(thread_id, workspace_id=workspace_id)),
|
||||
"outputs_path": str(self._paths.sandbox_outputs_dir(thread_id, workspace_id=workspace_id)),
|
||||
"user_id": user_id,
|
||||
"workspace_id": workspace_id,
|
||||
}
|
||||
|
||||
def _create_thread_directories(self, thread_id: str, user_id: str | None = None) -> dict[str, str]:
|
||||
"""Create the thread data directories.
|
||||
|
||||
Args:
|
||||
thread_id: The thread ID.
|
||||
user_id: Optional user ID for per-user path isolation.
|
||||
|
||||
Returns:
|
||||
Dictionary with the created directory paths.
|
||||
"""
|
||||
self._paths.ensure_thread_dirs(thread_id, user_id=user_id)
|
||||
return self._get_thread_paths(thread_id, user_id=user_id)
|
||||
def _create_thread_directories(self, thread_id: str, *, workspace_id: str, user_id: str) -> dict[str, str]:
|
||||
self._paths.ensure_thread_dirs(thread_id, workspace_id=workspace_id)
|
||||
return self._get_thread_paths(thread_id, workspace_id=workspace_id, user_id=user_id)
|
||||
|
||||
@override
|
||||
def before_agent(self, state: ThreadDataMiddlewareState, runtime: Runtime) -> dict | None:
|
||||
@@ -90,14 +80,15 @@ class ThreadDataMiddleware(AgentMiddleware[ThreadDataMiddlewareState]):
|
||||
raise ValueError("Thread ID is required in runtime context or config.configurable")
|
||||
|
||||
user_id = get_effective_user_id()
|
||||
workspace_id = get_effective_workspace_id()
|
||||
|
||||
if self._lazy_init:
|
||||
# Lazy initialization: only compute paths, don't create directories
|
||||
paths = self._get_thread_paths(thread_id, user_id=user_id)
|
||||
paths = self._get_thread_paths(thread_id, workspace_id=workspace_id, user_id=user_id)
|
||||
else:
|
||||
# Eager initialization: create directories immediately
|
||||
paths = self._create_thread_directories(thread_id, user_id=user_id)
|
||||
logger.debug("Created thread data directories for thread %s", thread_id)
|
||||
paths = self._create_thread_directories(thread_id, workspace_id=workspace_id, user_id=user_id)
|
||||
logger.debug("Created thread data directories for thread %s under workspace %s", thread_id, workspace_id)
|
||||
|
||||
messages = list(state.get("messages", []))
|
||||
last_message = messages[-1] if messages else None
|
||||
|
||||
@@ -10,6 +10,7 @@ VIRTUAL_PATH_PREFIX = "/mnt/user-data"
|
||||
|
||||
_SAFE_THREAD_ID_RE = re.compile(r"^[A-Za-z0-9_\-]+$")
|
||||
_SAFE_USER_ID_RE = re.compile(r"^[A-Za-z0-9_\-]+$")
|
||||
_SAFE_WORKSPACE_ID_RE = re.compile(r"^[A-Za-z0-9_\-]+$")
|
||||
|
||||
|
||||
def _default_local_base_dir() -> Path:
|
||||
@@ -31,6 +32,13 @@ def _validate_user_id(user_id: str) -> str:
|
||||
return user_id
|
||||
|
||||
|
||||
def _validate_workspace_id(workspace_id: str) -> str:
|
||||
"""Validate a workspace ID before using it in filesystem paths."""
|
||||
if not _SAFE_WORKSPACE_ID_RE.match(workspace_id):
|
||||
raise ValueError(f"Invalid workspace_id {workspace_id!r}: only alphanumeric characters, hyphens, and underscores are allowed.")
|
||||
return workspace_id
|
||||
|
||||
|
||||
def _join_host_path(base: str, *parts: str) -> str:
|
||||
"""Join host filesystem path segments while preserving native style.
|
||||
|
||||
@@ -148,116 +156,129 @@ class Paths:
|
||||
"""Legacy per-agent memory file: `{base_dir}/agents/{name}/memory.json`."""
|
||||
return self.agent_dir(name) / "memory.json"
|
||||
|
||||
def user_dir(self, user_id: str) -> Path:
|
||||
"""Directory for a specific user: `{base_dir}/users/{user_id}/`."""
|
||||
def workspace_dir(self, workspace_id: str) -> Path:
|
||||
"""Directory for a specific workspace: `{base_dir}/workspaces/{workspace_id}/`.
|
||||
|
||||
PR6 introduces this as the top-level isolation dimension. Per-user
|
||||
state and per-thread state both live underneath their workspace so
|
||||
a user with access to two workspaces never sees state bleed between
|
||||
them on the filesystem.
|
||||
"""
|
||||
return self.base_dir / "workspaces" / _validate_workspace_id(workspace_id)
|
||||
|
||||
def user_dir(self, user_id: str, *, workspace_id: str | None = None) -> Path:
|
||||
"""Directory for a specific user.
|
||||
|
||||
When ``workspace_id`` is provided (PR6+):
|
||||
``{base_dir}/workspaces/{wid}/users/{user_id}/``
|
||||
|
||||
Otherwise (legacy layout):
|
||||
``{base_dir}/users/{user_id}/``
|
||||
"""
|
||||
if workspace_id is not None:
|
||||
return self.workspace_dir(workspace_id) / "users" / _validate_user_id(user_id)
|
||||
return self.base_dir / "users" / _validate_user_id(user_id)
|
||||
|
||||
def user_memory_file(self, user_id: str) -> Path:
|
||||
"""Per-user memory file: `{base_dir}/users/{user_id}/memory.json`."""
|
||||
return self.user_dir(user_id) / "memory.json"
|
||||
def user_memory_file(self, user_id: str, *, workspace_id: str | None = None) -> Path:
|
||||
"""Per-user memory file under the active workspace (legacy without)."""
|
||||
return self.user_dir(user_id, workspace_id=workspace_id) / "memory.json"
|
||||
|
||||
def user_agents_dir(self, user_id: str) -> Path:
|
||||
"""Per-user root for that user's custom agents: `{base_dir}/users/{user_id}/agents/`."""
|
||||
return self.user_dir(user_id) / "agents"
|
||||
def user_agents_dir(self, user_id: str, *, workspace_id: str | None = None) -> Path:
|
||||
"""Per-user root for custom agents under the active workspace."""
|
||||
return self.user_dir(user_id, workspace_id=workspace_id) / "agents"
|
||||
|
||||
def user_agent_dir(self, user_id: str, agent_name: str) -> Path:
|
||||
"""Per-user per-agent directory: `{base_dir}/users/{user_id}/agents/{name}/`."""
|
||||
return self.user_agents_dir(user_id) / agent_name.lower()
|
||||
def user_agent_dir(self, user_id: str, agent_name: str, *, workspace_id: str | None = None) -> Path:
|
||||
"""Per-user per-agent directory under the active workspace."""
|
||||
return self.user_agents_dir(user_id, workspace_id=workspace_id) / agent_name.lower()
|
||||
|
||||
def user_agent_memory_file(self, user_id: str, agent_name: str) -> Path:
|
||||
"""Per-user per-agent memory: `{base_dir}/users/{user_id}/agents/{name}/memory.json`."""
|
||||
return self.user_agent_dir(user_id, agent_name) / "memory.json"
|
||||
def user_agent_memory_file(self, user_id: str, agent_name: str, *, workspace_id: str | None = None) -> Path:
|
||||
"""Per-user per-agent memory file under the active workspace."""
|
||||
return self.user_agent_dir(user_id, agent_name, workspace_id=workspace_id) / "memory.json"
|
||||
|
||||
def thread_dir(self, thread_id: str, *, user_id: str | None = None) -> Path:
|
||||
def thread_dir(self, thread_id: str, *, workspace_id: str | None = None, user_id: str | None = None) -> Path:
|
||||
"""
|
||||
Host path for a thread's data.
|
||||
|
||||
When *user_id* is provided:
|
||||
`{base_dir}/users/{user_id}/threads/{thread_id}/`
|
||||
Otherwise (legacy layout):
|
||||
`{base_dir}/threads/{thread_id}/`
|
||||
Precedence — workspace beats user, both beat legacy:
|
||||
|
||||
This directory contains a `user-data/` subdirectory that is mounted
|
||||
as `/mnt/user-data/` inside the sandbox.
|
||||
* ``workspace_id`` given (PR6+):
|
||||
``{base_dir}/workspaces/{wid}/threads/{thread_id}/``
|
||||
* ``user_id`` only (legacy after user-isolation migration):
|
||||
``{base_dir}/users/{user_id}/threads/{thread_id}/``
|
||||
* neither (very legacy, pre-isolation):
|
||||
``{base_dir}/threads/{thread_id}/``
|
||||
|
||||
The contained ``user-data/`` subdirectory is mounted as
|
||||
``/mnt/user-data/`` inside the sandbox regardless of which form is
|
||||
chosen — only the host-side parent differs.
|
||||
|
||||
Raises:
|
||||
ValueError: If `thread_id` or `user_id` contains unsafe characters (path
|
||||
separators or `..`) that could cause directory traversal.
|
||||
ValueError: If any of the supplied ids contains unsafe characters.
|
||||
"""
|
||||
if workspace_id is not None:
|
||||
return self.workspace_dir(workspace_id) / "threads" / _validate_thread_id(thread_id)
|
||||
if user_id is not None:
|
||||
return self.user_dir(user_id) / "threads" / _validate_thread_id(thread_id)
|
||||
return self.base_dir / "users" / _validate_user_id(user_id) / "threads" / _validate_thread_id(thread_id)
|
||||
return self.base_dir / "threads" / _validate_thread_id(thread_id)
|
||||
|
||||
def sandbox_work_dir(self, thread_id: str, *, user_id: str | None = None) -> Path:
|
||||
def sandbox_work_dir(self, thread_id: str, *, workspace_id: str | None = None, user_id: str | None = None) -> Path:
|
||||
"""
|
||||
Host path for the agent's workspace directory.
|
||||
Host: `{base_dir}/threads/{thread_id}/user-data/workspace/`
|
||||
Sandbox: `/mnt/user-data/workspace/`
|
||||
Host: ``{thread_dir}/user-data/workspace/``
|
||||
Sandbox: ``/mnt/user-data/workspace/``
|
||||
"""
|
||||
return self.thread_dir(thread_id, user_id=user_id) / "user-data" / "workspace"
|
||||
return self.thread_dir(thread_id, workspace_id=workspace_id, user_id=user_id) / "user-data" / "workspace"
|
||||
|
||||
def sandbox_uploads_dir(self, thread_id: str, *, user_id: str | None = None) -> Path:
|
||||
"""
|
||||
Host path for user-uploaded files.
|
||||
Host: `{base_dir}/threads/{thread_id}/user-data/uploads/`
|
||||
Sandbox: `/mnt/user-data/uploads/`
|
||||
"""
|
||||
return self.thread_dir(thread_id, user_id=user_id) / "user-data" / "uploads"
|
||||
def sandbox_uploads_dir(self, thread_id: str, *, workspace_id: str | None = None, user_id: str | None = None) -> Path:
|
||||
"""Host path for user-uploaded files; sandbox: ``/mnt/user-data/uploads/``."""
|
||||
return self.thread_dir(thread_id, workspace_id=workspace_id, user_id=user_id) / "user-data" / "uploads"
|
||||
|
||||
def sandbox_outputs_dir(self, thread_id: str, *, user_id: str | None = None) -> Path:
|
||||
"""
|
||||
Host path for agent-generated artifacts.
|
||||
Host: `{base_dir}/threads/{thread_id}/user-data/outputs/`
|
||||
Sandbox: `/mnt/user-data/outputs/`
|
||||
"""
|
||||
return self.thread_dir(thread_id, user_id=user_id) / "user-data" / "outputs"
|
||||
def sandbox_outputs_dir(self, thread_id: str, *, workspace_id: str | None = None, user_id: str | None = None) -> Path:
|
||||
"""Host path for agent-generated artifacts; sandbox: ``/mnt/user-data/outputs/``."""
|
||||
return self.thread_dir(thread_id, workspace_id=workspace_id, user_id=user_id) / "user-data" / "outputs"
|
||||
|
||||
def acp_workspace_dir(self, thread_id: str, *, user_id: str | None = None) -> Path:
|
||||
def acp_workspace_dir(self, thread_id: str, *, workspace_id: str | None = None, user_id: str | None = None) -> Path:
|
||||
"""
|
||||
Host path for the ACP workspace of a specific thread.
|
||||
Host: `{base_dir}/threads/{thread_id}/acp-workspace/`
|
||||
Sandbox: `/mnt/acp-workspace/`
|
||||
Host path for the ACP workspace of a specific thread; sandbox: ``/mnt/acp-workspace/``.
|
||||
|
||||
Each thread gets its own isolated ACP workspace so that concurrent
|
||||
sessions cannot read each other's ACP agent outputs.
|
||||
"""
|
||||
return self.thread_dir(thread_id, user_id=user_id) / "acp-workspace"
|
||||
return self.thread_dir(thread_id, workspace_id=workspace_id, user_id=user_id) / "acp-workspace"
|
||||
|
||||
def sandbox_user_data_dir(self, thread_id: str, *, user_id: str | None = None) -> Path:
|
||||
"""
|
||||
Host path for the user-data root.
|
||||
Host: `{base_dir}/threads/{thread_id}/user-data/`
|
||||
Sandbox: `/mnt/user-data/`
|
||||
"""
|
||||
return self.thread_dir(thread_id, user_id=user_id) / "user-data"
|
||||
def sandbox_user_data_dir(self, thread_id: str, *, workspace_id: str | None = None, user_id: str | None = None) -> Path:
|
||||
"""Host path for the user-data root; sandbox: ``/mnt/user-data/``."""
|
||||
return self.thread_dir(thread_id, workspace_id=workspace_id, user_id=user_id) / "user-data"
|
||||
|
||||
def host_thread_dir(self, thread_id: str, *, user_id: str | None = None) -> str:
|
||||
def host_thread_dir(self, thread_id: str, *, workspace_id: str | None = None, user_id: str | None = None) -> str:
|
||||
"""Host path for a thread directory, preserving Windows path syntax."""
|
||||
if workspace_id is not None:
|
||||
return _join_host_path(self._host_base_dir_str(), "workspaces", _validate_workspace_id(workspace_id), "threads", _validate_thread_id(thread_id))
|
||||
if user_id is not None:
|
||||
return _join_host_path(self._host_base_dir_str(), "users", _validate_user_id(user_id), "threads", _validate_thread_id(thread_id))
|
||||
return _join_host_path(self._host_base_dir_str(), "threads", _validate_thread_id(thread_id))
|
||||
|
||||
def host_sandbox_user_data_dir(self, thread_id: str, *, user_id: str | None = None) -> str:
|
||||
def host_sandbox_user_data_dir(self, thread_id: str, *, workspace_id: str | None = None, user_id: str | None = None) -> str:
|
||||
"""Host path for a thread's user-data root."""
|
||||
return _join_host_path(self.host_thread_dir(thread_id, user_id=user_id), "user-data")
|
||||
return _join_host_path(self.host_thread_dir(thread_id, workspace_id=workspace_id, user_id=user_id), "user-data")
|
||||
|
||||
def host_sandbox_work_dir(self, thread_id: str, *, user_id: str | None = None) -> str:
|
||||
def host_sandbox_work_dir(self, thread_id: str, *, workspace_id: str | None = None, user_id: str | None = None) -> str:
|
||||
"""Host path for the workspace mount source."""
|
||||
return _join_host_path(self.host_sandbox_user_data_dir(thread_id, user_id=user_id), "workspace")
|
||||
return _join_host_path(self.host_sandbox_user_data_dir(thread_id, workspace_id=workspace_id, user_id=user_id), "workspace")
|
||||
|
||||
def host_sandbox_uploads_dir(self, thread_id: str, *, user_id: str | None = None) -> str:
|
||||
def host_sandbox_uploads_dir(self, thread_id: str, *, workspace_id: str | None = None, user_id: str | None = None) -> str:
|
||||
"""Host path for the uploads mount source."""
|
||||
return _join_host_path(self.host_sandbox_user_data_dir(thread_id, user_id=user_id), "uploads")
|
||||
return _join_host_path(self.host_sandbox_user_data_dir(thread_id, workspace_id=workspace_id, user_id=user_id), "uploads")
|
||||
|
||||
def host_sandbox_outputs_dir(self, thread_id: str, *, user_id: str | None = None) -> str:
|
||||
def host_sandbox_outputs_dir(self, thread_id: str, *, workspace_id: str | None = None, user_id: str | None = None) -> str:
|
||||
"""Host path for the outputs mount source."""
|
||||
return _join_host_path(self.host_sandbox_user_data_dir(thread_id, user_id=user_id), "outputs")
|
||||
return _join_host_path(self.host_sandbox_user_data_dir(thread_id, workspace_id=workspace_id, user_id=user_id), "outputs")
|
||||
|
||||
def host_acp_workspace_dir(self, thread_id: str, *, user_id: str | None = None) -> str:
|
||||
def host_acp_workspace_dir(self, thread_id: str, *, workspace_id: str | None = None, user_id: str | None = None) -> str:
|
||||
"""Host path for the ACP workspace mount source."""
|
||||
return _join_host_path(self.host_thread_dir(thread_id, user_id=user_id), "acp-workspace")
|
||||
return _join_host_path(self.host_thread_dir(thread_id, workspace_id=workspace_id, user_id=user_id), "acp-workspace")
|
||||
|
||||
def ensure_thread_dirs(self, thread_id: str, *, user_id: str | None = None) -> None:
|
||||
def ensure_thread_dirs(self, thread_id: str, *, workspace_id: str | None = None, user_id: str | None = None) -> None:
|
||||
"""Create all standard sandbox directories for a thread.
|
||||
|
||||
Directories are created with mode 0o777 so that sandbox containers
|
||||
@@ -271,24 +292,28 @@ class Paths:
|
||||
ACP agent invocation.
|
||||
"""
|
||||
for d in [
|
||||
self.sandbox_work_dir(thread_id, user_id=user_id),
|
||||
self.sandbox_uploads_dir(thread_id, user_id=user_id),
|
||||
self.sandbox_outputs_dir(thread_id, user_id=user_id),
|
||||
self.acp_workspace_dir(thread_id, user_id=user_id),
|
||||
self.sandbox_work_dir(thread_id, workspace_id=workspace_id, user_id=user_id),
|
||||
self.sandbox_uploads_dir(thread_id, workspace_id=workspace_id, user_id=user_id),
|
||||
self.sandbox_outputs_dir(thread_id, workspace_id=workspace_id, user_id=user_id),
|
||||
self.acp_workspace_dir(thread_id, workspace_id=workspace_id, user_id=user_id),
|
||||
]:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
d.chmod(0o777)
|
||||
|
||||
def delete_thread_dir(self, thread_id: str, *, user_id: str | None = None) -> None:
|
||||
"""Delete all persisted data for a thread.
|
||||
|
||||
The operation is idempotent: missing thread directories are ignored.
|
||||
"""
|
||||
thread_dir = self.thread_dir(thread_id, user_id=user_id)
|
||||
def delete_thread_dir(self, thread_id: str, *, workspace_id: str | None = None, user_id: str | None = None) -> None:
|
||||
"""Delete all persisted data for a thread. Idempotent."""
|
||||
thread_dir = self.thread_dir(thread_id, workspace_id=workspace_id, user_id=user_id)
|
||||
if thread_dir.exists():
|
||||
shutil.rmtree(thread_dir)
|
||||
|
||||
def resolve_virtual_path(self, thread_id: str, virtual_path: str, *, user_id: str | None = None) -> Path:
|
||||
def resolve_virtual_path(
|
||||
self,
|
||||
thread_id: str,
|
||||
virtual_path: str,
|
||||
*,
|
||||
workspace_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
) -> Path:
|
||||
"""Resolve a sandbox virtual path to the actual host filesystem path.
|
||||
|
||||
Args:
|
||||
@@ -296,7 +321,8 @@ class Paths:
|
||||
virtual_path: Virtual path as seen inside the sandbox, e.g.
|
||||
``/mnt/user-data/outputs/report.pdf``.
|
||||
Leading slashes are stripped before matching.
|
||||
user_id: Optional user ID for user-scoped path resolution.
|
||||
workspace_id: Optional workspace ID for workspace-scoped resolution.
|
||||
user_id: Optional user ID for legacy user-scoped resolution.
|
||||
|
||||
Returns:
|
||||
The resolved absolute host filesystem path.
|
||||
@@ -314,7 +340,7 @@ class Paths:
|
||||
raise ValueError(f"Path must start with /{prefix}")
|
||||
|
||||
relative = stripped[len(prefix) :].lstrip("/")
|
||||
base = self.sandbox_user_data_dir(thread_id, user_id=user_id).resolve()
|
||||
base = self.sandbox_user_data_dir(thread_id, workspace_id=workspace_id, user_id=user_id).resolve()
|
||||
actual = (base / relative).resolve()
|
||||
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""API key persistence — ORM model only (Stage 0 PR8).
|
||||
|
||||
An API key is the credential a service_account uses to call the
|
||||
headless API. Each key has a public ``key_prefix`` (printed in audit
|
||||
logs and used for quick lookup) and a ``key_hash`` (sha-256 of the
|
||||
plaintext token, never reversed). Plaintext tokens are only ever
|
||||
returned to the caller at create time.
|
||||
|
||||
PR8 introduces only the schema + ORM row class. Token generation,
|
||||
hashing, scope parsing, rate limiting, and the API-key auth
|
||||
middleware live in Stage 1 alongside the headless API surface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from deerflow.persistence.api_key.model import ApiKeyRow
|
||||
|
||||
__all__ = ["ApiKeyRow"]
|
||||
@@ -0,0 +1,94 @@
|
||||
"""ORM model for API keys (credentials owned by a service account)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
|
||||
|
||||
class ApiKeyRow(Base):
|
||||
__tablename__ = "api_keys"
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
primary_key=True,
|
||||
comment="API key 主键,UUID 字符串(36 字符)",
|
||||
)
|
||||
service_account_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("service_accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="所属 service_account;service_account 删除时级联清掉所有 api_key",
|
||||
)
|
||||
key_prefix: Mapped[str] = mapped_column(
|
||||
String(16),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
comment="公开 prefix(如 'dfk_live_abc12345'),可在审计日志 / UI 中打印;全局唯一,撤销后亦不复用以避免审计混淆",
|
||||
)
|
||||
key_hash: Mapped[str] = mapped_column(
|
||||
String(128),
|
||||
nullable=False,
|
||||
comment="完整 token 的 sha-256 hex(64 字符;预留 128 以兼容未来更长哈希),plaintext token 仅在创建时返回给调用方",
|
||||
)
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
nullable=False,
|
||||
comment="key 的人类可读标签(如 'ci pipeline' / 'frontend prod'),同 service_account 内不强制唯一",
|
||||
)
|
||||
scopes: Mapped[str] = mapped_column(
|
||||
String(1024),
|
||||
nullable=False,
|
||||
default="",
|
||||
comment="scope 列表,逗号分隔字符串(如 'threads:read,threads:write');用 String 而非 PG text[] 以保 SQLite dev 兼容,Stage 2 切纯 PG 后可平滑迁",
|
||||
)
|
||||
rate_limit_rpm: Mapped[int | None] = mapped_column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
comment="每分钟请求数限制;NULL 表示走该 service_account 的默认限速(Stage 1 起生效)",
|
||||
)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment="过期时间(UTC);NULL = 不过期",
|
||||
)
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment="最近一次成功鉴权时间(UTC);Stage 1 鉴权中间件每次更新",
|
||||
)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment="撤销时间(UTC);NULL = 仍然有效。被撤销的 key 不删行(保留审计),但鉴权层据此拒绝",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
comment="创建时间(UTC)",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_api_keys_sa", "service_account_id"),
|
||||
# 部分索引:只索引活跃 key(revoked_at IS NULL),鉴权热路径走 prefix lookup,
|
||||
# 撤销后的 key 不进活跃索引以减小热索引大小。SQLite + Postgres 均支持
|
||||
# WHERE 子句的部分索引;双驱动维护两套等价 where 表达式。
|
||||
Index(
|
||||
"idx_api_keys_active",
|
||||
"key_prefix",
|
||||
sqlite_where=text("revoked_at IS NULL"),
|
||||
postgresql_where=text("revoked_at IS NULL"),
|
||||
),
|
||||
{
|
||||
"comment": (
|
||||
"API key 表(headless API 凭证)。每行属于唯一 service_account;key_prefix 全局唯一可在日志中打印,"
|
||||
"key_hash 是完整 token 的 sha-256,plaintext token 只在创建时返给调用方。撤销保留行(revoked_at 非空),"
|
||||
"活跃 key 走部分索引 idx_api_keys_active 加速鉴权热路径。Stage 0 仅落 schema;Stage 1 起接鉴权 + 限速。"
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
"""External user persistence — ORM model only (Stage 0 PR8).
|
||||
|
||||
An external user represents the end-user identity that a
|
||||
service_account passes through on each call (typically via an
|
||||
``X-External-User-Id`` header). The row is upserted each time a
|
||||
new ``external_id`` is seen under a given service_account.
|
||||
|
||||
PR8 introduces only the schema + ORM row class. The upsert logic,
|
||||
header parsing, and quota attribution all live in Stage 1.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from deerflow.persistence.external_user.model import ExternalUserRow
|
||||
|
||||
__all__ = ["ExternalUserRow"]
|
||||
@@ -0,0 +1,71 @@
|
||||
"""ORM model for external users (end-user identities passed through a service account)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import JSON, DateTime, ForeignKey, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
|
||||
|
||||
class ExternalUserRow(Base):
|
||||
__tablename__ = "external_users"
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
primary_key=True,
|
||||
comment="external_user 主键,UUID 字符串(36 字符)",
|
||||
)
|
||||
workspace_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("workspaces.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="所属 workspace(冗余存储——可经 service_account 间接得到,但直接存以加速 workspace-scope 查询);workspace 删除时级联",
|
||||
)
|
||||
service_account_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("service_accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="passthrough 的 service_account;service_account 删除时级联",
|
||||
)
|
||||
external_id: Mapped[str] = mapped_column(
|
||||
String(128),
|
||||
nullable=False,
|
||||
comment="终端调用方传入的 X-External-User-Id(最多 128 字符;推荐 UUID / opaque token,不要塞 PII)",
|
||||
)
|
||||
display_name: Mapped[str | None] = mapped_column(
|
||||
String(128),
|
||||
nullable=True,
|
||||
comment="可选显示名(如 'alice@customer.com');仅用于 admin UI 展示,不参与鉴权",
|
||||
)
|
||||
metadata_json: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON,
|
||||
nullable=False,
|
||||
default=dict,
|
||||
comment="任意 JSON 附属信息(plan tier / region / 自定义 tag);Stage 1 由 upsert 调用方写入",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
comment="首次见到该 external_id 的时间(UTC)",
|
||||
)
|
||||
last_seen_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment="最近一次该 external_id 触发请求的时间(UTC);Stage 1 鉴权层每次更新",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("service_account_id", "external_id", name="uq_external_users_sa_external"),
|
||||
{
|
||||
"comment": (
|
||||
"终端用户身份表(passthrough 模式下的 end-user)。每行由 service_account 的鉴权中间件 upsert——同一 "
|
||||
"(service_account_id, external_id) 组合只存一行。workspace_id 冗余存储以加速跨 SA 的 workspace-scope 聚合查询。"
|
||||
"Stage 0 仅落 schema;Stage 1 起接 upsert / 配额聚合。"
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import DateTime, String, Text, UniqueConstraint
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
@@ -13,20 +13,22 @@ from deerflow.persistence.base import Base
|
||||
class FeedbackRow(Base):
|
||||
__tablename__ = "feedback"
|
||||
|
||||
__table_args__ = (UniqueConstraint("thread_id", "run_id", "user_id", name="uq_feedback_thread_run_user"),)
|
||||
__table_args__ = (
|
||||
UniqueConstraint("thread_id", "run_id", "user_id", name="uq_feedback_thread_run_user"),
|
||||
{"comment": "用户对运行结果的反馈(点赞/点踩 + 文字评论),(thread, run, user) 唯一"},
|
||||
)
|
||||
|
||||
feedback_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
run_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
thread_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
user_id: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
message_id: Mapped[str | None] = mapped_column(String(64))
|
||||
# message_id is an optional RunEventStore event identifier —
|
||||
# allows feedback to target a specific message or the entire run
|
||||
|
||||
rating: Mapped[int] = mapped_column(nullable=False)
|
||||
# +1 (thumbs-up) or -1 (thumbs-down)
|
||||
|
||||
comment: Mapped[str | None] = mapped_column(Text)
|
||||
# Optional text feedback from the user
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
feedback_id: Mapped[str] = mapped_column(String(64), primary_key=True, comment="反馈主键")
|
||||
run_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="关联的运行 ID(runs.run_id)")
|
||||
thread_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="关联的会话 ID(threads_meta.thread_id)")
|
||||
user_id: Mapped[str | None] = mapped_column(String(64), index=True, comment="反馈作者;为 NULL 表示历史无主数据")
|
||||
workspace_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("workspaces.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="所属 workspace。PR5 引入时 nullable 用于回填;alembic 0003 + PR6 仓储接入完成后 NOT NULL",
|
||||
)
|
||||
message_id: Mapped[str | None] = mapped_column(String(64), comment="可选的 RunEventStore 事件 ID;为 NULL 表示针对整次运行而非单条消息")
|
||||
rating: Mapped[int] = mapped_column(nullable=False, comment="评分:+1 点赞,-1 点踩")
|
||||
comment: Mapped[str | None] = mapped_column(Text, comment="可选的文字评论")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), comment="创建时间(UTC)")
|
||||
|
||||
@@ -13,6 +13,13 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.feedback.model import FeedbackRow
|
||||
from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id
|
||||
from deerflow.runtime.workspace_context import AUTO as WORKSPACE_AUTO
|
||||
from deerflow.runtime.workspace_context import (
|
||||
_AutoSentinel as _WorkspaceAutoSentinel,
|
||||
)
|
||||
from deerflow.runtime.workspace_context import (
|
||||
resolve_workspace_id,
|
||||
)
|
||||
|
||||
|
||||
class FeedbackRepository:
|
||||
@@ -34,6 +41,7 @@ class FeedbackRepository:
|
||||
thread_id: str,
|
||||
rating: int,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
message_id: str | None = None,
|
||||
comment: str | None = None,
|
||||
) -> dict:
|
||||
@@ -41,11 +49,13 @@ class FeedbackRepository:
|
||||
if rating not in (1, -1):
|
||||
raise ValueError(f"rating must be +1 or -1, got {rating}")
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="FeedbackRepository.create")
|
||||
resolved_workspace_id = resolve_workspace_id(workspace_id, method_name="FeedbackRepository.create")
|
||||
row = FeedbackRow(
|
||||
feedback_id=str(uuid.uuid4()),
|
||||
run_id=run_id,
|
||||
thread_id=thread_id,
|
||||
user_id=resolved_user_id,
|
||||
workspace_id=resolved_workspace_id,
|
||||
message_id=message_id,
|
||||
rating=rating,
|
||||
comment=comment,
|
||||
@@ -62,12 +72,16 @@ class FeedbackRepository:
|
||||
feedback_id: str,
|
||||
*,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
) -> dict | None:
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="FeedbackRepository.get")
|
||||
resolved_workspace_id = resolve_workspace_id(workspace_id, method_name="FeedbackRepository.get")
|
||||
async with self._sf() as session:
|
||||
row = await session.get(FeedbackRow, feedback_id)
|
||||
if row is None:
|
||||
return None
|
||||
if resolved_workspace_id is not None and row.workspace_id != resolved_workspace_id:
|
||||
return None
|
||||
if resolved_user_id is not None and row.user_id != resolved_user_id:
|
||||
return None
|
||||
return self._row_to_dict(row)
|
||||
@@ -79,9 +93,13 @@ class FeedbackRepository:
|
||||
*,
|
||||
limit: int = 100,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
) -> list[dict]:
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="FeedbackRepository.list_by_run")
|
||||
resolved_workspace_id = resolve_workspace_id(workspace_id, method_name="FeedbackRepository.list_by_run")
|
||||
stmt = select(FeedbackRow).where(FeedbackRow.thread_id == thread_id, FeedbackRow.run_id == run_id)
|
||||
if resolved_workspace_id is not None:
|
||||
stmt = stmt.where(FeedbackRow.workspace_id == resolved_workspace_id)
|
||||
if resolved_user_id is not None:
|
||||
stmt = stmt.where(FeedbackRow.user_id == resolved_user_id)
|
||||
stmt = stmt.order_by(FeedbackRow.created_at.asc()).limit(limit)
|
||||
@@ -95,9 +113,13 @@ class FeedbackRepository:
|
||||
*,
|
||||
limit: int = 100,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
) -> list[dict]:
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="FeedbackRepository.list_by_thread")
|
||||
resolved_workspace_id = resolve_workspace_id(workspace_id, method_name="FeedbackRepository.list_by_thread")
|
||||
stmt = select(FeedbackRow).where(FeedbackRow.thread_id == thread_id)
|
||||
if resolved_workspace_id is not None:
|
||||
stmt = stmt.where(FeedbackRow.workspace_id == resolved_workspace_id)
|
||||
if resolved_user_id is not None:
|
||||
stmt = stmt.where(FeedbackRow.user_id == resolved_user_id)
|
||||
stmt = stmt.order_by(FeedbackRow.created_at.asc()).limit(limit)
|
||||
@@ -110,12 +132,16 @@ class FeedbackRepository:
|
||||
feedback_id: str,
|
||||
*,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
) -> bool:
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="FeedbackRepository.delete")
|
||||
resolved_workspace_id = resolve_workspace_id(workspace_id, method_name="FeedbackRepository.delete")
|
||||
async with self._sf() as session:
|
||||
row = await session.get(FeedbackRow, feedback_id)
|
||||
if row is None:
|
||||
return False
|
||||
if resolved_workspace_id is not None and row.workspace_id != resolved_workspace_id:
|
||||
return False
|
||||
if resolved_user_id is not None and row.user_id != resolved_user_id:
|
||||
return False
|
||||
await session.delete(row)
|
||||
@@ -129,18 +155,22 @@ class FeedbackRepository:
|
||||
thread_id: str,
|
||||
rating: int,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
comment: str | None = None,
|
||||
) -> dict:
|
||||
"""Create or update feedback for (thread_id, run_id, user_id). rating must be +1 or -1."""
|
||||
if rating not in (1, -1):
|
||||
raise ValueError(f"rating must be +1 or -1, got {rating}")
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="FeedbackRepository.upsert")
|
||||
resolved_workspace_id = resolve_workspace_id(workspace_id, method_name="FeedbackRepository.upsert")
|
||||
async with self._sf() as session:
|
||||
stmt = select(FeedbackRow).where(
|
||||
FeedbackRow.thread_id == thread_id,
|
||||
FeedbackRow.run_id == run_id,
|
||||
FeedbackRow.user_id == resolved_user_id,
|
||||
)
|
||||
if resolved_workspace_id is not None:
|
||||
stmt = stmt.where(FeedbackRow.workspace_id == resolved_workspace_id)
|
||||
result = await session.execute(stmt)
|
||||
row = result.scalar_one_or_none()
|
||||
if row is not None:
|
||||
@@ -153,6 +183,7 @@ class FeedbackRepository:
|
||||
run_id=run_id,
|
||||
thread_id=thread_id,
|
||||
user_id=resolved_user_id,
|
||||
workspace_id=resolved_workspace_id,
|
||||
rating=rating,
|
||||
comment=comment,
|
||||
created_at=datetime.now(UTC),
|
||||
@@ -168,15 +199,19 @@ class FeedbackRepository:
|
||||
thread_id: str,
|
||||
run_id: str,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
) -> bool:
|
||||
"""Delete the current user's feedback for a run. Returns True if a record was deleted."""
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="FeedbackRepository.delete_by_run")
|
||||
resolved_workspace_id = resolve_workspace_id(workspace_id, method_name="FeedbackRepository.delete_by_run")
|
||||
async with self._sf() as session:
|
||||
stmt = select(FeedbackRow).where(
|
||||
FeedbackRow.thread_id == thread_id,
|
||||
FeedbackRow.run_id == run_id,
|
||||
FeedbackRow.user_id == resolved_user_id,
|
||||
)
|
||||
if resolved_workspace_id is not None:
|
||||
stmt = stmt.where(FeedbackRow.workspace_id == resolved_workspace_id)
|
||||
result = await session.execute(stmt)
|
||||
row = result.scalar_one_or_none()
|
||||
if row is None:
|
||||
@@ -190,10 +225,14 @@ class FeedbackRepository:
|
||||
thread_id: str,
|
||||
*,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
) -> dict[str, dict]:
|
||||
"""Return feedback grouped by run_id for a thread: {run_id: feedback_dict}."""
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="FeedbackRepository.list_by_thread_grouped")
|
||||
resolved_workspace_id = resolve_workspace_id(workspace_id, method_name="FeedbackRepository.list_by_thread_grouped")
|
||||
stmt = select(FeedbackRow).where(FeedbackRow.thread_id == thread_id)
|
||||
if resolved_workspace_id is not None:
|
||||
stmt = stmt.where(FeedbackRow.workspace_id == resolved_workspace_id)
|
||||
if resolved_user_id is not None:
|
||||
stmt = stmt.where(FeedbackRow.user_id == resolved_user_id)
|
||||
async with self._sf() as session:
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
"""users.default_workspace_id column + FK to workspaces
|
||||
|
||||
Revision ID: 0001_users_default_workspace
|
||||
Revises: None
|
||||
Create Date: 2026-05-12
|
||||
|
||||
First Alembic revision for the DeerFlow application schema. Adds
|
||||
`users.default_workspace_id` so a newly-registered user can be sent
|
||||
back to their default workspace on next login without consulting the
|
||||
memberships table.
|
||||
|
||||
Existing deployments created `users` via `metadata.create_all()` without
|
||||
this column; running `alembic upgrade head` on those DBs will simply add
|
||||
the column (ON DELETE SET NULL FK), no data backfill needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# Alembic identifiers.
|
||||
revision: str = "0001_users_default_workspace"
|
||||
down_revision: str | None = None
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("users") as batch:
|
||||
batch.add_column(sa.Column("default_workspace_id", sa.String(36), nullable=True))
|
||||
batch.create_foreign_key(
|
||||
"fk_users_default_workspace",
|
||||
"workspaces",
|
||||
["default_workspace_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("users") as batch:
|
||||
batch.drop_constraint("fk_users_default_workspace", type_="foreignkey")
|
||||
batch.drop_column("default_workspace_id")
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
"""Business tables: nullable workspace_id + FK to workspaces
|
||||
|
||||
Revision ID: 0002_business_tables_workspace
|
||||
Revises: 0001_users_default_workspace
|
||||
Create Date: 2026-05-13
|
||||
|
||||
Stage 0 PR5 step 1/2 (the second step lives in revision 0003).
|
||||
|
||||
This revision adds a *nullable* ``workspace_id`` column to the four
|
||||
business tables that need tenancy scoping:
|
||||
|
||||
* ``threads_meta``
|
||||
* ``runs``
|
||||
* ``feedback``
|
||||
* ``run_events``
|
||||
|
||||
The column is nullable here on purpose — running ``upgrade`` on a
|
||||
database with existing rows leaves those rows with ``workspace_id = NULL``
|
||||
until ``scripts/backfill_workspace_id.py`` populates them. Once the
|
||||
backfill finishes, revision 0003 flips the column to ``NOT NULL`` and
|
||||
adds the threads_meta ``(workspace_id, thread_id)`` UNIQUE index.
|
||||
|
||||
A composite index ``idx_threads_meta_workspace_user_updated`` is added
|
||||
on ``threads_meta`` to support the common "list a workspace's threads
|
||||
for a user, newest first" access pattern that PR6 routers will rely on.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0002_business_tables_workspace"
|
||||
down_revision: str | None = "0001_users_default_workspace"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
# Tables that receive the new column. The order matters only for human
|
||||
# readability in migration logs — there are no inter-table data deps
|
||||
# during ALTER, and the FK references workspaces (introduced in PR3) which
|
||||
# is already present at this point.
|
||||
_BUSINESS_TABLES = ("threads_meta", "runs", "feedback", "run_events")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for table in _BUSINESS_TABLES:
|
||||
with op.batch_alter_table(table) as batch:
|
||||
batch.add_column(sa.Column("workspace_id", sa.String(36), nullable=True))
|
||||
batch.create_foreign_key(
|
||||
f"fk_{table}_workspace_id",
|
||||
"workspaces",
|
||||
["workspace_id"],
|
||||
["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
|
||||
op.create_index(
|
||||
"idx_threads_meta_workspace_user_updated",
|
||||
"threads_meta",
|
||||
["workspace_id", "user_id", "updated_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_threads_meta_workspace_user_updated", table_name="threads_meta")
|
||||
for table in reversed(_BUSINESS_TABLES):
|
||||
with op.batch_alter_table(table) as batch:
|
||||
batch.drop_constraint(f"fk_{table}_workspace_id", type_="foreignkey")
|
||||
batch.drop_column("workspace_id")
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
"""Business tables: workspace_id NOT NULL + UNIQUE(workspace_id, thread_id)
|
||||
|
||||
Revision ID: 0003_business_tables_workspace_not_null
|
||||
Revises: 0002_business_tables_workspace
|
||||
Create Date: 2026-05-13
|
||||
|
||||
Stage 0 PR5 step 2/2. Flips ``workspace_id`` on the four business
|
||||
tables to ``NOT NULL`` and adds the threads_meta ``(workspace_id,
|
||||
thread_id)`` UNIQUE index promised in workspace-schema-design §4.
|
||||
|
||||
**Refuses to upgrade** if any of the four tables still has rows with
|
||||
``workspace_id IS NULL`` — the operator must run
|
||||
``scripts/backfill_workspace_id.py`` first. Doing the NOT NULL ALTER
|
||||
with stragglers in place would either fail (Postgres) or silently
|
||||
corrupt SQLite tables via ``batch_alter_table`` rebuilds.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0003_business_tables_workspace_not_null"
|
||||
down_revision: str | None = "0002_business_tables_workspace"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
_BUSINESS_TABLES = ("threads_meta", "runs", "feedback", "run_events")
|
||||
|
||||
|
||||
class _BackfillRequiredError(RuntimeError):
|
||||
"""Raised when null workspace_id rows remain at the start of upgrade."""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
for table in _BUSINESS_TABLES:
|
||||
# Quoted identifier is fine here — table names are constants in this
|
||||
# module, no operator input reaches the SQL.
|
||||
count = conn.execute(sa.text(f"SELECT count(*) FROM {table} WHERE workspace_id IS NULL")).scalar() or 0
|
||||
if count > 0:
|
||||
raise _BackfillRequiredError(
|
||||
f"Cannot ALTER {table}.workspace_id to NOT NULL: {count} row(s) still have workspace_id=NULL. Run `python scripts/backfill_workspace_id.py` first.",
|
||||
)
|
||||
|
||||
for table in _BUSINESS_TABLES:
|
||||
with op.batch_alter_table(table) as batch:
|
||||
batch.alter_column("workspace_id", existing_type=sa.String(36), nullable=False)
|
||||
|
||||
op.create_index(
|
||||
"idx_threads_meta_workspace_thread",
|
||||
"threads_meta",
|
||||
["workspace_id", "thread_id"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_threads_meta_workspace_thread", table_name="threads_meta")
|
||||
for table in reversed(_BUSINESS_TABLES):
|
||||
with op.batch_alter_table(table) as batch:
|
||||
batch.alter_column("workspace_id", existing_type=sa.String(36), nullable=True)
|
||||
@@ -8,16 +8,37 @@ The actual ORM classes have moved to entity-specific subpackages:
|
||||
- ``deerflow.persistence.run``
|
||||
- ``deerflow.persistence.feedback``
|
||||
- ``deerflow.persistence.user``
|
||||
- ``deerflow.persistence.workspace`` (Stage 0 PR3)
|
||||
- ``deerflow.persistence.workspace_membership`` (Stage 0 PR3)
|
||||
- ``deerflow.persistence.service_account`` (Stage 0 PR8)
|
||||
- ``deerflow.persistence.api_key`` (Stage 0 PR8)
|
||||
- ``deerflow.persistence.external_user`` (Stage 0 PR8)
|
||||
|
||||
``RunEventRow`` remains in ``deerflow.persistence.models.run_event`` because
|
||||
its storage implementation lives in ``deerflow.runtime.events.store.db`` and
|
||||
there is no matching entity directory.
|
||||
"""
|
||||
|
||||
from deerflow.persistence.api_key.model import ApiKeyRow
|
||||
from deerflow.persistence.external_user.model import ExternalUserRow
|
||||
from deerflow.persistence.feedback.model import FeedbackRow
|
||||
from deerflow.persistence.models.run_event import RunEventRow
|
||||
from deerflow.persistence.run.model import RunRow
|
||||
from deerflow.persistence.service_account.model import ServiceAccountRow
|
||||
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
||||
from deerflow.persistence.user.model import UserRow
|
||||
from deerflow.persistence.workspace.model import WorkspaceRow
|
||||
from deerflow.persistence.workspace_membership.model import WorkspaceMembershipRow
|
||||
|
||||
__all__ = ["FeedbackRow", "RunEventRow", "RunRow", "ThreadMetaRow", "UserRow"]
|
||||
__all__ = [
|
||||
"ApiKeyRow",
|
||||
"ExternalUserRow",
|
||||
"FeedbackRow",
|
||||
"RunEventRow",
|
||||
"RunRow",
|
||||
"ServiceAccountRow",
|
||||
"ThreadMetaRow",
|
||||
"UserRow",
|
||||
"WorkspaceMembershipRow",
|
||||
"WorkspaceRow",
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import JSON, DateTime, Index, String, Text, UniqueConstraint
|
||||
from sqlalchemy import JSON, DateTime, ForeignKey, Index, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
@@ -13,23 +13,31 @@ from deerflow.persistence.base import Base
|
||||
class RunEventRow(Base):
|
||||
__tablename__ = "run_events"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
thread_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
run_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
# Owner of the conversation this event belongs to. Nullable for data
|
||||
# created before auth was introduced; populated by auth middleware on
|
||||
# new writes and by the boot-time orphan migration on existing rows.
|
||||
user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
event_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
category: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
# "message" | "trace" | "lifecycle"
|
||||
content: Mapped[str] = mapped_column(Text, default="")
|
||||
event_metadata: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
seq: Mapped[int] = mapped_column(nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True, comment="自增主键")
|
||||
thread_id: Mapped[str] = mapped_column(String(64), nullable=False, comment="所属会话 ID(threads_meta.thread_id)")
|
||||
run_id: Mapped[str] = mapped_column(String(64), nullable=False, comment="所属运行 ID(runs.run_id)")
|
||||
user_id: Mapped[str | None] = mapped_column(
|
||||
String(64),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="会话所有者;为 NULL 表示鉴权引入之前的历史数据,新写入由 auth 中间件填充,启动期 orphan 迁移会回填存量",
|
||||
)
|
||||
workspace_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("workspaces.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="所属 workspace。PR5 引入时 nullable 用于回填;alembic 0003 + PR6 仓储接入完成后 NOT NULL",
|
||||
)
|
||||
event_type: Mapped[str] = mapped_column(String(32), nullable=False, comment="事件子类型(具体含义由 category 决定,如 ai_message_chunk、tool_call、run_started)")
|
||||
category: Mapped[str] = mapped_column(String(16), nullable=False, comment='事件大类:"message" 消息 / "trace" 追踪 / "lifecycle" 生命周期')
|
||||
content: Mapped[str] = mapped_column(Text, default="", comment="事件文本内容(消息体、错误、状态字符串等)")
|
||||
event_metadata: Mapped[dict] = mapped_column(JSON, default=dict, comment="事件结构化元数据(JSON),随 event_type 而异")
|
||||
seq: Mapped[int] = mapped_column(nullable=False, comment="在 thread 内的全局递增序号;与 thread_id 组合唯一")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), comment="创建时间(UTC)")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("thread_id", "seq", name="uq_events_thread_seq"),
|
||||
Index("ix_events_thread_cat_seq", "thread_id", "category", "seq"),
|
||||
Index("ix_events_run", "thread_id", "run_id", "seq"),
|
||||
{"comment": "运行事件流(消息/追踪/生命周期事件按 seq 顺序追加,是消息回放与审计的真源)"},
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import JSON, DateTime, Index, String, Text
|
||||
from sqlalchemy import JSON, DateTime, ForeignKey, Index, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
@@ -13,37 +13,49 @@ from deerflow.persistence.base import Base
|
||||
class RunRow(Base):
|
||||
__tablename__ = "runs"
|
||||
|
||||
run_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
thread_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
assistant_id: Mapped[str | None] = mapped_column(String(128))
|
||||
user_id: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default="pending")
|
||||
# "pending" | "running" | "success" | "error" | "timeout" | "interrupted"
|
||||
run_id: Mapped[str] = mapped_column(String(64), primary_key=True, comment="运行主键")
|
||||
thread_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="所属会话 ID(threads_meta.thread_id)")
|
||||
assistant_id: Mapped[str | None] = mapped_column(String(128), comment="使用的 Assistant ID(自定义智能体名);为 NULL 表示默认 lead agent")
|
||||
user_id: Mapped[str | None] = mapped_column(String(64), index=True, comment="发起本次运行的用户 ID")
|
||||
workspace_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("workspaces.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="所属 workspace。PR5 引入时 nullable 用于回填;alembic 0003 + PR6 仓储接入完成后 NOT NULL",
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default="pending",
|
||||
comment='运行状态:"pending" / "running" / "success" / "error" / "timeout" / "interrupted"',
|
||||
)
|
||||
model_name: Mapped[str | None] = mapped_column(String(128), comment="本次运行的主模型名(来自 config.yaml.models[*].name)")
|
||||
multitask_strategy: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default="reject",
|
||||
comment='并发策略:同一 thread 已有运行时怎么处理("reject" / "interrupt" / "rollback" / "enqueue")',
|
||||
)
|
||||
metadata_json: Mapped[dict] = mapped_column(JSON, default=dict, comment="运行级元数据(JSON),如 channel/source 等")
|
||||
kwargs_json: Mapped[dict] = mapped_column(JSON, default=dict, comment="提交运行时的额外参数(JSON),如 thinking_enabled、tool 配置等")
|
||||
error: Mapped[str | None] = mapped_column(Text, comment="运行失败时的错误文本;成功时为 NULL")
|
||||
|
||||
model_name: Mapped[str | None] = mapped_column(String(128))
|
||||
multitask_strategy: Mapped[str] = mapped_column(String(20), default="reject")
|
||||
metadata_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
kwargs_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
error: Mapped[str | None] = mapped_column(Text)
|
||||
message_count: Mapped[int] = mapped_column(default=0, comment="本次运行产生的消息总数(便利字段,避免列表页查 RunEventStore)")
|
||||
first_human_message: Mapped[str | None] = mapped_column(Text, comment="首条用户消息文本预览(用于列表展示)")
|
||||
last_ai_message: Mapped[str | None] = mapped_column(Text, comment="末条 AI 消息文本预览(用于列表展示)")
|
||||
|
||||
# Convenience fields (for listing pages without querying RunEventStore)
|
||||
message_count: Mapped[int] = mapped_column(default=0)
|
||||
first_human_message: Mapped[str | None] = mapped_column(Text)
|
||||
last_ai_message: Mapped[str | None] = mapped_column(Text)
|
||||
total_input_tokens: Mapped[int] = mapped_column(default=0, comment="累计输入 token 数(运行结束时由 RunJournal 落盘)")
|
||||
total_output_tokens: Mapped[int] = mapped_column(default=0, comment="累计输出 token 数")
|
||||
total_tokens: Mapped[int] = mapped_column(default=0, comment="累计 token 总数 = input + output")
|
||||
llm_call_count: Mapped[int] = mapped_column(default=0, comment="累计 LLM 调用次数")
|
||||
lead_agent_tokens: Mapped[int] = mapped_column(default=0, comment="主 agent 自身消耗的 token 数")
|
||||
subagent_tokens: Mapped[int] = mapped_column(default=0, comment="子 agent(task 工具委派)消耗的 token 数")
|
||||
middleware_tokens: Mapped[int] = mapped_column(default=0, comment="中间件(如 summarization、title)消耗的 token 数")
|
||||
|
||||
# Token usage (accumulated in-memory by RunJournal, written on run completion)
|
||||
total_input_tokens: Mapped[int] = mapped_column(default=0)
|
||||
total_output_tokens: Mapped[int] = mapped_column(default=0)
|
||||
total_tokens: Mapped[int] = mapped_column(default=0)
|
||||
llm_call_count: Mapped[int] = mapped_column(default=0)
|
||||
lead_agent_tokens: Mapped[int] = mapped_column(default=0)
|
||||
subagent_tokens: Mapped[int] = mapped_column(default=0)
|
||||
middleware_tokens: Mapped[int] = mapped_column(default=0)
|
||||
follow_up_to_run_id: Mapped[str | None] = mapped_column(String(64), comment="续接的上一次运行 ID(用于'重新生成'/'继续'等链式调用)")
|
||||
|
||||
# Follow-up association
|
||||
follow_up_to_run_id: Mapped[str | None] = mapped_column(String(64))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), comment="创建时间(UTC)")
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC), comment="最近更新时间(UTC,写入时自动更新)")
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||
|
||||
__table_args__ = (Index("ix_runs_thread_status", "thread_id", "status"),)
|
||||
__table_args__ = (
|
||||
Index("ix_runs_thread_status", "thread_id", "status"),
|
||||
{"comment": "运行(一次完整 agent 执行)的元数据 + 累计 token 指标"},
|
||||
)
|
||||
|
||||
@@ -17,6 +17,13 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
from deerflow.persistence.run.model import RunRow
|
||||
from deerflow.runtime.runs.store.base import RunStore
|
||||
from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id
|
||||
from deerflow.runtime.workspace_context import AUTO as WORKSPACE_AUTO
|
||||
from deerflow.runtime.workspace_context import (
|
||||
_AutoSentinel as _WorkspaceAutoSentinel,
|
||||
)
|
||||
from deerflow.runtime.workspace_context import (
|
||||
resolve_workspace_id,
|
||||
)
|
||||
|
||||
|
||||
class RunRepository(RunStore):
|
||||
@@ -70,6 +77,7 @@ class RunRepository(RunStore):
|
||||
thread_id,
|
||||
assistant_id=None,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
status="pending",
|
||||
multitask_strategy="reject",
|
||||
metadata=None,
|
||||
@@ -79,12 +87,14 @@ class RunRepository(RunStore):
|
||||
follow_up_to_run_id=None,
|
||||
):
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="RunRepository.put")
|
||||
resolved_workspace_id = resolve_workspace_id(workspace_id, method_name="RunRepository.put")
|
||||
now = datetime.now(UTC)
|
||||
row = RunRow(
|
||||
run_id=run_id,
|
||||
thread_id=thread_id,
|
||||
assistant_id=assistant_id,
|
||||
user_id=resolved_user_id,
|
||||
workspace_id=resolved_workspace_id,
|
||||
status=status,
|
||||
multitask_strategy=multitask_strategy,
|
||||
metadata_json=self._safe_json(metadata) or {},
|
||||
@@ -103,12 +113,16 @@ class RunRepository(RunStore):
|
||||
run_id,
|
||||
*,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
):
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="RunRepository.get")
|
||||
resolved_workspace_id = resolve_workspace_id(workspace_id, method_name="RunRepository.get")
|
||||
async with self._sf() as session:
|
||||
row = await session.get(RunRow, run_id)
|
||||
if row is None:
|
||||
return None
|
||||
if resolved_workspace_id is not None and row.workspace_id != resolved_workspace_id:
|
||||
return None
|
||||
if resolved_user_id is not None and row.user_id != resolved_user_id:
|
||||
return None
|
||||
return self._row_to_dict(row)
|
||||
@@ -118,10 +132,14 @@ class RunRepository(RunStore):
|
||||
thread_id,
|
||||
*,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
limit=100,
|
||||
):
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="RunRepository.list_by_thread")
|
||||
resolved_workspace_id = resolve_workspace_id(workspace_id, method_name="RunRepository.list_by_thread")
|
||||
stmt = select(RunRow).where(RunRow.thread_id == thread_id)
|
||||
if resolved_workspace_id is not None:
|
||||
stmt = stmt.where(RunRow.workspace_id == resolved_workspace_id)
|
||||
if resolved_user_id is not None:
|
||||
stmt = stmt.where(RunRow.user_id == resolved_user_id)
|
||||
stmt = stmt.order_by(RunRow.created_at.desc()).limit(limit)
|
||||
@@ -142,12 +160,16 @@ class RunRepository(RunStore):
|
||||
run_id,
|
||||
*,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
):
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="RunRepository.delete")
|
||||
resolved_workspace_id = resolve_workspace_id(workspace_id, method_name="RunRepository.delete")
|
||||
async with self._sf() as session:
|
||||
row = await session.get(RunRow, run_id)
|
||||
if row is None:
|
||||
return
|
||||
if resolved_workspace_id is not None and row.workspace_id != resolved_workspace_id:
|
||||
return
|
||||
if resolved_user_id is not None and row.user_id != resolved_user_id:
|
||||
return
|
||||
await session.delete(row)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Service account persistence — ORM model only (Stage 0 PR8).
|
||||
|
||||
A service account is a non-human principal that lives inside a workspace
|
||||
and authenticates via API keys rather than email + password. Each
|
||||
service account belongs to exactly one workspace and is created by a
|
||||
human user (``created_by``).
|
||||
|
||||
PR8 introduces only the schema + ORM row class. Repository, API-key
|
||||
authentication middleware, and the ``@require_permission`` scope
|
||||
upgrade live in Stage 1 alongside the headless API surface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from deerflow.persistence.service_account.model import ServiceAccountRow
|
||||
|
||||
__all__ = ["ServiceAccountRow"]
|
||||
@@ -0,0 +1,83 @@
|
||||
"""ORM model for service accounts (non-human principals inside a workspace)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
|
||||
|
||||
class ServiceAccountRow(Base):
|
||||
__tablename__ = "service_accounts"
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
primary_key=True,
|
||||
comment="服务账号主键,UUID 字符串(36 字符),与 users.id 类型对齐",
|
||||
)
|
||||
workspace_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("workspaces.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="所属 workspace;workspace 删除时级联清掉所有 service_account(连同其 api_keys / external_users)",
|
||||
)
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
nullable=False,
|
||||
comment="服务账号显示名(同 workspace 内不强制唯一;Stage 1 可由 admin UI 重复使用同名 + 不同 key)",
|
||||
)
|
||||
role: Mapped[str] = mapped_column(
|
||||
String(16),
|
||||
nullable=False,
|
||||
default="member",
|
||||
comment='服务账号在 workspace 内的角色字符串:Stage 0 仅支持 "member";Stage 2 RBAC 打开 "admin"/"viewer"。用 String(16) 而非 enum 以便未来扩枚举值不动 schema',
|
||||
)
|
||||
identity_mode: Mapped[str] = mapped_column(
|
||||
String(16),
|
||||
nullable=False,
|
||||
default="collapsed",
|
||||
comment=(
|
||||
'身份模式三态:"collapsed"(所有调用 collapse 到该 service_account;不记录 external_user)/'
|
||||
' "external_passthrough"(每次调用必带 X-External-User-Id,写入 external_users 表)/'
|
||||
' "both"(带就写、不带就 collapse)。Stage 1 API key 鉴权层据此分流'
|
||||
),
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(16),
|
||||
nullable=False,
|
||||
default="active",
|
||||
comment='状态:"active"(正常)/ "suspended"(admin 暂停)/ "deleted"(软删;保留审计)',
|
||||
)
|
||||
created_by: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("users.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
comment="创建者 user_id;删除该 user 时 RESTRICT 阻拦(必须先转移或删除该 user 名下所有 service_account)",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
comment="创建时间(UTC)",
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
comment="最近更新时间(UTC,写入时自动更新)",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_service_accounts_workspace", "workspace_id", "status"),
|
||||
{
|
||||
"comment": (
|
||||
"服务账号表(headless API 的非人身份)。每个 service_account 属于唯一 workspace;"
|
||||
"通过 api_keys 表的 API key 鉴权调用 Gateway;identity_mode 控制是否在 external_users 表"
|
||||
"记录终端用户身份。Stage 0 仅落 schema;Stage 1 起接 API key 鉴权 + 路由 scope 升级。"
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -4,12 +4,18 @@ Implementations:
|
||||
- ThreadMetaRepository: SQL-backed (sqlite / postgres via SQLAlchemy)
|
||||
- MemoryThreadMetaStore: wraps LangGraph BaseStore (memory mode)
|
||||
|
||||
All mutating and querying methods accept a ``user_id`` parameter with
|
||||
three-state semantics (see :mod:`deerflow.runtime.user_context`):
|
||||
All mutating and querying methods accept both a ``user_id`` parameter
|
||||
(member-scoped owner check) and a ``workspace_id`` parameter (tenant
|
||||
scope). Both follow three-state semantics:
|
||||
|
||||
- ``AUTO`` (default): resolve from the request-scoped contextvar.
|
||||
- Explicit ``str``: use the provided value verbatim.
|
||||
- Explicit ``None``: bypass owner filtering (migration/CLI only).
|
||||
- Explicit ``None``: bypass that filter (migration / CLI only).
|
||||
|
||||
The workspace scope is the **outer** boundary: a row in workspace A is
|
||||
unreachable from any user_id under workspace B. ``check_access`` returns
|
||||
False on cross-workspace mismatch so the route layer can convert it into
|
||||
a 404 instead of leaking thread existence across tenants.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,6 +23,8 @@ from __future__ import annotations
|
||||
import abc
|
||||
|
||||
from deerflow.runtime.user_context import AUTO, _AutoSentinel
|
||||
from deerflow.runtime.workspace_context import AUTO as WORKSPACE_AUTO
|
||||
from deerflow.runtime.workspace_context import _AutoSentinel as _WorkspaceAutoSentinel
|
||||
|
||||
|
||||
class ThreadMetaStore(abc.ABC):
|
||||
@@ -27,13 +35,20 @@ class ThreadMetaStore(abc.ABC):
|
||||
*,
|
||||
assistant_id: str | None = None,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
display_name: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
) -> dict:
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get(self, thread_id: str, *, user_id: str | None | _AutoSentinel = AUTO) -> dict | None:
|
||||
async def get(
|
||||
self,
|
||||
thread_id: str,
|
||||
*,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
) -> dict | None:
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
@@ -45,32 +60,72 @@ class ThreadMetaStore(abc.ABC):
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
) -> list[dict]:
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
async def update_display_name(self, thread_id: str, display_name: str, *, user_id: str | None | _AutoSentinel = AUTO) -> None:
|
||||
async def update_display_name(
|
||||
self,
|
||||
thread_id: str,
|
||||
display_name: str,
|
||||
*,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
async def update_status(self, thread_id: str, status: str, *, user_id: str | None | _AutoSentinel = AUTO) -> None:
|
||||
async def update_status(
|
||||
self,
|
||||
thread_id: str,
|
||||
status: str,
|
||||
*,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
async def update_metadata(self, thread_id: str, metadata: dict, *, user_id: str | None | _AutoSentinel = AUTO) -> None:
|
||||
async def update_metadata(
|
||||
self,
|
||||
thread_id: str,
|
||||
metadata: dict,
|
||||
*,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
) -> None:
|
||||
"""Merge ``metadata`` into the thread's metadata field.
|
||||
|
||||
Existing keys are overwritten by the new values; keys absent from
|
||||
``metadata`` are preserved. No-op if the thread does not exist
|
||||
or the owner check fails.
|
||||
or the user/workspace check fails.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
async def check_access(self, thread_id: str, user_id: str, *, require_existing: bool = False) -> bool:
|
||||
"""Check if ``user_id`` has access to ``thread_id``."""
|
||||
async def check_access(
|
||||
self,
|
||||
thread_id: str,
|
||||
user_id: str,
|
||||
workspace_id: str,
|
||||
*,
|
||||
require_existing: bool = False,
|
||||
) -> bool:
|
||||
"""Check whether ``user_id`` (in ``workspace_id``) can access ``thread_id``.
|
||||
|
||||
Cross-workspace access returns ``False`` unconditionally so the
|
||||
decorator layer can convert it into a 404 — never leak the
|
||||
existence of a thread that belongs to a different tenant.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
async def delete(self, thread_id: str, *, user_id: str | None | _AutoSentinel = AUTO) -> None:
|
||||
async def delete(
|
||||
self,
|
||||
thread_id: str,
|
||||
*,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
@@ -13,6 +13,13 @@ from langgraph.store.base import BaseStore
|
||||
|
||||
from deerflow.persistence.thread_meta.base import ThreadMetaStore
|
||||
from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id
|
||||
from deerflow.runtime.workspace_context import AUTO as WORKSPACE_AUTO
|
||||
from deerflow.runtime.workspace_context import (
|
||||
_AutoSentinel as _WorkspaceAutoSentinel,
|
||||
)
|
||||
from deerflow.runtime.workspace_context import (
|
||||
resolve_workspace_id,
|
||||
)
|
||||
from deerflow.utils.time import coerce_iso, now_iso
|
||||
|
||||
THREADS_NS: tuple[str, ...] = ("threads",)
|
||||
@@ -26,15 +33,19 @@ class MemoryThreadMetaStore(ThreadMetaStore):
|
||||
self,
|
||||
thread_id: str,
|
||||
user_id: str | None | _AutoSentinel,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel,
|
||||
method_name: str,
|
||||
) -> dict | None:
|
||||
"""Fetch a record and verify ownership. Returns a mutable copy, or None."""
|
||||
resolved = resolve_user_id(user_id, method_name=method_name)
|
||||
"""Fetch a record and verify workspace + ownership. Returns a mutable copy, or None."""
|
||||
resolved_user = resolve_user_id(user_id, method_name=method_name)
|
||||
resolved_workspace = resolve_workspace_id(workspace_id, method_name=method_name)
|
||||
item = await self._store.aget(THREADS_NS, thread_id)
|
||||
if item is None:
|
||||
return None
|
||||
record = dict(item.value)
|
||||
if resolved is not None and record.get("user_id") != resolved:
|
||||
if resolved_workspace is not None and record.get("workspace_id") != resolved_workspace:
|
||||
return None
|
||||
if resolved_user is not None and record.get("user_id") != resolved_user:
|
||||
return None
|
||||
return record
|
||||
|
||||
@@ -44,15 +55,18 @@ class MemoryThreadMetaStore(ThreadMetaStore):
|
||||
*,
|
||||
assistant_id: str | None = None,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
display_name: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
) -> dict:
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="MemoryThreadMetaStore.create")
|
||||
resolved_workspace_id = resolve_workspace_id(workspace_id, method_name="MemoryThreadMetaStore.create")
|
||||
now = now_iso()
|
||||
record: dict[str, Any] = {
|
||||
"thread_id": thread_id,
|
||||
"assistant_id": assistant_id,
|
||||
"user_id": resolved_user_id,
|
||||
"workspace_id": resolved_workspace_id,
|
||||
"display_name": display_name,
|
||||
"status": "idle",
|
||||
"metadata": metadata or {},
|
||||
@@ -63,8 +77,14 @@ class MemoryThreadMetaStore(ThreadMetaStore):
|
||||
await self._store.aput(THREADS_NS, thread_id, record)
|
||||
return record
|
||||
|
||||
async def get(self, thread_id: str, *, user_id: str | None | _AutoSentinel = AUTO) -> dict | None:
|
||||
return await self._get_owned_record(thread_id, user_id, "MemoryThreadMetaStore.get")
|
||||
async def get(
|
||||
self,
|
||||
thread_id: str,
|
||||
*,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
) -> dict | None:
|
||||
return await self._get_owned_record(thread_id, user_id, workspace_id, "MemoryThreadMetaStore.get")
|
||||
|
||||
async def search(
|
||||
self,
|
||||
@@ -74,13 +94,17 @@ class MemoryThreadMetaStore(ThreadMetaStore):
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
) -> list[dict]:
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="MemoryThreadMetaStore.search")
|
||||
resolved_workspace_id = resolve_workspace_id(workspace_id, method_name="MemoryThreadMetaStore.search")
|
||||
filter_dict: dict[str, Any] = {}
|
||||
if metadata:
|
||||
filter_dict.update(metadata)
|
||||
if status:
|
||||
filter_dict["status"] = status
|
||||
if resolved_workspace_id is not None:
|
||||
filter_dict["workspace_id"] = resolved_workspace_id
|
||||
if resolved_user_id is not None:
|
||||
filter_dict["user_id"] = resolved_user_id
|
||||
|
||||
@@ -92,33 +116,64 @@ class MemoryThreadMetaStore(ThreadMetaStore):
|
||||
)
|
||||
return [self._item_to_dict(item) for item in items]
|
||||
|
||||
async def check_access(self, thread_id: str, user_id: str, *, require_existing: bool = False) -> bool:
|
||||
async def check_access(
|
||||
self,
|
||||
thread_id: str,
|
||||
user_id: str,
|
||||
workspace_id: str,
|
||||
*,
|
||||
require_existing: bool = False,
|
||||
) -> bool:
|
||||
item = await self._store.aget(THREADS_NS, thread_id)
|
||||
if item is None:
|
||||
return not require_existing
|
||||
record_workspace_id = item.value.get("workspace_id")
|
||||
if record_workspace_id is not None and record_workspace_id != workspace_id:
|
||||
return False
|
||||
record_user_id = item.value.get("user_id")
|
||||
if record_user_id is None:
|
||||
return True
|
||||
return record_user_id == user_id
|
||||
|
||||
async def update_display_name(self, thread_id: str, display_name: str, *, user_id: str | None | _AutoSentinel = AUTO) -> None:
|
||||
record = await self._get_owned_record(thread_id, user_id, "MemoryThreadMetaStore.update_display_name")
|
||||
async def update_display_name(
|
||||
self,
|
||||
thread_id: str,
|
||||
display_name: str,
|
||||
*,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
) -> None:
|
||||
record = await self._get_owned_record(thread_id, user_id, workspace_id, "MemoryThreadMetaStore.update_display_name")
|
||||
if record is None:
|
||||
return
|
||||
record["display_name"] = display_name
|
||||
record["updated_at"] = now_iso()
|
||||
await self._store.aput(THREADS_NS, thread_id, record)
|
||||
|
||||
async def update_status(self, thread_id: str, status: str, *, user_id: str | None | _AutoSentinel = AUTO) -> None:
|
||||
record = await self._get_owned_record(thread_id, user_id, "MemoryThreadMetaStore.update_status")
|
||||
async def update_status(
|
||||
self,
|
||||
thread_id: str,
|
||||
status: str,
|
||||
*,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
) -> None:
|
||||
record = await self._get_owned_record(thread_id, user_id, workspace_id, "MemoryThreadMetaStore.update_status")
|
||||
if record is None:
|
||||
return
|
||||
record["status"] = status
|
||||
record["updated_at"] = now_iso()
|
||||
await self._store.aput(THREADS_NS, thread_id, record)
|
||||
|
||||
async def update_metadata(self, thread_id: str, metadata: dict, *, user_id: str | None | _AutoSentinel = AUTO) -> None:
|
||||
record = await self._get_owned_record(thread_id, user_id, "MemoryThreadMetaStore.update_metadata")
|
||||
async def update_metadata(
|
||||
self,
|
||||
thread_id: str,
|
||||
metadata: dict,
|
||||
*,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
) -> None:
|
||||
record = await self._get_owned_record(thread_id, user_id, workspace_id, "MemoryThreadMetaStore.update_metadata")
|
||||
if record is None:
|
||||
return
|
||||
merged = dict(record.get("metadata") or {})
|
||||
@@ -127,8 +182,14 @@ class MemoryThreadMetaStore(ThreadMetaStore):
|
||||
record["updated_at"] = now_iso()
|
||||
await self._store.aput(THREADS_NS, thread_id, record)
|
||||
|
||||
async def delete(self, thread_id: str, *, user_id: str | None | _AutoSentinel = AUTO) -> None:
|
||||
record = await self._get_owned_record(thread_id, user_id, "MemoryThreadMetaStore.delete")
|
||||
async def delete(
|
||||
self,
|
||||
thread_id: str,
|
||||
*,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
) -> None:
|
||||
record = await self._get_owned_record(thread_id, user_id, workspace_id, "MemoryThreadMetaStore.delete")
|
||||
if record is None:
|
||||
return
|
||||
await self._store.adelete(THREADS_NS, thread_id)
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import JSON, DateTime, String
|
||||
from sqlalchemy import JSON, DateTime, ForeignKey, Index, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
@@ -13,11 +13,25 @@ from deerflow.persistence.base import Base
|
||||
class ThreadMetaRow(Base):
|
||||
__tablename__ = "threads_meta"
|
||||
|
||||
thread_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
assistant_id: Mapped[str | None] = mapped_column(String(128), index=True)
|
||||
user_id: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
display_name: Mapped[str | None] = mapped_column(String(256))
|
||||
status: Mapped[str] = mapped_column(String(20), default="idle")
|
||||
metadata_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||
thread_id: Mapped[str] = mapped_column(String(64), primary_key=True, comment="会话主键(LangGraph thread_id)")
|
||||
assistant_id: Mapped[str | None] = mapped_column(String(128), index=True, comment="关联的 Assistant ID(自定义智能体名);为 NULL 表示默认 lead agent")
|
||||
user_id: Mapped[str | None] = mapped_column(String(64), index=True, comment="会话所有者;为 NULL 表示历史无主数据")
|
||||
workspace_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("workspaces.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="所属 workspace。PR5 引入时 nullable 用于回填;alembic 0003 + PR6 仓储接入完成后 NOT NULL",
|
||||
)
|
||||
display_name: Mapped[str | None] = mapped_column(String(256), comment="会话显示名(自动生成的标题或用户手改)")
|
||||
status: Mapped[str] = mapped_column(String(20), default="idle", comment='会话状态:"idle" 空闲 / "busy" 正在产出')
|
||||
metadata_json: Mapped[dict] = mapped_column(JSON, default=dict, comment="任意扩展元数据(JSON)")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), comment="创建时间(UTC)")
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC), comment="最近更新时间(UTC,写入时自动更新)")
|
||||
|
||||
__table_args__ = (
|
||||
# Workspace-scoped "list threads of this user, newest first" index;
|
||||
# added by alembic 0002. Mirrored on the ORM side so create_all()
|
||||
# produces the same shape on fresh dev databases.
|
||||
Index("idx_threads_meta_workspace_user_updated", "workspace_id", "user_id", "updated_at"),
|
||||
{"comment": "会话元数据(每个 LangGraph thread 的概要信息)"},
|
||||
)
|
||||
|
||||
@@ -11,6 +11,13 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
from deerflow.persistence.thread_meta.base import ThreadMetaStore
|
||||
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
||||
from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id
|
||||
from deerflow.runtime.workspace_context import AUTO as WORKSPACE_AUTO
|
||||
from deerflow.runtime.workspace_context import (
|
||||
_AutoSentinel as _WorkspaceAutoSentinel,
|
||||
)
|
||||
from deerflow.runtime.workspace_context import (
|
||||
resolve_workspace_id,
|
||||
)
|
||||
|
||||
|
||||
class ThreadMetaRepository(ThreadMetaStore):
|
||||
@@ -33,17 +40,21 @@ class ThreadMetaRepository(ThreadMetaStore):
|
||||
*,
|
||||
assistant_id: str | None = None,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
display_name: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
) -> dict:
|
||||
# Auto-resolve user_id from contextvar when AUTO; explicit None
|
||||
# creates an orphan row (used by migration scripts).
|
||||
# Auto-resolve both user_id and workspace_id from contextvars when
|
||||
# AUTO; explicit None creates an orphan row (used by migration
|
||||
# scripts that intentionally bypass scope).
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="ThreadMetaRepository.create")
|
||||
resolved_workspace_id = resolve_workspace_id(workspace_id, method_name="ThreadMetaRepository.create")
|
||||
now = datetime.now(UTC)
|
||||
row = ThreadMetaRow(
|
||||
thread_id=thread_id,
|
||||
assistant_id=assistant_id,
|
||||
user_id=resolved_user_id,
|
||||
workspace_id=resolved_workspace_id,
|
||||
display_name=display_name,
|
||||
metadata_json=metadata or {},
|
||||
created_at=now,
|
||||
@@ -60,43 +71,52 @@ class ThreadMetaRepository(ThreadMetaStore):
|
||||
thread_id: str,
|
||||
*,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
) -> dict | None:
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="ThreadMetaRepository.get")
|
||||
resolved_workspace_id = resolve_workspace_id(workspace_id, method_name="ThreadMetaRepository.get")
|
||||
stmt = select(ThreadMetaRow).where(ThreadMetaRow.thread_id == thread_id)
|
||||
if resolved_workspace_id is not None:
|
||||
stmt = stmt.where(ThreadMetaRow.workspace_id == resolved_workspace_id)
|
||||
async with self._sf() as session:
|
||||
row = await session.get(ThreadMetaRow, thread_id)
|
||||
row = (await session.execute(stmt)).scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
# Enforce owner filter unless explicitly bypassed (user_id=None).
|
||||
# Owner filter still applies inside the workspace scope.
|
||||
if resolved_user_id is not None and row.user_id != resolved_user_id:
|
||||
return None
|
||||
return self._row_to_dict(row)
|
||||
|
||||
async def check_access(self, thread_id: str, user_id: str, *, require_existing: bool = False) -> bool:
|
||||
"""Check if ``user_id`` has access to ``thread_id``.
|
||||
async def check_access(
|
||||
self,
|
||||
thread_id: str,
|
||||
user_id: str,
|
||||
workspace_id: str,
|
||||
*,
|
||||
require_existing: bool = False,
|
||||
) -> bool:
|
||||
"""Check if ``user_id`` in ``workspace_id`` has access to ``thread_id``.
|
||||
|
||||
Two modes — one row, two distinct semantics depending on what
|
||||
the caller is about to do:
|
||||
Three filters layered, from outside in:
|
||||
|
||||
- ``require_existing=False`` (default, permissive):
|
||||
Returns True for: row missing (untracked legacy thread),
|
||||
``row.user_id`` is None (shared / pre-auth data),
|
||||
or ``row.user_id == user_id``. Use for **read-style**
|
||||
decorators where treating an untracked thread as accessible
|
||||
preserves backward-compat.
|
||||
|
||||
- ``require_existing=True`` (strict):
|
||||
Returns True **only** when the row exists AND
|
||||
(``row.user_id == user_id`` OR ``row.user_id is None``).
|
||||
Use for **destructive / mutating** decorators (DELETE, PATCH,
|
||||
state-update) so a thread that has *already been deleted*
|
||||
cannot be re-targeted by any caller — closing the
|
||||
delete-idempotence cross-user gap where the row vanishing
|
||||
made every other user appear to "own" it.
|
||||
- Cross-workspace is **always** denied (returns False), even when
|
||||
the row exists and ``user_id`` matches. The decorator layer
|
||||
converts a False into a 404 so cross-tenant access never leaks
|
||||
the existence of a thread.
|
||||
- Missing row honours ``require_existing``: False by default
|
||||
(permissive — untracked legacy threads still readable), True
|
||||
for destructive routes (DELETE / PATCH) so a re-targeted ghost
|
||||
row cannot be claimed.
|
||||
- Within the workspace, ``row.user_id IS NULL`` keeps the legacy
|
||||
"shared / pre-auth" semantics — readable by anyone in the
|
||||
workspace. ``row.user_id == user_id`` is the normal case.
|
||||
"""
|
||||
async with self._sf() as session:
|
||||
row = await session.get(ThreadMetaRow, thread_id)
|
||||
if row is None:
|
||||
return not require_existing
|
||||
if row.workspace_id is not None and row.workspace_id != workspace_id:
|
||||
return False
|
||||
if row.user_id is None:
|
||||
return True
|
||||
return row.user_id == user_id
|
||||
@@ -109,14 +129,19 @@ class ThreadMetaRepository(ThreadMetaStore):
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
) -> list[dict]:
|
||||
"""Search threads with optional metadata and status filters.
|
||||
|
||||
Owner filter is enforced by default: caller must be in a user
|
||||
context. Pass ``user_id=None`` to bypass (migration/CLI).
|
||||
Both workspace and owner filters are enforced by default. Pass
|
||||
``workspace_id=None`` and / or ``user_id=None`` to bypass for
|
||||
migration / CLI paths.
|
||||
"""
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="ThreadMetaRepository.search")
|
||||
resolved_workspace_id = resolve_workspace_id(workspace_id, method_name="ThreadMetaRepository.search")
|
||||
stmt = select(ThreadMetaRow).order_by(ThreadMetaRow.updated_at.desc())
|
||||
if resolved_workspace_id is not None:
|
||||
stmt = stmt.where(ThreadMetaRow.workspace_id == resolved_workspace_id)
|
||||
if resolved_user_id is not None:
|
||||
stmt = stmt.where(ThreadMetaRow.user_id == resolved_user_id)
|
||||
if status:
|
||||
@@ -138,12 +163,22 @@ class ThreadMetaRepository(ThreadMetaStore):
|
||||
result = await session.execute(stmt)
|
||||
return [self._row_to_dict(r) for r in result.scalars()]
|
||||
|
||||
async def _check_ownership(self, session: AsyncSession, thread_id: str, resolved_user_id: str | None) -> bool:
|
||||
"""Return True if the row exists and is owned (or filter bypassed)."""
|
||||
if resolved_user_id is None:
|
||||
return True # explicit bypass
|
||||
async def _check_ownership(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
thread_id: str,
|
||||
resolved_user_id: str | None,
|
||||
resolved_workspace_id: str | None,
|
||||
) -> bool:
|
||||
"""Return True if the row exists, is in scope, and is owned (or filter bypassed)."""
|
||||
row = await session.get(ThreadMetaRow, thread_id)
|
||||
return row is not None and row.user_id == resolved_user_id
|
||||
if row is None:
|
||||
return False
|
||||
if resolved_workspace_id is not None and row.workspace_id != resolved_workspace_id:
|
||||
return False
|
||||
if resolved_user_id is not None and row.user_id != resolved_user_id:
|
||||
return False
|
||||
return True
|
||||
|
||||
async def update_display_name(
|
||||
self,
|
||||
@@ -151,11 +186,13 @@ class ThreadMetaRepository(ThreadMetaStore):
|
||||
display_name: str,
|
||||
*,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
) -> None:
|
||||
"""Update the display_name (title) for a thread."""
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="ThreadMetaRepository.update_display_name")
|
||||
resolved_workspace_id = resolve_workspace_id(workspace_id, method_name="ThreadMetaRepository.update_display_name")
|
||||
async with self._sf() as session:
|
||||
if not await self._check_ownership(session, thread_id, resolved_user_id):
|
||||
if not await self._check_ownership(session, thread_id, resolved_user_id, resolved_workspace_id):
|
||||
return
|
||||
await session.execute(update(ThreadMetaRow).where(ThreadMetaRow.thread_id == thread_id).values(display_name=display_name, updated_at=datetime.now(UTC)))
|
||||
await session.commit()
|
||||
@@ -166,10 +203,12 @@ class ThreadMetaRepository(ThreadMetaStore):
|
||||
status: str,
|
||||
*,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
) -> None:
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="ThreadMetaRepository.update_status")
|
||||
resolved_workspace_id = resolve_workspace_id(workspace_id, method_name="ThreadMetaRepository.update_status")
|
||||
async with self._sf() as session:
|
||||
if not await self._check_ownership(session, thread_id, resolved_user_id):
|
||||
if not await self._check_ownership(session, thread_id, resolved_user_id, resolved_workspace_id):
|
||||
return
|
||||
await session.execute(update(ThreadMetaRow).where(ThreadMetaRow.thread_id == thread_id).values(status=status, updated_at=datetime.now(UTC)))
|
||||
await session.commit()
|
||||
@@ -180,18 +219,22 @@ class ThreadMetaRepository(ThreadMetaStore):
|
||||
metadata: dict,
|
||||
*,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
) -> None:
|
||||
"""Merge ``metadata`` into ``metadata_json``.
|
||||
|
||||
Read-modify-write inside a single session/transaction so concurrent
|
||||
callers see consistent state. No-op if the row does not exist or
|
||||
the user_id check fails.
|
||||
the workspace / user check fails.
|
||||
"""
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="ThreadMetaRepository.update_metadata")
|
||||
resolved_workspace_id = resolve_workspace_id(workspace_id, method_name="ThreadMetaRepository.update_metadata")
|
||||
async with self._sf() as session:
|
||||
row = await session.get(ThreadMetaRow, thread_id)
|
||||
if row is None:
|
||||
return
|
||||
if resolved_workspace_id is not None and row.workspace_id != resolved_workspace_id:
|
||||
return
|
||||
if resolved_user_id is not None and row.user_id != resolved_user_id:
|
||||
return
|
||||
merged = dict(row.metadata_json or {})
|
||||
@@ -205,12 +248,16 @@ class ThreadMetaRepository(ThreadMetaStore):
|
||||
thread_id: str,
|
||||
*,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
) -> None:
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="ThreadMetaRepository.delete")
|
||||
resolved_workspace_id = resolve_workspace_id(workspace_id, method_name="ThreadMetaRepository.delete")
|
||||
async with self._sf() as session:
|
||||
row = await session.get(ThreadMetaRow, thread_id)
|
||||
if row is None:
|
||||
return
|
||||
if resolved_workspace_id is not None and row.workspace_id != resolved_workspace_id:
|
||||
return
|
||||
if resolved_user_id is not None and row.user_id != resolved_user_id:
|
||||
return
|
||||
await session.delete(row)
|
||||
|
||||
@@ -13,7 +13,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Index, String, text
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
@@ -22,31 +22,26 @@ from deerflow.persistence.base import Base
|
||||
class UserRow(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
# UUIDs are stored as 36-char strings for cross-backend portability.
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
|
||||
email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False, index=True)
|
||||
password_hash: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
|
||||
# "admin" | "user" — kept as plain string to avoid ALTER TABLE pain
|
||||
# when new roles are introduced.
|
||||
system_role: Mapped[str] = mapped_column(String(16), nullable=False, default="user")
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, comment="用户主键,UUID 字符串(36 字符),跨数据库可移植")
|
||||
email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False, index=True, comment="登录邮箱,全局唯一")
|
||||
password_hash: Mapped[str | None] = mapped_column(String(128), nullable=True, comment="本地账户的密码哈希;OAuth-only 用户为 NULL")
|
||||
system_role: Mapped[str] = mapped_column(String(16), nullable=False, default="user", comment='系统角色:"admin" 或 "user";用字符串以便未来扩展角色而不必 ALTER TABLE')
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
comment="账户创建时间(UTC)",
|
||||
)
|
||||
oauth_provider: Mapped[str | None] = mapped_column(String(32), nullable=True, comment="OAuth 提供商名(如 google/github);本地账户为 NULL")
|
||||
oauth_id: Mapped[str | None] = mapped_column(String(128), nullable=True, comment="OAuth 提供商内的用户 ID;与 oauth_provider 组合需唯一")
|
||||
needs_setup: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, comment="是否需要完成首次设置(admin 自动创建后改密码/邮箱)")
|
||||
token_version: Mapped[int] = mapped_column(nullable=False, default=0, comment="JWT 令牌版本号;自增即吊销该用户所有旧令牌")
|
||||
default_workspace_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("workspaces.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
comment="登录后默认进入的 workspace;NULL 时强制走 picker(user 多 workspace 场景)",
|
||||
)
|
||||
|
||||
# OAuth linkage (optional). A partial unique index enforces one
|
||||
# account per (provider, oauth_id) pair, leaving NULL/NULL rows
|
||||
# unconstrained so plain password accounts can coexist.
|
||||
oauth_provider: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
oauth_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
|
||||
# Auth lifecycle flags
|
||||
needs_setup: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
token_version: Mapped[int] = mapped_column(nullable=False, default=0)
|
||||
|
||||
__table_args__ = (
|
||||
Index(
|
||||
@@ -56,4 +51,5 @@ class UserRow(Base):
|
||||
unique=True,
|
||||
sqlite_where=text("oauth_provider IS NOT NULL AND oauth_id IS NOT NULL"),
|
||||
),
|
||||
{"comment": "用户账户表(本地密码登录 + OAuth 联合登录)"},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Workspace persistence — ORM model + repository.
|
||||
|
||||
A workspace is the multi-tenant scope unit. Every user has at least
|
||||
one (auto-created on registration; their personal workspace where
|
||||
they are sole owner). Team plans get multi-member workspaces.
|
||||
|
||||
Stage 0 PR3 introduces the schema + repository; PR4 wires it into
|
||||
the registration / login flow; PR5+ ALTER existing business tables
|
||||
to FK back to ``workspaces.id``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from deerflow.persistence.workspace.model import WorkspaceRow
|
||||
from deerflow.persistence.workspace.sql import WorkspaceRepository, WorkspaceValidationError
|
||||
|
||||
__all__ = ["WorkspaceRepository", "WorkspaceRow", "WorkspaceValidationError"]
|
||||
@@ -0,0 +1,58 @@
|
||||
"""ORM model for workspaces (multi-tenant scope)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
|
||||
|
||||
class WorkspaceRow(Base):
|
||||
__tablename__ = "workspaces"
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
primary_key=True,
|
||||
comment="工作空间主键,UUID 字符串(36 字符),与 users.id 类型对齐",
|
||||
)
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
nullable=False,
|
||||
comment="工作空间显示名(用户注册时默认 <email 前缀>'s Workspace)",
|
||||
)
|
||||
slug: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
comment="URL 标识(^[a-z0-9](-?[a-z0-9])*$,3-32 字符);全局唯一,DB 存小写",
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(16),
|
||||
nullable=False,
|
||||
default="active",
|
||||
comment='状态:"active"(正常)/ "suspended"(平台 admin 暂停)/ "deleted"(软删)',
|
||||
)
|
||||
owner_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("users.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
comment="所有者 user_id;与 workspace_memberships 中 role='owner' 行严格一致(事务保证);删除 owner 时 RESTRICT 阻拦(必须先转让所有权)",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
comment="创建时间(UTC)",
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
comment="最近更新时间(UTC,写入时自动更新)",
|
||||
)
|
||||
|
||||
__table_args__ = ({"comment": ("工作空间表(多租户隔离粒度单位)。每个用户注册时自动建一个 1 人 workspace,owner 即注册者。团队订阅时 workspace 可有多个 member。")},)
|
||||
@@ -0,0 +1,230 @@
|
||||
"""SQLAlchemy-backed workspace repository.
|
||||
|
||||
CRUD + slug lookup for ``workspaces``. Membership-aware methods
|
||||
(``get``, ``list_by_user``) JOIN against ``workspace_memberships`` so
|
||||
callers cannot read workspaces they don't belong to.
|
||||
|
||||
Three-state ``user_id`` parameter (same convention as
|
||||
:class:`ThreadMetaRepository`):
|
||||
- ``AUTO`` → read from contextvar; raise if unset
|
||||
- explicit ``str`` → override contextvar (admin / tests)
|
||||
- explicit ``None`` → no filter (migration / CLI only)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.workspace.model import WorkspaceRow
|
||||
from deerflow.persistence.workspace_membership.model import WorkspaceMembershipRow
|
||||
from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id
|
||||
|
||||
# slug 字符集 / 长度(与 workspace-schema-design §2.1 锁定一致)。
|
||||
_SLUG_PATTERN = re.compile(r"^[a-z0-9](-?[a-z0-9])*$")
|
||||
_SLUG_MIN_LEN = 3
|
||||
_SLUG_MAX_LEN = 32
|
||||
|
||||
# slug 黑名单(应用层校验,不写 DB constraint)。包含 ADR-007 §4 保留 slug
|
||||
# + 路径 + Next.js 保留 + 业务保留词。
|
||||
SLUG_BLACKLIST = frozenset(
|
||||
{
|
||||
"admin",
|
||||
"api",
|
||||
"auth",
|
||||
"login",
|
||||
"signup",
|
||||
"accept-invite",
|
||||
"pricing",
|
||||
"docs",
|
||||
"status",
|
||||
"platform",
|
||||
"system",
|
||||
"health",
|
||||
"static",
|
||||
"public",
|
||||
"favicon.ico",
|
||||
"robots.txt",
|
||||
"sitemap.xml",
|
||||
"_next",
|
||||
".well-known",
|
||||
"settings",
|
||||
"billing",
|
||||
"onboarding",
|
||||
"select-workspace",
|
||||
}
|
||||
)
|
||||
|
||||
# 允许的 status 集合。
|
||||
_VALID_STATUSES = frozenset({"active", "suspended", "deleted"})
|
||||
|
||||
|
||||
class WorkspaceValidationError(ValueError):
|
||||
"""Raised when workspace input fails application-layer validation
|
||||
(slug format / blacklist / status enum)."""
|
||||
|
||||
|
||||
def _validate_slug(slug: str) -> None:
|
||||
"""Raise :class:`WorkspaceValidationError` if slug is invalid."""
|
||||
if not isinstance(slug, str):
|
||||
raise WorkspaceValidationError(f"slug must be a string, got {type(slug).__name__}")
|
||||
if not (_SLUG_MIN_LEN <= len(slug) <= _SLUG_MAX_LEN):
|
||||
raise WorkspaceValidationError(f"slug length must be between {_SLUG_MIN_LEN} and {_SLUG_MAX_LEN}, got {len(slug)}")
|
||||
if not _SLUG_PATTERN.fullmatch(slug):
|
||||
raise WorkspaceValidationError(f"slug {slug!r} does not match required pattern ^[a-z0-9](-?[a-z0-9])*$")
|
||||
if slug in SLUG_BLACKLIST:
|
||||
raise WorkspaceValidationError(f"slug {slug!r} is reserved")
|
||||
|
||||
|
||||
def _validate_status(status: str) -> None:
|
||||
if status not in _VALID_STATUSES:
|
||||
raise WorkspaceValidationError(f"status {status!r} is not in allowed set {_VALID_STATUSES!r}")
|
||||
|
||||
|
||||
class WorkspaceRepository:
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
@staticmethod
|
||||
def _row_to_dict(row: WorkspaceRow) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"name": row.name,
|
||||
"slug": row.slug,
|
||||
"status": row.status,
|
||||
"owner_id": row.owner_id,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
|
||||
async def create(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
slug: str,
|
||||
owner_id: str,
|
||||
workspace_id: str | None = None,
|
||||
status: str = "active",
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new workspace.
|
||||
|
||||
``workspace_id`` is optional — UUID v4 is generated if omitted.
|
||||
Caller is responsible for creating the matching ``owner`` row
|
||||
in ``workspace_memberships`` (this is typically done in the same
|
||||
transaction by the registration flow; we deliberately don't bundle
|
||||
it here to keep the repository single-responsibility).
|
||||
|
||||
Raises :class:`WorkspaceValidationError` for invalid slug / status.
|
||||
Raises :class:`sqlalchemy.exc.IntegrityError` for slug collision
|
||||
or invalid owner_id FK.
|
||||
"""
|
||||
_validate_slug(slug)
|
||||
_validate_status(status)
|
||||
wid = workspace_id or str(uuid.uuid4())
|
||||
now = datetime.now(UTC)
|
||||
row = WorkspaceRow(
|
||||
id=wid,
|
||||
name=name,
|
||||
slug=slug,
|
||||
status=status,
|
||||
owner_id=owner_id,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
async with self._sf() as session:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return self._row_to_dict(row)
|
||||
|
||||
async def get(
|
||||
self,
|
||||
workspace_id: str,
|
||||
*,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return workspace row IFF caller is a member.
|
||||
|
||||
``user_id=None`` bypasses the membership filter (migration / CLI).
|
||||
Returns ``None`` if the workspace does not exist or the caller is
|
||||
not a member.
|
||||
"""
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="WorkspaceRepository.get")
|
||||
async with self._sf() as session:
|
||||
row = await session.get(WorkspaceRow, workspace_id)
|
||||
if row is None:
|
||||
return None
|
||||
if resolved_user_id is None:
|
||||
# Explicit bypass (migration / admin path).
|
||||
return self._row_to_dict(row)
|
||||
# Membership check via separate SELECT (cheap; index covers it).
|
||||
membership = await session.execute(
|
||||
select(WorkspaceMembershipRow).where(
|
||||
WorkspaceMembershipRow.workspace_id == workspace_id,
|
||||
WorkspaceMembershipRow.user_id == resolved_user_id,
|
||||
)
|
||||
)
|
||||
if membership.scalar_one_or_none() is None:
|
||||
return None
|
||||
return self._row_to_dict(row)
|
||||
|
||||
async def get_by_slug(self, slug: str) -> dict[str, Any] | None:
|
||||
"""Public slug lookup — does NOT check membership.
|
||||
|
||||
Used by path-based routing (``/{slug}/...``) where we need to
|
||||
resolve slug → workspace_id BEFORE we know if caller belongs.
|
||||
Membership check happens downstream in the route handler.
|
||||
"""
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(select(WorkspaceRow).where(WorkspaceRow.slug == slug))
|
||||
row = result.scalar_one_or_none()
|
||||
return self._row_to_dict(row) if row else None
|
||||
|
||||
async def list_by_user(
|
||||
self,
|
||||
*,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return all workspaces caller is a member of, ordered by joined_at desc.
|
||||
|
||||
``user_id=None`` lists ALL workspaces (migration / admin path).
|
||||
"""
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="WorkspaceRepository.list_by_user")
|
||||
async with self._sf() as session:
|
||||
stmt = select(WorkspaceRow).order_by(WorkspaceRow.created_at.desc())
|
||||
if resolved_user_id is not None:
|
||||
stmt = stmt.join(
|
||||
WorkspaceMembershipRow,
|
||||
WorkspaceMembershipRow.workspace_id == WorkspaceRow.id,
|
||||
).where(WorkspaceMembershipRow.user_id == resolved_user_id)
|
||||
result = await session.execute(stmt)
|
||||
return [self._row_to_dict(r) for r in result.scalars()]
|
||||
|
||||
async def update_status(self, workspace_id: str, status: str) -> None:
|
||||
"""Platform-admin operation: change workspace status (active/suspended/deleted).
|
||||
|
||||
No membership check — this is for platform-level operations. Audit
|
||||
logging belongs at the route layer.
|
||||
"""
|
||||
_validate_status(status)
|
||||
async with self._sf() as session:
|
||||
await session.execute(update(WorkspaceRow).where(WorkspaceRow.id == workspace_id).values(status=status, updated_at=datetime.now(UTC)))
|
||||
await session.commit()
|
||||
|
||||
async def delete(self, workspace_id: str) -> None:
|
||||
"""Hard-delete a workspace. CASCADE drops all memberships.
|
||||
|
||||
Intentionally no membership check — caller (platform admin route)
|
||||
must enforce authorization. Stage 0 doesn't expose this to end
|
||||
users; Stage 2+ adds it behind owner_only permission.
|
||||
"""
|
||||
async with self._sf() as session:
|
||||
row = await session.get(WorkspaceRow, workspace_id)
|
||||
if row is not None:
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Workspace membership persistence — ORM model + repository.
|
||||
|
||||
Tracks who is a member of which workspace and what their role is
|
||||
within that workspace. Composite primary key ``(workspace_id, user_id)``;
|
||||
``role`` follows the three-state ``owner`` / ``admin`` / ``member`` model
|
||||
(Stage 0 only writes ``'owner'``; Stage 2 RBAC rollout opens admin/member).
|
||||
|
||||
A workspace MUST have exactly one ``owner`` — enforced by a partial
|
||||
unique index. Owner transfer is a two-row transactional swap.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from deerflow.persistence.workspace_membership.model import WorkspaceMembershipRow
|
||||
from deerflow.persistence.workspace_membership.sql import (
|
||||
MembershipValidationError,
|
||||
WorkspaceMembershipRepository,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"MembershipValidationError",
|
||||
"WorkspaceMembershipRepository",
|
||||
"WorkspaceMembershipRow",
|
||||
]
|
||||
@@ -0,0 +1,62 @@
|
||||
"""ORM model for workspace memberships (which user is in which workspace, with what role)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, String, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
|
||||
|
||||
class WorkspaceMembershipRow(Base):
|
||||
__tablename__ = "workspace_memberships"
|
||||
|
||||
workspace_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("workspaces.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
comment="所属 workspace;workspace 删除时级联清掉成员记录",
|
||||
)
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
comment="成员 user_id;用户删除时级联清掉成员记录",
|
||||
)
|
||||
role: Mapped[str] = mapped_column(
|
||||
String(16),
|
||||
nullable=False,
|
||||
comment='角色字符串:Stage 0 仅写 "owner";Stage 2 RBAC 打开 "admin"/"member"。用 String(16) 而非 enum 以便未来加 "viewer"/"auditor" 不动 schema',
|
||||
)
|
||||
invited_by: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
comment="邀请人 user_id(Stage 2 invitation 流程才写);邀请人被删时此字段清空(不影响成员记录本身)",
|
||||
)
|
||||
joined_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
comment="加入 workspace 的时间(UTC)",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
# 倒查索引:列出 user 所属的所有 workspace(/auth/me 用)。
|
||||
# 复合主键 (workspace_id, user_id) 的前导列是 workspace_id,
|
||||
# 所以"按 user_id 查"需要单独的索引。
|
||||
Index("idx_workspace_memberships_user", "user_id", "workspace_id"),
|
||||
# 一个 workspace 严格 1 个 owner —— partial unique on role='owner'。
|
||||
# SQLite + Postgres 都支持 WHERE 子句的 partial unique;双驱动
|
||||
# 维护两套等价 where 表达式。
|
||||
Index(
|
||||
"idx_one_owner_per_workspace",
|
||||
"workspace_id",
|
||||
unique=True,
|
||||
sqlite_where=text("role = 'owner'"),
|
||||
postgresql_where=text("role = 'owner'"),
|
||||
),
|
||||
{"comment": ("工作空间成员表(multi-tenant RBAC)。复合 PK (workspace_id, user_id);每个 workspace 必有恰好 1 个 owner(partial unique 约束保证)。Stage 0 仅写 owner;Stage 2 RBAC 打开 admin/member。")},
|
||||
)
|
||||
@@ -0,0 +1,149 @@
|
||||
"""SQLAlchemy-backed workspace membership repository.
|
||||
|
||||
Manages the ``workspace_memberships`` join table. Stage 0 only writes
|
||||
``role='owner'`` (single-user workspaces); the repository accepts the
|
||||
full Stage 2 RBAC role enum so the schema is forward-compatible.
|
||||
|
||||
Owner transfer is intentionally NOT modelled here as a single method —
|
||||
it requires a two-row transactional swap with careful retry semantics,
|
||||
and belongs in the auth router (PR4+) where it can be wrapped in a
|
||||
permission check.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.workspace_membership.model import WorkspaceMembershipRow
|
||||
|
||||
# 允许的 role 字面值。Stage 0 实际仅写 owner;admin/member 留给 Stage 2 RBAC。
|
||||
_VALID_ROLES = frozenset({"owner", "admin", "member"})
|
||||
|
||||
|
||||
class MembershipValidationError(ValueError):
|
||||
"""Raised when role is not in the allowed enum."""
|
||||
|
||||
|
||||
def _validate_role(role: str) -> None:
|
||||
if role not in _VALID_ROLES:
|
||||
raise MembershipValidationError(f"role {role!r} is not in allowed set {_VALID_ROLES!r}")
|
||||
|
||||
|
||||
class WorkspaceMembershipRepository:
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
@staticmethod
|
||||
def _row_to_dict(row: WorkspaceMembershipRow) -> dict[str, Any]:
|
||||
return {
|
||||
"workspace_id": row.workspace_id,
|
||||
"user_id": row.user_id,
|
||||
"role": row.role,
|
||||
"invited_by": row.invited_by,
|
||||
"joined_at": row.joined_at.isoformat() if row.joined_at else None,
|
||||
}
|
||||
|
||||
async def add(
|
||||
self,
|
||||
*,
|
||||
workspace_id: str,
|
||||
user_id: str,
|
||||
role: str,
|
||||
invited_by: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Insert a new membership row.
|
||||
|
||||
Raises:
|
||||
- :class:`MembershipValidationError` for unknown role values
|
||||
- :class:`sqlalchemy.exc.IntegrityError` for:
|
||||
- duplicate (workspace_id, user_id) — composite PK collision
|
||||
- second ``owner`` in the same workspace — partial unique index
|
||||
- invalid workspace_id / user_id / invited_by FK
|
||||
"""
|
||||
_validate_role(role)
|
||||
row = WorkspaceMembershipRow(
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_id,
|
||||
role=role,
|
||||
invited_by=invited_by,
|
||||
joined_at=datetime.now(UTC),
|
||||
)
|
||||
async with self._sf() as session:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return self._row_to_dict(row)
|
||||
|
||||
async def remove(self, *, workspace_id: str, user_id: str) -> bool:
|
||||
"""Delete a membership row; returns True if a row was deleted.
|
||||
|
||||
Owner removal is allowed at the repository layer — auth router
|
||||
(PR4) layers the "cannot remove last owner" rule on top.
|
||||
"""
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(
|
||||
delete(WorkspaceMembershipRow).where(
|
||||
WorkspaceMembershipRow.workspace_id == workspace_id,
|
||||
WorkspaceMembershipRow.user_id == user_id,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return (result.rowcount or 0) > 0
|
||||
|
||||
async def list_by_user(self, *, user_id: str) -> list[dict[str, Any]]:
|
||||
"""Return all memberships for ``user_id``, ordered by joined_at desc.
|
||||
|
||||
No contextvar resolution here — caller is responsible for passing
|
||||
the correct user_id. Used by ``/auth/me`` to list workspaces a
|
||||
user belongs to.
|
||||
"""
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(select(WorkspaceMembershipRow).where(WorkspaceMembershipRow.user_id == user_id).order_by(WorkspaceMembershipRow.joined_at.desc()))
|
||||
return [self._row_to_dict(r) for r in result.scalars()]
|
||||
|
||||
async def list_by_workspace(self, *, workspace_id: str) -> list[dict[str, Any]]:
|
||||
"""Return all members of a workspace, ordered by joined_at asc."""
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(select(WorkspaceMembershipRow).where(WorkspaceMembershipRow.workspace_id == workspace_id).order_by(WorkspaceMembershipRow.joined_at.asc()))
|
||||
return [self._row_to_dict(r) for r in result.scalars()]
|
||||
|
||||
async def get_role(self, *, workspace_id: str, user_id: str) -> str | None:
|
||||
"""Return the role string, or None if user is not a member."""
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(
|
||||
select(WorkspaceMembershipRow.role).where(
|
||||
WorkspaceMembershipRow.workspace_id == workspace_id,
|
||||
WorkspaceMembershipRow.user_id == user_id,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def change_role(
|
||||
self,
|
||||
*,
|
||||
workspace_id: str,
|
||||
user_id: str,
|
||||
new_role: str,
|
||||
) -> bool:
|
||||
"""Update a member's role; returns True iff a row was updated.
|
||||
|
||||
Validates ``new_role`` against the allowed enum. Owner-transfer
|
||||
flow needs to swap two rows atomically — do that with a manual
|
||||
transaction in the caller; this method is for non-owner changes.
|
||||
"""
|
||||
_validate_role(new_role)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(
|
||||
update(WorkspaceMembershipRow)
|
||||
.where(
|
||||
WorkspaceMembershipRow.workspace_id == workspace_id,
|
||||
WorkspaceMembershipRow.user_id == user_id,
|
||||
)
|
||||
.values(role=new_role)
|
||||
)
|
||||
await session.commit()
|
||||
return (result.rowcount or 0) > 0
|
||||
@@ -20,6 +20,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from langgraph.types import Checkpointer
|
||||
@@ -114,7 +115,14 @@ async def _async_checkpointer_from_database(db_config) -> AsyncIterator[Checkpoi
|
||||
if not db_config.postgres_url:
|
||||
raise ValueError("database.postgres_url is required for the postgres backend")
|
||||
|
||||
async with AsyncPostgresSaver.from_conn_string(db_config.postgres_url) as saver:
|
||||
# LangGraph's AsyncPostgresSaver wraps psycopg directly and expects a
|
||||
# libpq-style conninfo (`postgresql://...`). DeerFlow's own SQLAlchemy
|
||||
# engine uses the same `postgres_url` but needs the `+asyncpg` dialect
|
||||
# prefix. Strip the dialect prefix here so the same env var/config
|
||||
# value satisfies both paths.
|
||||
lg_conn_str = re.sub(r"^postgresql\+\w+://", "postgresql://", db_config.postgres_url)
|
||||
|
||||
async with AsyncPostgresSaver.from_conn_string(lg_conn_str) as saver:
|
||||
await saver.setup()
|
||||
yield saver
|
||||
return
|
||||
|
||||
@@ -17,6 +17,14 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
from deerflow.persistence.models.run_event import RunEventRow
|
||||
from deerflow.runtime.events.store.base import RunEventStore
|
||||
from deerflow.runtime.user_context import AUTO, _AutoSentinel, get_current_user, resolve_user_id
|
||||
from deerflow.runtime.workspace_context import AUTO as WORKSPACE_AUTO
|
||||
from deerflow.runtime.workspace_context import (
|
||||
_AutoSentinel as _WorkspaceAutoSentinel,
|
||||
)
|
||||
from deerflow.runtime.workspace_context import (
|
||||
get_current_workspace,
|
||||
resolve_workspace_id,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -86,6 +94,19 @@ class DbRunEventStore(RunEventStore):
|
||||
user = get_current_user()
|
||||
return str(user.id) if user is not None else None
|
||||
|
||||
@staticmethod
|
||||
def _workspace_id_from_context() -> str | None:
|
||||
"""Soft read of workspace_id from contextvar for write paths.
|
||||
|
||||
Mirrors :meth:`_user_id_from_context`. Returns ``None`` (no stamp)
|
||||
when no workspace is in context — typical for background worker
|
||||
writes that fire outside an HTTP request. The DB column is
|
||||
nullable through PR5 and becomes NOT NULL only after the alembic
|
||||
0003 migration runs (verified by the backfill path).
|
||||
"""
|
||||
workspace = get_current_workspace()
|
||||
return str(workspace.id) if workspace is not None else None
|
||||
|
||||
async def put(self, *, thread_id, run_id, event_type, category, content="", metadata=None, created_at=None): # noqa: D401
|
||||
"""Write a single event — low-frequency path only.
|
||||
|
||||
@@ -98,6 +119,7 @@ class DbRunEventStore(RunEventStore):
|
||||
content, metadata = self._truncate_trace(category, content, metadata)
|
||||
db_content, metadata = self._content_to_db(content, metadata)
|
||||
user_id = self._user_id_from_context()
|
||||
workspace_id = self._workspace_id_from_context()
|
||||
async with self._sf() as session:
|
||||
async with session.begin():
|
||||
# Use FOR UPDATE to serialize seq assignment within a thread.
|
||||
@@ -109,6 +131,7 @@ class DbRunEventStore(RunEventStore):
|
||||
thread_id=thread_id,
|
||||
run_id=run_id,
|
||||
user_id=user_id,
|
||||
workspace_id=workspace_id,
|
||||
event_type=event_type,
|
||||
category=category,
|
||||
content=db_content,
|
||||
@@ -123,6 +146,7 @@ class DbRunEventStore(RunEventStore):
|
||||
if not events:
|
||||
return []
|
||||
user_id = self._user_id_from_context()
|
||||
workspace_id = self._workspace_id_from_context()
|
||||
async with self._sf() as session:
|
||||
async with session.begin():
|
||||
# Get max seq for the thread (assume all events in batch belong to same thread).
|
||||
@@ -143,6 +167,7 @@ class DbRunEventStore(RunEventStore):
|
||||
thread_id=e["thread_id"],
|
||||
run_id=e["run_id"],
|
||||
user_id=e.get("user_id", user_id),
|
||||
workspace_id=e.get("workspace_id", workspace_id),
|
||||
event_type=e["event_type"],
|
||||
category=category,
|
||||
content=db_content,
|
||||
@@ -162,9 +187,13 @@ class DbRunEventStore(RunEventStore):
|
||||
before_seq=None,
|
||||
after_seq=None,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
):
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="DbRunEventStore.list_messages")
|
||||
resolved_workspace_id = resolve_workspace_id(workspace_id, method_name="DbRunEventStore.list_messages")
|
||||
stmt = select(RunEventRow).where(RunEventRow.thread_id == thread_id, RunEventRow.category == "message")
|
||||
if resolved_workspace_id is not None:
|
||||
stmt = stmt.where(RunEventRow.workspace_id == resolved_workspace_id)
|
||||
if resolved_user_id is not None:
|
||||
stmt = stmt.where(RunEventRow.user_id == resolved_user_id)
|
||||
if before_seq is not None:
|
||||
@@ -194,9 +223,13 @@ class DbRunEventStore(RunEventStore):
|
||||
event_types=None,
|
||||
limit=500,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
):
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="DbRunEventStore.list_events")
|
||||
resolved_workspace_id = resolve_workspace_id(workspace_id, method_name="DbRunEventStore.list_events")
|
||||
stmt = select(RunEventRow).where(RunEventRow.thread_id == thread_id, RunEventRow.run_id == run_id)
|
||||
if resolved_workspace_id is not None:
|
||||
stmt = stmt.where(RunEventRow.workspace_id == resolved_workspace_id)
|
||||
if resolved_user_id is not None:
|
||||
stmt = stmt.where(RunEventRow.user_id == resolved_user_id)
|
||||
if event_types:
|
||||
@@ -215,13 +248,17 @@ class DbRunEventStore(RunEventStore):
|
||||
before_seq=None,
|
||||
after_seq=None,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
):
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="DbRunEventStore.list_messages_by_run")
|
||||
resolved_workspace_id = resolve_workspace_id(workspace_id, method_name="DbRunEventStore.list_messages_by_run")
|
||||
stmt = select(RunEventRow).where(
|
||||
RunEventRow.thread_id == thread_id,
|
||||
RunEventRow.run_id == run_id,
|
||||
RunEventRow.category == "message",
|
||||
)
|
||||
if resolved_workspace_id is not None:
|
||||
stmt = stmt.where(RunEventRow.workspace_id == resolved_workspace_id)
|
||||
if resolved_user_id is not None:
|
||||
stmt = stmt.where(RunEventRow.user_id == resolved_user_id)
|
||||
if before_seq is not None:
|
||||
@@ -246,9 +283,13 @@ class DbRunEventStore(RunEventStore):
|
||||
thread_id,
|
||||
*,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
):
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="DbRunEventStore.count_messages")
|
||||
resolved_workspace_id = resolve_workspace_id(workspace_id, method_name="DbRunEventStore.count_messages")
|
||||
stmt = select(func.count()).select_from(RunEventRow).where(RunEventRow.thread_id == thread_id, RunEventRow.category == "message")
|
||||
if resolved_workspace_id is not None:
|
||||
stmt = stmt.where(RunEventRow.workspace_id == resolved_workspace_id)
|
||||
if resolved_user_id is not None:
|
||||
stmt = stmt.where(RunEventRow.user_id == resolved_user_id)
|
||||
async with self._sf() as session:
|
||||
@@ -259,10 +300,14 @@ class DbRunEventStore(RunEventStore):
|
||||
thread_id,
|
||||
*,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
):
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="DbRunEventStore.delete_by_thread")
|
||||
resolved_workspace_id = resolve_workspace_id(workspace_id, method_name="DbRunEventStore.delete_by_thread")
|
||||
async with self._sf() as session:
|
||||
count_conditions = [RunEventRow.thread_id == thread_id]
|
||||
if resolved_workspace_id is not None:
|
||||
count_conditions.append(RunEventRow.workspace_id == resolved_workspace_id)
|
||||
if resolved_user_id is not None:
|
||||
count_conditions.append(RunEventRow.user_id == resolved_user_id)
|
||||
count_stmt = select(func.count()).select_from(RunEventRow).where(*count_conditions)
|
||||
@@ -278,10 +323,14 @@ class DbRunEventStore(RunEventStore):
|
||||
run_id,
|
||||
*,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
||||
):
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="DbRunEventStore.delete_by_run")
|
||||
resolved_workspace_id = resolve_workspace_id(workspace_id, method_name="DbRunEventStore.delete_by_run")
|
||||
async with self._sf() as session:
|
||||
count_conditions = [RunEventRow.thread_id == thread_id, RunEventRow.run_id == run_id]
|
||||
if resolved_workspace_id is not None:
|
||||
count_conditions.append(RunEventRow.workspace_id == resolved_workspace_id)
|
||||
if resolved_user_id is not None:
|
||||
count_conditions.append(RunEventRow.user_id == resolved_user_id)
|
||||
count_stmt = select(func.count()).select_from(RunEventRow).where(*count_conditions)
|
||||
|
||||
@@ -19,6 +19,7 @@ from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from langgraph.store.base import BaseStore
|
||||
@@ -80,6 +81,57 @@ async def _async_store(config) -> AsyncIterator[BaseStore]:
|
||||
raise ValueError(f"Unknown store backend type: {config.type!r}")
|
||||
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def _async_store_from_database(db_config) -> AsyncIterator[BaseStore]:
|
||||
"""Async context manager that constructs a Store from a unified DatabaseConfig.
|
||||
|
||||
Mirrors :func:`deerflow.runtime.checkpointer.async_provider._async_checkpointer_from_database`
|
||||
so the store and checkpointer share one ``database`` section.
|
||||
"""
|
||||
if db_config.backend == "memory":
|
||||
from langgraph.store.memory import InMemoryStore
|
||||
|
||||
logger.info("Store: using InMemoryStore (in-process, not persistent)")
|
||||
yield InMemoryStore()
|
||||
return
|
||||
|
||||
if db_config.backend == "sqlite":
|
||||
try:
|
||||
from langgraph.store.sqlite.aio import AsyncSqliteStore
|
||||
except ImportError as exc:
|
||||
raise ImportError(SQLITE_STORE_INSTALL) from exc
|
||||
|
||||
conn_str = db_config.sqlite_path
|
||||
ensure_sqlite_parent_dir(conn_str)
|
||||
async with AsyncSqliteStore.from_conn_string(conn_str) as store:
|
||||
await store.setup()
|
||||
logger.info("Store: using AsyncSqliteStore (%s)", conn_str)
|
||||
yield store
|
||||
return
|
||||
|
||||
if db_config.backend == "postgres":
|
||||
try:
|
||||
from langgraph.store.postgres.aio import AsyncPostgresStore # type: ignore[import]
|
||||
except ImportError as exc:
|
||||
raise ImportError(POSTGRES_STORE_INSTALL) from exc
|
||||
|
||||
if not db_config.postgres_url:
|
||||
raise ValueError(POSTGRES_CONN_REQUIRED)
|
||||
|
||||
# LangGraph's AsyncPostgresStore wraps psycopg and expects a libpq-style
|
||||
# conninfo (`postgresql://...`). DeerFlow's SQLAlchemy engine uses the
|
||||
# same URL with the `+asyncpg` dialect prefix — strip it so one
|
||||
# DATABASE_URL satisfies both paths (same as the checkpointer factory).
|
||||
lg_conn_str = re.sub(r"^postgresql\+\w+://", "postgresql://", db_config.postgres_url)
|
||||
async with AsyncPostgresStore.from_conn_string(lg_conn_str) as store:
|
||||
await store.setup()
|
||||
logger.info("Store: using AsyncPostgresStore")
|
||||
yield store
|
||||
return
|
||||
|
||||
raise ValueError(f"Unknown database backend: {db_config.backend!r}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public async context manager
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -97,18 +149,29 @@ async def make_store(app_config: AppConfig | None = None) -> AsyncIterator[BaseS
|
||||
async with make_store(app_config) as store:
|
||||
app.state.store = store
|
||||
|
||||
Yields an :class:`~langgraph.store.memory.InMemoryStore` when no
|
||||
``checkpointer`` section is configured (emits a WARNING in that case).
|
||||
Priority (mirrors the checkpointer factory):
|
||||
1. Legacy ``checkpointer:`` config section (backward compatible)
|
||||
2. Unified ``database:`` config section
|
||||
3. Default InMemoryStore (emits a WARNING)
|
||||
"""
|
||||
if app_config is None:
|
||||
app_config = get_app_config()
|
||||
|
||||
if app_config.checkpointer is None:
|
||||
from langgraph.store.memory import InMemoryStore
|
||||
|
||||
logger.warning("No 'checkpointer' section in config.yaml — using InMemoryStore for the store. Thread list will be lost on server restart. Configure a sqlite or postgres backend for persistence.")
|
||||
yield InMemoryStore()
|
||||
# Legacy: standalone checkpointer config takes precedence
|
||||
if app_config.checkpointer is not None:
|
||||
async with _async_store(app_config.checkpointer) as store:
|
||||
yield store
|
||||
return
|
||||
|
||||
async with _async_store(app_config.checkpointer) as store:
|
||||
yield store
|
||||
# Unified database config
|
||||
db_config = getattr(app_config, "database", None)
|
||||
if db_config is not None and db_config.backend != "memory":
|
||||
async with _async_store_from_database(db_config) as store:
|
||||
yield store
|
||||
return
|
||||
|
||||
# Default: in-memory
|
||||
from langgraph.store.memory import InMemoryStore
|
||||
|
||||
logger.warning("No persistent store backend configured (no 'checkpointer' or 'database' section) — using InMemoryStore. Cross-thread store data will be lost on server restart. Configure a sqlite or postgres backend for persistence.")
|
||||
yield InMemoryStore()
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Request-scoped workspace context for multi-tenant authorization.
|
||||
|
||||
Sibling of :mod:`deerflow.runtime.user_context`. Holds a
|
||||
:class:`~contextvars.ContextVar` that the gateway's auth middleware sets
|
||||
after JWT verification (PR4 will wire this up). Repository methods read
|
||||
the contextvar via a sentinel default parameter, letting routers stay
|
||||
free of ``workspace_id`` boilerplate.
|
||||
|
||||
Three-state semantics for the repository ``workspace_id`` parameter:
|
||||
|
||||
- ``AUTO`` (sentinel, default): read from contextvar; raise
|
||||
:class:`RuntimeError` if unset.
|
||||
- Explicit ``str``: use the provided value, overriding contextvar.
|
||||
- Explicit ``None``: no WHERE clause — used only by migration scripts
|
||||
and admin CLIs that intentionally bypass workspace isolation.
|
||||
|
||||
Concept boundary
|
||||
----------------
|
||||
A workspace is the multi-tenant scope: a single-user free account is
|
||||
its own 1-person workspace; a team subscription is a multi-member
|
||||
workspace. The user_id contextvar narrows further to "which member of
|
||||
the workspace", letting some operations be member-scoped while others
|
||||
(skill install, billing, etc.) are workspace-scoped.
|
||||
|
||||
Dependency direction
|
||||
--------------------
|
||||
``persistence`` (lower layer) reads from this module; ``gateway.auth``
|
||||
(higher layer) writes to it. ``CurrentWorkspace`` is defined here as a
|
||||
:class:`typing.Protocol` so that ``persistence`` never needs to import
|
||||
the concrete ``Workspace`` row class from ``deerflow.persistence.workspace``.
|
||||
Any object with ``.id: str`` and ``.role: str`` attributes structurally
|
||||
satisfies the protocol.
|
||||
|
||||
Asyncio semantics
|
||||
-----------------
|
||||
Identical to ``user_context``: ``ContextVar`` is task-local under asyncio.
|
||||
``asyncio.create_task`` inherits the parent task's workspace context;
|
||||
threading.Timer does **not** (callers spawning timers must capture
|
||||
``get_effective_workspace_id()`` at enqueue time, the same way
|
||||
:mod:`deerflow.agents.memory.queue` captures ``user_id``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextvars import ContextVar, Token
|
||||
from typing import Final, Protocol, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CurrentWorkspace(Protocol):
|
||||
"""Structural type for the current active workspace.
|
||||
|
||||
Any object with ``.id: str`` and ``.role: str`` attributes satisfies
|
||||
this protocol. Concrete implementations live in
|
||||
``app.gateway.auth.models`` (PR4 will add them).
|
||||
|
||||
``role`` is the *caller's* role within this workspace
|
||||
(``'owner'`` / ``'admin'`` / ``'member'``), not the workspace's
|
||||
own metadata. Stage 0 sees ``'owner'`` only — Stage 2 RBAC rollout
|
||||
opens up the other values.
|
||||
"""
|
||||
|
||||
id: str
|
||||
role: str
|
||||
|
||||
|
||||
_current_workspace: Final[ContextVar[CurrentWorkspace | None]] = ContextVar("deerflow_current_workspace", default=None)
|
||||
|
||||
|
||||
def set_current_workspace(workspace: CurrentWorkspace) -> Token[CurrentWorkspace | None]:
|
||||
"""Set the current workspace for this async task.
|
||||
|
||||
Returns a reset token that should be passed to
|
||||
:func:`reset_current_workspace` in a ``finally`` block to restore
|
||||
the previous context.
|
||||
"""
|
||||
return _current_workspace.set(workspace)
|
||||
|
||||
|
||||
def reset_current_workspace(token: Token[CurrentWorkspace | None]) -> None:
|
||||
"""Restore the context to the state captured by ``token``."""
|
||||
_current_workspace.reset(token)
|
||||
|
||||
|
||||
def get_current_workspace() -> CurrentWorkspace | None:
|
||||
"""Return the current workspace, or ``None`` if unset.
|
||||
|
||||
Safe to call in any context. Used by code paths that can proceed
|
||||
without a workspace (migration scripts, public endpoints).
|
||||
"""
|
||||
return _current_workspace.get()
|
||||
|
||||
|
||||
def require_current_workspace() -> CurrentWorkspace:
|
||||
"""Return the current workspace, or raise :class:`RuntimeError`.
|
||||
|
||||
Used by repository code that must not be called outside a
|
||||
request-authenticated context. The error message is phrased so
|
||||
that a caller debugging a stack trace can locate the offending
|
||||
code path.
|
||||
"""
|
||||
workspace = _current_workspace.get()
|
||||
if workspace is None:
|
||||
raise RuntimeError("repository accessed without workspace context")
|
||||
return workspace
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Effective workspace_id helpers (filesystem isolation)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_WORKSPACE_ID: Final[str] = "default"
|
||||
|
||||
|
||||
def get_effective_workspace_id() -> str:
|
||||
"""Return the current workspace id as a string, or DEFAULT_WORKSPACE_ID if unset.
|
||||
|
||||
Unlike :func:`require_current_workspace` this never raises — it is
|
||||
designed for filesystem-path resolution where a valid workspace
|
||||
bucket is always needed (PR6 will switch
|
||||
``Paths.thread_dir(workspace_id=...)`` to read from here).
|
||||
"""
|
||||
workspace = _current_workspace.get()
|
||||
if workspace is None:
|
||||
return DEFAULT_WORKSPACE_ID
|
||||
return str(workspace.id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sentinel-based workspace_id resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Repository methods accept a ``workspace_id`` keyword-only argument that
|
||||
# defaults to ``AUTO``. The three possible values drive distinct
|
||||
# behaviours; see the docstring on :func:`resolve_workspace_id`.
|
||||
|
||||
|
||||
class _AutoSentinel:
|
||||
"""Singleton marker meaning 'resolve workspace_id from contextvar'."""
|
||||
|
||||
_instance: _AutoSentinel | None = None
|
||||
|
||||
def __new__(cls) -> _AutoSentinel:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "<AUTO>"
|
||||
|
||||
|
||||
AUTO: Final[_AutoSentinel] = _AutoSentinel()
|
||||
|
||||
|
||||
def resolve_workspace_id(
|
||||
value: str | None | _AutoSentinel,
|
||||
*,
|
||||
method_name: str = "repository method",
|
||||
) -> str | None:
|
||||
"""Resolve the workspace_id parameter passed to a repository method.
|
||||
|
||||
Three-state semantics:
|
||||
|
||||
- :data:`AUTO` (default): read from contextvar; raise
|
||||
:class:`RuntimeError` if no workspace is in context. This is the
|
||||
common case for request-scoped calls.
|
||||
- Explicit ``str``: use the provided id verbatim, overriding any
|
||||
contextvar value. Useful for tests and admin-override flows.
|
||||
- Explicit ``None``: no filter — the repository should skip the
|
||||
workspace_id WHERE clause entirely. Reserved for migration scripts
|
||||
and CLI tools that intentionally bypass workspace isolation.
|
||||
"""
|
||||
if isinstance(value, _AutoSentinel):
|
||||
workspace = _current_workspace.get()
|
||||
if workspace is None:
|
||||
raise RuntimeError(
|
||||
f"{method_name} called with workspace_id=AUTO but no workspace context is set; pass an explicit workspace_id, set the contextvar via auth middleware, or opt out with workspace_id=None for migration/CLI paths."
|
||||
)
|
||||
# Coerce to ``str`` at the boundary; persistence stores
|
||||
# ``workspace_id`` as ``String(36)`` (UUID v4 text).
|
||||
return str(workspace.id)
|
||||
return value
|
||||
@@ -46,6 +46,10 @@ postgres = [
|
||||
"psycopg[binary]>=3.3.3",
|
||||
"psycopg-pool>=3.3.0",
|
||||
]
|
||||
postgres-test = [
|
||||
"deerflow-harness[postgres]",
|
||||
"testcontainers[postgres]>=4.0",
|
||||
]
|
||||
pymupdf = ["pymupdf4llm>=0.0.17"]
|
||||
|
||||
[build-system]
|
||||
|
||||
@@ -25,6 +25,7 @@ dependencies = [
|
||||
|
||||
[project.optional-dependencies]
|
||||
postgres = ["deerflow-harness[postgres]"]
|
||||
postgres-test = ["deerflow-harness[postgres-test]"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
@@ -36,7 +37,9 @@ dev = [
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
markers = [
|
||||
"no_auto_user: disable the conftest autouse contextvar fixture for this test",
|
||||
"no_auto_user: disable the conftest autouse user contextvar fixture for this test",
|
||||
"no_auto_workspace: disable the conftest autouse workspace contextvar fixture for this test",
|
||||
"postgres: requires a Postgres testcontainer (Docker daemon); skipped if unavailable",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
"""Backfill ``workspace_id`` on PR5 business tables.
|
||||
|
||||
Three-step backfill (each idempotent — re-running picks up where a crash
|
||||
left off because every step's WHERE clause filters already-processed rows):
|
||||
|
||||
1. For each user without ``default_workspace_id``: create a personal
|
||||
workspace + ``owner`` membership + write back the user's
|
||||
``default_workspace_id``.
|
||||
2. ``UPDATE`` each of ``threads_meta`` / ``runs`` / ``feedback`` /
|
||||
``run_events`` setting ``workspace_id`` from the row's owner's
|
||||
``users.default_workspace_id``. Only touches rows where
|
||||
``workspace_id IS NULL`` and ``user_id IS NOT NULL``.
|
||||
3. Any rows still with ``workspace_id IS NULL`` (truly orphan — they had
|
||||
``user_id = NULL`` to begin with) are assigned the *legacy* workspace
|
||||
UUID ``00000000-0000-0000-0000-000000000000``. The script creates
|
||||
that workspace on demand, owned by the platform admin.
|
||||
|
||||
Usage::
|
||||
|
||||
PYTHONPATH=. python scripts/backfill_workspace_id.py [--dry-run]
|
||||
|
||||
T5.4 only ships the skeleton — the three step bodies are filled in by
|
||||
T5.5 / T5.6 / T5.7 along with their per-step tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.gateway.auth.workspace_slug import auto_slug_from_email, next_available_slug
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.feedback.model import FeedbackRow
|
||||
from deerflow.persistence.models.run_event import RunEventRow
|
||||
from deerflow.persistence.run.model import RunRow
|
||||
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
||||
from deerflow.persistence.user.model import UserRow
|
||||
from deerflow.persistence.workspace import WorkspaceRepository
|
||||
from deerflow.persistence.workspace.sql import SLUG_BLACKLIST
|
||||
from deerflow.persistence.workspace_membership import WorkspaceMembershipRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The legacy workspace anchor. Stage 0 LOCK'd UUID — chosen as the standard
|
||||
# nil UUID so SQL log scans can spot it instantly.
|
||||
LEGACY_WORKSPACE_ID = "00000000-0000-0000-0000-000000000000"
|
||||
LEGACY_WORKSPACE_SLUG = "legacy"
|
||||
LEGACY_WORKSPACE_NAME = "Legacy Workspace"
|
||||
|
||||
# The four business tables that gained ``workspace_id`` in alembic 0002.
|
||||
_BUSINESS_TABLES: tuple[str, ...] = ("threads_meta", "runs", "feedback", "run_events")
|
||||
|
||||
# Map table-name to ORM class so we can build a portable correlated UPDATE
|
||||
# using SQLAlchemy expression language (SQLite < 3.33 lacks UPDATE-FROM
|
||||
# but supports correlated subqueries on every version we ship).
|
||||
_TABLE_MODELS: dict[str, type[Base]] = {
|
||||
"threads_meta": ThreadMetaRow,
|
||||
"runs": RunRow,
|
||||
"feedback": FeedbackRow,
|
||||
"run_events": RunEventRow,
|
||||
}
|
||||
|
||||
|
||||
async def _step1_create_workspaces_for_users(
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
*,
|
||||
dry_run: bool,
|
||||
) -> int:
|
||||
"""Create one workspace + owner membership for each user missing default_workspace_id.
|
||||
|
||||
Returns the count of users a workspace was created for. Idempotent —
|
||||
users that already have ``default_workspace_id`` are skipped so a
|
||||
crashed run can resume safely.
|
||||
"""
|
||||
async with session_factory() as session:
|
||||
result = await session.execute(select(UserRow.id, UserRow.email).where(UserRow.default_workspace_id.is_(None)))
|
||||
candidates = [(row.id, row.email) for row in result]
|
||||
|
||||
if not candidates:
|
||||
return 0
|
||||
|
||||
ws_repo = WorkspaceRepository(session_factory)
|
||||
m_repo = WorkspaceMembershipRepository(session_factory)
|
||||
created = 0
|
||||
for user_id, email in candidates:
|
||||
base_slug = auto_slug_from_email(email)
|
||||
|
||||
async def slug_exists(s: str) -> bool:
|
||||
if s in SLUG_BLACKLIST:
|
||||
return True
|
||||
return (await ws_repo.get_by_slug(s)) is not None
|
||||
|
||||
unique_slug = await next_available_slug(base_slug, exists_check=slug_exists)
|
||||
|
||||
if dry_run:
|
||||
logger.info("WOULD create workspace for user=%s email=%s slug=%s", user_id, email, unique_slug)
|
||||
created += 1
|
||||
continue
|
||||
|
||||
display_local = email.split("@", 1)[0]
|
||||
workspace = await ws_repo.create(
|
||||
name=f"{display_local}'s Workspace"[:64],
|
||||
slug=unique_slug,
|
||||
owner_id=user_id,
|
||||
)
|
||||
await m_repo.add(workspace_id=workspace["id"], user_id=user_id, role="owner")
|
||||
async with session_factory() as session:
|
||||
await session.execute(update(UserRow).where(UserRow.id == user_id).values(default_workspace_id=workspace["id"]))
|
||||
await session.commit()
|
||||
created += 1
|
||||
logger.info("Created workspace %s (slug=%s) for user=%s", workspace["id"], unique_slug, user_id)
|
||||
|
||||
return created
|
||||
|
||||
|
||||
async def _step2_update_table_from_users(
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
table: str,
|
||||
*,
|
||||
dry_run: bool,
|
||||
) -> int:
|
||||
"""UPDATE *table* setting workspace_id from owner's users.default_workspace_id.
|
||||
|
||||
Uses a correlated subquery (portable across SQLite + Postgres). Filters
|
||||
``workspace_id IS NULL AND user_id IS NOT NULL`` so already-set rows
|
||||
and truly orphan rows are skipped (Step 3 handles the latter).
|
||||
|
||||
Returns the number of rows updated (or that *would* be updated under
|
||||
``dry_run``).
|
||||
"""
|
||||
model = _TABLE_MODELS[table]
|
||||
workspace_col = model.workspace_id
|
||||
user_col = model.user_id
|
||||
|
||||
# Subquery: pull the user's default_workspace_id for each row.
|
||||
correlated_default = select(UserRow.default_workspace_id).where(UserRow.id == user_col).scalar_subquery()
|
||||
|
||||
if dry_run:
|
||||
# Count rows whose owner has a default_workspace_id assigned — only
|
||||
# those would get touched by the actual UPDATE.
|
||||
count_stmt = select(func.count()).select_from(model).join(UserRow, UserRow.id == user_col).where(workspace_col.is_(None), user_col.is_not(None), UserRow.default_workspace_id.is_not(None))
|
||||
async with session_factory() as session:
|
||||
count = (await session.execute(count_stmt)).scalar_one() or 0
|
||||
logger.info("WOULD update %d rows in %s from users.default_workspace_id", count, table)
|
||||
return int(count)
|
||||
|
||||
stmt = update(model).where(workspace_col.is_(None), user_col.is_not(None)).values(workspace_id=correlated_default)
|
||||
async with session_factory() as session:
|
||||
result = await session.execute(stmt)
|
||||
await session.commit()
|
||||
rowcount = result.rowcount or 0
|
||||
logger.info("Updated %d rows in %s from users.default_workspace_id", rowcount, table)
|
||||
return int(rowcount)
|
||||
|
||||
|
||||
async def _ensure_legacy_workspace(
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
*,
|
||||
dry_run: bool,
|
||||
) -> bool:
|
||||
"""Create the ``legacy_workspace`` anchor row idempotently.
|
||||
|
||||
The anchor is needed before Step 3 can point orphan rows at it. We
|
||||
pick the platform admin (``system_role='admin'``) as owner; if no
|
||||
admin exists yet we fall back to the oldest user. If the database
|
||||
has no users at all we refuse to continue — running this script on
|
||||
an unbootstrapped DB would create a workspace with no owner and the
|
||||
FK to ``users`` would fail anyway.
|
||||
|
||||
Returns True if the workspace was just created (or would be, under
|
||||
``dry_run``). False if it already existed.
|
||||
"""
|
||||
from deerflow.persistence.workspace.model import WorkspaceRow
|
||||
|
||||
async with session_factory() as session:
|
||||
existing = await session.get(WorkspaceRow, LEGACY_WORKSPACE_ID)
|
||||
if existing is not None:
|
||||
return False
|
||||
|
||||
async with session_factory() as session:
|
||||
admin_id = (await session.execute(select(UserRow.id).where(UserRow.system_role == "admin").order_by(UserRow.created_at).limit(1))).scalar_one_or_none()
|
||||
if admin_id is None:
|
||||
admin_id = (await session.execute(select(UserRow.id).order_by(UserRow.created_at).limit(1))).scalar_one_or_none()
|
||||
|
||||
if admin_id is None:
|
||||
raise RuntimeError(
|
||||
"Cannot create legacy_workspace: no users exist. Bootstrap an admin via /auth/initialize before running backfill.",
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
logger.info("WOULD create legacy_workspace (id=%s) owned by user=%s", LEGACY_WORKSPACE_ID, admin_id)
|
||||
return True
|
||||
|
||||
ws_repo = WorkspaceRepository(session_factory)
|
||||
await ws_repo.create(
|
||||
workspace_id=LEGACY_WORKSPACE_ID,
|
||||
name=LEGACY_WORKSPACE_NAME,
|
||||
slug=LEGACY_WORKSPACE_SLUG,
|
||||
owner_id=admin_id,
|
||||
)
|
||||
m_repo = WorkspaceMembershipRepository(session_factory)
|
||||
await m_repo.add(workspace_id=LEGACY_WORKSPACE_ID, user_id=admin_id, role="owner")
|
||||
logger.info("Created legacy_workspace (id=%s) owned by user=%s", LEGACY_WORKSPACE_ID, admin_id)
|
||||
return True
|
||||
|
||||
|
||||
async def _step3_assign_legacy_workspace(
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
table: str,
|
||||
*,
|
||||
dry_run: bool,
|
||||
) -> int:
|
||||
"""Assign LEGACY_WORKSPACE_ID to *table* rows still missing workspace_id.
|
||||
|
||||
Callers should ensure :func:`_ensure_legacy_workspace` has run first;
|
||||
the orchestrator does this between Step 2 and Step 3. Orphan rows are
|
||||
rows whose ``user_id`` was already NULL (or pointed at a deleted user)
|
||||
so Step 2's correlated subquery left them untouched.
|
||||
"""
|
||||
model = _TABLE_MODELS[table]
|
||||
workspace_col = model.workspace_id
|
||||
|
||||
if dry_run:
|
||||
count_stmt = select(func.count()).select_from(model).where(workspace_col.is_(None))
|
||||
async with session_factory() as session:
|
||||
count = (await session.execute(count_stmt)).scalar_one() or 0
|
||||
logger.info("WOULD assign %d orphan row(s) in %s to legacy_workspace", count, table)
|
||||
return int(count)
|
||||
|
||||
stmt = update(model).where(workspace_col.is_(None)).values(workspace_id=LEGACY_WORKSPACE_ID)
|
||||
async with session_factory() as session:
|
||||
result = await session.execute(stmt)
|
||||
await session.commit()
|
||||
rowcount = result.rowcount or 0
|
||||
logger.info("Assigned %d orphan row(s) in %s to legacy_workspace", rowcount, table)
|
||||
return int(rowcount)
|
||||
|
||||
|
||||
async def backfill(
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Run all three backfill steps; return a per-step row-count report.
|
||||
|
||||
Order matters: Step 1 must populate ``users.default_workspace_id``
|
||||
before Step 2 can correlate business rows back through ``users``.
|
||||
"""
|
||||
report: dict[str, Any] = {"dry_run": dry_run}
|
||||
|
||||
report["users_workspaces_created"] = await _step1_create_workspaces_for_users(session_factory, dry_run=dry_run)
|
||||
for table in _BUSINESS_TABLES:
|
||||
report[f"{table}_from_users"] = await _step2_update_table_from_users(session_factory, table, dry_run=dry_run)
|
||||
report["legacy_workspace_created"] = await _ensure_legacy_workspace(session_factory, dry_run=dry_run)
|
||||
for table in _BUSINESS_TABLES:
|
||||
report[f"{table}_legacy"] = await _step3_assign_legacy_workspace(session_factory, table, dry_run=dry_run)
|
||||
|
||||
return report
|
||||
|
||||
|
||||
def _build_session_factory_from_config() -> async_sessionmaker[AsyncSession]:
|
||||
"""Build an async session factory from the active config.yaml.
|
||||
|
||||
Avoids importing on module load so unit tests can stub
|
||||
``session_factory`` directly without booting the full config pipeline.
|
||||
"""
|
||||
from deerflow.config import get_app_config
|
||||
from deerflow.persistence.engine import get_session_factory, init_engine_from_config
|
||||
|
||||
asyncio.run(init_engine_from_config(get_app_config().database))
|
||||
sf = get_session_factory()
|
||||
if sf is None:
|
||||
raise RuntimeError(
|
||||
"database.backend=memory: nothing to backfill. Switch config.yaml to sqlite/postgres first.",
|
||||
)
|
||||
return sf
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Backfill workspace_id on Stage 0 business tables (idempotent).")
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Print the rows each step would touch without writing.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
||||
|
||||
sf = _build_session_factory_from_config()
|
||||
report = asyncio.run(backfill(sf, dry_run=args.dry_run))
|
||||
|
||||
logger.info("Backfill report (dry_run=%s):", args.dry_run)
|
||||
for key, value in report.items():
|
||||
if key == "dry_run":
|
||||
continue
|
||||
logger.info(" %s: %s", key, value)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,212 @@
|
||||
"""One-time migration: lift per-user thread / memory / agent dirs into per-workspace layout.
|
||||
|
||||
PR4 introduced ``{base_dir}/users/{user_id}/...`` for per-user isolation. PR6
|
||||
adds the multi-tenant top dimension: ``{base_dir}/workspaces/{wid}/...``,
|
||||
with per-user state nested under each workspace
|
||||
(``{base_dir}/workspaces/{wid}/users/{uid}/memory.json`` etc.). This script
|
||||
walks the old PR4 layout, looks up each user's ``default_workspace_id``
|
||||
from the ``users`` table, and rewrites the path.
|
||||
|
||||
Usage:
|
||||
PYTHONPATH=. python scripts/migrate_paths_to_workspace.py [--dry-run] [--default-workspace WID]
|
||||
|
||||
The script is idempotent — re-running it after a successful migration is a no-op.
|
||||
A ``--dry-run`` invocation must not write anything; it only logs what would happen.
|
||||
|
||||
Mapping rules:
|
||||
|
||||
- ``{base_dir}/users/{uid}/threads/{tid}/`` -> ``{base_dir}/workspaces/{wid}/threads/{tid}/``
|
||||
- ``{base_dir}/users/{uid}/memory.json`` -> ``{base_dir}/workspaces/{wid}/users/{uid}/memory.json``
|
||||
- ``{base_dir}/users/{uid}/agents/{name}/`` -> ``{base_dir}/workspaces/{wid}/users/{uid}/agents/{name}/``
|
||||
|
||||
``{wid}`` is read from ``users.default_workspace_id``. Users without one
|
||||
fall through to ``--default-workspace`` (defaults to the special bucket
|
||||
``"legacy_workspace"`` so they can be triaged manually). Pre-existing
|
||||
destinations are preserved; legacy copies are moved to
|
||||
``{base_dir}/migration-conflicts/<...>`` for human review.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import shutil
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from deerflow.config.paths import Paths, get_paths
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LEGACY_WORKSPACE_FALLBACK = "legacy_workspace"
|
||||
|
||||
|
||||
def _load_user_workspaces(paths: Paths) -> dict[str, str | None]:
|
||||
"""Read ``user_id -> default_workspace_id`` from the local sqlite DB.
|
||||
|
||||
Returns an empty dict when the database does not exist (fresh install
|
||||
/ Postgres-only deployments). The Postgres path is out of scope for
|
||||
this script — the operator should run it with ``--default-workspace``
|
||||
set to the target workspace and skip the DB lookup.
|
||||
"""
|
||||
db_path = paths.base_dir / "deer-flow.db"
|
||||
if not db_path.exists():
|
||||
logger.info("No sqlite database at %s — every user will use the fallback workspace.", db_path)
|
||||
return {}
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
try:
|
||||
try:
|
||||
cursor = conn.execute("SELECT id, default_workspace_id FROM users")
|
||||
except sqlite3.OperationalError as e:
|
||||
logger.warning("Failed to query users.default_workspace_id: %s", e)
|
||||
return {}
|
||||
return {row[0]: row[1] for row in cursor.fetchall()}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _resolve_workspace(user_id: str, user_workspaces: dict[str, str | None], fallback: str) -> str:
|
||||
wid = user_workspaces.get(user_id)
|
||||
return wid or fallback
|
||||
|
||||
|
||||
def _move(src: Path, dest: Path, conflict_root: Path, *, dry_run: bool, label: str) -> str:
|
||||
"""Move ``src`` to ``dest``; on conflict, divert legacy under ``conflict_root``.
|
||||
|
||||
Returns a short string describing what happened (for the report).
|
||||
"""
|
||||
if dest.exists():
|
||||
conflict_dest = conflict_root / label / src.name
|
||||
if not dry_run:
|
||||
conflict_dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(src), str(conflict_dest))
|
||||
logger.warning("Conflict for %s/%s: legacy copy diverted to %s", label, src.name, conflict_dest)
|
||||
return f"conflict -> {conflict_dest}"
|
||||
|
||||
if not dry_run:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(src), str(dest))
|
||||
return f"moved -> {dest}"
|
||||
|
||||
|
||||
def migrate_user_tree(
|
||||
paths: Paths,
|
||||
user_id: str,
|
||||
workspace_id: str,
|
||||
*,
|
||||
dry_run: bool,
|
||||
) -> list[dict]:
|
||||
"""Lift one user's tree under a workspace. Returns per-asset report rows."""
|
||||
report: list[dict] = []
|
||||
legacy_user_dir = paths.base_dir / "users" / user_id
|
||||
if not legacy_user_dir.exists():
|
||||
return report
|
||||
|
||||
conflict_root = paths.base_dir / "migration-conflicts" / "workspace-migration"
|
||||
|
||||
# 1. threads/{tid}/ -> workspaces/{wid}/threads/{tid}/
|
||||
legacy_threads = legacy_user_dir / "threads"
|
||||
if legacy_threads.exists():
|
||||
for thread_dir in sorted(legacy_threads.iterdir()):
|
||||
if not thread_dir.is_dir():
|
||||
continue
|
||||
dest = paths.thread_dir(thread_dir.name, workspace_id=workspace_id)
|
||||
action = _move(thread_dir, dest, conflict_root, dry_run=dry_run, label=f"threads/{workspace_id}")
|
||||
report.append({"asset": "thread", "user_id": user_id, "workspace_id": workspace_id, "name": thread_dir.name, "action": action})
|
||||
if not dry_run and legacy_threads.exists() and not any(legacy_threads.iterdir()):
|
||||
legacy_threads.rmdir()
|
||||
|
||||
# 2. memory.json -> workspaces/{wid}/users/{uid}/memory.json
|
||||
legacy_mem = legacy_user_dir / "memory.json"
|
||||
if legacy_mem.exists():
|
||||
dest = paths.user_memory_file(user_id, workspace_id=workspace_id)
|
||||
action = _move(legacy_mem, dest, conflict_root, dry_run=dry_run, label=f"users/{user_id}/memory")
|
||||
report.append({"asset": "memory", "user_id": user_id, "workspace_id": workspace_id, "name": "memory.json", "action": action})
|
||||
|
||||
# 3. agents/{name}/ -> workspaces/{wid}/users/{uid}/agents/{name}/
|
||||
legacy_agents = legacy_user_dir / "agents"
|
||||
if legacy_agents.exists():
|
||||
for agent_dir in sorted(legacy_agents.iterdir()):
|
||||
if not agent_dir.is_dir():
|
||||
continue
|
||||
dest = paths.user_agent_dir(user_id, agent_dir.name, workspace_id=workspace_id)
|
||||
action = _move(agent_dir, dest, conflict_root, dry_run=dry_run, label=f"users/{user_id}/agents")
|
||||
report.append({"asset": "agent", "user_id": user_id, "workspace_id": workspace_id, "name": agent_dir.name, "action": action})
|
||||
if not dry_run and legacy_agents.exists() and not any(legacy_agents.iterdir()):
|
||||
legacy_agents.rmdir()
|
||||
|
||||
# 4. If the user dir is now empty, remove it so lifespan warnings clear.
|
||||
if not dry_run and legacy_user_dir.exists() and not any(legacy_user_dir.iterdir()):
|
||||
legacy_user_dir.rmdir()
|
||||
|
||||
return report
|
||||
|
||||
|
||||
def migrate(
|
||||
paths: Paths,
|
||||
*,
|
||||
user_workspaces: dict[str, str | None],
|
||||
fallback_workspace: str,
|
||||
dry_run: bool,
|
||||
) -> list[dict]:
|
||||
"""Top-level entry point: iterate every user under ``{base_dir}/users``."""
|
||||
legacy_users = paths.base_dir / "users"
|
||||
if not legacy_users.exists():
|
||||
logger.info("No legacy ``users/`` directory under %s — nothing to migrate.", paths.base_dir)
|
||||
return []
|
||||
|
||||
report: list[dict] = []
|
||||
for user_dir in sorted(legacy_users.iterdir()):
|
||||
if not user_dir.is_dir():
|
||||
continue
|
||||
user_id = user_dir.name
|
||||
workspace_id = _resolve_workspace(user_id, user_workspaces, fallback_workspace)
|
||||
logger.info("Migrating user %s -> workspace %s", user_id, workspace_id)
|
||||
report.extend(migrate_user_tree(paths, user_id, workspace_id, dry_run=dry_run))
|
||||
|
||||
if not dry_run and legacy_users.exists() and not any(legacy_users.iterdir()):
|
||||
legacy_users.rmdir()
|
||||
|
||||
return report
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Lift legacy per-user paths into per-workspace layout (PR6).")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Log actions without making changes.")
|
||||
parser.add_argument(
|
||||
"--default-workspace",
|
||||
default=LEGACY_WORKSPACE_FALLBACK,
|
||||
metavar="WID",
|
||||
help=(f"Workspace id to use for users without a ``default_workspace_id`` in the DB. Defaults to ``{LEGACY_WORKSPACE_FALLBACK}`` (matches the orphan-row bucket used by PR5 backfill)."),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
||||
|
||||
paths = get_paths()
|
||||
logger.info("Base directory: %s", paths.base_dir)
|
||||
logger.info("Dry run: %s", args.dry_run)
|
||||
logger.info("Fallback workspace: %s", args.default_workspace)
|
||||
|
||||
user_workspaces = _load_user_workspaces(paths)
|
||||
logger.info("Loaded %d user->workspace mappings from DB", len(user_workspaces))
|
||||
|
||||
report = migrate(
|
||||
paths,
|
||||
user_workspaces=user_workspaces,
|
||||
fallback_workspace=args.default_workspace,
|
||||
dry_run=args.dry_run,
|
||||
)
|
||||
|
||||
if not report:
|
||||
logger.info("Nothing to migrate.")
|
||||
return
|
||||
|
||||
logger.info("Migration report (%d entries):", len(report))
|
||||
for entry in report:
|
||||
logger.info(" asset=%s user=%s workspace=%s name=%s action=%s", entry["asset"], entry["user_id"], entry["workspace_id"], entry["name"], entry["action"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+436
@@ -0,0 +1,436 @@
|
||||
#!/usr/bin/env bash
|
||||
# verify_stage0.sh — systematic verification of the Stage 0 multi-tenant rollout.
|
||||
#
|
||||
# Layers (each can be run individually; defaults run all that are applicable):
|
||||
# static — full pytest suite + lint + boundary scan (no external deps)
|
||||
# paths — local filesystem layout: legacy users/ should be empty after migration
|
||||
# rds — schema + index + alembic state on the remote Postgres (needs DATABASE_URL)
|
||||
# runtime — gateway health probes (needs `make dev` running)
|
||||
# e2e — register 2 users via curl, each creates a thread, cross-access must 404
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/verify_stage0.sh # run everything that has prerequisites
|
||||
# ./scripts/verify_stage0.sh static rds # only those two
|
||||
# DATABASE_URL=postgres://... ./scripts/verify_stage0.sh
|
||||
# GATEWAY_URL=http://localhost:8001 ./scripts/verify_stage0.sh runtime e2e
|
||||
#
|
||||
# Exit code: 0 if every executed assertion passes, 1 if any fail.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# ── colours ──────────────────────────────────────────────────────────────
|
||||
if [ -t 1 ]; then
|
||||
C_RED=$'\033[0;31m'; C_GREEN=$'\033[0;32m'; C_YELLOW=$'\033[0;33m'
|
||||
C_BLUE=$'\033[0;34m'; C_BOLD=$'\033[1m'; C_DIM=$'\033[2m'; C_RESET=$'\033[0m'
|
||||
else
|
||||
C_RED=''; C_GREEN=''; C_YELLOW=''; C_BLUE=''; C_BOLD=''; C_DIM=''; C_RESET=''
|
||||
fi
|
||||
|
||||
# ── counters ─────────────────────────────────────────────────────────────
|
||||
PASS_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
WARN_COUNT=0
|
||||
FAILED_STEPS=()
|
||||
SKIPPED_PHASES=()
|
||||
|
||||
ok() { printf ' %s✓%s %s\n' "$C_GREEN" "$C_RESET" "$1"; PASS_COUNT=$((PASS_COUNT+1)); }
|
||||
fail() { printf ' %s✗%s %s\n' "$C_RED" "$C_RESET" "$1"; FAIL_COUNT=$((FAIL_COUNT+1)); FAILED_STEPS+=("$1"); }
|
||||
warn() { printf ' %s!%s %s\n' "$C_YELLOW" "$C_RESET" "$1"; WARN_COUNT=$((WARN_COUNT+1)); }
|
||||
info() { printf ' %s·%s %s\n' "$C_DIM" "$C_RESET" "$1"; }
|
||||
phase() { printf '\n%s== %s ==%s\n' "$C_BLUE$C_BOLD" "$1" "$C_RESET"; }
|
||||
|
||||
# Run-cmd helpers — capture both streams for grep but return original exit code.
|
||||
run() {
|
||||
local label="$1"; shift
|
||||
local out
|
||||
out=$("$@" 2>&1)
|
||||
local rc=$?
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
ok "$label"
|
||||
printf '%s' "$out"
|
||||
return 0
|
||||
fi
|
||||
fail "$label (exit $rc)"
|
||||
printf '%s%s%s\n' "$C_DIM" "$out" "$C_RESET" >&2
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
# ── env ──────────────────────────────────────────────────────────────────
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
BACKEND_DIR="$REPO_ROOT/backend"
|
||||
GATEWAY_URL="${GATEWAY_URL:-http://localhost:8001}"
|
||||
DEER_FLOW_HOME="${DEER_FLOW_HOME:-$REPO_ROOT/.deer-flow}"
|
||||
|
||||
# ── phase: static ────────────────────────────────────────────────────────
|
||||
phase_static() {
|
||||
phase "STATIC — unit + boundary + full pytest"
|
||||
cd "$BACKEND_DIR"
|
||||
|
||||
# boundary scans first — fast and high-signal
|
||||
info "running boundary scans (harness + workspace)"
|
||||
if PYTHONPATH=. uv run pytest -q \
|
||||
tests/test_harness_boundary.py \
|
||||
tests/test_workspace_boundary.py \
|
||||
tests/test_workspace_boundary_self.py >/tmp/verify_boundary.log 2>&1; then
|
||||
local boundary_passed
|
||||
boundary_passed=$(grep -Eo '[0-9]+ passed' /tmp/verify_boundary.log | head -1)
|
||||
ok "boundary scans green ($boundary_passed)"
|
||||
else
|
||||
fail "boundary scans red — see /tmp/verify_boundary.log"
|
||||
fi
|
||||
|
||||
info "running full pytest (this takes ~90-130s)"
|
||||
local pytest_log=/tmp/verify_full_pytest.log
|
||||
PYTHONPATH=. uv run pytest -q >"$pytest_log" 2>&1
|
||||
local rc=$?
|
||||
local last_line
|
||||
last_line=$(tail -1 "$pytest_log")
|
||||
info "result: $last_line"
|
||||
# Parse "<X> passed, <Y> failed, <Z> skipped"
|
||||
local passed_n failed_n
|
||||
passed_n=$(printf '%s' "$last_line" | grep -Eo '[0-9]+ passed' | grep -Eo '[0-9]+' | head -1 || echo 0)
|
||||
failed_n=$(printf '%s' "$last_line" | grep -Eo '[0-9]+ failed' | grep -Eo '[0-9]+' | head -1 || echo 0)
|
||||
if [ "${passed_n:-0}" -ge 3250 ]; then
|
||||
ok "pytest pass count $passed_n ≥ 3250 (PR8 baseline)"
|
||||
else
|
||||
fail "pytest pass count $passed_n < 3250 — regression suspected"
|
||||
fi
|
||||
if [ "${failed_n:-0}" -le 18 ]; then
|
||||
ok "pytest fail count $failed_n ≤ 18 (Stage 0 known-flake ceiling)"
|
||||
[ "${failed_n:-0}" -gt 0 ] && warn "non-zero failures expected to be in the 18 caplog flake set; cross-check tail of /tmp/verify_full_pytest.log"
|
||||
else
|
||||
fail "pytest fail count $failed_n > 18 — new failures introduced beyond known caplog flake set"
|
||||
fi
|
||||
|
||||
info "running ruff lint"
|
||||
if make lint >/tmp/verify_lint.log 2>&1; then
|
||||
ok "ruff lint clean"
|
||||
else
|
||||
fail "ruff lint dirty — see /tmp/verify_lint.log"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── phase: paths ─────────────────────────────────────────────────────────
|
||||
phase_paths() {
|
||||
phase "PATHS — legacy users/ migration state"
|
||||
if [ ! -d "$DEER_FLOW_HOME" ]; then
|
||||
warn "DEER_FLOW_HOME ($DEER_FLOW_HOME) does not exist — fresh install, nothing to migrate"
|
||||
return
|
||||
fi
|
||||
|
||||
info "DEER_FLOW_HOME = $DEER_FLOW_HOME"
|
||||
local legacy_dir="$DEER_FLOW_HOME/users"
|
||||
if [ ! -d "$legacy_dir" ]; then
|
||||
ok "no legacy users/ directory present (PR6 migration not needed or already done)"
|
||||
else
|
||||
local count
|
||||
count=$(find "$legacy_dir" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l | tr -d ' ')
|
||||
if [ "$count" -eq 0 ]; then
|
||||
ok "legacy users/ is empty (migration complete or no prior data)"
|
||||
else
|
||||
warn "legacy users/ still has $count user dir(s) — run \`make migrate-paths\` (after \`make migrate-paths DRY_RUN=1\` to preview)"
|
||||
fi
|
||||
fi
|
||||
|
||||
local workspace_dir="$DEER_FLOW_HOME/workspaces"
|
||||
if [ -d "$workspace_dir" ]; then
|
||||
local wcount
|
||||
wcount=$(find "$workspace_dir" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l | tr -d ' ')
|
||||
ok "workspaces/ layout in place ($wcount workspace dir(s))"
|
||||
else
|
||||
warn "workspaces/ dir does not exist yet — will be created when the first thread is opened"
|
||||
fi
|
||||
|
||||
# Stage 0 PR6 dry-run check: just exercise the script flag, do not perform writes.
|
||||
info "exercising migrate-paths dry-run (no writes)"
|
||||
cd "$REPO_ROOT"
|
||||
if make migrate-paths DRY_RUN=1 >/tmp/verify_migrate_dry.log 2>&1; then
|
||||
ok "make migrate-paths DRY_RUN=1 ran without error"
|
||||
else
|
||||
fail "make migrate-paths DRY_RUN=1 failed — see /tmp/verify_migrate_dry.log"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── phase: rds ───────────────────────────────────────────────────────────
|
||||
phase_rds() {
|
||||
phase "RDS — schema + alembic + indexes"
|
||||
if [ -z "${DATABASE_URL:-}" ]; then
|
||||
warn "DATABASE_URL not set — skipping RDS phase"
|
||||
SKIPPED_PHASES+=("rds (DATABASE_URL unset)")
|
||||
return
|
||||
fi
|
||||
if ! command -v psql >/dev/null 2>&1; then
|
||||
fail "psql not installed — cannot run RDS phase"
|
||||
return
|
||||
fi
|
||||
info "DATABASE_URL host: $(printf '%s' "$DATABASE_URL" | sed -E 's#.*@([^/]+)/.*#\1#')"
|
||||
|
||||
# PG version sanity
|
||||
local pg_version
|
||||
pg_version=$(psql "$DATABASE_URL" -tA -c "SELECT version()" 2>/dev/null | head -1)
|
||||
if [ -z "$pg_version" ]; then
|
||||
fail "cannot connect to RDS — check DATABASE_URL"
|
||||
return
|
||||
fi
|
||||
ok "PG reachable: $pg_version"
|
||||
|
||||
# Alembic head
|
||||
info "checking alembic head against expected 0003"
|
||||
cd "$BACKEND_DIR"
|
||||
if PYTHONPATH=. uv run alembic current 2>/tmp/verify_alembic.err | tee /tmp/verify_alembic.log | grep -q "^0003"; then
|
||||
ok "alembic current is at 0003 (workspace_id NOT NULL + UNIQUE(wid, tid))"
|
||||
else
|
||||
fail "alembic current is not at 0003 — see /tmp/verify_alembic.log"
|
||||
fi
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# PR8 tables present
|
||||
info "checking PR8 tables exist"
|
||||
local pr8_count
|
||||
pr8_count=$(psql "$DATABASE_URL" -tA -c "
|
||||
SELECT count(*) FROM information_schema.tables
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name IN ('service_accounts','api_keys','external_users');
|
||||
" 2>/dev/null | tr -d ' ')
|
||||
if [ "${pr8_count:-0}" -eq 3 ]; then
|
||||
ok "service_accounts + api_keys + external_users all present"
|
||||
else
|
||||
fail "PR8 tables missing — expected 3, got ${pr8_count:-0}"
|
||||
fi
|
||||
|
||||
# Partial index on api_keys: WHERE revoked_at IS NULL
|
||||
info "checking idx_api_keys_active partial-index predicate"
|
||||
local idx_def
|
||||
idx_def=$(psql "$DATABASE_URL" -tA -c "
|
||||
SELECT indexdef FROM pg_indexes
|
||||
WHERE schemaname = current_schema()
|
||||
AND indexname = 'idx_api_keys_active';
|
||||
" 2>/dev/null | head -1)
|
||||
if printf '%s' "$idx_def" | grep -qi 'WHERE.*revoked_at IS NULL'; then
|
||||
ok "idx_api_keys_active has 'WHERE revoked_at IS NULL' predicate"
|
||||
else
|
||||
fail "idx_api_keys_active missing or wrong predicate — got: ${idx_def:-<none>}"
|
||||
fi
|
||||
|
||||
# 4 business tables workspace_id NOT NULL
|
||||
info "checking workspace_id NOT NULL on 4 business tables"
|
||||
local nullable_rows
|
||||
nullable_rows=$(psql "$DATABASE_URL" -tA -c "
|
||||
SELECT table_name FROM information_schema.columns
|
||||
WHERE table_schema = current_schema()
|
||||
AND column_name = 'workspace_id'
|
||||
AND table_name IN ('thread_meta','runs','feedback','run_events')
|
||||
AND is_nullable = 'YES';
|
||||
" 2>/dev/null)
|
||||
if [ -z "$nullable_rows" ]; then
|
||||
ok "thread_meta + runs + feedback + run_events all have workspace_id NOT NULL"
|
||||
else
|
||||
fail "workspace_id is nullable in: $(printf '%s' "$nullable_rows" | tr '\n' ' ')"
|
||||
fi
|
||||
|
||||
# Unique (workspace_id, thread_id) on thread_meta
|
||||
info "checking UNIQUE(workspace_id, thread_id) on thread_meta"
|
||||
local uq_count
|
||||
uq_count=$(psql "$DATABASE_URL" -tA -c "
|
||||
SELECT count(*) FROM pg_indexes
|
||||
WHERE schemaname = current_schema()
|
||||
AND tablename = 'thread_meta'
|
||||
AND indexdef ILIKE '%UNIQUE%workspace_id%thread_id%';
|
||||
" 2>/dev/null | tr -d ' ')
|
||||
if [ "${uq_count:-0}" -ge 1 ]; then
|
||||
ok "UNIQUE(workspace_id, thread_id) constraint/index present"
|
||||
else
|
||||
fail "no UNIQUE(workspace_id, thread_id) on thread_meta"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── phase: runtime ───────────────────────────────────────────────────────
|
||||
phase_runtime() {
|
||||
phase "RUNTIME — gateway health"
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
fail "curl missing — cannot run runtime phase"
|
||||
return
|
||||
fi
|
||||
info "probing $GATEWAY_URL/health"
|
||||
local health
|
||||
health=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 "$GATEWAY_URL/health" 2>/dev/null || true)
|
||||
if [ "$health" = "200" ]; then
|
||||
ok "gateway /health returns 200"
|
||||
else
|
||||
fail "gateway /health returned '$health' — is \`make dev\` running?"
|
||||
SKIPPED_PHASES+=("e2e (gateway not reachable)")
|
||||
SKIP_E2E=1
|
||||
fi
|
||||
}
|
||||
|
||||
# ── phase: e2e ───────────────────────────────────────────────────────────
|
||||
phase_e2e() {
|
||||
phase "E2E — register 2 users + cross-workspace 404"
|
||||
if [ "${SKIP_E2E:-0}" = "1" ]; then
|
||||
warn "skipped because gateway probe failed"
|
||||
return
|
||||
fi
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
fail "curl missing"
|
||||
return
|
||||
fi
|
||||
|
||||
local tag
|
||||
tag=$(date +%s)
|
||||
local alice="alice-${tag}@verify-stage0.com"
|
||||
local bob="bob-${tag}@verify-stage0.com"
|
||||
local pw="VerifyStage0_${tag}!"
|
||||
local jar_a=/tmp/verify_alice_${tag}.cookies
|
||||
local jar_b=/tmp/verify_bob_${tag}.cookies
|
||||
rm -f "$jar_a" "$jar_b"
|
||||
|
||||
# Ensure system is initialized (admin account exists) before registering users.
|
||||
info "ensuring admin account exists (POST /api/v1/auth/initialize)"
|
||||
local init_code
|
||||
init_code=$(curl -sS -o /dev/null -w '%{http_code}' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"email\":\"admin-${tag}@verify-stage0.com\",\"password\":\"$pw\"}" \
|
||||
"$GATEWAY_URL/api/v1/auth/initialize")
|
||||
if [ "$init_code" = "201" ]; then
|
||||
ok "admin initialized (first boot)"
|
||||
elif [ "$init_code" = "409" ]; then
|
||||
ok "admin already exists (system previously initialized)"
|
||||
else
|
||||
fail "admin initialization returned $init_code"
|
||||
return
|
||||
fi
|
||||
|
||||
register_user() {
|
||||
local jar="$1"; local email="$2"
|
||||
curl -sS -c "$jar" -o /tmp/verify_register_$$.json -w '%{http_code}' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"email\":\"$email\",\"password\":\"$pw\"}" \
|
||||
"$GATEWAY_URL/api/v1/auth/register"
|
||||
}
|
||||
|
||||
info "registering Alice + Bob"
|
||||
local code_a code_b
|
||||
code_a=$(register_user "$jar_a" "$alice")
|
||||
code_b=$(register_user "$jar_b" "$bob")
|
||||
if [ "$code_a" = "201" ] && [ "$code_b" = "201" ]; then
|
||||
ok "both users registered (201 / 201)"
|
||||
else
|
||||
fail "registration failed: alice=$code_a, bob=$code_b"
|
||||
return
|
||||
fi
|
||||
|
||||
# Pull CSRF tokens from cookie jars (csrf_token cookie value, 4th-from-last field).
|
||||
csrf_from_jar() {
|
||||
awk '$6 == "csrf_token" { print $7 }' "$1" | tail -1
|
||||
}
|
||||
local csrf_a csrf_b
|
||||
csrf_a=$(csrf_from_jar "$jar_a")
|
||||
csrf_b=$(csrf_from_jar "$jar_b")
|
||||
if [ -n "$csrf_a" ] && [ -n "$csrf_b" ]; then
|
||||
ok "CSRF token captured for both sessions"
|
||||
else
|
||||
fail "CSRF cookie missing (alice='${csrf_a:0:8}...', bob='${csrf_b:0:8}...')"
|
||||
return
|
||||
fi
|
||||
|
||||
create_thread() {
|
||||
local jar="$1"; local csrf="$2"; local tid="$3"
|
||||
curl -sS -b "$jar" -o /tmp/verify_thread_$$.json -w '%{http_code}' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H "X-CSRF-Token: $csrf" \
|
||||
-d "{\"thread_id\":\"$tid\"}" \
|
||||
"$GATEWAY_URL/api/threads"
|
||||
}
|
||||
|
||||
local tid_a="verify-alice-${tag}"
|
||||
local tid_b="verify-bob-${tag}"
|
||||
info "Alice creates thread $tid_a; Bob creates thread $tid_b"
|
||||
local cta ctb
|
||||
cta=$(create_thread "$jar_a" "$csrf_a" "$tid_a")
|
||||
ctb=$(create_thread "$jar_b" "$csrf_b" "$tid_b")
|
||||
if [ "$cta" = "200" ] && [ "$ctb" = "200" ]; then
|
||||
ok "both threads created (200 / 200)"
|
||||
else
|
||||
fail "thread creation failed: alice=$cta, bob=$ctb"
|
||||
return
|
||||
fi
|
||||
|
||||
# Cross access: Alice tries to GET Bob's thread → must be 404 (per PR6 contract:
|
||||
# cross-workspace returns 404, not 403, to avoid leaking existence).
|
||||
info "Alice → Bob's thread (GET)"
|
||||
local cross_get
|
||||
cross_get=$(curl -sS -b "$jar_a" -o /dev/null -w '%{http_code}' "$GATEWAY_URL/api/threads/$tid_b")
|
||||
if [ "$cross_get" = "404" ]; then
|
||||
ok "cross-workspace GET returns 404 (no existence leak)"
|
||||
else
|
||||
fail "cross-workspace GET returned '$cross_get', expected 404 — PR6 isolation broken"
|
||||
fi
|
||||
|
||||
info "Alice → Bob's thread (DELETE)"
|
||||
local cross_del
|
||||
cross_del=$(curl -sS -b "$jar_a" -X DELETE -H "X-CSRF-Token: $csrf_a" \
|
||||
-o /dev/null -w '%{http_code}' "$GATEWAY_URL/api/threads/$tid_b")
|
||||
if [ "$cross_del" = "404" ]; then
|
||||
ok "cross-workspace DELETE returns 404"
|
||||
else
|
||||
fail "cross-workspace DELETE returned '$cross_del', expected 404"
|
||||
fi
|
||||
|
||||
# Same-workspace GET — sanity check Alice can still reach her own thread.
|
||||
info "Alice → Alice's thread (sanity)"
|
||||
local same_get
|
||||
same_get=$(curl -sS -b "$jar_a" -o /dev/null -w '%{http_code}' "$GATEWAY_URL/api/threads/$tid_a")
|
||||
if [ "$same_get" = "200" ]; then
|
||||
ok "same-workspace GET returns 200 (isolation is not over-blocking)"
|
||||
else
|
||||
fail "same-workspace GET returned '$same_get', expected 200"
|
||||
fi
|
||||
|
||||
info "cookie jars left in /tmp for debugging: $jar_a $jar_b"
|
||||
}
|
||||
|
||||
# ── main ─────────────────────────────────────────────────────────────────
|
||||
main() {
|
||||
local args=("$@")
|
||||
if [ ${#args[@]} -eq 0 ]; then
|
||||
args=(static paths rds runtime e2e)
|
||||
fi
|
||||
|
||||
printf '%s%sStage 0 verification — %s%s\n' "$C_BOLD" "$C_BLUE" "$(date)" "$C_RESET"
|
||||
printf 'Repo root: %s\n' "$REPO_ROOT"
|
||||
printf 'Phases: %s\n' "${args[*]}"
|
||||
|
||||
for phase_name in "${args[@]}"; do
|
||||
case "$phase_name" in
|
||||
static) phase_static ;;
|
||||
paths) phase_paths ;;
|
||||
rds) phase_rds ;;
|
||||
runtime) phase_runtime ;;
|
||||
e2e) phase_e2e ;;
|
||||
all)
|
||||
phase_static; phase_paths; phase_rds; phase_runtime; phase_e2e
|
||||
;;
|
||||
*)
|
||||
warn "unknown phase: $phase_name"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
printf '\n%s%s──── summary ────%s\n' "$C_BOLD" "$C_BLUE" "$C_RESET"
|
||||
printf ' %s%d passed%s %s%d failed%s %s%d warn%s\n' \
|
||||
"$C_GREEN" "$PASS_COUNT" "$C_RESET" \
|
||||
"$C_RED" "$FAIL_COUNT" "$C_RESET" \
|
||||
"$C_YELLOW" "$WARN_COUNT" "$C_RESET"
|
||||
if [ ${#SKIPPED_PHASES[@]} -gt 0 ]; then
|
||||
printf ' skipped: %s\n' "${SKIPPED_PHASES[*]}"
|
||||
fi
|
||||
if [ ${#FAILED_STEPS[@]} -gt 0 ]; then
|
||||
printf '\n%sfailing steps:%s\n' "$C_RED" "$C_RESET"
|
||||
for step in "${FAILED_STEPS[@]}"; do
|
||||
printf ' ✗ %s\n' "$step"
|
||||
done
|
||||
fi
|
||||
[ "$FAIL_COUNT" -eq 0 ]
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -36,8 +36,16 @@ from fastapi import FastAPI, Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
from app.gateway.auth.models import User
|
||||
from app.gateway.auth.models import ActiveWorkspace, User
|
||||
from app.gateway.authz import AuthContext, Permissions
|
||||
from deerflow.runtime.user_context import (
|
||||
reset_current_user,
|
||||
set_current_user,
|
||||
)
|
||||
from deerflow.runtime.workspace_context import (
|
||||
reset_current_workspace,
|
||||
set_current_workspace,
|
||||
)
|
||||
|
||||
# Default permission set granted to the stub user. Mirrors `_ALL_PERMISSIONS`
|
||||
# in authz.py — kept inline so the tests don't import a private symbol.
|
||||
@@ -67,22 +75,55 @@ class _StubAuthMiddleware(BaseHTTPMiddleware):
|
||||
Mirrors what production ``AuthMiddleware`` does after the JWT decode
|
||||
+ DB lookup short-circuit, so ``@require_permission`` finds an
|
||||
authenticated context and skips its own re-authentication path.
|
||||
|
||||
Optionally stamps the workspace contextvar too — needed by the PR6
|
||||
decorator path that calls ``get_effective_workspace_id()`` before
|
||||
delegating to ``check_access``.
|
||||
"""
|
||||
|
||||
def __init__(self, app: ASGIApp, user_factory: Callable[[], User]) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
app: ASGIApp,
|
||||
user_factory: Callable[[], User],
|
||||
workspace_factory: Callable[[], ActiveWorkspace | None] | None = None,
|
||||
override_user_contextvar: bool = False,
|
||||
) -> None:
|
||||
super().__init__(app)
|
||||
self._user_factory = user_factory
|
||||
self._workspace_factory = workspace_factory
|
||||
# Tests that only need ``request.state.auth`` (the @require_permission
|
||||
# path) keep the autouse user contextvar — flipping it to a per-call
|
||||
# UUID would break legacy tests whose routes resolve paths via
|
||||
# ``get_effective_user_id()``. Cross-user / cross-workspace tests opt
|
||||
# in by setting this flag so the contextvar matches the request user.
|
||||
self._override_user_contextvar = override_user_contextvar
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
user = self._user_factory()
|
||||
request.state.user = user
|
||||
request.state.auth = AuthContext(user=user, permissions=list(_STUB_PERMISSIONS))
|
||||
return await call_next(request)
|
||||
|
||||
user_token = set_current_user(user) if self._override_user_contextvar else None
|
||||
ws_token = None
|
||||
if self._workspace_factory is not None:
|
||||
workspace = self._workspace_factory()
|
||||
if workspace is not None:
|
||||
request.state.workspace = workspace
|
||||
ws_token = set_current_workspace(workspace)
|
||||
try:
|
||||
return await call_next(request)
|
||||
finally:
|
||||
if ws_token is not None:
|
||||
reset_current_workspace(ws_token)
|
||||
if user_token is not None:
|
||||
reset_current_user(user_token)
|
||||
|
||||
|
||||
def make_authed_test_app(
|
||||
*,
|
||||
user_factory: Callable[[], User] | None = None,
|
||||
workspace_factory: Callable[[], ActiveWorkspace | None] | None = None,
|
||||
override_user_contextvar: bool = False,
|
||||
owner_check_passes: bool = True,
|
||||
) -> FastAPI:
|
||||
"""Build a FastAPI test app with stub auth + permissive thread_store.
|
||||
@@ -103,7 +144,12 @@ def make_authed_test_app(
|
||||
"""
|
||||
factory = user_factory or _make_stub_user
|
||||
app = FastAPI()
|
||||
app.add_middleware(_StubAuthMiddleware, user_factory=factory)
|
||||
app.add_middleware(
|
||||
_StubAuthMiddleware,
|
||||
user_factory=factory,
|
||||
workspace_factory=workspace_factory,
|
||||
override_user_contextvar=override_user_contextvar,
|
||||
)
|
||||
|
||||
repo = MagicMock()
|
||||
repo.check_access = AsyncMock(return_value=owner_check_passes)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# PR7 — boundary scan allowlist for direct LangGraph checkpoint/saver imports.
|
||||
#
|
||||
# Paths are relative to backend/. A path here means: "this file is permitted to
|
||||
# import langgraph.checkpoint.* at runtime". Adding to this list requires a
|
||||
# matching justification in the PR that introduces the new importer.
|
||||
#
|
||||
# Consumed by tests/test_workspace_boundary.py.
|
||||
#
|
||||
# Imports inside `if TYPE_CHECKING:` blocks are exempt automatically — they do
|
||||
# not pull the symbol into runtime — so type-only references (e.g. annotations
|
||||
# on `BaseCheckpointSaver` parameters) do NOT need to be listed here.
|
||||
|
||||
langgraph_checkpoint_importers = [
|
||||
# Gateway thread plumbing: constructs an empty checkpoint when initialising
|
||||
# a new thread's state via the LangGraph runtime.
|
||||
"app/gateway/routers/threads.py",
|
||||
|
||||
# Harness checkpointer factories: the single authorised place to construct
|
||||
# InMemorySaver / SqliteSaver / PostgresSaver implementations. Everywhere
|
||||
# else must obtain a checkpointer via `app.gateway.deps.get_checkpointer`
|
||||
# or `deerflow.runtime.checkpointer` helpers.
|
||||
"packages/harness/deerflow/runtime/checkpointer/async_provider.py",
|
||||
"packages/harness/deerflow/runtime/checkpointer/provider.py",
|
||||
|
||||
# Background run worker: uses `empty_checkpoint` to seed state for runs
|
||||
# resumed from a missing/expired checkpoint id.
|
||||
"packages/harness/deerflow/runtime/runs/worker.py",
|
||||
]
|
||||
@@ -15,6 +15,13 @@ import pytest
|
||||
# Make 'app' and 'deerflow' importable from any working directory
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "scripts"))
|
||||
# Make 'fixtures.*' importable as plugin modules from this conftest.
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
# Register fixture plugin modules so tests can request fixtures by name
|
||||
# without ad-hoc imports. ``fixtures.postgres`` provides
|
||||
# ``postgres_container`` (session-scoped) and ``postgres_url`` (per-test).
|
||||
pytest_plugins = ["fixtures.postgres"]
|
||||
|
||||
# Break the circular import chain that exists in production code:
|
||||
# deerflow.subagents.__init__
|
||||
@@ -38,6 +45,95 @@ _executor_mock.get_background_task_result = MagicMock()
|
||||
sys.modules["deerflow.subagents.executor"] = _executor_mock
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-seed test workspace + user when Base.metadata.create_all() runs
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# PR6 makes every business-row INSERT carry ``workspace_id`` (resolved
|
||||
# from the autouse workspace contextvar = "test-workspace-autouse"). The
|
||||
# Stage 0 schema has a NOT NULL FK from those rows to ``workspaces`` and
|
||||
# from ``workspaces.owner_id`` to ``users``. Without the seed below,
|
||||
# every legacy repo test would fail with a FOREIGN KEY error the moment
|
||||
# it tries to insert a thread.
|
||||
#
|
||||
# We register an ``after_create`` hook on ``Base.metadata`` so that
|
||||
# whenever ``init_engine`` finishes ``create_all()`` (the auto-create
|
||||
# path used by tests and dev), the two anchor rows are present. Alembic
|
||||
# migration tests don't trigger create_all so they are unaffected and
|
||||
# keep exercising real FK constraints in isolation.
|
||||
|
||||
|
||||
def _register_test_seed_listener() -> None:
|
||||
"""Attach an after_create hook that seeds the autouse user + workspace."""
|
||||
try:
|
||||
from sqlalchemy import event, update
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.user.model import UserRow
|
||||
from deerflow.persistence.workspace.model import WorkspaceRow
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
def _seed(_target, connection, **kw): # noqa: ARG001
|
||||
tables = {t.name for t in kw.get("tables", []) or []}
|
||||
if "users" not in tables or "workspaces" not in tables:
|
||||
return
|
||||
|
||||
dialect = connection.dialect.name
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Seed both rows with a consistent ``default_workspace_id`` so the
|
||||
# PR5 backfill script (which scans ``users.default_workspace_id IS
|
||||
# NULL``) does not pick up the test fixtures as candidates.
|
||||
# Insert user first with NULL default_workspace_id (chicken-and-egg
|
||||
# with workspaces.owner_id FK), then workspace, then UPDATE the user
|
||||
# row to point at the workspace so the PR5 backfill script does not
|
||||
# pick up the autouse user as a candidate.
|
||||
user_values = {
|
||||
"id": "test-user-autouse",
|
||||
"email": "test-user-autouse@local",
|
||||
"password_hash": None,
|
||||
"system_role": "user",
|
||||
"created_at": now,
|
||||
"oauth_provider": None,
|
||||
"oauth_id": None,
|
||||
"needs_setup": False,
|
||||
"token_version": 0,
|
||||
"default_workspace_id": None,
|
||||
}
|
||||
workspace_values = {
|
||||
"id": "test-workspace-autouse",
|
||||
"name": "Autouse Test Workspace",
|
||||
"slug": "autouse-test",
|
||||
"status": "active",
|
||||
"owner_id": "test-user-autouse",
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
|
||||
if dialect == "sqlite":
|
||||
user_stmt = sqlite_insert(UserRow.__table__).values(**user_values).on_conflict_do_nothing(index_elements=["id"])
|
||||
ws_stmt = sqlite_insert(WorkspaceRow.__table__).values(**workspace_values).on_conflict_do_nothing(index_elements=["id"])
|
||||
elif dialect == "postgresql":
|
||||
user_stmt = pg_insert(UserRow.__table__).values(**user_values).on_conflict_do_nothing(index_elements=["id"])
|
||||
ws_stmt = pg_insert(WorkspaceRow.__table__).values(**workspace_values).on_conflict_do_nothing(index_elements=["id"])
|
||||
else:
|
||||
return
|
||||
|
||||
connection.execute(user_stmt)
|
||||
connection.execute(ws_stmt)
|
||||
connection.execute(update(UserRow.__table__).where(UserRow.__table__.c.id == "test-user-autouse").where(UserRow.__table__.c.default_workspace_id.is_(None)).values(default_workspace_id="test-workspace-autouse"))
|
||||
|
||||
event.listen(Base.metadata, "after_create", _seed)
|
||||
|
||||
|
||||
_register_test_seed_listener()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def provisioner_module():
|
||||
"""Load docker/provisioner/app.py as an importable test module.
|
||||
@@ -110,3 +206,34 @@ def _auto_user_context(request):
|
||||
yield
|
||||
finally:
|
||||
reset_current_user(token)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _auto_workspace_context(request):
|
||||
"""Inject a default ``test-workspace-autouse`` into the workspace contextvar.
|
||||
|
||||
Mirror of :func:`_auto_user_context`. PR6 adds ``workspace_id=AUTO``
|
||||
sentinels to every repository method; without an autouse workspace
|
||||
fixture every legacy persistence test would raise RuntimeError.
|
||||
|
||||
Opt-out via ``@pytest.mark.no_auto_workspace``.
|
||||
"""
|
||||
if request.node.get_closest_marker("no_auto_workspace"):
|
||||
yield
|
||||
return
|
||||
|
||||
try:
|
||||
from deerflow.runtime.workspace_context import (
|
||||
reset_current_workspace,
|
||||
set_current_workspace,
|
||||
)
|
||||
except ImportError:
|
||||
yield
|
||||
return
|
||||
|
||||
workspace = SimpleNamespace(id="test-workspace-autouse", role="owner")
|
||||
token = set_current_workspace(workspace)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
|
||||
Vendored
+82
@@ -0,0 +1,82 @@
|
||||
"""Postgres testcontainer fixtures for Stage 0 PR1.
|
||||
|
||||
Provides per-test ephemeral database isolation atop a single session-scoped
|
||||
container. Tests marked ``@pytest.mark.postgres`` request the ``postgres_url``
|
||||
fixture, which yields an asyncpg connection URL pointing at a freshly-created
|
||||
database. The database is force-dropped after the test (any leaked connections
|
||||
get pg_terminate_backend'd first).
|
||||
|
||||
Why per-database rather than per-schema:
|
||||
asyncpg (the SQLAlchemy async driver we use) doesn't honor URL-embedded
|
||||
search_path the way psycopg does. Per-database isolation is one extra
|
||||
CREATE/DROP per test (~50ms), but lets test app code use its full schema
|
||||
unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def postgres_container():
|
||||
"""Session-scoped Postgres 16 container shared across all postgres tests.
|
||||
|
||||
Started once per pytest session. Subsequent tests piggy-back on the same
|
||||
container; each gets its own database via the ``postgres_url`` fixture.
|
||||
|
||||
Skipped (and the test marked skip) if Docker is unavailable on the host —
|
||||
testcontainers raises ``DockerException`` when it can't reach the daemon.
|
||||
"""
|
||||
try:
|
||||
from testcontainers.postgres import PostgresContainer
|
||||
except ImportError as exc: # pragma: no cover - install boundary
|
||||
pytest.skip(f"testcontainers[postgres] not installed: {exc}")
|
||||
|
||||
try:
|
||||
# Image tag aligned with the production Aliyun RDS (PostgreSQL 17.9
|
||||
# confirmed by `make doctor` 2026-05-11). Bump together with RDS upgrades.
|
||||
with PostgresContainer("postgres:17-alpine") as pg:
|
||||
yield pg
|
||||
except Exception as exc: # pragma: no cover - environment-dependent
|
||||
# DockerException, ConnectionError, etc. — surface a skip rather than
|
||||
# an error so devs without Docker can still run the rest of the suite.
|
||||
pytest.skip(f"could not start Postgres container ({exc})")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def postgres_url(postgres_container) -> Iterator[str]:
|
||||
"""Per-test ephemeral database URL (asyncpg dialect).
|
||||
|
||||
Each invocation creates a unique database on the shared container and
|
||||
yields its URL. Teardown force-drops the database, terminating any
|
||||
backend connections the test forgot to close.
|
||||
"""
|
||||
import psycopg
|
||||
from psycopg import sql
|
||||
|
||||
db_name = f"test_{secrets.token_hex(8)}"
|
||||
raw = postgres_container.get_connection_url() # postgresql+psycopg2://...
|
||||
# Strip the SQLAlchemy dialect prefix so plain psycopg can connect.
|
||||
base = raw.replace("postgresql+psycopg2://", "postgresql://")
|
||||
parent_url = base.rsplit("/", 1)[0] + "/postgres"
|
||||
|
||||
# CREATE DATABASE must run outside a transaction; psycopg autocommit=True.
|
||||
with psycopg.connect(parent_url, autocommit=True) as conn:
|
||||
conn.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(db_name)))
|
||||
|
||||
asyncpg_url = raw.replace("postgresql+psycopg2://", "postgresql+asyncpg://").rsplit("/", 1)[0] + f"/{db_name}"
|
||||
|
||||
try:
|
||||
yield asyncpg_url
|
||||
finally:
|
||||
with psycopg.connect(parent_url, autocommit=True) as conn:
|
||||
# Kick any leaked connections so DROP DATABASE doesn't block.
|
||||
conn.execute(
|
||||
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = %s AND pid <> pg_backend_pid()",
|
||||
(db_name,),
|
||||
)
|
||||
conn.execute(sql.SQL("DROP DATABASE IF EXISTS {}").format(sql.Identifier(db_name)))
|
||||
@@ -0,0 +1,357 @@
|
||||
"""Alembic 0002 / 0003 round-trips on both Postgres and SQLite.
|
||||
|
||||
PR5 has two migrations:
|
||||
|
||||
* ``0002_business_tables_workspace`` — adds *nullable* ``workspace_id``
|
||||
+ FK to ``workspaces`` on ``threads_meta`` / ``runs`` / ``feedback`` /
|
||||
``run_events``, plus a composite index on threads_meta for the common
|
||||
"list a workspace's threads for a user, newest first" query.
|
||||
* ``0003_business_tables_workspace_not_null`` — flips the column to
|
||||
``NOT NULL`` (refusing to upgrade if NULL rows remain) and adds the
|
||||
UNIQUE (workspace_id, thread_id) index on ``threads_meta``.
|
||||
|
||||
Pattern mirrors ``test_alembic_default_workspace_id.py``: synchronous
|
||||
test bodies (alembic's command layer is sync, ``env.py`` calls
|
||||
``asyncio.run`` internally — running under pytest-anyio would explode
|
||||
that nested event loop). The pre-migration schema is bootstrapped with
|
||||
just the columns those migrations touch, so we don't depend on the
|
||||
production ORM staying frozen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
_ALEMBIC_INI = Path(__file__).resolve().parents[1] / "packages" / "harness" / "deerflow" / "persistence" / "migrations" / "alembic.ini"
|
||||
|
||||
# Tables the PR5 migrations touch.
|
||||
_BUSINESS_TABLES = ("threads_meta", "runs", "feedback", "run_events")
|
||||
|
||||
|
||||
def _make_alembic_config(url: str) -> Config:
|
||||
cfg = Config(str(_ALEMBIC_INI))
|
||||
cfg.set_main_option("sqlalchemy.url", url)
|
||||
return cfg
|
||||
|
||||
|
||||
def _bootstrap_pre_pr5_schema(sync_url: str) -> None:
|
||||
"""Create the post-PR4 schema PR5 migrations expect.
|
||||
|
||||
Includes ``workspaces`` (FK target), ``users`` (already has
|
||||
``default_workspace_id`` from 0001 — but we skip 0001 here and bootstrap
|
||||
the columns directly so the migration's behaviour can be tested in
|
||||
isolation), and the four business tables.
|
||||
"""
|
||||
engine = create_engine(sync_url)
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE workspaces (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
name VARCHAR(64) NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE users (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
default_workspace_id VARCHAR(36)
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE threads_meta (
|
||||
thread_id VARCHAR(64) PRIMARY KEY,
|
||||
user_id VARCHAR(64),
|
||||
updated_at TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE runs (
|
||||
run_id VARCHAR(64) PRIMARY KEY,
|
||||
thread_id VARCHAR(64) NOT NULL,
|
||||
user_id VARCHAR(64)
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE feedback (
|
||||
feedback_id VARCHAR(64) PRIMARY KEY,
|
||||
thread_id VARCHAR(64) NOT NULL,
|
||||
run_id VARCHAR(64) NOT NULL,
|
||||
user_id VARCHAR(64),
|
||||
rating INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE run_events (
|
||||
id INTEGER PRIMARY KEY,
|
||||
thread_id VARCHAR(64) NOT NULL,
|
||||
run_id VARCHAR(64) NOT NULL,
|
||||
user_id VARCHAR(64),
|
||||
event_type VARCHAR(32) NOT NULL,
|
||||
category VARCHAR(16) NOT NULL,
|
||||
content TEXT,
|
||||
seq INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
# Pretend 0001 has already run so 0002 is the next revision applied.
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE alembic_version (version_num VARCHAR(32) NOT NULL PRIMARY KEY)
|
||||
"""
|
||||
)
|
||||
)
|
||||
conn.execute(text("INSERT INTO alembic_version (version_num) VALUES ('0001_users_default_workspace')"))
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _assert_workspace_id_present(sync_url: str, *, nullable: bool) -> None:
|
||||
engine = create_engine(sync_url)
|
||||
insp = inspect(engine)
|
||||
for table in _BUSINESS_TABLES:
|
||||
cols = {c["name"]: c for c in insp.get_columns(table)}
|
||||
assert "workspace_id" in cols, f"{table}: workspace_id missing; got {list(cols)}"
|
||||
col = cols["workspace_id"]
|
||||
assert col["nullable"] is nullable, f"{table}.workspace_id nullable expected {nullable}, got {col['nullable']}"
|
||||
fks = insp.get_foreign_keys(table)
|
||||
ws_fks = [fk for fk in fks if fk.get("referred_table") == "workspaces" and fk.get("constrained_columns") == ["workspace_id"]]
|
||||
assert ws_fks, f"{table}: FK to workspaces missing; got {fks}"
|
||||
idxs = {i["name"] for i in insp.get_indexes("threads_meta")}
|
||||
assert "idx_threads_meta_workspace_user_updated" in idxs, f"threads_meta composite index missing; got {idxs}"
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _assert_workspace_id_absent(sync_url: str) -> None:
|
||||
engine = create_engine(sync_url)
|
||||
insp = inspect(engine)
|
||||
for table in _BUSINESS_TABLES:
|
||||
cols = {c["name"] for c in insp.get_columns(table)}
|
||||
assert "workspace_id" not in cols, f"{table}: workspace_id still present; got {cols}"
|
||||
idxs = {i["name"] for i in insp.get_indexes("threads_meta")}
|
||||
assert "idx_threads_meta_workspace_user_updated" not in idxs, f"threads_meta composite index still present; got {idxs}"
|
||||
engine.dispose()
|
||||
|
||||
|
||||
# ---------- SQLite tests ----------------------------------------------------
|
||||
|
||||
|
||||
def test_sqlite_upgrade_0002_adds_workspace_id_column() -> None:
|
||||
"""0002 adds nullable workspace_id + FK + composite index on SQLite."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db_path = Path(tmp) / "test.db"
|
||||
sync_url = f"sqlite:///{db_path}"
|
||||
async_url = f"sqlite+aiosqlite:///{db_path}"
|
||||
|
||||
_bootstrap_pre_pr5_schema(sync_url)
|
||||
cfg = _make_alembic_config(async_url)
|
||||
command.upgrade(cfg, "0002_business_tables_workspace")
|
||||
_assert_workspace_id_present(sync_url, nullable=True)
|
||||
|
||||
|
||||
def test_sqlite_downgrade_0002_removes_workspace_id_column() -> None:
|
||||
"""0002 downgrade drops the column + FK + composite index cleanly."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db_path = Path(tmp) / "test.db"
|
||||
sync_url = f"sqlite:///{db_path}"
|
||||
async_url = f"sqlite+aiosqlite:///{db_path}"
|
||||
|
||||
_bootstrap_pre_pr5_schema(sync_url)
|
||||
cfg = _make_alembic_config(async_url)
|
||||
command.upgrade(cfg, "0002_business_tables_workspace")
|
||||
_assert_workspace_id_present(sync_url, nullable=True)
|
||||
command.downgrade(cfg, "-1")
|
||||
_assert_workspace_id_absent(sync_url)
|
||||
|
||||
|
||||
# ---------- Postgres tests --------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.postgres
|
||||
def test_postgres_upgrade_0002_adds_workspace_id_column(postgres_url: str) -> None:
|
||||
"""Postgres: 0002 adds nullable workspace_id + FK + composite index."""
|
||||
sync_url = postgres_url.replace("+asyncpg", "+psycopg")
|
||||
_bootstrap_pre_pr5_schema(sync_url)
|
||||
|
||||
cfg = _make_alembic_config(postgres_url)
|
||||
command.upgrade(cfg, "0002_business_tables_workspace")
|
||||
_assert_workspace_id_present(sync_url, nullable=True)
|
||||
|
||||
|
||||
@pytest.mark.postgres
|
||||
def test_postgres_downgrade_0002_removes_workspace_id_column(postgres_url: str) -> None:
|
||||
"""Postgres: 0002 downgrade drops column + FK + index cleanly."""
|
||||
sync_url = postgres_url.replace("+asyncpg", "+psycopg")
|
||||
_bootstrap_pre_pr5_schema(sync_url)
|
||||
|
||||
cfg = _make_alembic_config(postgres_url)
|
||||
command.upgrade(cfg, "0002_business_tables_workspace")
|
||||
_assert_workspace_id_present(sync_url, nullable=True)
|
||||
command.downgrade(cfg, "-1")
|
||||
_assert_workspace_id_absent(sync_url)
|
||||
|
||||
|
||||
# ---------- 0003 helpers ----------------------------------------------------
|
||||
|
||||
|
||||
def _populate_business_rows_for_0003(sync_url: str, workspace_id: str = "w1", thread_id: str = "t1", run_id: str = "r1") -> None:
|
||||
"""Insert one row per business table with workspace_id populated.
|
||||
|
||||
Used as the pre-condition for the 0003 happy-path test: every row
|
||||
has a non-NULL workspace_id, so the pre-flight count is zero and
|
||||
the NOT NULL ALTER succeeds.
|
||||
"""
|
||||
engine = create_engine(sync_url)
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("INSERT INTO workspaces (id, name) VALUES (:id, :name)"), {"id": workspace_id, "name": "W"})
|
||||
conn.execute(text("INSERT INTO threads_meta (thread_id, workspace_id) VALUES (:t, :w)"), {"t": thread_id, "w": workspace_id})
|
||||
conn.execute(text("INSERT INTO runs (run_id, thread_id, workspace_id) VALUES (:r, :t, :w)"), {"r": run_id, "t": thread_id, "w": workspace_id})
|
||||
conn.execute(text("INSERT INTO feedback (feedback_id, thread_id, run_id, rating, workspace_id) VALUES ('f1', :t, :r, 1, :w)"), {"t": thread_id, "r": run_id, "w": workspace_id})
|
||||
conn.execute(text("INSERT INTO run_events (thread_id, run_id, event_type, category, seq, workspace_id) VALUES (:t, :r, 'x', 'lifecycle', 1, :w)"), {"t": thread_id, "r": run_id, "w": workspace_id})
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _assert_workspace_id_not_null_and_unique_index(sync_url: str) -> None:
|
||||
engine = create_engine(sync_url)
|
||||
insp = inspect(engine)
|
||||
for table in _BUSINESS_TABLES:
|
||||
cols = {c["name"]: c for c in insp.get_columns(table)}
|
||||
assert cols["workspace_id"]["nullable"] is False, f"{table}.workspace_id should be NOT NULL after 0003; got {cols['workspace_id']}"
|
||||
idxs = {i["name"]: i for i in insp.get_indexes("threads_meta")}
|
||||
assert "idx_threads_meta_workspace_thread" in idxs, f"UNIQUE index missing; got {idxs}"
|
||||
# SQLAlchemy reflection returns ``unique`` as int(1) on SQLite and
|
||||
# bool(True) on Postgres — assert truthiness so both backends pass.
|
||||
assert idxs["idx_threads_meta_workspace_thread"]["unique"], f"index should be UNIQUE; got {idxs['idx_threads_meta_workspace_thread']}"
|
||||
engine.dispose()
|
||||
|
||||
|
||||
# ---------- 0003 SQLite tests -----------------------------------------------
|
||||
|
||||
|
||||
def test_sqlite_upgrade_0003_succeeds_with_populated_workspace_id() -> None:
|
||||
"""0003 NOT NULL ALTER + UNIQUE index lands when no NULL rows remain."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db_path = Path(tmp) / "test.db"
|
||||
sync_url = f"sqlite:///{db_path}"
|
||||
async_url = f"sqlite+aiosqlite:///{db_path}"
|
||||
|
||||
_bootstrap_pre_pr5_schema(sync_url)
|
||||
cfg = _make_alembic_config(async_url)
|
||||
command.upgrade(cfg, "0002_business_tables_workspace")
|
||||
_populate_business_rows_for_0003(sync_url)
|
||||
|
||||
command.upgrade(cfg, "0003_business_tables_workspace_not_null")
|
||||
_assert_workspace_id_not_null_and_unique_index(sync_url)
|
||||
|
||||
|
||||
def test_sqlite_upgrade_0003_requires_no_null_workspace_id() -> None:
|
||||
"""0003 refuses to upgrade if any business row still has workspace_id=NULL."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db_path = Path(tmp) / "test.db"
|
||||
sync_url = f"sqlite:///{db_path}"
|
||||
async_url = f"sqlite+aiosqlite:///{db_path}"
|
||||
|
||||
_bootstrap_pre_pr5_schema(sync_url)
|
||||
cfg = _make_alembic_config(async_url)
|
||||
command.upgrade(cfg, "0002_business_tables_workspace")
|
||||
|
||||
# Leave one threads_meta row with workspace_id NULL.
|
||||
engine = create_engine(sync_url)
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("INSERT INTO threads_meta (thread_id) VALUES ('t-orphan')"))
|
||||
engine.dispose()
|
||||
|
||||
with pytest.raises(RuntimeError, match="Cannot ALTER"):
|
||||
command.upgrade(cfg, "0003_business_tables_workspace_not_null")
|
||||
|
||||
|
||||
def test_sqlite_threads_meta_unique_workspace_thread() -> None:
|
||||
"""After 0003, duplicate (workspace_id, thread_id) raises IntegrityError on insert."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db_path = Path(tmp) / "test.db"
|
||||
sync_url = f"sqlite:///{db_path}"
|
||||
async_url = f"sqlite+aiosqlite:///{db_path}"
|
||||
|
||||
_bootstrap_pre_pr5_schema(sync_url)
|
||||
cfg = _make_alembic_config(async_url)
|
||||
command.upgrade(cfg, "0002_business_tables_workspace")
|
||||
_populate_business_rows_for_0003(sync_url)
|
||||
command.upgrade(cfg, "0003_business_tables_workspace_not_null")
|
||||
|
||||
engine = create_engine(sync_url)
|
||||
# threads_meta.thread_id is the table's PRIMARY KEY in our bootstrap
|
||||
# schema, so a second row with the same thread_id would always fail.
|
||||
# Use a *different* thread_id with the same (workspace_id, thread_id)
|
||||
# pair would imply changing thread_id — that's not possible. Instead
|
||||
# we drop the PK constraint via a fresh table that omits it, so the
|
||||
# UNIQUE index is the only barrier.
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("CREATE TABLE threads_meta_test_unique (id INTEGER PRIMARY KEY, thread_id VARCHAR(64), workspace_id VARCHAR(36))"))
|
||||
conn.execute(text("CREATE UNIQUE INDEX idx_test_unique ON threads_meta_test_unique (workspace_id, thread_id)"))
|
||||
conn.execute(text("INSERT INTO threads_meta_test_unique (thread_id, workspace_id) VALUES ('t', 'w')"))
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("INSERT INTO threads_meta_test_unique (thread_id, workspace_id) VALUES ('t', 'w')"))
|
||||
engine.dispose()
|
||||
|
||||
|
||||
# ---------- 0003 Postgres tests ---------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.postgres
|
||||
def test_postgres_upgrade_0003_succeeds_with_populated_workspace_id(postgres_url: str) -> None:
|
||||
sync_url = postgres_url.replace("+asyncpg", "+psycopg")
|
||||
_bootstrap_pre_pr5_schema(sync_url)
|
||||
|
||||
cfg = _make_alembic_config(postgres_url)
|
||||
command.upgrade(cfg, "0002_business_tables_workspace")
|
||||
_populate_business_rows_for_0003(sync_url)
|
||||
command.upgrade(cfg, "0003_business_tables_workspace_not_null")
|
||||
_assert_workspace_id_not_null_and_unique_index(sync_url)
|
||||
|
||||
|
||||
@pytest.mark.postgres
|
||||
def test_postgres_upgrade_0003_requires_no_null_workspace_id(postgres_url: str) -> None:
|
||||
sync_url = postgres_url.replace("+asyncpg", "+psycopg")
|
||||
_bootstrap_pre_pr5_schema(sync_url)
|
||||
|
||||
cfg = _make_alembic_config(postgres_url)
|
||||
command.upgrade(cfg, "0002_business_tables_workspace")
|
||||
|
||||
engine = create_engine(sync_url)
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("INSERT INTO threads_meta (thread_id) VALUES ('t-orphan')"))
|
||||
engine.dispose()
|
||||
|
||||
with pytest.raises(RuntimeError, match="Cannot ALTER"):
|
||||
command.upgrade(cfg, "0003_business_tables_workspace_not_null")
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Alembic revision 0001 round-trips on both Postgres and SQLite.
|
||||
|
||||
Verifies the first DeerFlow migration adds `users.default_workspace_id`
|
||||
(with FK to `workspaces`) on upgrade and removes it on downgrade. Both
|
||||
backends are exercised because the migration relies on
|
||||
`op.batch_alter_table` for SQLite ALTER compatibility — we want to know
|
||||
if either dialect regresses.
|
||||
|
||||
These tests are synchronous: alembic's command layer is sync, and our
|
||||
`env.py` calls `asyncio.run(...)` internally. Running under
|
||||
pytest-anyio would put us inside an event loop and crash that
|
||||
`asyncio.run` call, so we keep the test bodies plain `def`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
|
||||
_ALEMBIC_INI = Path(__file__).resolve().parents[1] / "packages" / "harness" / "deerflow" / "persistence" / "migrations" / "alembic.ini"
|
||||
|
||||
|
||||
def _make_alembic_config(url: str) -> Config:
|
||||
cfg = Config(str(_ALEMBIC_INI))
|
||||
cfg.set_main_option("sqlalchemy.url", url)
|
||||
return cfg
|
||||
|
||||
|
||||
def _bootstrap_pre_pr4_schema(sync_url: str) -> None:
|
||||
"""Create the minimal pre-PR4 schema the migration needs to ALTER.
|
||||
|
||||
Only `users` (without `default_workspace_id`) and `workspaces` (just `id`)
|
||||
are required so the FK target resolves. The rest of the production
|
||||
schema is irrelevant to this migration.
|
||||
"""
|
||||
engine = create_engine(sync_url)
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE workspaces (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
name VARCHAR(64) NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE users (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _assert_column_present(sync_url: str) -> None:
|
||||
engine = create_engine(sync_url)
|
||||
insp = inspect(engine)
|
||||
cols = {c["name"] for c in insp.get_columns("users")}
|
||||
assert "default_workspace_id" in cols, f"column missing; got {cols}"
|
||||
fks = insp.get_foreign_keys("users")
|
||||
fk_to_ws = [fk for fk in fks if fk.get("referred_table") == "workspaces"]
|
||||
assert fk_to_ws, f"FK to workspaces missing; got {fks}"
|
||||
assert fk_to_ws[0]["constrained_columns"] == ["default_workspace_id"]
|
||||
assert fk_to_ws[0]["referred_columns"] == ["id"]
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _assert_column_absent(sync_url: str) -> None:
|
||||
engine = create_engine(sync_url)
|
||||
insp = inspect(engine)
|
||||
cols = {c["name"] for c in insp.get_columns("users")}
|
||||
assert "default_workspace_id" not in cols, f"column still present; got {cols}"
|
||||
engine.dispose()
|
||||
|
||||
|
||||
# ---------- SQLite tests ----------------------------------------------------
|
||||
|
||||
|
||||
def test_sqlite_upgrade_adds_default_workspace_id_with_fk() -> None:
|
||||
"""SQLite: upgrade 0001 adds column + FK; batch_alter_table works."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db_path = Path(tmp) / "test.db"
|
||||
sync_url = f"sqlite:///{db_path}"
|
||||
async_url = f"sqlite+aiosqlite:///{db_path}"
|
||||
|
||||
_bootstrap_pre_pr4_schema(sync_url)
|
||||
|
||||
cfg = _make_alembic_config(async_url)
|
||||
command.upgrade(cfg, "0001_users_default_workspace")
|
||||
|
||||
_assert_column_present(sync_url)
|
||||
|
||||
|
||||
def test_sqlite_downgrade_removes_default_workspace_id() -> None:
|
||||
"""SQLite: downgrade 0001 removes the column it added."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db_path = Path(tmp) / "test.db"
|
||||
sync_url = f"sqlite:///{db_path}"
|
||||
async_url = f"sqlite+aiosqlite:///{db_path}"
|
||||
|
||||
_bootstrap_pre_pr4_schema(sync_url)
|
||||
|
||||
cfg = _make_alembic_config(async_url)
|
||||
command.upgrade(cfg, "0001_users_default_workspace")
|
||||
_assert_column_present(sync_url)
|
||||
|
||||
command.downgrade(cfg, "-1")
|
||||
_assert_column_absent(sync_url)
|
||||
|
||||
|
||||
# ---------- Postgres tests --------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.postgres
|
||||
def test_postgres_upgrade_adds_default_workspace_id_with_fk(postgres_url: str) -> None:
|
||||
"""Postgres: upgrade 0001 adds column + FK pointing at workspaces(id)."""
|
||||
sync_url = postgres_url.replace("+asyncpg", "+psycopg")
|
||||
_bootstrap_pre_pr4_schema(sync_url)
|
||||
|
||||
cfg = _make_alembic_config(postgres_url)
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
_assert_column_present(sync_url)
|
||||
|
||||
|
||||
@pytest.mark.postgres
|
||||
def test_postgres_downgrade_removes_default_workspace_id(postgres_url: str) -> None:
|
||||
"""Postgres: downgrade 0001 cleanly drops the FK and column."""
|
||||
sync_url = postgres_url.replace("+asyncpg", "+psycopg")
|
||||
_bootstrap_pre_pr4_schema(sync_url)
|
||||
|
||||
cfg = _make_alembic_config(postgres_url)
|
||||
command.upgrade(cfg, "head")
|
||||
_assert_column_present(sync_url)
|
||||
|
||||
command.downgrade(cfg, "-1")
|
||||
_assert_column_absent(sync_url)
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Schema tests for ``ApiKeyRow`` (Stage 0 PR8).
|
||||
|
||||
PR8 is schema-only — no repository class, no API. Tests exercise raw
|
||||
ORM behaviour: column-level UNIQUE on key_prefix, the dual-dialect
|
||||
partial index DDL (sqlite_where + postgresql_where), and CASCADE on
|
||||
service_account delete.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from deerflow.persistence.api_key import ApiKeyRow
|
||||
from deerflow.persistence.service_account import ServiceAccountRow
|
||||
from deerflow.persistence.user.model import UserRow
|
||||
from deerflow.persistence.workspace.model import WorkspaceRow
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend() -> str:
|
||||
return "asyncio"
|
||||
|
||||
|
||||
async def _setup(tmp_path):
|
||||
from deerflow.persistence.engine import get_session_factory, init_engine
|
||||
|
||||
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
|
||||
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
|
||||
return get_session_factory()
|
||||
|
||||
|
||||
async def _cleanup():
|
||||
from deerflow.persistence.engine import close_engine
|
||||
|
||||
await close_engine()
|
||||
|
||||
|
||||
async def _seed_parents(sf) -> None:
|
||||
now = datetime.now(UTC)
|
||||
async with sf() as session:
|
||||
session.add(UserRow(id="u-alice", email="alice@example.com"))
|
||||
await session.commit()
|
||||
async with sf() as session:
|
||||
session.add(WorkspaceRow(id="w-1", name="Alice WS", slug="alice", owner_id="u-alice"))
|
||||
await session.commit()
|
||||
async with sf() as session:
|
||||
session.add(
|
||||
ServiceAccountRow(
|
||||
id="sa-1",
|
||||
workspace_id="w-1",
|
||||
name="bot",
|
||||
role="member",
|
||||
identity_mode="collapsed",
|
||||
status="active",
|
||||
created_by="u-alice",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
def _make_key(*, key_id: str, prefix: str, revoked_at: datetime | None = None) -> ApiKeyRow:
|
||||
now = datetime.now(UTC)
|
||||
return ApiKeyRow(
|
||||
id=key_id,
|
||||
service_account_id="sa-1",
|
||||
key_prefix=prefix,
|
||||
key_hash="0" * 64,
|
||||
name="default",
|
||||
scopes="",
|
||||
rate_limit_rpm=None,
|
||||
expires_at=None,
|
||||
last_used_at=None,
|
||||
revoked_at=revoked_at,
|
||||
created_at=now,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T8.4-1 — column-level UNIQUE on key_prefix
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_unique_key_prefix_enforced(tmp_path):
|
||||
"""Two api_key rows sharing the same key_prefix raise IntegrityError."""
|
||||
sf = await _setup(tmp_path)
|
||||
try:
|
||||
await _seed_parents(sf)
|
||||
async with sf() as session:
|
||||
session.add(_make_key(key_id="ak-1", prefix="dfk_live_abc12345"))
|
||||
await session.commit()
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
async with sf() as session:
|
||||
session.add(_make_key(key_id="ak-2", prefix="dfk_live_abc12345"))
|
||||
await session.commit()
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T8.4-2 — partial index DDL covers both SQLite and Postgres
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_active_index_declares_both_dialect_where_clauses():
|
||||
"""idx_api_keys_active must compile to a partial index on both drivers.
|
||||
|
||||
The plan locks ``sqlite_where`` *and* ``postgresql_where`` so the same
|
||||
Index emits a partial index regardless of backend (Stage 0 dev still
|
||||
runs SQLite locally; production is Postgres). Both ``dialect_options``
|
||||
entries must be present.
|
||||
"""
|
||||
active_idx = next((idx for idx in ApiKeyRow.__table__.indexes if idx.name == "idx_api_keys_active"), None)
|
||||
assert active_idx is not None, "idx_api_keys_active not declared"
|
||||
sqlite_where = active_idx.dialect_options.get("sqlite", {}).get("where")
|
||||
postgres_where = active_idx.dialect_options.get("postgresql", {}).get("where")
|
||||
assert sqlite_where is not None, "sqlite_where missing on idx_api_keys_active"
|
||||
assert postgres_where is not None, "postgresql_where missing on idx_api_keys_active"
|
||||
assert "revoked_at IS NULL" in str(sqlite_where)
|
||||
assert "revoked_at IS NULL" in str(postgres_where)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T8.4-3 — CASCADE on service_account delete
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_cascade_on_service_account_delete(tmp_path):
|
||||
"""Deleting the parent service_account removes all child api_keys."""
|
||||
sf = await _setup(tmp_path)
|
||||
try:
|
||||
await _seed_parents(sf)
|
||||
async with sf() as session:
|
||||
session.add(_make_key(key_id="ak-cascade-1", prefix="dfk_live_cascade1"))
|
||||
session.add(_make_key(key_id="ak-cascade-2", prefix="dfk_live_cascade2"))
|
||||
await session.commit()
|
||||
|
||||
async with sf() as session:
|
||||
await session.execute(delete(ServiceAccountRow).where(ServiceAccountRow.id == "sa-1"))
|
||||
await session.commit()
|
||||
|
||||
async with sf() as session:
|
||||
row1 = await session.get(ApiKeyRow, "ak-cascade-1")
|
||||
row2 = await session.get(ApiKeyRow, "ak-cascade-2")
|
||||
assert row1 is None
|
||||
assert row2 is None
|
||||
finally:
|
||||
await _cleanup()
|
||||
@@ -101,7 +101,7 @@ def test_create_and_decode_token():
|
||||
import os
|
||||
|
||||
os.environ["AUTH_JWT_SECRET"] = "test-secret-key-for-jwt-testing-minimum-32-chars"
|
||||
token = create_access_token(user_id)
|
||||
token = create_access_token(user_id, workspace_id="ws-test", role="owner")
|
||||
assert isinstance(token, str)
|
||||
|
||||
payload = decode_token(token)
|
||||
@@ -132,7 +132,7 @@ def test_decode_token_invalid():
|
||||
def test_create_token_custom_expiry():
|
||||
"""Custom expiry is respected."""
|
||||
user_id = str(uuid4())
|
||||
token = create_access_token(user_id, expires_delta=timedelta(hours=1))
|
||||
token = create_access_token(user_id, expires_delta=timedelta(hours=1), workspace_id="ws-test", role="owner")
|
||||
payload = decode_token(token)
|
||||
assert payload is not None
|
||||
assert payload.sub == user_id
|
||||
@@ -420,7 +420,7 @@ def test_jwt_encodes_ver():
|
||||
from app.gateway.auth.errors import TokenError
|
||||
|
||||
os.environ["AUTH_JWT_SECRET"] = "test-secret-key-for-jwt-testing-minimum-32-chars"
|
||||
token = create_access_token(str(uuid4()), token_version=3)
|
||||
token = create_access_token(str(uuid4()), token_version=3, workspace_id="ws-test", role="owner")
|
||||
payload = decode_token(token)
|
||||
assert not isinstance(payload, TokenError)
|
||||
assert payload.ver == 3
|
||||
@@ -433,7 +433,7 @@ def test_jwt_default_ver_zero():
|
||||
from app.gateway.auth.errors import TokenError
|
||||
|
||||
os.environ["AUTH_JWT_SECRET"] = "test-secret-key-for-jwt-testing-minimum-32-chars"
|
||||
token = create_access_token(str(uuid4()))
|
||||
token = create_access_token(str(uuid4()), workspace_id="ws-test", role="owner")
|
||||
payload = decode_token(token)
|
||||
assert not isinstance(payload, TokenError)
|
||||
assert payload.ver == 0
|
||||
@@ -447,7 +447,7 @@ def test_token_version_mismatch_rejects():
|
||||
os.environ["AUTH_JWT_SECRET"] = "test-secret-key-for-jwt-testing-minimum-32-chars"
|
||||
|
||||
user_id = str(uuid4())
|
||||
token = create_access_token(user_id, token_version=0)
|
||||
token = create_access_token(user_id, token_version=0, workspace_id="ws-test", role="owner")
|
||||
|
||||
mock_user = User(id=user_id, email="test@example.com", password_hash="hash", token_version=1)
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ def test_decode_token_returns_token_error_on_malformed():
|
||||
|
||||
def test_decode_token_returns_payload_on_valid():
|
||||
_setup_config()
|
||||
token = create_access_token("user-123")
|
||||
token = create_access_token("user-123", workspace_id="ws-test", role="owner")
|
||||
result = decode_token(token)
|
||||
assert not isinstance(result, TokenError)
|
||||
assert result.sub == "user-123"
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""JWT carries wid + role claims (Stage 0 PR4).
|
||||
|
||||
Tokens issued after PR4 must include `wid` (workspace_id) and `role`
|
||||
(owner/admin/member) so the AuthMiddleware can resolve the active
|
||||
workspace without a DB hit. Legacy token compatibility lives in
|
||||
:mod:`test_legacy_token_compat`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
|
||||
from app.gateway.auth import create_access_token, decode_token
|
||||
from app.gateway.auth.config import get_auth_config
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stable_jwt_secret(monkeypatch):
|
||||
"""Pin a deterministic JWT secret across tests in this module."""
|
||||
monkeypatch.setenv("AUTH_JWT_SECRET", "test-secret-key-for-jwt-testing-minimum-32-chars")
|
||||
yield
|
||||
|
||||
|
||||
def test_jwt_includes_wid_and_role() -> None:
|
||||
"""create_access_token records wid + role in the encoded payload."""
|
||||
user_id = str(uuid4())
|
||||
workspace_id = str(uuid4())
|
||||
|
||||
token = create_access_token(user_id, workspace_id=workspace_id, role="owner")
|
||||
|
||||
raw = jwt.decode(token, get_auth_config().jwt_secret, algorithms=["HS256"])
|
||||
assert raw["sub"] == user_id
|
||||
assert raw["wid"] == workspace_id
|
||||
assert raw["role"] == "owner"
|
||||
|
||||
|
||||
def test_decode_round_trip_keeps_wid_and_role() -> None:
|
||||
"""decode_token returns a TokenPayload exposing wid + role attributes."""
|
||||
user_id = str(uuid4())
|
||||
workspace_id = str(uuid4())
|
||||
|
||||
token = create_access_token(user_id, workspace_id=workspace_id, role="member")
|
||||
payload = decode_token(token)
|
||||
|
||||
# decode_token returns TokenError on failure — must be the success branch here.
|
||||
assert hasattr(payload, "wid"), f"got {payload!r}"
|
||||
assert payload.sub == user_id
|
||||
assert payload.wid == workspace_id
|
||||
assert payload.role == "member"
|
||||
@@ -0,0 +1,90 @@
|
||||
"""``GET /auth/me`` returns the user's workspace memberships.
|
||||
|
||||
Stage 0 PR4 T4.11. After PR4 the frontend needs to discover which
|
||||
workspaces the current user belongs to (eventually for a picker UI).
|
||||
Each membership entry surfaces ``id``, ``name``, ``slug``, ``role``
|
||||
so the picker can render the list without a second roundtrip.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
os.environ.setdefault("AUTH_JWT_SECRET", "test-secret-key-auth-me-workspaces-32+")
|
||||
|
||||
from app.gateway.auth.config import AuthConfig, set_auth_config
|
||||
|
||||
_TEST_SECRET = "test-secret-key-auth-me-workspaces-32+"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup_auth(tmp_path):
|
||||
from app.gateway import deps
|
||||
from app.gateway.routers.auth import _SETUP_STATUS_COOLDOWN
|
||||
from deerflow.persistence.engine import close_engine, init_engine
|
||||
|
||||
set_auth_config(AuthConfig(jwt_secret=_TEST_SECRET))
|
||||
url = f"sqlite+aiosqlite:///{tmp_path}/auth_me.db"
|
||||
asyncio.run(init_engine("sqlite", url=url, sqlite_dir=str(tmp_path)))
|
||||
deps._cached_local_provider = None
|
||||
deps._cached_repo = None
|
||||
_SETUP_STATUS_COOLDOWN.clear()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
deps._cached_local_provider = None
|
||||
deps._cached_repo = None
|
||||
_SETUP_STATUS_COOLDOWN.clear()
|
||||
asyncio.run(close_engine())
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(_setup_auth):
|
||||
from app.gateway.app import create_app
|
||||
|
||||
set_auth_config(AuthConfig(jwt_secret=_TEST_SECRET))
|
||||
yield TestClient(create_app())
|
||||
|
||||
|
||||
def test_auth_me_returns_workspaces_list(client):
|
||||
"""After registration, /auth/me lists the user's single personal workspace."""
|
||||
client.post("/api/v1/auth/initialize", json={"email": "admin@example.com", "password": "Str0ng!Pass99"})
|
||||
reg = client.post("/api/v1/auth/register", json={"email": "alice@example.com", "password": "Tr0ub4dor3a-strong!"})
|
||||
user_id = reg.json()["id"]
|
||||
|
||||
resp = client.get("/api/v1/auth/me")
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
body = resp.json()
|
||||
assert body["id"] == user_id
|
||||
assert body["email"] == "alice@example.com"
|
||||
assert body["default_workspace_id"], "user should land with a default workspace"
|
||||
|
||||
assert len(body["workspaces"]) == 1, body
|
||||
ws = body["workspaces"][0]
|
||||
assert ws["id"] == body["default_workspace_id"]
|
||||
assert ws["slug"] == "alice"
|
||||
assert ws["role"] == "owner"
|
||||
assert ws["name"] # whatever the helper picks — just sanity check it's non-empty
|
||||
|
||||
|
||||
def test_auth_me_workspaces_isolated_per_user(client):
|
||||
"""Two users see only their own workspaces in /auth/me."""
|
||||
client.post("/api/v1/auth/initialize", json={"email": "admin@example.com", "password": "Str0ng!Pass99"})
|
||||
client.post("/api/v1/auth/register", json={"email": "alice@example.com", "password": "Tr0ub4dor3a-strong!"})
|
||||
a_id = client.get("/api/v1/auth/me").json()["id"]
|
||||
a_workspaces = client.get("/api/v1/auth/me").json()["workspaces"]
|
||||
|
||||
client.cookies.clear()
|
||||
client.post("/api/v1/auth/register", json={"email": "bob@example.com", "password": "Tr0ub4dor3a-strong!"})
|
||||
b_id = client.get("/api/v1/auth/me").json()["id"]
|
||||
b_workspaces = client.get("/api/v1/auth/me").json()["workspaces"]
|
||||
|
||||
assert a_id != b_id
|
||||
assert {w["id"] for w in a_workspaces}.isdisjoint({w["id"] for w in b_workspaces})
|
||||
assert {w["slug"] for w in a_workspaces} == {"alice"}
|
||||
assert {w["slug"] for w in b_workspaces} == {"bob"}
|
||||
@@ -0,0 +1,131 @@
|
||||
"""AuthMiddleware injects the workspace ContextVar (Stage 0 PR4 T4.7).
|
||||
|
||||
After PR4 every authenticated request has a workspace bound on
|
||||
``deerflow.runtime.workspace_context._current_workspace``. The middleware
|
||||
populates it from the JWT's ``wid`` / ``role`` claims, mirrors what it
|
||||
already does for ``user_context``, and tears both down in a single
|
||||
``try/finally`` so leaks don't cross requests.
|
||||
|
||||
Legacy 4-field tokens (no ``wid``) are rejected upstream by
|
||||
``decode_token`` (T4.6) — those should never reach the workspace
|
||||
injection branch; this file pins that the 401 they trigger carries
|
||||
``AuthErrorCode.WORKSPACE_REQUIRED``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from app.gateway.auth import create_access_token
|
||||
from app.gateway.auth.config import get_auth_config
|
||||
from app.gateway.auth.models import User
|
||||
from app.gateway.auth_middleware import AuthMiddleware
|
||||
from deerflow.runtime.workspace_context import get_current_workspace
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stable_jwt_secret(monkeypatch):
|
||||
monkeypatch.setenv("AUTH_JWT_SECRET", "test-secret-key-for-jwt-testing-minimum-32-chars")
|
||||
yield
|
||||
|
||||
|
||||
def _make_app() -> FastAPI:
|
||||
"""App with AuthMiddleware + an inspect route that surfaces the contextvar."""
|
||||
app = FastAPI()
|
||||
app.add_middleware(AuthMiddleware)
|
||||
|
||||
@app.get("/api/v1/auth/setup-status") # public — never gates on wid
|
||||
async def setup_status():
|
||||
return {"needs_setup": False}
|
||||
|
||||
@app.get("/api/models") # protected — exercises wid injection
|
||||
async def inspect_workspace():
|
||||
ws = get_current_workspace()
|
||||
if ws is None:
|
||||
return {"workspace": None}
|
||||
return {"workspace": {"id": ws.id, "role": ws.role}}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _make_user(uid: str) -> User:
|
||||
return User(id=uid, email="t@example.com", password_hash="hash", token_version=0)
|
||||
|
||||
|
||||
def _make_legacy_token() -> str:
|
||||
"""Encode a pre-PR4 JWT (no wid/role) directly."""
|
||||
now = datetime.now(UTC)
|
||||
payload = {
|
||||
"sub": str(uuid4()),
|
||||
"exp": now + timedelta(hours=1),
|
||||
"iat": now,
|
||||
"ver": 0,
|
||||
}
|
||||
return jwt.encode(payload, get_auth_config().jwt_secret, algorithm="HS256")
|
||||
|
||||
|
||||
def test_new_jwt_injects_workspace_into_contextvar() -> None:
|
||||
"""Cookie with wid+role → route observes the workspace via the contextvar."""
|
||||
uid = str(uuid4())
|
||||
token = create_access_token(uid, workspace_id="ws-abc", role="owner")
|
||||
|
||||
with patch("app.gateway.deps.get_local_provider") as fn:
|
||||
fn.return_value.get_user = AsyncMock(return_value=_make_user(uid))
|
||||
client = TestClient(_make_app())
|
||||
res = client.get("/api/models", cookies={"access_token": token})
|
||||
|
||||
assert res.status_code == 200, res.text
|
||||
assert res.json() == {"workspace": {"id": "ws-abc", "role": "owner"}}
|
||||
|
||||
|
||||
def test_legacy_jwt_rejected_with_workspace_required() -> None:
|
||||
"""No-wid tokens get 401 with AuthErrorCode.WORKSPACE_REQUIRED, not generic token_invalid."""
|
||||
client = TestClient(_make_app())
|
||||
res = client.get("/api/models", cookies={"access_token": _make_legacy_token()})
|
||||
|
||||
assert res.status_code == 401
|
||||
assert res.json()["detail"]["code"] == "workspace_required"
|
||||
|
||||
|
||||
def test_public_path_skips_workspace_check() -> None:
|
||||
"""Public whitelist (e.g. /api/v1/auth/setup-status) does not require wid."""
|
||||
client = TestClient(_make_app())
|
||||
res = client.get("/api/v1/auth/setup-status") # no cookie at all
|
||||
assert res.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.no_auto_workspace
|
||||
def test_workspace_contextvar_resets_between_requests() -> None:
|
||||
"""After dispatch returns the contextvar must be clear (no leak across requests).
|
||||
|
||||
Why we test this: if the try/finally is wired only for user_context but
|
||||
not workspace_context, two back-to-back requests can see each other's
|
||||
workspace under asyncio task switching.
|
||||
"""
|
||||
uid = str(uuid4())
|
||||
token = create_access_token(uid, workspace_id="ws-first", role="owner")
|
||||
|
||||
# First request resolves to ws-first
|
||||
with patch("app.gateway.deps.get_local_provider") as fn:
|
||||
fn.return_value.get_user = AsyncMock(return_value=_make_user(uid))
|
||||
client = TestClient(_make_app())
|
||||
res1 = client.get("/api/models", cookies={"access_token": token})
|
||||
assert res1.json() == {"workspace": {"id": "ws-first", "role": "owner"}}
|
||||
|
||||
# Outside the request scope the contextvar must be empty again.
|
||||
assert get_current_workspace() is None
|
||||
|
||||
# Second request with a different workspace must not see ws-first.
|
||||
token2 = create_access_token(uid, workspace_id="ws-second", role="owner")
|
||||
with patch("app.gateway.deps.get_local_provider") as fn:
|
||||
fn.return_value.get_user = AsyncMock(return_value=_make_user(uid))
|
||||
client = TestClient(_make_app())
|
||||
res2 = client.get("/api/models", cookies={"access_token": token2})
|
||||
assert res2.json() == {"workspace": {"id": "ws-second", "role": "owner"}}
|
||||
@@ -0,0 +1,367 @@
|
||||
"""Tests for ``scripts/backfill_workspace_id.py`` (Stage 0 PR5).
|
||||
|
||||
Each step in the three-step backfill is exercised in isolation against
|
||||
a SQLite-on-disk database. The script wires in ``app.gateway.auth.workspace_slug``,
|
||||
so we get the same slug semantics that the registration flow uses.
|
||||
|
||||
Pattern mirrors :mod:`test_workspace_repo`: ``init_engine`` + per-test
|
||||
tmp_path, with explicit ``close_engine`` teardown so the singleton
|
||||
session factory does not leak across tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from deerflow.persistence.feedback.model import FeedbackRow
|
||||
from deerflow.persistence.models.run_event import RunEventRow
|
||||
from deerflow.persistence.run.model import RunRow
|
||||
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
||||
from deerflow.persistence.user.model import UserRow
|
||||
from deerflow.persistence.workspace.model import WorkspaceRow
|
||||
from deerflow.persistence.workspace_membership.model import WorkspaceMembershipRow
|
||||
from scripts.backfill_workspace_id import (
|
||||
LEGACY_WORKSPACE_ID,
|
||||
_ensure_legacy_workspace,
|
||||
_step1_create_workspaces_for_users,
|
||||
_step2_update_table_from_users,
|
||||
_step3_assign_legacy_workspace,
|
||||
backfill,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend() -> str:
|
||||
return "asyncio"
|
||||
|
||||
|
||||
_BUSINESS_ROWS = (ThreadMetaRow, RunRow, FeedbackRow, RunEventRow)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _relax_workspace_id_nullable():
|
||||
"""Simulate alembic 0002 (pre-backfill) state during these tests.
|
||||
|
||||
PR6 T5.11 flipped ``workspace_id`` to ``nullable=False`` on the four
|
||||
business ORM models — production correctness comes from alembic 0003.
|
||||
The backfill script's job is precisely to fill the rows that were
|
||||
inserted between 0002 (column added, nullable) and 0003 (NOT NULL),
|
||||
so tests for it must be able to insert NULL rows. We mutate
|
||||
``column.nullable`` for the four tables before ``create_all`` runs,
|
||||
then restore on teardown so other tests see the production shape.
|
||||
"""
|
||||
saved: list[tuple] = []
|
||||
for model in _BUSINESS_ROWS:
|
||||
col = model.__table__.c.workspace_id
|
||||
saved.append((col, col.nullable))
|
||||
col.nullable = True
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for col, original in saved:
|
||||
col.nullable = original
|
||||
|
||||
|
||||
async def _init_engine(tmp_path):
|
||||
from sqlalchemy import delete
|
||||
|
||||
from deerflow.persistence.engine import get_session_factory, init_engine
|
||||
|
||||
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
|
||||
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
|
||||
sf = get_session_factory()
|
||||
# The PR6 conftest seeds an autouse user + workspace so business-row FKs
|
||||
# resolve in the wider test suite. Backfill tests model "fresh DB needs
|
||||
# backfill" semantics, so wipe those rows here. Order: clear the FK
|
||||
# pointer first, then the rows.
|
||||
async with sf() as session:
|
||||
await session.execute(delete(WorkspaceMembershipRow))
|
||||
await session.execute(delete(WorkspaceRow).where(WorkspaceRow.id == "test-workspace-autouse"))
|
||||
await session.execute(delete(UserRow).where(UserRow.id == "test-user-autouse"))
|
||||
await session.commit()
|
||||
return sf
|
||||
|
||||
|
||||
async def _close():
|
||||
from deerflow.persistence.engine import close_engine
|
||||
|
||||
await close_engine()
|
||||
|
||||
|
||||
async def _seed_user(sf, *, email: str, default_workspace_id: str | None = None, system_role: str = "user") -> str:
|
||||
user_id = str(uuid.uuid4())
|
||||
async with sf() as session:
|
||||
session.add(UserRow(id=user_id, email=email, default_workspace_id=default_workspace_id, system_role=system_role))
|
||||
await session.commit()
|
||||
return user_id
|
||||
|
||||
|
||||
async def _seed_business_row(sf, model, **fields) -> None:
|
||||
async with sf() as session:
|
||||
session.add(model(**fields))
|
||||
await session.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 1: per-user workspace creation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_creates_workspace_per_user_without_default(tmp_path):
|
||||
"""Each user with NULL default_workspace_id gets a workspace + owner membership."""
|
||||
sf = await _init_engine(tmp_path)
|
||||
try:
|
||||
u_alice = await _seed_user(sf, email="alice@example.com")
|
||||
u_bob = await _seed_user(sf, email="bob+spam@example.com")
|
||||
|
||||
count = await _step1_create_workspaces_for_users(sf, dry_run=False)
|
||||
assert count == 2
|
||||
|
||||
async with sf() as session:
|
||||
workspaces = (await session.execute(select(WorkspaceRow))).scalars().all()
|
||||
memberships = (await session.execute(select(WorkspaceMembershipRow))).scalars().all()
|
||||
users = {u.id: u for u in (await session.execute(select(UserRow))).scalars().all()}
|
||||
|
||||
# 2 workspaces, each with exactly one owner membership matching its user.
|
||||
assert len(workspaces) == 2
|
||||
assert len(memberships) == 2
|
||||
owners_by_ws = {m.workspace_id: m.user_id for m in memberships if m.role == "owner"}
|
||||
assert {m.role for m in memberships} == {"owner"}
|
||||
for ws in workspaces:
|
||||
assert owners_by_ws[ws.id] == ws.owner_id
|
||||
assert users[ws.owner_id].default_workspace_id == ws.id
|
||||
|
||||
# Slug semantics: alice@ → "alice", bob+spam@ → "bob-spam".
|
||||
slugs = {ws.slug for ws in workspaces}
|
||||
assert slugs == {"alice", "bob-spam"}
|
||||
_ = u_alice, u_bob # captured for readability
|
||||
finally:
|
||||
await _close()
|
||||
|
||||
|
||||
async def test_step1_is_idempotent(tmp_path):
|
||||
"""Second run is a no-op when every user already has a default_workspace_id."""
|
||||
sf = await _init_engine(tmp_path)
|
||||
try:
|
||||
await _seed_user(sf, email="carol@example.com")
|
||||
first = await _step1_create_workspaces_for_users(sf, dry_run=False)
|
||||
assert first == 1
|
||||
# Re-running picks up the just-populated default_workspace_id, so the
|
||||
# candidate set is empty.
|
||||
second = await _step1_create_workspaces_for_users(sf, dry_run=False)
|
||||
assert second == 0
|
||||
async with sf() as session:
|
||||
ws_count = len((await session.execute(select(WorkspaceRow))).scalars().all())
|
||||
mem_count = len((await session.execute(select(WorkspaceMembershipRow))).scalars().all())
|
||||
assert ws_count == 1
|
||||
assert mem_count == 1
|
||||
finally:
|
||||
await _close()
|
||||
|
||||
|
||||
async def test_backfill_updates_4_tables_from_users(tmp_path):
|
||||
"""Step 2 propagates each user's default_workspace_id into 4 business tables."""
|
||||
sf = await _init_engine(tmp_path)
|
||||
try:
|
||||
user_id = await _seed_user(sf, email="dave@example.com")
|
||||
# Pre-seed business rows owned by the user with NULL workspace_id.
|
||||
await _seed_business_row(sf, ThreadMetaRow, thread_id="t-1", user_id=user_id)
|
||||
await _seed_business_row(sf, RunRow, run_id="r-1", thread_id="t-1", user_id=user_id)
|
||||
await _seed_business_row(sf, FeedbackRow, feedback_id="f-1", thread_id="t-1", run_id="r-1", user_id=user_id, rating=1)
|
||||
await _seed_business_row(sf, RunEventRow, thread_id="t-1", run_id="r-1", user_id=user_id, event_type="lifecycle_started", category="lifecycle", seq=1)
|
||||
|
||||
# Step 1 first so users.default_workspace_id is populated.
|
||||
await _step1_create_workspaces_for_users(sf, dry_run=False)
|
||||
|
||||
async with sf() as session:
|
||||
ws_id = (await session.execute(select(WorkspaceRow.id))).scalar_one()
|
||||
|
||||
# Step 2 updates each table.
|
||||
for table in ("threads_meta", "runs", "feedback", "run_events"):
|
||||
count = await _step2_update_table_from_users(sf, table, dry_run=False)
|
||||
assert count == 1, table
|
||||
|
||||
async with sf() as session:
|
||||
tm = (await session.execute(select(ThreadMetaRow))).scalar_one()
|
||||
run = (await session.execute(select(RunRow))).scalar_one()
|
||||
fb = (await session.execute(select(FeedbackRow))).scalar_one()
|
||||
ev = (await session.execute(select(RunEventRow))).scalar_one()
|
||||
assert tm.workspace_id == ws_id
|
||||
assert run.workspace_id == ws_id
|
||||
assert fb.workspace_id == ws_id
|
||||
assert ev.workspace_id == ws_id
|
||||
|
||||
# Re-running Step 2 is a no-op (filtered by workspace_id IS NULL).
|
||||
for table in ("threads_meta", "runs", "feedback", "run_events"):
|
||||
assert await _step2_update_table_from_users(sf, table, dry_run=False) == 0
|
||||
finally:
|
||||
await _close()
|
||||
|
||||
|
||||
async def test_backfill_step2_isolates_per_user(tmp_path):
|
||||
"""Two users with different default workspaces get their own threads tagged independently."""
|
||||
sf = await _init_engine(tmp_path)
|
||||
try:
|
||||
u_eve = await _seed_user(sf, email="eve@example.com")
|
||||
u_frank = await _seed_user(sf, email="frank@example.com")
|
||||
await _seed_business_row(sf, ThreadMetaRow, thread_id="t-eve", user_id=u_eve)
|
||||
await _seed_business_row(sf, ThreadMetaRow, thread_id="t-frank", user_id=u_frank)
|
||||
|
||||
await _step1_create_workspaces_for_users(sf, dry_run=False)
|
||||
await _step2_update_table_from_users(sf, "threads_meta", dry_run=False)
|
||||
|
||||
async with sf() as session:
|
||||
rows = {r.thread_id: r.workspace_id for r in (await session.execute(select(ThreadMetaRow))).scalars().all()}
|
||||
users = {u.id: u.default_workspace_id for u in (await session.execute(select(UserRow))).scalars().all()}
|
||||
assert rows["t-eve"] == users[u_eve]
|
||||
assert rows["t-frank"] == users[u_frank]
|
||||
assert rows["t-eve"] != rows["t-frank"]
|
||||
finally:
|
||||
await _close()
|
||||
|
||||
|
||||
async def test_step1_skips_blacklisted_base_slug(tmp_path):
|
||||
"""A user with email like admin@... gets bumped past the slug blacklist via the walker."""
|
||||
sf = await _init_engine(tmp_path)
|
||||
try:
|
||||
await _seed_user(sf, email="admin@example.com")
|
||||
await _step1_create_workspaces_for_users(sf, dry_run=False)
|
||||
async with sf() as session:
|
||||
ws = (await session.execute(select(WorkspaceRow))).scalar_one()
|
||||
# The walker treats "admin" as taken (blacklisted), so it falls
|
||||
# through to "admin-2" — the same behaviour the registration flow
|
||||
# uses for reserved slugs.
|
||||
assert ws.slug == "admin-2"
|
||||
finally:
|
||||
await _close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 3: orphan rows -> legacy_workspace
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_backfill_orphan_rows_go_to_legacy_workspace(tmp_path):
|
||||
"""Rows with user_id=NULL get assigned the legacy_workspace UUID after Step 3."""
|
||||
sf = await _init_engine(tmp_path)
|
||||
try:
|
||||
# Seed a platform admin so the legacy workspace has an owner.
|
||||
await _seed_user(sf, email="admin@example.com", system_role="admin")
|
||||
# Orphan business rows (user_id=NULL): legacy data from before auth.
|
||||
await _seed_business_row(sf, ThreadMetaRow, thread_id="t-orphan", user_id=None)
|
||||
await _seed_business_row(sf, RunRow, run_id="r-orphan", thread_id="t-orphan", user_id=None)
|
||||
await _seed_business_row(sf, FeedbackRow, feedback_id="f-orphan", thread_id="t-orphan", run_id="r-orphan", user_id=None, rating=1)
|
||||
await _seed_business_row(sf, RunEventRow, thread_id="t-orphan", run_id="r-orphan", user_id=None, event_type="legacy", category="lifecycle", seq=1)
|
||||
|
||||
# Ensure the anchor + reassign per table.
|
||||
created = await _ensure_legacy_workspace(sf, dry_run=False)
|
||||
assert created is True
|
||||
for table in ("threads_meta", "runs", "feedback", "run_events"):
|
||||
count = await _step3_assign_legacy_workspace(sf, table, dry_run=False)
|
||||
assert count == 1, table
|
||||
|
||||
# Re-running the anchor helper is a no-op.
|
||||
assert await _ensure_legacy_workspace(sf, dry_run=False) is False
|
||||
|
||||
async with sf() as session:
|
||||
tm = (await session.execute(select(ThreadMetaRow))).scalar_one()
|
||||
run = (await session.execute(select(RunRow))).scalar_one()
|
||||
fb = (await session.execute(select(FeedbackRow))).scalar_one()
|
||||
ev = (await session.execute(select(RunEventRow))).scalar_one()
|
||||
legacy = (await session.execute(select(WorkspaceRow).where(WorkspaceRow.id == LEGACY_WORKSPACE_ID))).scalar_one()
|
||||
legacy_mem = (await session.execute(select(WorkspaceMembershipRow).where(WorkspaceMembershipRow.workspace_id == LEGACY_WORKSPACE_ID))).scalar_one()
|
||||
|
||||
assert tm.workspace_id == LEGACY_WORKSPACE_ID
|
||||
assert run.workspace_id == LEGACY_WORKSPACE_ID
|
||||
assert fb.workspace_id == LEGACY_WORKSPACE_ID
|
||||
assert ev.workspace_id == LEGACY_WORKSPACE_ID
|
||||
assert legacy.slug == "legacy"
|
||||
assert legacy_mem.role == "owner"
|
||||
finally:
|
||||
await _close()
|
||||
|
||||
|
||||
async def test_ensure_legacy_workspace_refuses_when_no_users(tmp_path):
|
||||
"""ensure_legacy_workspace raises a clear error if the DB has no users."""
|
||||
sf = await _init_engine(tmp_path)
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match="no users exist"):
|
||||
await _ensure_legacy_workspace(sf, dry_run=False)
|
||||
finally:
|
||||
await _close()
|
||||
|
||||
|
||||
async def test_backfill_dry_run_does_not_write(tmp_path):
|
||||
"""``backfill(..., dry_run=True)`` reports counts but writes nothing."""
|
||||
sf = await _init_engine(tmp_path)
|
||||
try:
|
||||
await _seed_user(sf, email="admin@example.com", system_role="admin")
|
||||
user_id = await _seed_user(sf, email="helen@example.com")
|
||||
await _seed_business_row(sf, ThreadMetaRow, thread_id="t-owned", user_id=user_id)
|
||||
await _seed_business_row(sf, ThreadMetaRow, thread_id="t-orphan", user_id=None)
|
||||
await _seed_business_row(sf, RunRow, run_id="r-owned", thread_id="t-owned", user_id=user_id)
|
||||
|
||||
# Snapshot row counts BEFORE the dry run so we can confirm
|
||||
# nothing changed AFTER.
|
||||
async with sf() as session:
|
||||
ws_before = len((await session.execute(select(WorkspaceRow))).scalars().all())
|
||||
mem_before = len((await session.execute(select(WorkspaceMembershipRow))).scalars().all())
|
||||
users_with_default_before = len((await session.execute(select(UserRow).where(UserRow.default_workspace_id.is_not(None)))).scalars().all())
|
||||
|
||||
report = await backfill(sf, dry_run=True)
|
||||
assert report["dry_run"] is True
|
||||
# Step 1 reports 2 candidates (admin + helen, both without default).
|
||||
assert report["users_workspaces_created"] == 2
|
||||
# Step 2 reports 0 because Step 1 didn't actually populate
|
||||
# users.default_workspace_id under dry_run — the JOIN comes up empty.
|
||||
assert report["threads_meta_from_users"] == 0
|
||||
assert report["runs_from_users"] == 0
|
||||
# Step 3 reports the 3 NULL business rows (t-owned, t-orphan, r-owned).
|
||||
assert report["legacy_workspace_created"] is True
|
||||
assert report["threads_meta_legacy"] == 2
|
||||
assert report["runs_legacy"] == 1
|
||||
|
||||
# State did not change.
|
||||
async with sf() as session:
|
||||
ws_after = len((await session.execute(select(WorkspaceRow))).scalars().all())
|
||||
mem_after = len((await session.execute(select(WorkspaceMembershipRow))).scalars().all())
|
||||
users_with_default_after = len((await session.execute(select(UserRow).where(UserRow.default_workspace_id.is_not(None)))).scalars().all())
|
||||
rows = (await session.execute(select(ThreadMetaRow.workspace_id))).scalars().all()
|
||||
assert ws_after == ws_before
|
||||
assert mem_after == mem_before
|
||||
assert users_with_default_after == users_with_default_before
|
||||
assert all(w is None for w in rows)
|
||||
finally:
|
||||
await _close()
|
||||
|
||||
|
||||
async def test_full_backfill_orchestrator(tmp_path):
|
||||
"""End-to-end: backfill() runs all three steps and reports per-step counts."""
|
||||
sf = await _init_engine(tmp_path)
|
||||
try:
|
||||
await _seed_user(sf, email="admin@example.com", system_role="admin")
|
||||
user_id = await _seed_user(sf, email="gina@example.com")
|
||||
await _seed_business_row(sf, ThreadMetaRow, thread_id="t-owned", user_id=user_id)
|
||||
await _seed_business_row(sf, ThreadMetaRow, thread_id="t-orphan", user_id=None)
|
||||
|
||||
report = await backfill(sf, dry_run=False)
|
||||
assert report["dry_run"] is False
|
||||
# Two users were missing a default workspace (admin too — we
|
||||
# didn't pre-populate admin's default_workspace_id).
|
||||
assert report["users_workspaces_created"] == 2
|
||||
assert report["threads_meta_from_users"] == 1
|
||||
assert report["legacy_workspace_created"] is True
|
||||
assert report["threads_meta_legacy"] == 1
|
||||
|
||||
async with sf() as session:
|
||||
rows = {r.thread_id: r.workspace_id for r in (await session.execute(select(ThreadMetaRow))).scalars().all()}
|
||||
assert rows["t-orphan"] == LEGACY_WORKSPACE_ID
|
||||
assert rows["t-owned"] != LEGACY_WORKSPACE_ID
|
||||
assert rows["t-owned"] is not None
|
||||
finally:
|
||||
await _close()
|
||||
@@ -0,0 +1,108 @@
|
||||
"""``change_password`` and ``login_local`` re-issue JWTs carrying wid + role.
|
||||
|
||||
Stage 0 PR4 T4.12. The contract:
|
||||
|
||||
- After ``POST /auth/change-password`` the new session cookie's JWT must
|
||||
still encode the user's workspace under ``wid`` (and ``role='owner'``),
|
||||
with ``ver`` bumped. Dropping ``wid`` here would lock the user out of
|
||||
every protected endpoint immediately after a password change.
|
||||
- Same for ``POST /auth/login/local`` — it issues a fresh JWT and must
|
||||
also encode ``wid``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
os.environ.setdefault("AUTH_JWT_SECRET", "test-secret-key-change-password-wid-32x")
|
||||
|
||||
from app.gateway.auth.config import AuthConfig, set_auth_config
|
||||
|
||||
_TEST_SECRET = "test-secret-key-change-password-wid-32x"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup_auth(tmp_path):
|
||||
from app.gateway import deps
|
||||
from app.gateway.routers.auth import _SETUP_STATUS_COOLDOWN
|
||||
from deerflow.persistence.engine import close_engine, init_engine
|
||||
|
||||
set_auth_config(AuthConfig(jwt_secret=_TEST_SECRET))
|
||||
url = f"sqlite+aiosqlite:///{tmp_path}/change_pwd.db"
|
||||
asyncio.run(init_engine("sqlite", url=url, sqlite_dir=str(tmp_path)))
|
||||
deps._cached_local_provider = None
|
||||
deps._cached_repo = None
|
||||
_SETUP_STATUS_COOLDOWN.clear()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
deps._cached_local_provider = None
|
||||
deps._cached_repo = None
|
||||
_SETUP_STATUS_COOLDOWN.clear()
|
||||
asyncio.run(close_engine())
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(_setup_auth):
|
||||
from app.gateway.app import create_app
|
||||
|
||||
set_auth_config(AuthConfig(jwt_secret=_TEST_SECRET))
|
||||
yield TestClient(create_app())
|
||||
|
||||
|
||||
def _decode(token: str) -> dict:
|
||||
return jwt.decode(token, _TEST_SECRET, algorithms=["HS256"])
|
||||
|
||||
|
||||
def _bootstrap_user(client) -> tuple[str, dict]:
|
||||
"""Register a user and return (user_id, initial claims)."""
|
||||
client.post("/api/v1/auth/initialize", json={"email": "admin@example.com", "password": "Str0ng!Pass99"})
|
||||
resp = client.post("/api/v1/auth/register", json={"email": "alice@example.com", "password": "Tr0ub4dor3a-strong!"})
|
||||
assert resp.status_code == 201, resp.text
|
||||
return resp.json()["id"], _decode(resp.cookies["access_token"])
|
||||
|
||||
|
||||
def test_change_password_keeps_wid_and_bumps_ver(client):
|
||||
"""change_password re-signs the JWT with wid + role; ver moves forward."""
|
||||
user_id, initial_claims = _bootstrap_user(client)
|
||||
|
||||
# /register set the csrf cookie; the matching header is required on
|
||||
# the change-password POST (Double Submit Cookie pattern).
|
||||
csrf = client.cookies.get("csrf_token")
|
||||
assert csrf, "register must have set csrf_token cookie"
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/auth/change-password",
|
||||
json={"current_password": "Tr0ub4dor3a-strong!", "new_password": "Tr0ub4dor3a-strong2!"},
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
new_claims = _decode(resp.cookies["access_token"])
|
||||
assert new_claims["sub"] == user_id
|
||||
assert new_claims["wid"] == initial_claims["wid"], "wid must survive a password change"
|
||||
assert new_claims["role"] == "owner"
|
||||
assert new_claims["ver"] == initial_claims["ver"] + 1, "token_version must advance"
|
||||
|
||||
|
||||
def test_login_issues_wid_carrying_jwt(client):
|
||||
"""/auth/login/local issues a JWT that the workspace middleware will accept."""
|
||||
user_id, _ = _bootstrap_user(client)
|
||||
|
||||
# Clear the session cookie set by /register so the login response is observed in isolation.
|
||||
client.cookies.clear()
|
||||
resp = client.post(
|
||||
"/api/v1/auth/login/local",
|
||||
data={"username": "alice@example.com", "password": "Tr0ub4dor3a-strong!"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
claims = _decode(resp.cookies["access_token"])
|
||||
assert claims["sub"] == user_id
|
||||
assert claims.get("wid"), "login must include wid"
|
||||
assert claims["role"] == "owner"
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Stage 0 PR2 · default backend regression + sqlite-still-works.
|
||||
|
||||
Two facts pinned here:
|
||||
T2.3 — explicit ``database.backend: sqlite`` still produces a working
|
||||
config (backwards-compat for users who deliberately stay on SQLite).
|
||||
T2.4 — ``config.example.yaml`` default ``database.backend`` is ``postgres``
|
||||
(Stage 0 PR2 flipped this from sqlite).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from deerflow.config.app_config import AppConfig
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _write_extensions_config(path: Path) -> None:
|
||||
path.write_text(json.dumps({"mcpServers": {}, "skills": {}}), encoding="utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T2.3 · sqlite backend regression
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_explicit_sqlite_backend_still_works(tmp_path, monkeypatch) -> None:
|
||||
"""A config with explicit ``database.backend: sqlite`` must still parse.
|
||||
|
||||
Pin: PR2 made postgres the example default. This test guarantees that
|
||||
users who copy the SQLite fallback block to their config.yaml do not
|
||||
silently regress.
|
||||
"""
|
||||
config_path = tmp_path / "config.yaml"
|
||||
extensions_path = tmp_path / "extensions_config.json"
|
||||
_write_extensions_config(extensions_path)
|
||||
config_path.write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"name": "test",
|
||||
"use": "langchain_openai:ChatOpenAI",
|
||||
"model": "gpt-4",
|
||||
}
|
||||
],
|
||||
"database": {
|
||||
"backend": "sqlite",
|
||||
"sqlite_dir": "/custom/sqlite/path",
|
||||
},
|
||||
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
|
||||
|
||||
config = AppConfig.from_file(str(config_path))
|
||||
|
||||
assert config.database.backend == "sqlite"
|
||||
assert config.database.sqlite_dir == "/custom/sqlite/path"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T2.4 · default config.example.yaml uses postgres
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_config_example_default_backend_is_postgres(monkeypatch) -> None:
|
||||
"""Pin Stage 0 PR2's commitment: the example config defaults to Postgres.
|
||||
|
||||
Loaded directly from the on-disk ``config.example.yaml`` so any future
|
||||
accidental flip back to sqlite would fail this test.
|
||||
"""
|
||||
example_path = REPO_ROOT / "config.example.yaml"
|
||||
if not example_path.exists():
|
||||
pytest.skip(f"config.example.yaml not found at {example_path}")
|
||||
|
||||
raw = yaml.safe_load(example_path.read_text(encoding="utf-8")) or {}
|
||||
db = raw.get("database") or {}
|
||||
|
||||
assert db.get("backend") == "postgres", f"config.example.yaml database.backend is {db.get('backend')!r}; PR2 requires 'postgres' as the default"
|
||||
# The PG URL must come from the env (referenced as $DATABASE_URL),
|
||||
# never hardcoded with credentials.
|
||||
assert db.get("postgres_url") == "$DATABASE_URL", f"postgres_url should be '$DATABASE_URL' env reference, got {db.get('postgres_url')!r}"
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Lifespan hook backfills missing workspaces for pre-PR4 admins.
|
||||
|
||||
Stage 0 PR4 T4.13. Production scenario: a deployment that pre-dates
|
||||
PR4 has an admin user whose ``users.default_workspace_id`` is NULL.
|
||||
After the upgrade, the first time the app boots, the lifespan hook
|
||||
must create the admin's personal workspace + owner membership so the
|
||||
admin can immediately log in without hitting the post-PR4 workspace
|
||||
gate (T4.7).
|
||||
|
||||
This test directly invokes ``_ensure_admin_user`` against a fixture
|
||||
SQLite DB, simulating the upgrade path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.gateway.auth.config import AuthConfig, set_auth_config
|
||||
|
||||
_TEST_SECRET = "test-secret-key-admin-backfill-32-chars"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(tmp_path):
|
||||
from app.gateway import deps
|
||||
from deerflow.persistence.engine import close_engine, init_engine
|
||||
|
||||
set_auth_config(AuthConfig(jwt_secret=_TEST_SECRET))
|
||||
url = f"sqlite+aiosqlite:///{tmp_path}/admin_backfill.db"
|
||||
asyncio.run(init_engine("sqlite", url=url, sqlite_dir=str(tmp_path)))
|
||||
deps._cached_local_provider = None
|
||||
deps._cached_repo = None
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
deps._cached_local_provider = None
|
||||
deps._cached_repo = None
|
||||
asyncio.run(close_engine())
|
||||
|
||||
|
||||
async def _seed_pre_pr4_admin(email: str = "admin@example.com") -> str:
|
||||
"""Insert an admin user with default_workspace_id=NULL (pre-PR4 state)."""
|
||||
from app.gateway.deps import get_local_provider
|
||||
|
||||
provider = get_local_provider()
|
||||
user = await provider.create_user(email=email, password="Str0ng!Pass99", system_role="admin")
|
||||
# Belt + suspenders: pretend this user pre-dates PR4 even if the
|
||||
# provider added a workspace_id (it does not today, but explicit
|
||||
# is better).
|
||||
user.default_workspace_id = None
|
||||
await provider.update_user(user)
|
||||
return str(user.id)
|
||||
|
||||
|
||||
async def _read_workspace_state(user_id: str) -> dict:
|
||||
from sqlalchemy import select
|
||||
|
||||
from deerflow.persistence.engine import get_session_factory
|
||||
from deerflow.persistence.user.model import UserRow
|
||||
from deerflow.persistence.workspace.model import WorkspaceRow
|
||||
from deerflow.persistence.workspace_membership.model import WorkspaceMembershipRow
|
||||
|
||||
sf = get_session_factory()
|
||||
async with sf() as session:
|
||||
user = await session.get(UserRow, user_id)
|
||||
memberships = (await session.execute(select(WorkspaceMembershipRow).where(WorkspaceMembershipRow.user_id == user_id))).scalars().all()
|
||||
workspaces = []
|
||||
if memberships:
|
||||
workspaces = (await session.execute(select(WorkspaceRow).where(WorkspaceRow.id.in_([m.workspace_id for m in memberships])))).scalars().all()
|
||||
return {
|
||||
"default_workspace_id": user.default_workspace_id if user else None,
|
||||
"memberships": [(m.workspace_id, m.role) for m in memberships],
|
||||
"workspaces": [(w.id, w.slug) for w in workspaces],
|
||||
}
|
||||
|
||||
|
||||
def test_ensure_admin_user_creates_missing_workspace():
|
||||
"""Pre-PR4 admin without default_workspace_id → lifespan backfills it."""
|
||||
from app.gateway.app import _ensure_admin_user
|
||||
|
||||
admin_id = asyncio.run(_seed_pre_pr4_admin())
|
||||
before = asyncio.run(_read_workspace_state(admin_id))
|
||||
assert before["default_workspace_id"] is None
|
||||
assert before["workspaces"] == []
|
||||
|
||||
asyncio.run(_ensure_admin_user(FastAPI()))
|
||||
|
||||
after = asyncio.run(_read_workspace_state(admin_id))
|
||||
assert after["default_workspace_id"] is not None, "lifespan should set default_workspace_id"
|
||||
assert len(after["workspaces"]) == 1
|
||||
ws_id, _slug = after["workspaces"][0]
|
||||
assert after["memberships"] == [(ws_id, "owner")]
|
||||
|
||||
|
||||
def test_ensure_admin_user_is_idempotent():
|
||||
"""Running the lifespan hook twice does not create duplicate workspaces."""
|
||||
from app.gateway.app import _ensure_admin_user
|
||||
|
||||
admin_id = asyncio.run(_seed_pre_pr4_admin())
|
||||
asyncio.run(_ensure_admin_user(FastAPI()))
|
||||
state_after_first = asyncio.run(_read_workspace_state(admin_id))
|
||||
|
||||
asyncio.run(_ensure_admin_user(FastAPI()))
|
||||
state_after_second = asyncio.run(_read_workspace_state(admin_id))
|
||||
|
||||
assert state_after_first == state_after_second, "second run must be a no-op"
|
||||
assert len(state_after_second["workspaces"]) == 1
|
||||
|
||||
|
||||
def test_ensure_admin_user_skips_when_admin_already_has_workspace():
|
||||
"""An admin with a workspace already set should not get a second one."""
|
||||
from app.gateway.app import _ensure_admin_user
|
||||
from app.gateway.deps import get_local_provider
|
||||
|
||||
admin_id = asyncio.run(_seed_pre_pr4_admin())
|
||||
|
||||
async def _set_default(workspace_id: str):
|
||||
provider = get_local_provider()
|
||||
user = await provider.get_user(admin_id)
|
||||
user.default_workspace_id = workspace_id
|
||||
await provider.update_user(user)
|
||||
|
||||
# Run the lifespan hook once to seed a workspace, then re-run.
|
||||
asyncio.run(_ensure_admin_user(FastAPI()))
|
||||
state_seeded = asyncio.run(_read_workspace_state(admin_id))
|
||||
assert len(state_seeded["workspaces"]) == 1
|
||||
seeded_ws_id = state_seeded["workspaces"][0][0]
|
||||
|
||||
asyncio.run(_set_default(seeded_ws_id)) # ensure default is still pointing at it
|
||||
asyncio.run(_ensure_admin_user(FastAPI()))
|
||||
|
||||
final = asyncio.run(_read_workspace_state(admin_id))
|
||||
assert final["default_workspace_id"] == seeded_ws_id
|
||||
assert [w[0] for w in final["workspaces"]] == [seeded_ws_id]
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Schema tests for ``ExternalUserRow`` (Stage 0 PR8).
|
||||
|
||||
PR8 is schema-only — no repository class, no API. Tests exercise raw
|
||||
ORM behaviour: the composite UNIQUE constraint (service_account_id,
|
||||
external_id) and CASCADE on service_account delete.
|
||||
|
||||
An external user is the end-user identity passed through by a
|
||||
service_account whose ``identity_mode`` is ``external_passthrough`` or
|
||||
``both``: every call carries an ``X-External-User-Id`` header which is
|
||||
upserted into this table for audit / quota attribution.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from deerflow.persistence.external_user import ExternalUserRow
|
||||
from deerflow.persistence.service_account import ServiceAccountRow
|
||||
from deerflow.persistence.user.model import UserRow
|
||||
from deerflow.persistence.workspace.model import WorkspaceRow
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend() -> str:
|
||||
return "asyncio"
|
||||
|
||||
|
||||
async def _setup(tmp_path):
|
||||
from deerflow.persistence.engine import get_session_factory, init_engine
|
||||
|
||||
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
|
||||
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
|
||||
return get_session_factory()
|
||||
|
||||
|
||||
async def _cleanup():
|
||||
from deerflow.persistence.engine import close_engine
|
||||
|
||||
await close_engine()
|
||||
|
||||
|
||||
async def _seed_parents(sf) -> None:
|
||||
now = datetime.now(UTC)
|
||||
async with sf() as session:
|
||||
session.add(UserRow(id="u-alice", email="alice@example.com"))
|
||||
await session.commit()
|
||||
async with sf() as session:
|
||||
session.add(WorkspaceRow(id="w-1", name="Alice WS", slug="alice", owner_id="u-alice"))
|
||||
await session.commit()
|
||||
async with sf() as session:
|
||||
session.add(
|
||||
ServiceAccountRow(
|
||||
id="sa-1",
|
||||
workspace_id="w-1",
|
||||
name="passthrough-bot",
|
||||
role="member",
|
||||
identity_mode="external_passthrough",
|
||||
status="active",
|
||||
created_by="u-alice",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
def _make_external_user(*, eu_id: str, external_id: str) -> ExternalUserRow:
|
||||
now = datetime.now(UTC)
|
||||
return ExternalUserRow(
|
||||
id=eu_id,
|
||||
workspace_id="w-1",
|
||||
service_account_id="sa-1",
|
||||
external_id=external_id,
|
||||
display_name=None,
|
||||
metadata_json={},
|
||||
created_at=now,
|
||||
last_seen_at=None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T8.5-1 — UNIQUE (service_account_id, external_id)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_unique_service_account_id_plus_external_id(tmp_path):
|
||||
"""The same external_id may be inserted twice only under different SAs."""
|
||||
sf = await _setup(tmp_path)
|
||||
try:
|
||||
await _seed_parents(sf)
|
||||
async with sf() as session:
|
||||
session.add(_make_external_user(eu_id="eu-1", external_id="client-42"))
|
||||
await session.commit()
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
async with sf() as session:
|
||||
session.add(_make_external_user(eu_id="eu-2", external_id="client-42"))
|
||||
await session.commit()
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T8.5-2 — CASCADE on service_account delete
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_cascade_on_service_account_delete(tmp_path):
|
||||
"""Deleting the parent service_account removes all child external_users rows."""
|
||||
sf = await _setup(tmp_path)
|
||||
try:
|
||||
await _seed_parents(sf)
|
||||
async with sf() as session:
|
||||
session.add(_make_external_user(eu_id="eu-c1", external_id="endpoint-A"))
|
||||
session.add(_make_external_user(eu_id="eu-c2", external_id="endpoint-B"))
|
||||
await session.commit()
|
||||
|
||||
async with sf() as session:
|
||||
await session.execute(delete(ServiceAccountRow).where(ServiceAccountRow.id == "sa-1"))
|
||||
await session.commit()
|
||||
|
||||
async with sf() as session:
|
||||
row1 = await session.get(ExternalUserRow, "eu-c1")
|
||||
row2 = await session.get(ExternalUserRow, "eu-c2")
|
||||
assert row1 is None
|
||||
assert row2 is None
|
||||
finally:
|
||||
await _cleanup()
|
||||
@@ -74,7 +74,7 @@ def test_expired_jwt_raises_401():
|
||||
|
||||
|
||||
def test_user_not_found_raises_401():
|
||||
token = create_access_token("ghost")
|
||||
token = create_access_token("ghost", workspace_id="ws-test", role="owner")
|
||||
with patch("app.gateway.langgraph_auth.get_local_provider", return_value=_mock_provider(None)):
|
||||
with pytest.raises(Auth.exceptions.HTTPException) as exc:
|
||||
asyncio.run(authenticate(_req({"access_token": token})))
|
||||
@@ -84,7 +84,7 @@ def test_user_not_found_raises_401():
|
||||
|
||||
def test_token_version_mismatch_raises_401():
|
||||
user = _user(token_version=2)
|
||||
token = create_access_token(str(user.id), token_version=1)
|
||||
token = create_access_token(str(user.id), token_version=1, workspace_id="ws-test", role="owner")
|
||||
with patch("app.gateway.langgraph_auth.get_local_provider", return_value=_mock_provider(user)):
|
||||
with pytest.raises(Auth.exceptions.HTTPException) as exc:
|
||||
asyncio.run(authenticate(_req({"access_token": token})))
|
||||
@@ -94,7 +94,7 @@ def test_token_version_mismatch_raises_401():
|
||||
|
||||
def test_valid_token_returns_user_id():
|
||||
user = _user(token_version=0)
|
||||
token = create_access_token(str(user.id), token_version=0)
|
||||
token = create_access_token(str(user.id), token_version=0, workspace_id="ws-test", role="owner")
|
||||
with patch("app.gateway.langgraph_auth.get_local_provider", return_value=_mock_provider(user)):
|
||||
result = asyncio.run(authenticate(_req({"access_token": token})))
|
||||
assert result == str(user.id)
|
||||
@@ -102,7 +102,7 @@ def test_valid_token_returns_user_id():
|
||||
|
||||
def test_valid_token_matching_version():
|
||||
user = _user(token_version=5)
|
||||
token = create_access_token(str(user.id), token_version=5)
|
||||
token = create_access_token(str(user.id), token_version=5, workspace_id="ws-test", role="owner")
|
||||
with patch("app.gateway.langgraph_auth.get_local_provider", return_value=_mock_provider(user)):
|
||||
result = asyncio.run(authenticate(_req({"access_token": token})))
|
||||
assert result == str(user.id)
|
||||
@@ -113,7 +113,7 @@ def test_valid_token_matching_version():
|
||||
|
||||
def test_provider_exception_propagates():
|
||||
"""Provider raises → should not be swallowed silently."""
|
||||
token = create_access_token("user-1")
|
||||
token = create_access_token("user-1", workspace_id="ws-test", role="owner")
|
||||
p = AsyncMock()
|
||||
p.get_user = AsyncMock(side_effect=RuntimeError("DB down"))
|
||||
with patch("app.gateway.langgraph_auth.get_local_provider", return_value=p):
|
||||
@@ -126,7 +126,11 @@ def test_jwt_missing_ver_defaults_to_zero():
|
||||
import jwt as pyjwt
|
||||
|
||||
uid = str(uuid4())
|
||||
raw = pyjwt.encode({"sub": uid, "exp": 9999999999, "iat": 1000000000}, _JWT_SECRET, algorithm="HS256")
|
||||
raw = pyjwt.encode(
|
||||
{"sub": uid, "wid": "ws-test", "role": "owner", "exp": 9999999999, "iat": 1000000000},
|
||||
_JWT_SECRET,
|
||||
algorithm="HS256",
|
||||
)
|
||||
user = _user(user_id=uid, token_version=0)
|
||||
with patch("app.gateway.langgraph_auth.get_local_provider", return_value=_mock_provider(user)):
|
||||
result = asyncio.run(authenticate(_req({"access_token": raw})))
|
||||
@@ -138,7 +142,11 @@ def test_jwt_missing_ver_rejected_when_user_version_nonzero():
|
||||
import jwt as pyjwt
|
||||
|
||||
uid = str(uuid4())
|
||||
raw = pyjwt.encode({"sub": uid, "exp": 9999999999, "iat": 1000000000}, _JWT_SECRET, algorithm="HS256")
|
||||
raw = pyjwt.encode(
|
||||
{"sub": uid, "wid": "ws-test", "role": "owner", "exp": 9999999999, "iat": 1000000000},
|
||||
_JWT_SECRET,
|
||||
algorithm="HS256",
|
||||
)
|
||||
user = _user(user_id=uid, token_version=1)
|
||||
with patch("app.gateway.langgraph_auth.get_local_provider", return_value=_mock_provider(user)):
|
||||
with pytest.raises(Auth.exceptions.HTTPException) as exc:
|
||||
@@ -221,7 +229,7 @@ def test_filter_with_empty_metadata():
|
||||
|
||||
|
||||
def test_shared_jwt_secret():
|
||||
token = create_access_token("user-1", token_version=3)
|
||||
token = create_access_token("user-1", token_version=3, workspace_id="ws-test", role="owner")
|
||||
payload = decode_token(token)
|
||||
from app.gateway.auth.errors import TokenError
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Legacy 4-field JWT compatibility (Stage 0 PR4 T4.6).
|
||||
|
||||
Before PR4 every JWT carried only `{sub, exp, iat, ver}`. After PR4 the
|
||||
server expects `wid` (workspace_id) on every protected request. Old
|
||||
cookies in the wild must NOT collapse into ``TokenError.MALFORMED`` —
|
||||
that hides the actual problem (workspace required) and prevents the
|
||||
frontend from steering the user to ``/select-workspace``.
|
||||
|
||||
The contract: ``decode_token`` returns ``TokenError.WORKSPACE_MISSING``
|
||||
specifically when the JWT signature checks out and the payload is
|
||||
otherwise well-formed but does NOT carry a ``wid`` claim.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
|
||||
from app.gateway.auth.config import get_auth_config
|
||||
from app.gateway.auth.errors import TokenError
|
||||
from app.gateway.auth.jwt import decode_token
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stable_jwt_secret(monkeypatch):
|
||||
monkeypatch.setenv("AUTH_JWT_SECRET", "test-secret-key-for-jwt-testing-minimum-32-chars")
|
||||
yield
|
||||
|
||||
|
||||
def _make_legacy_token(*, expired: bool = False) -> str:
|
||||
"""Encode a pre-PR4 JWT directly (bypassing create_access_token)."""
|
||||
now = datetime.now(UTC)
|
||||
payload = {
|
||||
"sub": "u-legacy",
|
||||
"exp": now + (timedelta(seconds=-1) if expired else timedelta(hours=1)),
|
||||
"iat": now,
|
||||
"ver": 0,
|
||||
}
|
||||
return jwt.encode(payload, get_auth_config().jwt_secret, algorithm="HS256")
|
||||
|
||||
|
||||
def test_decode_legacy_token_returns_workspace_missing_error() -> None:
|
||||
"""4-field token (no wid) → TokenError.WORKSPACE_MISSING (not MALFORMED)."""
|
||||
token = _make_legacy_token()
|
||||
result = decode_token(token)
|
||||
assert result == TokenError.WORKSPACE_MISSING
|
||||
|
||||
|
||||
def test_decode_legacy_token_with_expired_still_reports_expired() -> None:
|
||||
"""Expired legacy tokens keep reporting EXPIRED — that signal takes priority.
|
||||
|
||||
Why: an expired token must trigger /auth/refresh logic before we
|
||||
decide it also lacks workspace; reporting WORKSPACE_MISSING on an
|
||||
expired token would steer the user to /select-workspace instead.
|
||||
"""
|
||||
token = _make_legacy_token(expired=True)
|
||||
result = decode_token(token)
|
||||
assert result == TokenError.EXPIRED
|
||||
@@ -0,0 +1,155 @@
|
||||
"""PR6 T6.13 — workspace-path migration script tests.
|
||||
|
||||
Builds the legacy ``users/{uid}/...`` tree under a temp base dir,
|
||||
points a populated sqlite ``users`` table at it via the conventional
|
||||
``deer-flow.db`` location, and asserts the migration produces the new
|
||||
``workspaces/{wid}/...`` layout. Covers:
|
||||
|
||||
- threads / memory.json / custom agents all rewritten under the workspace
|
||||
- ``--dry-run`` writes nothing
|
||||
- pre-existing destinations get diverted to ``migration-conflicts/``
|
||||
- users without a ``default_workspace_id`` fall back to the explicit flag
|
||||
- empty legacy dirs are cleaned up
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from deerflow.config.paths import Paths
|
||||
from scripts.migrate_paths_to_workspace import (
|
||||
LEGACY_WORKSPACE_FALLBACK,
|
||||
_load_user_workspaces,
|
||||
migrate,
|
||||
)
|
||||
|
||||
|
||||
def _build_legacy_tree(base: Path, *, user_id: str, thread_ids: tuple[str, ...] = (), with_memory: bool = False, agent_names: tuple[str, ...] = ()) -> None:
|
||||
user_root = base / "users" / user_id
|
||||
user_root.mkdir(parents=True, exist_ok=True)
|
||||
for tid in thread_ids:
|
||||
(user_root / "threads" / tid / "user-data" / "workspace").mkdir(parents=True, exist_ok=True)
|
||||
(user_root / "threads" / tid / "user-data" / "workspace" / "marker.txt").write_text(f"{user_id}/{tid}", encoding="utf-8")
|
||||
if with_memory:
|
||||
(user_root / "memory.json").write_text(f'{{"user_id": "{user_id}"}}', encoding="utf-8")
|
||||
for name in agent_names:
|
||||
(user_root / "agents" / name).mkdir(parents=True, exist_ok=True)
|
||||
(user_root / "agents" / name / "SOUL.md").write_text(f"# {name}", encoding="utf-8")
|
||||
|
||||
|
||||
def _seed_db(base: Path, *, users: dict[str, str | None]) -> None:
|
||||
db_path = base / "deer-flow.db"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
try:
|
||||
conn.execute("CREATE TABLE users (id TEXT PRIMARY KEY, default_workspace_id TEXT)")
|
||||
conn.executemany("INSERT INTO users (id, default_workspace_id) VALUES (?, ?)", list(users.items()))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def base(tmp_path: Path) -> Path:
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_migrate_threads_under_workspace(base: Path):
|
||||
_build_legacy_tree(base, user_id="alice", thread_ids=("t1",))
|
||||
_seed_db(base, users={"alice": "ws-alpha"})
|
||||
paths = Paths(base)
|
||||
|
||||
report = migrate(paths, user_workspaces=_load_user_workspaces(paths), fallback_workspace=LEGACY_WORKSPACE_FALLBACK, dry_run=False)
|
||||
|
||||
assert (base / "workspaces" / "ws-alpha" / "threads" / "t1" / "user-data" / "workspace" / "marker.txt").read_text(encoding="utf-8") == "alice/t1"
|
||||
assert not (base / "users" / "alice" / "threads").exists()
|
||||
assert {entry["asset"] for entry in report} == {"thread"}
|
||||
|
||||
|
||||
def test_migrate_memory_and_agents_nested_under_workspace_and_user(base: Path):
|
||||
_build_legacy_tree(base, user_id="alice", with_memory=True, agent_names=("code-reviewer",))
|
||||
_seed_db(base, users={"alice": "ws-alpha"})
|
||||
paths = Paths(base)
|
||||
|
||||
migrate(paths, user_workspaces=_load_user_workspaces(paths), fallback_workspace=LEGACY_WORKSPACE_FALLBACK, dry_run=False)
|
||||
|
||||
assert (base / "workspaces" / "ws-alpha" / "users" / "alice" / "memory.json").exists()
|
||||
assert (base / "workspaces" / "ws-alpha" / "users" / "alice" / "agents" / "code-reviewer" / "SOUL.md").exists()
|
||||
|
||||
|
||||
def test_dry_run_writes_nothing(base: Path):
|
||||
_build_legacy_tree(base, user_id="alice", thread_ids=("t1",), with_memory=True, agent_names=("a1",))
|
||||
_seed_db(base, users={"alice": "ws-alpha"})
|
||||
paths = Paths(base)
|
||||
|
||||
report = migrate(paths, user_workspaces=_load_user_workspaces(paths), fallback_workspace=LEGACY_WORKSPACE_FALLBACK, dry_run=True)
|
||||
|
||||
# Source unchanged
|
||||
assert (base / "users" / "alice" / "threads" / "t1" / "user-data" / "workspace" / "marker.txt").exists()
|
||||
assert (base / "users" / "alice" / "memory.json").exists()
|
||||
assert (base / "users" / "alice" / "agents" / "a1" / "SOUL.md").exists()
|
||||
# No destination created
|
||||
assert not (base / "workspaces").exists()
|
||||
# Report still populated so operator sees what *would* happen
|
||||
assert len(report) == 3
|
||||
|
||||
|
||||
def test_fallback_workspace_used_when_user_has_no_default(base: Path):
|
||||
_build_legacy_tree(base, user_id="alice", thread_ids=("t1",))
|
||||
_seed_db(base, users={"alice": None})
|
||||
paths = Paths(base)
|
||||
|
||||
migrate(paths, user_workspaces=_load_user_workspaces(paths), fallback_workspace="legacy_workspace", dry_run=False)
|
||||
|
||||
assert (base / "workspaces" / "legacy_workspace" / "threads" / "t1" / "user-data" / "workspace" / "marker.txt").exists()
|
||||
|
||||
|
||||
def test_conflict_routes_legacy_to_migration_conflicts(base: Path):
|
||||
# Pre-create the destination with a different marker so the move sees a conflict.
|
||||
_build_legacy_tree(base, user_id="alice", thread_ids=("t1",))
|
||||
(base / "workspaces" / "ws-alpha" / "threads" / "t1" / "user-data" / "workspace").mkdir(parents=True)
|
||||
(base / "workspaces" / "ws-alpha" / "threads" / "t1" / "user-data" / "workspace" / "marker.txt").write_text("preexisting", encoding="utf-8")
|
||||
_seed_db(base, users={"alice": "ws-alpha"})
|
||||
paths = Paths(base)
|
||||
|
||||
report = migrate(paths, user_workspaces=_load_user_workspaces(paths), fallback_workspace=LEGACY_WORKSPACE_FALLBACK, dry_run=False)
|
||||
|
||||
assert (base / "workspaces" / "ws-alpha" / "threads" / "t1" / "user-data" / "workspace" / "marker.txt").read_text(encoding="utf-8") == "preexisting"
|
||||
conflict_marker = base / "migration-conflicts" / "workspace-migration" / "threads/ws-alpha" / "t1"
|
||||
assert conflict_marker.exists()
|
||||
assert any("conflict" in entry["action"] for entry in report)
|
||||
|
||||
|
||||
def test_empty_users_dir_removed_after_full_migration(base: Path):
|
||||
_build_legacy_tree(base, user_id="alice", thread_ids=("t1",))
|
||||
_seed_db(base, users={"alice": "ws-alpha"})
|
||||
paths = Paths(base)
|
||||
|
||||
migrate(paths, user_workspaces=_load_user_workspaces(paths), fallback_workspace=LEGACY_WORKSPACE_FALLBACK, dry_run=False)
|
||||
|
||||
assert not (base / "users").exists(), "Empty legacy users/ dir should be cleaned up"
|
||||
|
||||
|
||||
def test_no_users_directory_is_noop(base: Path):
|
||||
paths = Paths(base)
|
||||
report = migrate(paths, user_workspaces={}, fallback_workspace=LEGACY_WORKSPACE_FALLBACK, dry_run=False)
|
||||
assert report == []
|
||||
|
||||
|
||||
def test_missing_db_returns_empty_mapping(base: Path):
|
||||
paths = Paths(base)
|
||||
assert _load_user_workspaces(paths) == {}
|
||||
|
||||
|
||||
def test_db_without_users_table_returns_empty(base: Path):
|
||||
db_path = base / "deer-flow.db"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
try:
|
||||
conn.execute("CREATE TABLE other_table (x INTEGER)")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
paths = Paths(base)
|
||||
assert _load_user_workspaces(paths) == {}
|
||||
@@ -0,0 +1,45 @@
|
||||
"""PR6 T6.14 — lifespan warns when legacy users/ tree still has content."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.gateway.app import _check_path_migration_pending
|
||||
|
||||
|
||||
class _FakePaths:
|
||||
def __init__(self, base: Path):
|
||||
self.base_dir = base
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def base(tmp_path: Path) -> Path:
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _patch_paths(base: Path):
|
||||
return patch("deerflow.config.paths.get_paths", return_value=_FakePaths(base))
|
||||
|
||||
|
||||
def test_warns_when_legacy_users_dir_has_content(base: Path, caplog: pytest.LogCaptureFixture):
|
||||
(base / "users" / "alice" / "threads" / "t1").mkdir(parents=True)
|
||||
with _patch_paths(base), caplog.at_level(logging.WARNING, logger="app.gateway.app"):
|
||||
_check_path_migration_pending(app=None) # type: ignore[arg-type]
|
||||
assert any("make migrate-paths" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
def test_silent_when_legacy_users_dir_missing(base: Path, caplog: pytest.LogCaptureFixture):
|
||||
with _patch_paths(base), caplog.at_level(logging.WARNING, logger="app.gateway.app"):
|
||||
_check_path_migration_pending(app=None) # type: ignore[arg-type]
|
||||
assert not any("migrate-paths" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
def test_silent_when_legacy_users_dir_empty(base: Path, caplog: pytest.LogCaptureFixture):
|
||||
(base / "users").mkdir()
|
||||
with _patch_paths(base), caplog.at_level(logging.WARNING, logger="app.gateway.app"):
|
||||
_check_path_migration_pending(app=None) # type: ignore[arg-type]
|
||||
assert not any("migrate-paths" in rec.message for rec in caplog.records)
|
||||
@@ -0,0 +1,115 @@
|
||||
"""PR6 T6.9 / T6.10 — workspace-scoped path resolution.
|
||||
|
||||
The Paths class learns a new top-level dimension for multi-tenant
|
||||
filesystems: ``{base_dir}/workspaces/{wid}/...``. Precedence:
|
||||
|
||||
- ``workspace_id`` given → new shape ``workspaces/{wid}/threads/{tid}/...``
|
||||
- only ``user_id`` given → legacy shape ``users/{uid}/threads/{tid}/...``
|
||||
- neither → very-legacy shape ``threads/{tid}/...``
|
||||
|
||||
Per-user filesystem state (memory.json, custom agents) lives under the
|
||||
workspace too: ``workspaces/{wid}/users/{uid}/memory.json`` etc. — so a
|
||||
user's memory cannot be reused across workspaces by mistake.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from deerflow.config.paths import Paths
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def paths(tmp_path: Path) -> Paths:
|
||||
return Paths(tmp_path)
|
||||
|
||||
|
||||
class TestValidateWorkspaceId:
|
||||
def test_valid_workspace_id(self, paths: Paths):
|
||||
d = paths.workspace_dir("ws-abc-123")
|
||||
assert d == paths.base_dir / "workspaces" / "ws-abc-123"
|
||||
|
||||
def test_rejects_path_traversal(self, paths: Paths):
|
||||
with pytest.raises(ValueError, match="Invalid workspace_id"):
|
||||
paths.workspace_dir("../escape")
|
||||
|
||||
def test_rejects_slash(self, paths: Paths):
|
||||
with pytest.raises(ValueError, match="Invalid workspace_id"):
|
||||
paths.workspace_dir("ws/bar")
|
||||
|
||||
def test_rejects_empty(self, paths: Paths):
|
||||
with pytest.raises(ValueError, match="Invalid workspace_id"):
|
||||
paths.workspace_dir("")
|
||||
|
||||
|
||||
class TestWorkspaceScopedThreadDir:
|
||||
def test_workspace_takes_precedence_over_user(self, paths: Paths):
|
||||
"""When both are given, workspace wins — user_id is recorded in the row, not the filesystem."""
|
||||
expected = paths.base_dir / "workspaces" / "ws-alpha" / "threads" / "t1"
|
||||
assert paths.thread_dir("t1", workspace_id="ws-alpha", user_id="alice") == expected
|
||||
|
||||
def test_workspace_only(self, paths: Paths):
|
||||
expected = paths.base_dir / "workspaces" / "ws-alpha" / "threads" / "t1"
|
||||
assert paths.thread_dir("t1", workspace_id="ws-alpha") == expected
|
||||
|
||||
def test_user_only_still_legacy(self, paths: Paths):
|
||||
"""Legacy callers keep the user_id-only shape until migration runs."""
|
||||
expected = paths.base_dir / "users" / "alice" / "threads" / "t1"
|
||||
assert paths.thread_dir("t1", user_id="alice") == expected
|
||||
|
||||
def test_no_ids_very_legacy(self, paths: Paths):
|
||||
expected = paths.base_dir / "threads" / "t1"
|
||||
assert paths.thread_dir("t1") == expected
|
||||
|
||||
|
||||
class TestEnsureThreadDirsWorkspace:
|
||||
def test_creates_workspace_layout(self, paths: Paths):
|
||||
paths.ensure_thread_dirs("t1", workspace_id="ws-alpha")
|
||||
root = paths.base_dir / "workspaces" / "ws-alpha" / "threads" / "t1"
|
||||
for sub in ("user-data/workspace", "user-data/uploads", "user-data/outputs", "acp-workspace"):
|
||||
assert (root / sub).is_dir(), f"missing {sub}"
|
||||
|
||||
|
||||
class TestSandboxDirsWorkspace:
|
||||
def test_sandbox_work_dir(self, paths: Paths):
|
||||
expected = paths.base_dir / "workspaces" / "ws-alpha" / "threads" / "t1" / "user-data" / "workspace"
|
||||
assert paths.sandbox_work_dir("t1", workspace_id="ws-alpha") == expected
|
||||
|
||||
def test_sandbox_uploads_dir(self, paths: Paths):
|
||||
expected = paths.base_dir / "workspaces" / "ws-alpha" / "threads" / "t1" / "user-data" / "uploads"
|
||||
assert paths.sandbox_uploads_dir("t1", workspace_id="ws-alpha") == expected
|
||||
|
||||
def test_sandbox_outputs_dir(self, paths: Paths):
|
||||
expected = paths.base_dir / "workspaces" / "ws-alpha" / "threads" / "t1" / "user-data" / "outputs"
|
||||
assert paths.sandbox_outputs_dir("t1", workspace_id="ws-alpha") == expected
|
||||
|
||||
|
||||
class TestUserMemoryUnderWorkspace:
|
||||
def test_user_memory_file_under_workspace(self, paths: Paths):
|
||||
expected = paths.base_dir / "workspaces" / "ws-alpha" / "users" / "alice" / "memory.json"
|
||||
assert paths.user_memory_file("alice", workspace_id="ws-alpha") == expected
|
||||
|
||||
def test_user_memory_file_legacy_without_workspace(self, paths: Paths):
|
||||
expected = paths.base_dir / "users" / "alice" / "memory.json"
|
||||
assert paths.user_memory_file("alice") == expected
|
||||
|
||||
def test_user_agents_dir_under_workspace(self, paths: Paths):
|
||||
expected = paths.base_dir / "workspaces" / "ws-alpha" / "users" / "alice" / "agents"
|
||||
assert paths.user_agents_dir("alice", workspace_id="ws-alpha") == expected
|
||||
|
||||
def test_user_agent_memory_file_under_workspace(self, paths: Paths):
|
||||
expected = paths.base_dir / "workspaces" / "ws-alpha" / "users" / "alice" / "agents" / "code-reviewer" / "memory.json"
|
||||
assert paths.user_agent_memory_file("alice", "code-reviewer", workspace_id="ws-alpha") == expected
|
||||
|
||||
|
||||
class TestVirtualPathResolutionWorkspace:
|
||||
def test_resolve_virtual_path_workspace_scope(self, paths: Paths):
|
||||
expected = paths.base_dir / "workspaces" / "ws-alpha" / "threads" / "t1" / "user-data" / "outputs" / "x.json"
|
||||
actual = paths.resolve_virtual_path("t1", "/mnt/user-data/outputs/x.json", workspace_id="ws-alpha")
|
||||
assert actual == expected.resolve()
|
||||
|
||||
def test_resolve_virtual_path_rejects_traversal_under_workspace(self, paths: Paths):
|
||||
with pytest.raises(ValueError, match="path traversal"):
|
||||
paths.resolve_virtual_path("t1", "/mnt/user-data/../../etc/passwd", workspace_id="ws-alpha")
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Stage 0 PR1 · Postgres smoke tests via testcontainers.
|
||||
|
||||
These tests exercise the postgres_url fixture and verify that DeerFlow's
|
||||
existing ``init_engine`` + ORM ``Base.metadata.create_all`` works against
|
||||
Postgres exactly the same way it works against SQLite (no Stage 0
|
||||
schema changes here — that's PR3+).
|
||||
|
||||
All tests are gated by ``@pytest.mark.postgres`` and skip cleanly when
|
||||
Docker is unavailable (the postgres_container fixture handles that).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
|
||||
import pytest
|
||||
|
||||
# Mark every test in this module as `postgres` so they only run when
|
||||
# explicitly requested with ``pytest -m postgres``.
|
||||
pytestmark = [pytest.mark.postgres, pytest.mark.anyio]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend() -> str:
|
||||
"""anyio uses asyncio backend (matches DeerFlow's runtime)."""
|
||||
return "asyncio"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T1.4 · Fixture self-test — verify per-test isolation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_postgres_url_creates_isolated_database(postgres_url: str) -> None:
|
||||
"""The fixture should yield a usable URL pointing at a unique database."""
|
||||
import asyncpg
|
||||
|
||||
# The URL form is `postgresql+asyncpg://user:pass@host:port/test_<hex>`.
|
||||
assert postgres_url.startswith("postgresql+asyncpg://")
|
||||
assert "/test_" in postgres_url
|
||||
|
||||
# Connect with raw asyncpg (strip SQLAlchemy dialect prefix) and confirm
|
||||
# we landed in the named test DB.
|
||||
raw = postgres_url.replace("postgresql+asyncpg://", "postgresql://")
|
||||
conn = await asyncpg.connect(raw)
|
||||
try:
|
||||
current = await conn.fetchval("SELECT current_database()")
|
||||
assert current.startswith("test_"), f"expected test_*, got {current!r}"
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
async def test_postgres_url_isolates_between_tests(postgres_container, postgres_url: str) -> None:
|
||||
"""Two invocations of the fixture should yield two distinct databases.
|
||||
|
||||
Hand-rolls a second database via the same recipe to prove isolation
|
||||
without depending on pytest's own per-test invocation timing.
|
||||
"""
|
||||
import asyncpg
|
||||
import psycopg
|
||||
from psycopg import sql
|
||||
|
||||
# Read what DB we're in.
|
||||
raw = postgres_url.replace("postgresql+asyncpg://", "postgresql://")
|
||||
conn = await asyncpg.connect(raw)
|
||||
try:
|
||||
db_a = await conn.fetchval("SELECT current_database()")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
# Create a *second* DB on the same container directly.
|
||||
container_url = postgres_container.get_connection_url().replace("postgresql+psycopg2://", "postgresql://")
|
||||
parent_url = container_url.rsplit("/", 1)[0] + "/postgres"
|
||||
db_b_name = f"test_{secrets.token_hex(8)}"
|
||||
with psycopg.connect(parent_url, autocommit=True) as c:
|
||||
c.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(db_b_name)))
|
||||
try:
|
||||
assert db_a != db_b_name, "fixture should not reuse a DB name across tests"
|
||||
finally:
|
||||
with psycopg.connect(parent_url, autocommit=True) as c:
|
||||
c.execute(sql.SQL("DROP DATABASE IF EXISTS {}").format(sql.Identifier(db_b_name)))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T1.5 · init_engine smoke — Base.metadata.create_all() works on Postgres
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_init_engine_postgres_creates_tables(postgres_url: str) -> None:
|
||||
"""``init_engine`` against Postgres must auto-create existing ORM tables.
|
||||
|
||||
Verifies the four current business tables (users, threads_meta, runs,
|
||||
feedback) plus run_events appear in information_schema after init —
|
||||
proving that DeerFlow's ``Base.metadata.create_all()`` path works
|
||||
identically on PG and SQLite.
|
||||
"""
|
||||
from sqlalchemy import text
|
||||
|
||||
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
|
||||
|
||||
await init_engine("postgres", url=postgres_url)
|
||||
try:
|
||||
sf = get_session_factory()
|
||||
async with sf() as session:
|
||||
result = await session.execute(text("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'"))
|
||||
tables = {row[0] for row in result.all()}
|
||||
|
||||
# Existing tables before any Stage 0 schema additions:
|
||||
expected = {"users", "threads_meta", "runs", "feedback", "run_events"}
|
||||
missing = expected - tables
|
||||
assert not missing, f"create_all() did not produce {missing}; got {tables}"
|
||||
finally:
|
||||
await close_engine()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T1.6 · Repository round-trip on Postgres
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_thread_meta_repo_postgres_round_trip(postgres_url: str) -> None:
|
||||
"""ThreadMetaRepository.create + get must behave identically on PG.
|
||||
|
||||
Pin: this is a regression net for any SQLAlchemy / asyncpg surprise
|
||||
that diverges from SQLite behavior in dict shape, default values,
|
||||
or timestamp precision.
|
||||
"""
|
||||
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
|
||||
from deerflow.persistence.thread_meta import ThreadMetaRepository
|
||||
|
||||
await init_engine("postgres", url=postgres_url)
|
||||
try:
|
||||
repo = ThreadMetaRepository(get_session_factory())
|
||||
# Note: conftest's `_auto_user_context` autouse fixture has already
|
||||
# injected `id="test-user-autouse"` so create() picks it via AUTO.
|
||||
created = await repo.create(
|
||||
thread_id="thread-1",
|
||||
display_name="hello",
|
||||
metadata={"k": "v"},
|
||||
)
|
||||
assert created["thread_id"] == "thread-1"
|
||||
assert created["user_id"] == "test-user-autouse"
|
||||
assert created["display_name"] == "hello"
|
||||
assert created["metadata"] == {"k": "v"}
|
||||
|
||||
fetched = await repo.get("thread-1")
|
||||
assert fetched is not None
|
||||
assert fetched["thread_id"] == "thread-1"
|
||||
assert fetched["user_id"] == "test-user-autouse"
|
||||
finally:
|
||||
await close_engine()
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Acceptance test for PR8: ``Base.metadata.create_all()`` automatically
|
||||
provisions ``service_accounts`` / ``api_keys`` / ``external_users``.
|
||||
|
||||
The harness layer registers all ORM models through ``deerflow.persistence.models``
|
||||
(imported for side effects from ``engine.init_engine``). This test guards
|
||||
against a row class being defined but accidentally left out of the
|
||||
registration entry point — a class table that never gets created at
|
||||
``init_engine`` time would otherwise silently break Stage 1 once the
|
||||
API-key auth layer starts inserting rows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import inspect
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend() -> str:
|
||||
return "asyncio"
|
||||
|
||||
|
||||
async def test_pr8_tables_present_after_init_engine(tmp_path):
|
||||
from deerflow.persistence.engine import close_engine, get_engine, init_engine
|
||||
|
||||
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
|
||||
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
|
||||
try:
|
||||
engine = get_engine()
|
||||
assert engine is not None
|
||||
|
||||
def _table_names(sync_conn):
|
||||
return set(inspect(sync_conn).get_table_names())
|
||||
|
||||
async with engine.connect() as conn:
|
||||
tables = await conn.run_sync(_table_names)
|
||||
|
||||
assert {"service_accounts", "api_keys", "external_users"}.issubset(tables), f"PR8 tables missing from create_all: have {sorted(tables)}"
|
||||
finally:
|
||||
await close_engine()
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Registration endpoints (POST /initialize, POST /register) auto-create a workspace.
|
||||
|
||||
Stage 0 PR4 T4.8 + T4.9. Every newly registered user must end up with:
|
||||
|
||||
- a single ``workspaces`` row (their personal workspace),
|
||||
- a single ``workspace_memberships`` row with ``role='owner'``,
|
||||
- ``users.default_workspace_id`` pointing at that workspace,
|
||||
- a session cookie whose JWT carries the workspace as the ``wid`` claim.
|
||||
|
||||
Tests run against a per-test SQLite engine bootstrapped by the
|
||||
fixture; the registration router is exercised through the real
|
||||
TestClient so the full handler + DB transaction path is covered.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
os.environ.setdefault("AUTH_JWT_SECRET", "test-secret-key-register-workspace-32+")
|
||||
|
||||
from app.gateway.auth.config import AuthConfig, set_auth_config
|
||||
|
||||
_TEST_SECRET = "test-secret-key-register-workspace-32+"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup_auth(tmp_path):
|
||||
from app.gateway import deps
|
||||
from app.gateway.routers.auth import _SETUP_STATUS_COOLDOWN
|
||||
from deerflow.persistence.engine import close_engine, init_engine
|
||||
|
||||
set_auth_config(AuthConfig(jwt_secret=_TEST_SECRET))
|
||||
url = f"sqlite+aiosqlite:///{tmp_path}/register_ws.db"
|
||||
asyncio.run(init_engine("sqlite", url=url, sqlite_dir=str(tmp_path)))
|
||||
deps._cached_local_provider = None
|
||||
deps._cached_repo = None
|
||||
_SETUP_STATUS_COOLDOWN.clear()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
deps._cached_local_provider = None
|
||||
deps._cached_repo = None
|
||||
_SETUP_STATUS_COOLDOWN.clear()
|
||||
asyncio.run(close_engine())
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(_setup_auth):
|
||||
from app.gateway.app import create_app
|
||||
|
||||
set_auth_config(AuthConfig(jwt_secret=_TEST_SECRET))
|
||||
app = create_app()
|
||||
yield TestClient(app)
|
||||
|
||||
|
||||
def _init_payload(**extra):
|
||||
return {"email": "admin@example.com", "password": "Str0ng!Pass99", **extra}
|
||||
|
||||
|
||||
def _register_payload(email: str = "alice@example.com", **extra):
|
||||
return {"email": email, "password": "Tr0ub4dor3a-strong!", **extra}
|
||||
|
||||
|
||||
def _decode(token: str) -> dict:
|
||||
"""Decode a JWT (signature-checked) and return the raw payload."""
|
||||
return jwt.decode(token, _TEST_SECRET, algorithms=["HS256"])
|
||||
|
||||
|
||||
async def _read_workspace_state(user_id: str) -> dict:
|
||||
"""Inspect the per-user workspace state after a registration call.
|
||||
|
||||
Returns a dict with the workspace row, the owner membership row and
|
||||
the user's default_workspace_id, so each test can pick what it
|
||||
cares about.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from deerflow.persistence.engine import get_session_factory
|
||||
from deerflow.persistence.user.model import UserRow
|
||||
from deerflow.persistence.workspace.model import WorkspaceRow
|
||||
from deerflow.persistence.workspace_membership.model import WorkspaceMembershipRow
|
||||
|
||||
sf = get_session_factory()
|
||||
async with sf() as session:
|
||||
user = await session.get(UserRow, user_id)
|
||||
memberships = (await session.execute(select(WorkspaceMembershipRow).where(WorkspaceMembershipRow.user_id == user_id))).scalars().all()
|
||||
workspaces = []
|
||||
if memberships:
|
||||
workspaces = (await session.execute(select(WorkspaceRow).where(WorkspaceRow.id.in_([m.workspace_id for m in memberships])))).scalars().all()
|
||||
return {
|
||||
"user_default_workspace_id": getattr(user, "default_workspace_id", None) if user else None,
|
||||
"memberships": [(m.workspace_id, m.user_id, m.role) for m in memberships],
|
||||
"workspaces": [(w.id, w.slug, w.owner_id) for w in workspaces],
|
||||
}
|
||||
|
||||
|
||||
# ---------- T4.8 — /initialize ---------------------------------------------
|
||||
|
||||
|
||||
def test_initialize_creates_admin_with_default_workspace(client):
|
||||
"""POST /initialize → admin + workspace + owner membership + wid cookie."""
|
||||
resp = client.post("/api/v1/auth/initialize", json=_init_payload())
|
||||
assert resp.status_code == 201, resp.text
|
||||
user_id = resp.json()["id"]
|
||||
|
||||
state = asyncio.run(_read_workspace_state(user_id))
|
||||
|
||||
assert len(state["workspaces"]) == 1, state
|
||||
ws_id, ws_slug, ws_owner = state["workspaces"][0]
|
||||
assert ws_owner == user_id
|
||||
# base slug "admin" is reserved, walker bumps to first free suffix
|
||||
assert ws_slug == "admin-2"
|
||||
|
||||
assert state["memberships"] == [(ws_id, user_id, "owner")]
|
||||
assert state["user_default_workspace_id"] == ws_id
|
||||
|
||||
token = resp.cookies["access_token"]
|
||||
claims = _decode(token)
|
||||
assert claims["wid"] == ws_id
|
||||
assert claims["role"] == "owner"
|
||||
|
||||
|
||||
# ---------- T4.9 — /register -----------------------------------------------
|
||||
|
||||
|
||||
def test_register_creates_user_with_default_workspace(client):
|
||||
"""POST /register → user + workspace + owner membership + wid cookie."""
|
||||
# Initialize an admin first so the system is past first-boot.
|
||||
client.post("/api/v1/auth/initialize", json=_init_payload())
|
||||
|
||||
resp = client.post("/api/v1/auth/register", json=_register_payload())
|
||||
assert resp.status_code == 201, resp.text
|
||||
user_id = resp.json()["id"]
|
||||
|
||||
state = asyncio.run(_read_workspace_state(user_id))
|
||||
|
||||
assert len(state["workspaces"]) == 1, state
|
||||
ws_id, ws_slug, ws_owner = state["workspaces"][0]
|
||||
assert ws_owner == user_id
|
||||
assert ws_slug == "alice"
|
||||
|
||||
assert state["memberships"] == [(ws_id, user_id, "owner")]
|
||||
assert state["user_default_workspace_id"] == ws_id
|
||||
|
||||
token = resp.cookies["access_token"]
|
||||
claims = _decode(token)
|
||||
assert claims["wid"] == ws_id
|
||||
assert claims["role"] == "owner"
|
||||
|
||||
|
||||
def test_two_registrations_isolate_workspaces_and_avoid_slug_collision(client):
|
||||
"""Two users with colliding email local-parts → distinct workspaces, slug suffix bump."""
|
||||
client.post("/api/v1/auth/initialize", json=_init_payload())
|
||||
|
||||
r1 = client.post("/api/v1/auth/register", json=_register_payload(email="alice@example.com"))
|
||||
r2 = client.post("/api/v1/auth/register", json=_register_payload(email="alice@somewhere.else"))
|
||||
assert r1.status_code == 201, r1.text
|
||||
assert r2.status_code == 201, r2.text
|
||||
|
||||
s1 = asyncio.run(_read_workspace_state(r1.json()["id"]))
|
||||
s2 = asyncio.run(_read_workspace_state(r2.json()["id"]))
|
||||
|
||||
ws1 = s1["workspaces"][0]
|
||||
ws2 = s2["workspaces"][0]
|
||||
assert ws1[0] != ws2[0], "workspaces must be distinct"
|
||||
assert ws1[1] == "alice"
|
||||
assert ws2[1] == "alice-2", "slug collision walker should land on -2"
|
||||
@@ -0,0 +1,136 @@
|
||||
"""PR6 T6.6 — `@require_permission(owner_check=True)` workspace upgrade.
|
||||
|
||||
The decorator must:
|
||||
|
||||
1. Pull workspace_id from ``get_effective_workspace_id()`` (set by
|
||||
AuthMiddleware per request) and pass it as the third positional to
|
||||
``ThreadMetaStore.check_access``.
|
||||
2. Raise **HTTPException 404** when check_access returns False — never
|
||||
403 — so a cross-workspace request cannot distinguish "thread exists
|
||||
in another tenant" from "thread does not exist".
|
||||
|
||||
These tests build a fake router with the same decorator usage as the
|
||||
production code and verify the decorator's behaviour via a Mock
|
||||
``thread_store`` whose ``check_access`` call we inspect, plus a TestClient
|
||||
exercising the full HTTP boundary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
import pytest
|
||||
from _router_auth_helpers import make_authed_test_app
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.gateway.auth.models import ActiveWorkspace
|
||||
from app.gateway.authz import require_permission
|
||||
from deerflow.runtime.workspace_context import (
|
||||
reset_current_workspace,
|
||||
set_current_workspace,
|
||||
)
|
||||
|
||||
|
||||
def _make_workspace(wid: str) -> Callable[[], ActiveWorkspace]:
|
||||
"""Factory closure stable per call so the middleware reinjects the same id."""
|
||||
|
||||
def _factory() -> ActiveWorkspace:
|
||||
return ActiveWorkspace(id=wid, role="owner")
|
||||
|
||||
return _factory
|
||||
|
||||
|
||||
def _mount_routes(app):
|
||||
router = APIRouter()
|
||||
|
||||
@router.delete("/probe/{thread_id}")
|
||||
@require_permission("threads", "delete", owner_check=True, require_existing=True)
|
||||
async def _delete_probe(thread_id: str, request: Request): # noqa: ARG001
|
||||
return {"ok": True, "thread_id": thread_id}
|
||||
|
||||
@router.get("/probe/{thread_id}")
|
||||
@require_permission("threads", "read", owner_check=True)
|
||||
async def _get_probe(thread_id: str, request: Request): # noqa: ARG001
|
||||
return {"ok": True, "thread_id": thread_id}
|
||||
|
||||
app.include_router(router)
|
||||
return app
|
||||
|
||||
|
||||
def test_cross_workspace_returns_404():
|
||||
"""check_access returning False surfaces as 404, never 403."""
|
||||
app = make_authed_test_app(
|
||||
workspace_factory=_make_workspace("ws-alpha"),
|
||||
owner_check_passes=False,
|
||||
)
|
||||
_mount_routes(app)
|
||||
with TestClient(app) as client:
|
||||
response = client.delete("/probe/t1")
|
||||
assert response.status_code == 404
|
||||
assert "not found" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_same_workspace_delete_allowed():
|
||||
"""check_access returning True lets the route execute."""
|
||||
app = make_authed_test_app(
|
||||
workspace_factory=_make_workspace("ws-alpha"),
|
||||
owner_check_passes=True,
|
||||
)
|
||||
_mount_routes(app)
|
||||
with TestClient(app) as client:
|
||||
response = client.delete("/probe/t1")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["ok"] is True
|
||||
|
||||
|
||||
def test_workspace_id_passed_to_check_access():
|
||||
"""check_access receives the contextvar workspace_id as the 3rd positional."""
|
||||
app = make_authed_test_app(
|
||||
workspace_factory=_make_workspace("ws-alpha"),
|
||||
owner_check_passes=True,
|
||||
)
|
||||
_mount_routes(app)
|
||||
with TestClient(app) as client:
|
||||
client.delete("/probe/t1")
|
||||
|
||||
call = app.state.thread_store.check_access.call_args
|
||||
assert call is not None
|
||||
args = call.args
|
||||
# (thread_id, user_id, workspace_id)
|
||||
assert args[0] == "t1"
|
||||
assert args[2] == "ws-alpha"
|
||||
|
||||
|
||||
@pytest.mark.no_auto_workspace
|
||||
def test_no_workspace_in_context_falls_back_to_default():
|
||||
"""No-auth dev mode (no workspace contextvar) uses DEFAULT_WORKSPACE_ID."""
|
||||
app = make_authed_test_app(workspace_factory=None, owner_check_passes=True)
|
||||
_mount_routes(app)
|
||||
with TestClient(app) as client:
|
||||
client.delete("/probe/t1")
|
||||
|
||||
args = app.state.thread_store.check_access.call_args.args
|
||||
assert args[2] == "default"
|
||||
|
||||
|
||||
def test_get_route_also_uses_workspace_id():
|
||||
"""Read-style routes (require_existing=False) also pass workspace_id through."""
|
||||
app = make_authed_test_app(
|
||||
workspace_factory=_make_workspace("ws-beta"),
|
||||
owner_check_passes=True,
|
||||
)
|
||||
_mount_routes(app)
|
||||
with TestClient(app) as client:
|
||||
client.get("/probe/t-read")
|
||||
args = app.state.thread_store.check_access.call_args.args
|
||||
assert args[2] == "ws-beta"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _reset_ws():
|
||||
"""Helper for direct-call paths that mutate the contextvar."""
|
||||
tokens: list = []
|
||||
yield lambda wid: tokens.append(set_current_workspace(ActiveWorkspace(id=wid, role="owner")))
|
||||
for token in reversed(tokens):
|
||||
reset_current_workspace(token)
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Tests for Run/Feedback/RunEvent repository workspace_id filtering (PR6 T6.5)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from deerflow.runtime.workspace_context import (
|
||||
reset_current_workspace,
|
||||
set_current_workspace,
|
||||
)
|
||||
|
||||
|
||||
async def _init_engine(tmp_path, *, workspaces: tuple[str, ...] = ()):
|
||||
from deerflow.persistence.engine import get_session_factory, init_engine
|
||||
|
||||
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
|
||||
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
|
||||
for wid in workspaces:
|
||||
await _seed_workspace(wid)
|
||||
return get_session_factory()
|
||||
|
||||
|
||||
async def _seed_workspace(wid: str) -> None:
|
||||
from deerflow.persistence.engine import get_session_factory
|
||||
from deerflow.persistence.workspace.model import WorkspaceRow
|
||||
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
if await session.get(WorkspaceRow, wid) is not None:
|
||||
return
|
||||
now = datetime.now(UTC)
|
||||
session.add(
|
||||
WorkspaceRow(
|
||||
id=wid,
|
||||
name=f"WS {wid}",
|
||||
slug=wid.replace("_", "-")[:32],
|
||||
status="active",
|
||||
owner_id="test-user-autouse",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _cleanup():
|
||||
from deerflow.persistence.engine import close_engine
|
||||
|
||||
await close_engine()
|
||||
|
||||
|
||||
def _use_workspace(wid: str):
|
||||
return set_current_workspace(SimpleNamespace(id=wid, role="owner"))
|
||||
|
||||
|
||||
class TestRunRepositoryWorkspace:
|
||||
@pytest.mark.anyio
|
||||
async def test_put_records_workspace_id(self, tmp_path):
|
||||
from deerflow.persistence.run import RunRepository
|
||||
|
||||
sf = await _init_engine(tmp_path, workspaces=("ws-alpha",))
|
||||
repo = RunRepository(sf)
|
||||
token = _use_workspace("ws-alpha")
|
||||
try:
|
||||
await repo.put("r1", thread_id="t1", user_id="alice")
|
||||
record = await repo.get("r1", user_id="alice")
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
await _cleanup()
|
||||
assert record["workspace_id"] == "ws-alpha"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_filters_cross_workspace(self, tmp_path):
|
||||
from deerflow.persistence.run import RunRepository
|
||||
|
||||
sf = await _init_engine(tmp_path, workspaces=("ws-alpha", "ws-beta"))
|
||||
repo = RunRepository(sf)
|
||||
token = _use_workspace("ws-alpha")
|
||||
try:
|
||||
await repo.put("r1", thread_id="t1", user_id="alice")
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
token = _use_workspace("ws-beta")
|
||||
try:
|
||||
assert await repo.get("r1", user_id="alice") is None
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
await _cleanup()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_by_thread_filters_workspace(self, tmp_path):
|
||||
from deerflow.persistence.run import RunRepository
|
||||
|
||||
sf = await _init_engine(tmp_path, workspaces=("ws-alpha", "ws-beta"))
|
||||
repo = RunRepository(sf)
|
||||
token = _use_workspace("ws-alpha")
|
||||
try:
|
||||
await repo.put("r1", thread_id="t1", user_id="alice")
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
token = _use_workspace("ws-beta")
|
||||
try:
|
||||
await repo.put("r2", thread_id="t1", user_id="alice")
|
||||
rows = await repo.list_by_thread("t1", user_id="alice")
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
await _cleanup()
|
||||
assert [r["run_id"] for r in rows] == ["r2"]
|
||||
|
||||
|
||||
class TestFeedbackRepositoryWorkspace:
|
||||
@pytest.mark.anyio
|
||||
async def test_create_records_workspace_id(self, tmp_path):
|
||||
from deerflow.persistence.feedback.sql import FeedbackRepository
|
||||
|
||||
sf = await _init_engine(tmp_path, workspaces=("ws-alpha",))
|
||||
repo = FeedbackRepository(sf)
|
||||
token = _use_workspace("ws-alpha")
|
||||
try:
|
||||
row = await repo.create(run_id="r1", thread_id="t1", rating=1, user_id="alice")
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
await _cleanup()
|
||||
assert row["workspace_id"] == "ws-alpha"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_by_thread_filters_workspace(self, tmp_path):
|
||||
from deerflow.persistence.feedback.sql import FeedbackRepository
|
||||
|
||||
sf = await _init_engine(tmp_path, workspaces=("ws-alpha", "ws-beta"))
|
||||
repo = FeedbackRepository(sf)
|
||||
token = _use_workspace("ws-alpha")
|
||||
try:
|
||||
await repo.create(run_id="r1", thread_id="t1", rating=1, user_id="alice")
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
token = _use_workspace("ws-beta")
|
||||
try:
|
||||
rows = await repo.list_by_thread("t1", user_id="alice")
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
await _cleanup()
|
||||
assert rows == []
|
||||
|
||||
|
||||
class TestRunEventStoreWorkspace:
|
||||
@pytest.mark.anyio
|
||||
async def test_put_records_workspace_id(self, tmp_path):
|
||||
from deerflow.runtime.events.store.db import DbRunEventStore
|
||||
|
||||
sf = await _init_engine(tmp_path, workspaces=("ws-alpha",))
|
||||
store = DbRunEventStore(sf)
|
||||
token = _use_workspace("ws-alpha")
|
||||
try:
|
||||
row = await store.put(thread_id="t1", run_id="r1", event_type="msg", category="message", content="hi")
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
await _cleanup()
|
||||
assert row["workspace_id"] == "ws-alpha"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_messages_filters_cross_workspace(self, tmp_path):
|
||||
from deerflow.runtime.events.store.db import DbRunEventStore
|
||||
|
||||
sf = await _init_engine(tmp_path, workspaces=("ws-alpha", "ws-beta"))
|
||||
store = DbRunEventStore(sf)
|
||||
token = _use_workspace("ws-alpha")
|
||||
try:
|
||||
await store.put(thread_id="t1", run_id="r1", event_type="msg", category="message", content="from-alpha")
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
token = _use_workspace("ws-beta")
|
||||
try:
|
||||
rows = await store.list_messages("t1", user_id="test-user-autouse")
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
await _cleanup()
|
||||
assert rows == []
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Schema tests for ``ServiceAccountRow`` (Stage 0 PR8).
|
||||
|
||||
Pattern mirrors :mod:`test_workspace_repo`: ephemeral SQLite per test via
|
||||
``tmp_path``, no Postgres required at this layer.
|
||||
|
||||
PR8 is schema-only — no repository class, no API. Tests exercise raw
|
||||
ORM behaviour: insert smoke, CASCADE on workspace delete, and RESTRICT
|
||||
on the ``created_by`` user FK.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from deerflow.persistence.service_account import ServiceAccountRow
|
||||
from deerflow.persistence.user.model import UserRow
|
||||
from deerflow.persistence.workspace.model import WorkspaceRow
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend() -> str:
|
||||
return "asyncio"
|
||||
|
||||
|
||||
async def _setup(tmp_path):
|
||||
from deerflow.persistence.engine import get_session_factory, init_engine
|
||||
|
||||
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
|
||||
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
|
||||
return get_session_factory()
|
||||
|
||||
|
||||
async def _cleanup():
|
||||
from deerflow.persistence.engine import close_engine
|
||||
|
||||
await close_engine()
|
||||
|
||||
|
||||
async def _seed_user(sf, user_id: str = "u-alice", email: str = "alice@example.com") -> None:
|
||||
async with sf() as session:
|
||||
session.add(UserRow(id=user_id, email=email))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _seed_workspace(sf, workspace_id: str = "w-1", owner_id: str = "u-alice", slug: str = "alice") -> None:
|
||||
async with sf() as session:
|
||||
session.add(WorkspaceRow(id=workspace_id, name="Alice's WS", slug=slug, owner_id=owner_id))
|
||||
await session.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T8.1 — insert smoke
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_insert_smoke(tmp_path):
|
||||
"""A minimal service_account row can be inserted and read back."""
|
||||
sf = await _setup(tmp_path)
|
||||
try:
|
||||
await _seed_user(sf)
|
||||
await _seed_workspace(sf)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
async with sf() as session:
|
||||
session.add(
|
||||
ServiceAccountRow(
|
||||
id="sa-1",
|
||||
workspace_id="w-1",
|
||||
name="ci-bot",
|
||||
role="member",
|
||||
identity_mode="collapsed",
|
||||
status="active",
|
||||
created_by="u-alice",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async with sf() as session:
|
||||
row = await session.get(ServiceAccountRow, "sa-1")
|
||||
assert row is not None
|
||||
assert row.workspace_id == "w-1"
|
||||
assert row.name == "ci-bot"
|
||||
assert row.role == "member"
|
||||
assert row.identity_mode == "collapsed"
|
||||
assert row.status == "active"
|
||||
assert row.created_by == "u-alice"
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T8.2 — CASCADE on workspace delete
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_cascade_on_workspace_delete(tmp_path):
|
||||
"""Deleting the parent workspace removes the service_account row (FK CASCADE)."""
|
||||
sf = await _setup(tmp_path)
|
||||
try:
|
||||
await _seed_user(sf)
|
||||
await _seed_workspace(sf)
|
||||
now = datetime.now(UTC)
|
||||
async with sf() as session:
|
||||
session.add(
|
||||
ServiceAccountRow(
|
||||
id="sa-2",
|
||||
workspace_id="w-1",
|
||||
name="bot",
|
||||
role="member",
|
||||
identity_mode="collapsed",
|
||||
status="active",
|
||||
created_by="u-alice",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async with sf() as session:
|
||||
await session.execute(delete(WorkspaceRow).where(WorkspaceRow.id == "w-1"))
|
||||
await session.commit()
|
||||
|
||||
async with sf() as session:
|
||||
row = await session.get(ServiceAccountRow, "sa-2")
|
||||
assert row is None
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T8.3 — RESTRICT on created_by user delete
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_restrict_on_created_by_user_delete(tmp_path):
|
||||
"""Deleting the creator user is blocked while their service_account survives."""
|
||||
sf = await _setup(tmp_path)
|
||||
try:
|
||||
await _seed_user(sf)
|
||||
await _seed_workspace(sf)
|
||||
now = datetime.now(UTC)
|
||||
async with sf() as session:
|
||||
session.add(
|
||||
ServiceAccountRow(
|
||||
id="sa-3",
|
||||
workspace_id="w-1",
|
||||
name="bot",
|
||||
role="member",
|
||||
identity_mode="collapsed",
|
||||
status="active",
|
||||
created_by="u-alice",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
async with sf() as session:
|
||||
await session.execute(delete(UserRow).where(UserRow.id == "u-alice"))
|
||||
await session.commit()
|
||||
|
||||
# The service_account is still there after the rollback.
|
||||
async with sf() as session:
|
||||
row = await session.get(ServiceAccountRow, "sa-3")
|
||||
assert row is not None
|
||||
finally:
|
||||
await _cleanup()
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Tests for the async Store factory's backend selection.
|
||||
|
||||
Mirrors the checkpointer factory: when no legacy ``checkpointer`` section
|
||||
is configured but a unified ``database`` section is, the store must use
|
||||
that database backend instead of silently falling back to InMemoryStore.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from deerflow.config.database_config import DatabaseConfig
|
||||
from deerflow.runtime.store.async_provider import make_store
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend() -> str:
|
||||
return "asyncio"
|
||||
|
||||
|
||||
class TestStoreDatabaseFallback:
|
||||
@pytest.mark.anyio
|
||||
async def test_postgres_store_from_database_when_no_checkpointer_section(self):
|
||||
"""make_store uses AsyncPostgresStore from the database section when
|
||||
no legacy checkpointer section is present. The +asyncpg dialect prefix
|
||||
is stripped so the same DATABASE_URL satisfies both SQLAlchemy and
|
||||
LangGraph's psycopg-based store."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.checkpointer = None
|
||||
mock_config.database = DatabaseConfig(
|
||||
backend="postgres",
|
||||
postgres_url="postgresql+asyncpg://postgres:pw@localhost:5432/deerflow",
|
||||
)
|
||||
|
||||
mock_store = AsyncMock()
|
||||
mock_cm = AsyncMock()
|
||||
mock_cm.__aenter__.return_value = mock_store
|
||||
mock_cm.__aexit__.return_value = False
|
||||
|
||||
mock_store_cls = MagicMock()
|
||||
mock_store_cls.from_conn_string.return_value = mock_cm
|
||||
|
||||
mock_module = MagicMock()
|
||||
mock_module.AsyncPostgresStore = mock_store_cls
|
||||
|
||||
with (
|
||||
patch("deerflow.runtime.store.async_provider.get_app_config", return_value=mock_config),
|
||||
patch.dict(sys.modules, {"langgraph.store.postgres.aio": mock_module}),
|
||||
):
|
||||
async with make_store() as store:
|
||||
assert store is mock_store
|
||||
|
||||
# dialect prefix stripped to libpq conninfo
|
||||
mock_store_cls.from_conn_string.assert_called_once_with("postgresql://postgres:pw@localhost:5432/deerflow")
|
||||
mock_store.setup.assert_awaited_once()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_memory_when_no_checkpointer_and_no_database(self):
|
||||
"""With neither a checkpointer section nor a non-memory database,
|
||||
the store falls back to InMemoryStore."""
|
||||
from langgraph.store.memory import InMemoryStore
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.checkpointer = None
|
||||
mock_config.database = DatabaseConfig(backend="memory")
|
||||
|
||||
with patch("deerflow.runtime.store.async_provider.get_app_config", return_value=mock_config):
|
||||
async with make_store() as store:
|
||||
assert isinstance(store, InMemoryStore)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_checkpointer_section_takes_precedence_over_database(self):
|
||||
"""A legacy checkpointer section still wins over the database section."""
|
||||
from deerflow.config.checkpointer_config import CheckpointerConfig
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.checkpointer = CheckpointerConfig(type="memory")
|
||||
mock_config.database = DatabaseConfig(
|
||||
backend="postgres",
|
||||
postgres_url="postgresql+asyncpg://postgres:pw@localhost:5432/deerflow",
|
||||
)
|
||||
|
||||
from langgraph.store.memory import InMemoryStore
|
||||
|
||||
with patch("deerflow.runtime.store.async_provider.get_app_config", return_value=mock_config):
|
||||
async with make_store() as store:
|
||||
# checkpointer.type == memory → InMemoryStore, database ignored
|
||||
assert isinstance(store, InMemoryStore)
|
||||
@@ -0,0 +1,88 @@
|
||||
"""PR6 T6.11 — ThreadDataMiddleware writes under workspace layout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from deerflow.agents.middlewares.thread_data_middleware import ThreadDataMiddleware
|
||||
from deerflow.config.paths import Paths
|
||||
from deerflow.runtime.workspace_context import (
|
||||
reset_current_workspace,
|
||||
set_current_workspace,
|
||||
)
|
||||
|
||||
|
||||
class _FakeRuntime:
|
||||
def __init__(self, *, thread_id: str = "t1", run_id: str = "r1"):
|
||||
self.context = {"thread_id": thread_id, "run_id": run_id}
|
||||
|
||||
|
||||
def test_paths_resolve_under_workspace(tmp_path):
|
||||
paths = Paths(tmp_path)
|
||||
middleware = ThreadDataMiddleware(base_dir=str(tmp_path), lazy_init=True)
|
||||
middleware._paths = paths
|
||||
|
||||
token = set_current_workspace(SimpleNamespace(id="ws-alpha", role="owner"))
|
||||
try:
|
||||
out = middleware.before_agent({"messages": []}, _FakeRuntime())
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
|
||||
expected_root = tmp_path / "workspaces" / "ws-alpha" / "threads" / "t1" / "user-data"
|
||||
assert out["thread_data"]["workspace_path"] == str(expected_root / "workspace")
|
||||
assert out["thread_data"]["uploads_path"] == str(expected_root / "uploads")
|
||||
assert out["thread_data"]["outputs_path"] == str(expected_root / "outputs")
|
||||
assert out["thread_data"]["workspace_id"] == "ws-alpha"
|
||||
|
||||
|
||||
@pytest.mark.no_auto_workspace
|
||||
def test_falls_back_to_default_workspace(tmp_path):
|
||||
"""Without a workspace contextvar, `get_effective_workspace_id` returns 'default'."""
|
||||
paths = Paths(tmp_path)
|
||||
middleware = ThreadDataMiddleware(base_dir=str(tmp_path), lazy_init=True)
|
||||
middleware._paths = paths
|
||||
|
||||
out = middleware.before_agent({"messages": []}, _FakeRuntime())
|
||||
|
||||
expected_root = tmp_path / "workspaces" / "default" / "threads" / "t1" / "user-data"
|
||||
assert out["thread_data"]["workspace_path"] == str(expected_root / "workspace")
|
||||
assert out["thread_data"]["workspace_id"] == "default"
|
||||
|
||||
|
||||
def test_eager_creates_directories_under_workspace(tmp_path):
|
||||
paths = Paths(tmp_path)
|
||||
middleware = ThreadDataMiddleware(base_dir=str(tmp_path), lazy_init=False)
|
||||
middleware._paths = paths
|
||||
|
||||
token = set_current_workspace(SimpleNamespace(id="ws-beta", role="owner"))
|
||||
try:
|
||||
middleware.before_agent({"messages": []}, _FakeRuntime(thread_id="t2"))
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
|
||||
root = tmp_path / "workspaces" / "ws-beta" / "threads" / "t2" / "user-data"
|
||||
assert (root / "workspace").is_dir()
|
||||
assert (root / "uploads").is_dir()
|
||||
assert (root / "outputs").is_dir()
|
||||
|
||||
|
||||
def test_get_config_fallback_still_workspace_scoped(tmp_path):
|
||||
"""Thread_id resolution via LangGraph config still routes through workspace."""
|
||||
paths = Paths(tmp_path)
|
||||
middleware = ThreadDataMiddleware(base_dir=str(tmp_path), lazy_init=True)
|
||||
middleware._paths = paths
|
||||
|
||||
class _Runtime:
|
||||
context: dict = {}
|
||||
|
||||
with patch("deerflow.agents.middlewares.thread_data_middleware.get_config", return_value={"configurable": {"thread_id": "t-cfg"}}):
|
||||
token = set_current_workspace(SimpleNamespace(id="ws-gamma", role="owner"))
|
||||
try:
|
||||
out = middleware.before_agent({"messages": []}, _Runtime())
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
|
||||
assert "workspaces/ws-gamma/threads/t-cfg/user-data/workspace" in out["thread_data"]["workspace_path"]
|
||||
@@ -64,21 +64,21 @@ class TestThreadMetaRepository:
|
||||
@pytest.mark.anyio
|
||||
async def test_check_access_no_record_allows(self, tmp_path):
|
||||
repo = await _make_repo(tmp_path)
|
||||
assert await repo.check_access("unknown", "user1") is True
|
||||
assert await repo.check_access("unknown", "user1", "test-workspace-autouse") is True
|
||||
await _cleanup()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_check_access_owner_matches(self, tmp_path):
|
||||
repo = await _make_repo(tmp_path)
|
||||
await repo.create("t1", user_id="user1")
|
||||
assert await repo.check_access("t1", "user1") is True
|
||||
assert await repo.check_access("t1", "user1", "test-workspace-autouse") is True
|
||||
await _cleanup()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_check_access_owner_mismatch(self, tmp_path):
|
||||
repo = await _make_repo(tmp_path)
|
||||
await repo.create("t1", user_id="user1")
|
||||
assert await repo.check_access("t1", "user2") is False
|
||||
assert await repo.check_access("t1", "user2", "test-workspace-autouse") is False
|
||||
await _cleanup()
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -87,7 +87,7 @@ class TestThreadMetaRepository:
|
||||
# Explicit user_id=None to bypass the new AUTO default that
|
||||
# would otherwise pick up the test user from the autouse fixture.
|
||||
await repo.create("t1", user_id=None)
|
||||
assert await repo.check_access("t1", "anyone") is True
|
||||
assert await repo.check_access("t1", "anyone", "test-workspace-autouse") is True
|
||||
await _cleanup()
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -99,21 +99,21 @@ class TestThreadMetaRepository:
|
||||
caller "claim" it as untracked. The strict mode demands a row.
|
||||
"""
|
||||
repo = await _make_repo(tmp_path)
|
||||
assert await repo.check_access("never-existed", "user1", require_existing=True) is False
|
||||
assert await repo.check_access("never-existed", "user1", "test-workspace-autouse", require_existing=True) is False
|
||||
await _cleanup()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_check_access_strict_owner_match_allowed(self, tmp_path):
|
||||
repo = await _make_repo(tmp_path)
|
||||
await repo.create("t1", user_id="user1")
|
||||
assert await repo.check_access("t1", "user1", require_existing=True) is True
|
||||
assert await repo.check_access("t1", "user1", "test-workspace-autouse", require_existing=True) is True
|
||||
await _cleanup()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_check_access_strict_owner_mismatch_denied(self, tmp_path):
|
||||
repo = await _make_repo(tmp_path)
|
||||
await repo.create("t1", user_id="user1")
|
||||
assert await repo.check_access("t1", "user2", require_existing=True) is False
|
||||
assert await repo.check_access("t1", "user2", "test-workspace-autouse", require_existing=True) is False
|
||||
await _cleanup()
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -126,7 +126,7 @@ class TestThreadMetaRepository:
|
||||
"""
|
||||
repo = await _make_repo(tmp_path)
|
||||
await repo.create("t1", user_id=None)
|
||||
assert await repo.check_access("t1", "anyone", require_existing=True) is True
|
||||
assert await repo.check_access("t1", "anyone", "test-workspace-autouse", require_existing=True) is True
|
||||
await _cleanup()
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Tests for ThreadMetaRepository workspace_id filtering (PR6 T6.1-T6.4).
|
||||
|
||||
The repository's three-state ``workspace_id`` semantics mirror ``user_id``:
|
||||
|
||||
- :data:`AUTO` (default): read from workspace contextvar
|
||||
- Explicit ``str``: use the provided id
|
||||
- Explicit ``None``: bypass workspace filter (migration / CLI)
|
||||
|
||||
Cross-workspace access (a thread in workspace A queried with workspace B)
|
||||
must return ``None``, never the row. This is the load-bearing isolation
|
||||
boundary tested here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from deerflow.persistence.thread_meta import ThreadMetaRepository
|
||||
from deerflow.runtime.workspace_context import (
|
||||
reset_current_workspace,
|
||||
set_current_workspace,
|
||||
)
|
||||
|
||||
|
||||
async def _seed_workspace(wid: str, *, owner_id: str = "test-user-autouse") -> None:
|
||||
"""Insert a workspace row so threads_meta.workspace_id FK resolves."""
|
||||
from deerflow.persistence.engine import get_session_factory
|
||||
from deerflow.persistence.workspace.model import WorkspaceRow
|
||||
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
existing = await session.get(WorkspaceRow, wid)
|
||||
if existing is not None:
|
||||
return
|
||||
now = datetime.now(UTC)
|
||||
session.add(
|
||||
WorkspaceRow(
|
||||
id=wid,
|
||||
name=f"WS {wid}",
|
||||
slug=wid.replace("_", "-")[:32],
|
||||
status="active",
|
||||
owner_id=owner_id,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _make_repo(tmp_path, *, workspaces: tuple[str, ...] = ()):
|
||||
from deerflow.persistence.engine import get_session_factory, init_engine
|
||||
|
||||
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
|
||||
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
|
||||
for wid in workspaces:
|
||||
await _seed_workspace(wid)
|
||||
return ThreadMetaRepository(get_session_factory())
|
||||
|
||||
|
||||
async def _cleanup():
|
||||
from deerflow.persistence.engine import close_engine
|
||||
|
||||
await close_engine()
|
||||
|
||||
|
||||
def _use_workspace(wid: str, role: str = "owner"):
|
||||
"""Replace the autouse workspace contextvar inside a single test."""
|
||||
return set_current_workspace(SimpleNamespace(id=wid, role=role))
|
||||
|
||||
|
||||
class TestCreateWorkspace:
|
||||
@pytest.mark.anyio
|
||||
async def test_create_uses_workspace_context(self, tmp_path):
|
||||
"""AUTO sentinel pulls workspace_id from the contextvar."""
|
||||
repo = await _make_repo(tmp_path, workspaces=("ws-alpha",))
|
||||
token = _use_workspace("ws-alpha")
|
||||
try:
|
||||
record = await repo.create("t1")
|
||||
assert record["workspace_id"] == "ws-alpha"
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
await _cleanup()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_explicit_workspace_overrides_context(self, tmp_path):
|
||||
repo = await _make_repo(tmp_path, workspaces=("ws-alpha", "ws-beta"))
|
||||
token = _use_workspace("ws-alpha")
|
||||
try:
|
||||
record = await repo.create("t1", workspace_id="ws-beta")
|
||||
assert record["workspace_id"] == "ws-beta"
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
await _cleanup()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_workspace_none_rejected_by_orm(self, tmp_path):
|
||||
"""After T5.11 (ORM nullable=False) explicit None creates fail at the DB layer.
|
||||
|
||||
Pre-PR6 the migration scripts relied on `workspace_id=None` to insert
|
||||
orphan rows; that use is now restricted to **read** paths (filter
|
||||
bypass). Writes must always carry a workspace.
|
||||
"""
|
||||
import sqlalchemy
|
||||
|
||||
repo = await _make_repo(tmp_path)
|
||||
with pytest.raises((sqlalchemy.exc.IntegrityError, sqlalchemy.exc.DBAPIError)):
|
||||
await repo.create("t1", workspace_id=None)
|
||||
await _cleanup()
|
||||
|
||||
|
||||
class TestGetWorkspace:
|
||||
@pytest.mark.anyio
|
||||
async def test_get_filters_by_workspace(self, tmp_path):
|
||||
"""Cross-workspace get returns None even when user_id matches."""
|
||||
repo = await _make_repo(tmp_path, workspaces=("ws-alpha", "ws-beta"))
|
||||
token = _use_workspace("ws-alpha")
|
||||
try:
|
||||
await repo.create("t1", user_id="alice")
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
|
||||
token = _use_workspace("ws-beta")
|
||||
try:
|
||||
assert await repo.get("t1", user_id="alice") is None
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
await _cleanup()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_returns_row_in_same_workspace(self, tmp_path):
|
||||
repo = await _make_repo(tmp_path, workspaces=("ws-alpha",))
|
||||
token = _use_workspace("ws-alpha")
|
||||
try:
|
||||
await repo.create("t1", user_id="alice")
|
||||
record = await repo.get("t1", user_id="alice")
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
await _cleanup()
|
||||
assert record is not None
|
||||
assert record["thread_id"] == "t1"
|
||||
assert record["workspace_id"] == "ws-alpha"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_workspace_none_bypasses_filter(self, tmp_path):
|
||||
"""Explicit workspace_id=None lets migration scripts see any row."""
|
||||
repo = await _make_repo(tmp_path, workspaces=("ws-alpha",))
|
||||
token = _use_workspace("ws-alpha")
|
||||
try:
|
||||
await repo.create("t1", user_id="alice")
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
|
||||
token = _use_workspace("ws-beta")
|
||||
try:
|
||||
assert await repo.get("t1", user_id=None, workspace_id=None) is not None
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
await _cleanup()
|
||||
|
||||
|
||||
class TestSearchUpdateDeleteWorkspace:
|
||||
@pytest.mark.anyio
|
||||
async def test_search_only_returns_current_workspace(self, tmp_path):
|
||||
repo = await _make_repo(tmp_path, workspaces=("ws-alpha", "ws-beta"))
|
||||
token = _use_workspace("ws-alpha")
|
||||
try:
|
||||
await repo.create("t1", user_id="alice")
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
token = _use_workspace("ws-beta")
|
||||
try:
|
||||
await repo.create("t2", user_id="alice")
|
||||
rows = await repo.search(user_id="alice")
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
await _cleanup()
|
||||
ids = {r["thread_id"] for r in rows}
|
||||
assert ids == {"t2"}
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_status_blocked_across_workspace(self, tmp_path):
|
||||
repo = await _make_repo(tmp_path, workspaces=("ws-alpha", "ws-beta"))
|
||||
token = _use_workspace("ws-alpha")
|
||||
try:
|
||||
await repo.create("t1", user_id="alice")
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
token = _use_workspace("ws-beta")
|
||||
try:
|
||||
await repo.update_status("t1", "busy", user_id="alice")
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
|
||||
token = _use_workspace("ws-alpha")
|
||||
try:
|
||||
row = await repo.get("t1", user_id="alice")
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
await _cleanup()
|
||||
assert row["status"] == "idle"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_display_name_blocked_across_workspace(self, tmp_path):
|
||||
repo = await _make_repo(tmp_path, workspaces=("ws-alpha", "ws-beta"))
|
||||
token = _use_workspace("ws-alpha")
|
||||
try:
|
||||
await repo.create("t1", user_id="alice", display_name="A")
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
token = _use_workspace("ws-beta")
|
||||
try:
|
||||
await repo.update_display_name("t1", "B", user_id="alice")
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
token = _use_workspace("ws-alpha")
|
||||
try:
|
||||
row = await repo.get("t1", user_id="alice")
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
await _cleanup()
|
||||
assert row["display_name"] == "A"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_metadata_blocked_across_workspace(self, tmp_path):
|
||||
repo = await _make_repo(tmp_path, workspaces=("ws-alpha", "ws-beta"))
|
||||
token = _use_workspace("ws-alpha")
|
||||
try:
|
||||
await repo.create("t1", user_id="alice", metadata={"k": "alpha"})
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
token = _use_workspace("ws-beta")
|
||||
try:
|
||||
await repo.update_metadata("t1", {"k": "beta"}, user_id="alice")
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
token = _use_workspace("ws-alpha")
|
||||
try:
|
||||
row = await repo.get("t1", user_id="alice")
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
await _cleanup()
|
||||
assert row["metadata"] == {"k": "alpha"}
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_check_access_cross_workspace_false(self, tmp_path):
|
||||
"""`check_access` returns False for cross-workspace, even with matching user_id."""
|
||||
repo = await _make_repo(tmp_path, workspaces=("ws-alpha", "ws-beta"))
|
||||
token = _use_workspace("ws-alpha")
|
||||
try:
|
||||
await repo.create("t1", user_id="alice")
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
try:
|
||||
assert await repo.check_access("t1", "alice", "ws-beta") is False
|
||||
assert await repo.check_access("t1", "alice", "ws-alpha") is True
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_check_access_strict_cross_workspace_false(self, tmp_path):
|
||||
"""require_existing=True path also denies cross-workspace."""
|
||||
repo = await _make_repo(tmp_path, workspaces=("ws-alpha", "ws-beta"))
|
||||
token = _use_workspace("ws-alpha")
|
||||
try:
|
||||
await repo.create("t1", user_id="alice")
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
try:
|
||||
assert await repo.check_access("t1", "alice", "ws-beta", require_existing=True) is False
|
||||
assert await repo.check_access("t1", "alice", "ws-alpha", require_existing=True) is True
|
||||
finally:
|
||||
await _cleanup()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_delete_blocked_across_workspace(self, tmp_path):
|
||||
repo = await _make_repo(tmp_path, workspaces=("ws-alpha", "ws-beta"))
|
||||
token = _use_workspace("ws-alpha")
|
||||
try:
|
||||
await repo.create("t1", user_id="alice")
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
token = _use_workspace("ws-beta")
|
||||
try:
|
||||
await repo.delete("t1", user_id="alice")
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
token = _use_workspace("ws-alpha")
|
||||
try:
|
||||
row = await repo.get("t1", user_id="alice")
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
await _cleanup()
|
||||
assert row is not None and row["thread_id"] == "t1"
|
||||
@@ -0,0 +1,59 @@
|
||||
"""PR6 T6.7 — POST /api/threads writes workspace_id from contextvar.
|
||||
|
||||
The `routers/threads.py:create_thread` path delegates to
|
||||
``ThreadMetaStore.create`` *without* an explicit ``workspace_id`` — it
|
||||
relies on the AUTO sentinel pulling the value from the active workspace
|
||||
contextvar that AuthMiddleware (or the test stub) sets. This test
|
||||
covers the integration through the FastAPI TestClient stack: post a
|
||||
thread under workspace A, then verify the persisted record carries
|
||||
``workspace_id="ws-alpha"``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from _router_auth_helpers import make_authed_test_app
|
||||
from fastapi.testclient import TestClient
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
from langgraph.store.memory import InMemoryStore
|
||||
|
||||
from app.gateway.auth.models import ActiveWorkspace
|
||||
from app.gateway.routers import threads
|
||||
from deerflow.persistence.thread_meta.memory import MemoryThreadMetaStore
|
||||
|
||||
|
||||
def _workspace_factory(wid: str) -> Callable[[], ActiveWorkspace]:
|
||||
def _factory() -> ActiveWorkspace:
|
||||
return ActiveWorkspace(id=wid, role="owner")
|
||||
|
||||
return _factory
|
||||
|
||||
|
||||
def _build_app(workspace_id: str):
|
||||
app = make_authed_test_app(workspace_factory=_workspace_factory(workspace_id))
|
||||
store = InMemoryStore()
|
||||
checkpointer = InMemorySaver()
|
||||
app.state.store = store
|
||||
app.state.checkpointer = checkpointer
|
||||
app.state.thread_store = MemoryThreadMetaStore(store)
|
||||
app.include_router(threads.router)
|
||||
return app, store
|
||||
|
||||
|
||||
def test_post_thread_stamps_workspace_id_from_contextvar():
|
||||
app, store = _build_app("ws-alpha")
|
||||
with TestClient(app) as client:
|
||||
response = client.post("/api/threads", json={"thread_id": "t1", "metadata": {}})
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
item = store.get(("threads",), "t1")
|
||||
assert item is not None
|
||||
assert item.value["workspace_id"] == "ws-alpha"
|
||||
|
||||
|
||||
def test_post_thread_under_different_workspace():
|
||||
app, store = _build_app("ws-beta")
|
||||
with TestClient(app) as client:
|
||||
client.post("/api/threads", json={"thread_id": "t2", "metadata": {}})
|
||||
assert store.get(("threads",), "t2").value["workspace_id"] == "ws-beta"
|
||||
@@ -27,21 +27,21 @@ class _PermissiveThreadMetaStore(MemoryThreadMetaStore):
|
||||
timestamp wire format.
|
||||
"""
|
||||
|
||||
async def _get_owned_record(self, thread_id, user_id, method_name): # type: ignore[override]
|
||||
async def _get_owned_record(self, thread_id, user_id, workspace_id, method_name): # type: ignore[override]
|
||||
item = await self._store.aget(THREADS_NS, thread_id)
|
||||
return dict(item.value) if item is not None else None
|
||||
|
||||
async def check_access(self, thread_id, user_id, *, require_existing=False): # type: ignore[override]
|
||||
async def check_access(self, thread_id, user_id, workspace_id, *, require_existing=False): # type: ignore[override]
|
||||
item = await self._store.aget(THREADS_NS, thread_id)
|
||||
if item is None:
|
||||
return not require_existing
|
||||
return True
|
||||
|
||||
async def create(self, thread_id, *, assistant_id=None, user_id=None, display_name=None, metadata=None): # type: ignore[override]
|
||||
return await super().create(thread_id, assistant_id=assistant_id, user_id=None, display_name=display_name, metadata=metadata)
|
||||
async def create(self, thread_id, *, assistant_id=None, user_id=None, workspace_id=None, display_name=None, metadata=None): # type: ignore[override]
|
||||
return await super().create(thread_id, assistant_id=assistant_id, user_id=None, workspace_id=None, display_name=display_name, metadata=metadata)
|
||||
|
||||
async def search(self, *, metadata=None, status=None, limit=100, offset=0, user_id=None): # type: ignore[override]
|
||||
return await super().search(metadata=metadata, status=status, limit=limit, offset=offset, user_id=None)
|
||||
async def search(self, *, metadata=None, status=None, limit=100, offset=0, user_id=None, workspace_id=None): # type: ignore[override]
|
||||
return await super().search(metadata=metadata, status=status, limit=limit, offset=offset, user_id=None, workspace_id=None)
|
||||
|
||||
|
||||
def _build_thread_app() -> tuple[FastAPI, InMemoryStore, InMemorySaver]:
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Boundary check: only allowlisted modules may directly import LangGraph
|
||||
checkpoint/saver clients.
|
||||
|
||||
Direct use of the LangGraph checkpoint API anywhere outside the gateway thread
|
||||
plumbing and the harness checkpointer factory is a workspace-isolation hazard:
|
||||
arbitrary code paths could otherwise reach across threads/workspaces by
|
||||
constructing their own savers. PR7 enforces this with an AST static scan.
|
||||
|
||||
Imports inside ``if TYPE_CHECKING:`` blocks are intentionally ignored — they
|
||||
never execute at runtime and therefore cannot bypass the boundary.
|
||||
|
||||
Allowlist lives in ``tests/boundary_allowlist.toml`` as a plain list of paths
|
||||
relative to ``backend/``. Adding a new legitimate importer means appending a
|
||||
line there in the same PR that introduces the import.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
BACKEND_ROOT = Path(__file__).parent.parent # backend/
|
||||
ALLOWLIST_FILE = Path(__file__).parent / "boundary_allowlist.toml"
|
||||
|
||||
# Match any submodule of these top-level packages.
|
||||
TARGET_MODULE_PREFIXES: tuple[str, ...] = (
|
||||
"langgraph.checkpoint",
|
||||
"langgraph_checkpoint_postgres",
|
||||
"langgraph_checkpoint_sqlite",
|
||||
)
|
||||
|
||||
# Directories under backend/ that are not part of the running app.
|
||||
EXCLUDED_TOP_LEVEL = ("tests", ".venv", "build", "dist", "docs", ".pytest_cache", "node_modules")
|
||||
|
||||
|
||||
def _matches_target(name: str) -> bool:
|
||||
return any(name == prefix or name.startswith(prefix + ".") for prefix in TARGET_MODULE_PREFIXES)
|
||||
|
||||
|
||||
def _build_parent_map(tree: ast.AST) -> dict[int, ast.AST]:
|
||||
parents: dict[int, ast.AST] = {}
|
||||
for parent in ast.walk(tree):
|
||||
for child in ast.iter_child_nodes(parent):
|
||||
parents[id(child)] = parent
|
||||
return parents
|
||||
|
||||
|
||||
def _is_type_checking_test(test: ast.expr) -> bool:
|
||||
"""Return True for ``TYPE_CHECKING`` or ``typing.TYPE_CHECKING`` conditions."""
|
||||
if isinstance(test, ast.Name) and test.id == "TYPE_CHECKING":
|
||||
return True
|
||||
if isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _inside_type_checking(node: ast.AST, parents: dict[int, ast.AST]) -> bool:
|
||||
current = parents.get(id(node))
|
||||
while current is not None:
|
||||
if isinstance(current, ast.If) and _is_type_checking_test(current.test):
|
||||
return True
|
||||
current = parents.get(id(current))
|
||||
return False
|
||||
|
||||
|
||||
def collect_runtime_checkpoint_imports(filepath: Path) -> list[tuple[int, str]]:
|
||||
"""Return ``(lineno, module_path)`` for every runtime import that targets a banned module."""
|
||||
source = filepath.read_text(encoding="utf-8")
|
||||
try:
|
||||
tree = ast.parse(source, filename=str(filepath))
|
||||
except SyntaxError:
|
||||
return []
|
||||
|
||||
parents = _build_parent_map(tree)
|
||||
hits: list[tuple[int, str]] = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom):
|
||||
module = node.module or ""
|
||||
if _matches_target(module) and not _inside_type_checking(node, parents):
|
||||
hits.append((node.lineno, module))
|
||||
elif isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
if _matches_target(alias.name) and not _inside_type_checking(node, parents):
|
||||
hits.append((node.lineno, alias.name))
|
||||
return hits
|
||||
|
||||
|
||||
def _iter_backend_py_files() -> list[Path]:
|
||||
candidates: list[Path] = []
|
||||
for path in sorted(BACKEND_ROOT.rglob("*.py")):
|
||||
rel_parts = path.relative_to(BACKEND_ROOT).parts
|
||||
if rel_parts and rel_parts[0] in EXCLUDED_TOP_LEVEL:
|
||||
continue
|
||||
if "__pycache__" in rel_parts:
|
||||
continue
|
||||
candidates.append(path)
|
||||
return candidates
|
||||
|
||||
|
||||
def _load_allowlist() -> set[str]:
|
||||
data = tomllib.loads(ALLOWLIST_FILE.read_text(encoding="utf-8"))
|
||||
raw = data.get("langgraph_checkpoint_importers", [])
|
||||
return set(raw)
|
||||
|
||||
|
||||
def scan_violations(allowlist: set[str]) -> list[str]:
|
||||
"""Return formatted violation lines (one per banned import outside the allowlist)."""
|
||||
violations: list[str] = []
|
||||
for py in _iter_backend_py_files():
|
||||
rel = py.relative_to(BACKEND_ROOT).as_posix()
|
||||
for lineno, module in collect_runtime_checkpoint_imports(py):
|
||||
if rel in allowlist:
|
||||
continue
|
||||
violations.append(f" {rel}:{lineno} imports {module}")
|
||||
return violations
|
||||
|
||||
|
||||
def test_only_allowlisted_modules_import_langgraph_checkpoint() -> None:
|
||||
allowlist = _load_allowlist()
|
||||
violations = scan_violations(allowlist)
|
||||
assert not violations, (
|
||||
"Unauthorized direct imports of langgraph.checkpoint.* detected. "
|
||||
"Either route the access through `app.gateway.deps.get_checkpointer` "
|
||||
"or, if this is a legitimate new importer, add the path to "
|
||||
"tests/boundary_allowlist.toml in the same PR.\n" + "\n".join(violations)
|
||||
)
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Self-tests for the boundary scanner in ``test_workspace_boundary``.
|
||||
|
||||
These tests guard against the scanner silently passing because it cannot
|
||||
detect anything. They feed synthetic ``.py`` snippets to the per-file
|
||||
collector and assert it produces (or correctly suppresses) the expected
|
||||
hits — so we know the integration test in ``test_workspace_boundary.py``
|
||||
would actually fail if a real violation appeared.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from test_workspace_boundary import collect_runtime_checkpoint_imports
|
||||
|
||||
|
||||
def _write(tmp_path: Path, body: str) -> Path:
|
||||
target = tmp_path / "sample.py"
|
||||
target.write_text(body, encoding="utf-8")
|
||||
return target
|
||||
|
||||
|
||||
def test_flags_direct_from_import(tmp_path: Path) -> None:
|
||||
target = _write(
|
||||
tmp_path,
|
||||
"from langgraph.checkpoint.postgres import AsyncPostgresSaver\n",
|
||||
)
|
||||
hits = collect_runtime_checkpoint_imports(target)
|
||||
assert hits == [(1, "langgraph.checkpoint.postgres")]
|
||||
|
||||
|
||||
def test_flags_bare_module_import(tmp_path: Path) -> None:
|
||||
target = _write(
|
||||
tmp_path,
|
||||
"import langgraph.checkpoint.memory # noqa: F401\n",
|
||||
)
|
||||
hits = collect_runtime_checkpoint_imports(target)
|
||||
assert hits == [(1, "langgraph.checkpoint.memory")]
|
||||
|
||||
|
||||
def test_flags_third_party_checkpoint_package(tmp_path: Path) -> None:
|
||||
target = _write(
|
||||
tmp_path,
|
||||
"from langgraph_checkpoint_postgres import PostgresSaver\nfrom langgraph_checkpoint_sqlite import SqliteSaver\n",
|
||||
)
|
||||
hits = collect_runtime_checkpoint_imports(target)
|
||||
assert (1, "langgraph_checkpoint_postgres") in hits
|
||||
assert (2, "langgraph_checkpoint_sqlite") in hits
|
||||
|
||||
|
||||
def test_skips_type_checking_block_name_form(tmp_path: Path) -> None:
|
||||
target = _write(
|
||||
tmp_path,
|
||||
"from typing import TYPE_CHECKING\n\nif TYPE_CHECKING:\n from langgraph.checkpoint.base import BaseCheckpointSaver # noqa: F401\n",
|
||||
)
|
||||
assert collect_runtime_checkpoint_imports(target) == []
|
||||
|
||||
|
||||
def test_skips_type_checking_block_attribute_form(tmp_path: Path) -> None:
|
||||
target = _write(
|
||||
tmp_path,
|
||||
"import typing\n\nif typing.TYPE_CHECKING:\n from langgraph.checkpoint.base import BaseCheckpointSaver # noqa: F401\n",
|
||||
)
|
||||
assert collect_runtime_checkpoint_imports(target) == []
|
||||
|
||||
|
||||
def test_skips_nested_type_checking_block(tmp_path: Path) -> None:
|
||||
target = _write(
|
||||
tmp_path,
|
||||
"from typing import TYPE_CHECKING\n\nif True:\n if TYPE_CHECKING:\n from langgraph.checkpoint.postgres import AsyncPostgresSaver # noqa: F401\n",
|
||||
)
|
||||
assert collect_runtime_checkpoint_imports(target) == []
|
||||
|
||||
|
||||
def test_does_not_flag_unrelated_imports(tmp_path: Path) -> None:
|
||||
target = _write(
|
||||
tmp_path,
|
||||
"import os\nfrom langgraph.graph.state import CompiledStateGraph # noqa: F401\nfrom app.gateway.deps import get_checkpointer # noqa: F401\n",
|
||||
)
|
||||
assert collect_runtime_checkpoint_imports(target) == []
|
||||
|
||||
|
||||
def test_does_not_flag_string_literal_with_module_name(tmp_path: Path) -> None:
|
||||
target = _write(
|
||||
tmp_path,
|
||||
'TARGET = "langgraph.checkpoint.postgres"\n',
|
||||
)
|
||||
assert collect_runtime_checkpoint_imports(target) == []
|
||||
|
||||
|
||||
def test_handles_syntax_error_gracefully(tmp_path: Path) -> None:
|
||||
target = _write(tmp_path, "def broken(:\n")
|
||||
assert collect_runtime_checkpoint_imports(target) == []
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Tests for runtime.workspace_context — workspace contextvar semantics.
|
||||
|
||||
Mirrors :mod:`test_user_context` but for the workspace contextvar
|
||||
introduced in Stage 0 PR3. No autouse workspace fixture exists yet
|
||||
(PR4 will add it together with the AuthMiddleware injection), so these
|
||||
tests run against a clean contextvar.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from deerflow.runtime.workspace_context import (
|
||||
AUTO,
|
||||
DEFAULT_WORKSPACE_ID,
|
||||
CurrentWorkspace,
|
||||
get_current_workspace,
|
||||
get_effective_workspace_id,
|
||||
require_current_workspace,
|
||||
reset_current_workspace,
|
||||
resolve_workspace_id,
|
||||
set_current_workspace,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_current_workspace / require_current_workspace / set+reset round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.no_auto_workspace
|
||||
def test_default_is_none():
|
||||
"""Before any set, contextvar returns None."""
|
||||
assert get_current_workspace() is None
|
||||
|
||||
|
||||
@pytest.mark.no_auto_workspace
|
||||
def test_set_and_reset_roundtrip():
|
||||
"""set_current_workspace returns a token that reset restores."""
|
||||
workspace = SimpleNamespace(id="ws-1", role="owner")
|
||||
token = set_current_workspace(workspace)
|
||||
try:
|
||||
assert get_current_workspace() is workspace
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
assert get_current_workspace() is None
|
||||
|
||||
|
||||
@pytest.mark.no_auto_workspace
|
||||
def test_require_current_workspace_raises_when_unset():
|
||||
"""require_current_workspace raises RuntimeError if contextvar is unset."""
|
||||
assert get_current_workspace() is None
|
||||
with pytest.raises(RuntimeError, match="without workspace context"):
|
||||
require_current_workspace()
|
||||
|
||||
|
||||
def test_require_current_workspace_returns_workspace_when_set():
|
||||
"""require_current_workspace returns the workspace when contextvar is set."""
|
||||
workspace = SimpleNamespace(id="ws-2", role="admin")
|
||||
token = set_current_workspace(workspace)
|
||||
try:
|
||||
assert require_current_workspace() is workspace
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CurrentWorkspace Protocol — must require BOTH .id and .role
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_protocol_accepts_id_and_role():
|
||||
"""CurrentWorkspace is satisfied by any object with .id and .role."""
|
||||
workspace = SimpleNamespace(id="ws-3", role="member")
|
||||
assert isinstance(workspace, CurrentWorkspace)
|
||||
|
||||
|
||||
def test_protocol_rejects_missing_role():
|
||||
"""An object with only .id (no .role) is NOT a workspace."""
|
||||
user_shaped = SimpleNamespace(id="ws-4")
|
||||
assert not isinstance(user_shaped, CurrentWorkspace)
|
||||
|
||||
|
||||
def test_protocol_rejects_no_id():
|
||||
"""An object without .id does not satisfy CurrentWorkspace."""
|
||||
not_a_workspace = SimpleNamespace(role="owner")
|
||||
assert not isinstance(not_a_workspace, CurrentWorkspace)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_effective_workspace_id / DEFAULT_WORKSPACE_ID tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_default_workspace_id_is_default():
|
||||
assert DEFAULT_WORKSPACE_ID == "default"
|
||||
|
||||
|
||||
@pytest.mark.no_auto_workspace
|
||||
def test_effective_workspace_id_returns_default_when_no_workspace():
|
||||
"""No workspace in context -> fallback to DEFAULT_WORKSPACE_ID."""
|
||||
assert get_effective_workspace_id() == "default"
|
||||
|
||||
|
||||
def test_effective_workspace_id_returns_workspace_id_when_set():
|
||||
workspace = SimpleNamespace(id="ws-abc-123", role="owner")
|
||||
token = set_current_workspace(workspace)
|
||||
try:
|
||||
assert get_effective_workspace_id() == "ws-abc-123"
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
|
||||
|
||||
def test_effective_workspace_id_coerces_to_str():
|
||||
"""workspace.id might be a UUID object; must come back as str."""
|
||||
wid = uuid.uuid4()
|
||||
workspace = SimpleNamespace(id=wid, role="owner")
|
||||
token = set_current_workspace(workspace)
|
||||
try:
|
||||
assert get_effective_workspace_id() == str(wid)
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_workspace_id three-state semantics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resolve_auto_reads_from_contextvar():
|
||||
workspace = SimpleNamespace(id="ws-resolve-1", role="owner")
|
||||
token = set_current_workspace(workspace)
|
||||
try:
|
||||
assert resolve_workspace_id(AUTO) == "ws-resolve-1"
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
|
||||
|
||||
@pytest.mark.no_auto_workspace
|
||||
def test_resolve_auto_raises_when_unset():
|
||||
assert get_current_workspace() is None
|
||||
with pytest.raises(RuntimeError, match="workspace_id=AUTO but no workspace"):
|
||||
resolve_workspace_id(AUTO, method_name="TestRepo.search")
|
||||
|
||||
|
||||
def test_resolve_explicit_str_overrides_contextvar():
|
||||
workspace = SimpleNamespace(id="ws-ctx", role="owner")
|
||||
token = set_current_workspace(workspace)
|
||||
try:
|
||||
# Explicit value beats contextvar — admin override / test path.
|
||||
assert resolve_workspace_id("ws-explicit") == "ws-explicit"
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
|
||||
|
||||
def test_resolve_explicit_none_means_no_filter():
|
||||
workspace = SimpleNamespace(id="ws-ctx-2", role="owner")
|
||||
token = set_current_workspace(workspace)
|
||||
try:
|
||||
# Explicit None opts out of workspace filtering (migration scripts).
|
||||
assert resolve_workspace_id(None) is None
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
|
||||
|
||||
def test_resolve_auto_coerces_uuid_to_str():
|
||||
"""resolve_workspace_id with AUTO returns str even if workspace.id is UUID."""
|
||||
wid = uuid.uuid4()
|
||||
workspace = SimpleNamespace(id=wid, role="owner")
|
||||
token = set_current_workspace(workspace)
|
||||
try:
|
||||
resolved = resolve_workspace_id(AUTO)
|
||||
assert resolved == str(wid)
|
||||
assert isinstance(resolved, str)
|
||||
finally:
|
||||
reset_current_workspace(token)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user