docs(awooop): add Phase 1 Isolation Foundation implementation plan (ADR-106 P1)
This commit is contained in:
@@ -0,0 +1,797 @@
|
||||
# AwoooP Phase 1 — Isolation Foundation Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 在不改變任何業務行為的前提下,為整個 AWOOOI codebase 補上 `project_id` 隔離地基——Redis key namespace、DB table project_id 欄位、session_id 格式標準化、agent_loader 路徑可配置、統一 ACL 與 Budget 資料模型。
|
||||
|
||||
**Architecture:** 純粹的防火隔離重構。所有改動都保留現有行為(`project_id=awoooi` 為預設值),不改任何 AI 推理路徑、不改任何 Telegram 發送邏輯、不新增 API endpoint。改完後 AWOOOI 的 Redis 不再會與未來的 EwoooC 碰撞,DB audit table 的 project_id 為後續 Control Plane 打好欄位基礎。
|
||||
|
||||
**Tech Stack:** Python/FastAPI, PostgreSQL (SQLAlchemy + Alembic), Redis (aioredis), pytest, Pydantic Settings
|
||||
|
||||
**Reference:** `docs/adr/ADR-106-agent-platform-architecture.md` (D3, Phase 1)
|
||||
|
||||
---
|
||||
|
||||
## File Map(所有將建立或修改的檔案)
|
||||
|
||||
**修改:**
|
||||
- `apps/api/src/services/telegram_gateway.py` — 3 個 Redis key 常數加 `awoooi:` 前綴
|
||||
- `apps/api/src/hermes/nl_gateway.py` — 2 個 Redis key 模式加 `awoooi:` 前綴
|
||||
- `apps/api/src/services/ai_rate_limiter.py` — 5 個 Redis key 模式加 `awoooi:` 前綴
|
||||
- `apps/api/src/services/ollama_failover_manager.py` — `ollama:*` key 改為 `global:ollama:*`
|
||||
- `apps/api/src/hermes/agent_loader.py` — 移除硬碼路徑,改讀 env var
|
||||
- `apps/api/src/core/config.py` — 新增 `HERMES_AGENTS_DIR`、`PLATFORM_PROJECT_ID` 設定
|
||||
|
||||
**新建 Migration:**
|
||||
- `apps/api/migrations/adr106_p1_project_isolation.sql` — 4 張 table 加 project_id 欄位 + backfill + 建立 2 張新 table
|
||||
|
||||
**新建 Model:**
|
||||
- `apps/api/src/db/models.py` — 新增 `PlatformUserAccess`、`PlatformBudget` 兩個 ORM model
|
||||
|
||||
**新建 Tests:**
|
||||
- `apps/api/tests/test_p1_redis_namespace.py` — Redis key namespace 單元測試
|
||||
- `apps/api/tests/test_p1_agent_loader.py` — agent_loader env var 測試
|
||||
- `apps/api/tests/test_p1_project_isolation.py` — DB migration + project_id 隔離整合測試
|
||||
|
||||
---
|
||||
|
||||
### Task 1:為 AWOOOI 定義 `PLATFORM_PROJECT_ID` 常數
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/api/src/core/config.py`
|
||||
|
||||
- [ ] **Step 1: 找到 config.py 裡 SYSTEM_NAME 所在行**
|
||||
|
||||
```bash
|
||||
grep -n "SYSTEM_NAME" apps/api/src/core/config.py
|
||||
```
|
||||
Expected: 顯示行號(約 L44)
|
||||
|
||||
- [ ] **Step 2: 在 SYSTEM_NAME 下方新增兩個欄位**
|
||||
|
||||
在 `SYSTEM_NAME: str = "awoooi"` 後加入:
|
||||
|
||||
```python
|
||||
PLATFORM_PROJECT_ID: str = Field(
|
||||
default="awoooi",
|
||||
description="ADR-106 P1: 本服務的 project_id,用於 Redis key namespace 和 DB 隔離",
|
||||
)
|
||||
HERMES_AGENTS_DIR: str = Field(
|
||||
default="/app/agents",
|
||||
description="ADR-106 P1: Hermes agent_loader 載入 .md 的目錄,取代硬碼的 /Users/ogt/awoooi/.claude/agents",
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 確認語法正確**
|
||||
|
||||
```bash
|
||||
cd apps/api && python3 -m py_compile src/core/config.py && echo "OK"
|
||||
```
|
||||
Expected: `OK`
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/api/src/core/config.py
|
||||
git commit -m "feat(platform): add PLATFORM_PROJECT_ID and HERMES_AGENTS_DIR to config"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2:修 agent_loader.py 硬碼路徑
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/api/src/hermes/agent_loader.py:9`
|
||||
|
||||
- [ ] **Step 1: 寫失敗測試**
|
||||
|
||||
建立 `apps/api/tests/test_p1_agent_loader.py`:
|
||||
|
||||
```python
|
||||
import os
|
||||
import pathlib
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
def test_agent_loader_respects_env_var(tmp_path):
|
||||
"""agent_loader 應從 HERMES_AGENTS_DIR 而非硬碼路徑讀取 agent md"""
|
||||
# 建立假 agent md
|
||||
(tmp_path / "debugger.md").write_text("---\nname: debugger\n---\nYou are a debugger.")
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_AGENTS_DIR": str(tmp_path)}):
|
||||
# 清除 lru_cache 讓 import 重新讀 env
|
||||
import importlib
|
||||
import src.hermes.agent_loader as loader
|
||||
importlib.reload(loader)
|
||||
result = loader.get_agent_system_prompt("debugger")
|
||||
|
||||
assert result is not None
|
||||
assert "debugger" in result.lower()
|
||||
|
||||
def test_agent_loader_returns_none_for_missing(tmp_path):
|
||||
"""找不到的 agent 應回傳 None"""
|
||||
with patch.dict(os.environ, {"HERMES_AGENTS_DIR": str(tmp_path)}):
|
||||
import importlib
|
||||
import src.hermes.agent_loader as loader
|
||||
importlib.reload(loader)
|
||||
result = loader.get_agent_system_prompt("nonexistent-agent")
|
||||
|
||||
assert result is None
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 執行確認測試失敗**
|
||||
|
||||
```bash
|
||||
cd apps/api && pytest tests/test_p1_agent_loader.py -v
|
||||
```
|
||||
Expected: FAIL(因為目前 agent_loader 還是用硬碼路徑)
|
||||
|
||||
- [ ] **Step 3: 修改 agent_loader.py**
|
||||
|
||||
將 `apps/api/src/hermes/agent_loader.py` 改為:
|
||||
|
||||
```python
|
||||
"""載入 agents/*.md 並解析 system prompt(ADR-095, ADR-106 P1)
|
||||
|
||||
ADR-106 P1: 移除硬碼路徑 /Users/ogt/awoooi/.claude/agents,改從
|
||||
HERMES_AGENTS_DIR env var 讀取,預設 /app/agents。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import pathlib
|
||||
from functools import lru_cache
|
||||
|
||||
def _get_agents_dir() -> pathlib.Path:
|
||||
return pathlib.Path(os.environ.get("HERMES_AGENTS_DIR", "/app/agents"))
|
||||
|
||||
def _parse_agent_md(path: pathlib.Path) -> str:
|
||||
"""去除 YAML frontmatter,回傳 body 作為 system prompt"""
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if text.startswith("---"):
|
||||
end = text.find("---", 3)
|
||||
if end != -1:
|
||||
return text[end + 3:].strip()
|
||||
return text.strip()
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def get_agent_system_prompt(agent_name: str) -> str | None:
|
||||
path = _get_agents_dir() / f"{agent_name}.md"
|
||||
if not path.exists():
|
||||
return None
|
||||
return _parse_agent_md(path)
|
||||
|
||||
def list_available_agents() -> list[str]:
|
||||
return [p.stem for p in sorted(_get_agents_dir().glob("*.md"))]
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 執行確認測試通過**
|
||||
|
||||
```bash
|
||||
cd apps/api && pytest tests/test_p1_agent_loader.py -v
|
||||
```
|
||||
Expected: 2 passed
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/api/src/hermes/agent_loader.py apps/api/tests/test_p1_agent_loader.py
|
||||
git commit -m "fix(hermes): replace hardcoded agent_loader path with HERMES_AGENTS_DIR env var (ADR-106 P1)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3:Redis key namespace — telegram_gateway.py
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/api/src/services/telegram_gateway.py`
|
||||
|
||||
- [ ] **Step 1: 找到現有 key 常數位置**
|
||||
|
||||
```bash
|
||||
grep -n "telegram_snooze\|telegram_silence\|polling:leader" apps/api/src/services/telegram_gateway.py
|
||||
```
|
||||
Expected: 顯示 3 個常數行號
|
||||
|
||||
- [ ] **Step 2: 寫失敗測試**
|
||||
|
||||
建立 `apps/api/tests/test_p1_redis_namespace.py`:
|
||||
|
||||
```python
|
||||
"""ADR-106 P1: 所有 Redis key 必須帶 project_id 前綴"""
|
||||
from src.services.telegram_gateway import (
|
||||
SNOOZE_KEY_PREFIX,
|
||||
SILENCE_KEY_PREFIX,
|
||||
POLLING_LEADER_KEY,
|
||||
)
|
||||
|
||||
def test_snooze_key_has_project_prefix():
|
||||
assert SNOOZE_KEY_PREFIX.startswith("awoooi:"), (
|
||||
f"SNOOZE_KEY_PREFIX '{SNOOZE_KEY_PREFIX}' 缺少 awoooi: 前綴"
|
||||
)
|
||||
|
||||
def test_silence_key_has_project_prefix():
|
||||
assert SILENCE_KEY_PREFIX.startswith("awoooi:"), (
|
||||
f"SILENCE_KEY_PREFIX '{SILENCE_KEY_PREFIX}' 缺少 awoooi: 前綴"
|
||||
)
|
||||
|
||||
def test_polling_leader_key_has_project_prefix():
|
||||
assert POLLING_LEADER_KEY.startswith("awoooi:"), (
|
||||
f"POLLING_LEADER_KEY '{POLLING_LEADER_KEY}' 缺少 awoooi: 前綴"
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 確認測試失敗**
|
||||
|
||||
```bash
|
||||
cd apps/api && pytest tests/test_p1_redis_namespace.py -v
|
||||
```
|
||||
Expected: 3 FAILED
|
||||
|
||||
- [ ] **Step 4: 修改 telegram_gateway.py 的 key 常數**
|
||||
|
||||
找到並更新三個常數:
|
||||
|
||||
```python
|
||||
# ADR-106 P1: 所有 key 加 awoooi: 前綴防止多專案碰撞
|
||||
SNOOZE_KEY_PREFIX = "awoooi:telegram_snooze:" # 原: "telegram_snooze:"
|
||||
SILENCE_KEY_PREFIX = "awoooi:telegram_silence:" # 原: "telegram_silence:"
|
||||
SNOOZE_TTL_SECONDS = 30 * 60
|
||||
SILENCE_TTL_SECONDS = 60 * 60
|
||||
|
||||
POLLING_LEADER_KEY = "awoooi:telegram:polling:leader" # 原: "telegram:polling:leader"
|
||||
POLLING_LEADER_TTL = 45
|
||||
POLLING_LEADER_RENEW = 20
|
||||
POLLING_LEADER_WATCH = 30
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 確認測試通過**
|
||||
|
||||
```bash
|
||||
cd apps/api && pytest tests/test_p1_redis_namespace.py -v
|
||||
```
|
||||
Expected: 3 passed
|
||||
|
||||
- [ ] **Step 6: 確認語法**
|
||||
|
||||
```bash
|
||||
cd apps/api && python3 -m py_compile src/services/telegram_gateway.py && echo "OK"
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/api/src/services/telegram_gateway.py apps/api/tests/test_p1_redis_namespace.py
|
||||
git commit -m "fix(telegram): add awoooi: project prefix to all Redis keys (ADR-106 P1)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4:Redis key namespace — hermes/nl_gateway.py
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/api/src/hermes/nl_gateway.py`
|
||||
|
||||
- [ ] **Step 1: 找到現有 key 格式字串**
|
||||
|
||||
```bash
|
||||
grep -n "hermes:rl\|hermes:session" apps/api/src/hermes/nl_gateway.py
|
||||
```
|
||||
Expected: 顯示 2-4 行,是 f-string 格式
|
||||
|
||||
- [ ] **Step 2: 在 test_p1_redis_namespace.py 中補充 hermes 測試**
|
||||
|
||||
在 `apps/api/tests/test_p1_redis_namespace.py` 末尾追加:
|
||||
|
||||
```python
|
||||
def test_hermes_rate_limit_key_format():
|
||||
"""驗證 hermes rate limit key 格式包含 awoooi: 前綴"""
|
||||
import inspect
|
||||
import src.hermes.nl_gateway as nl
|
||||
source = inspect.getsource(nl)
|
||||
assert "awoooi:hermes:rl:" in source, "hermes rate limit key 缺少 awoooi: 前綴"
|
||||
|
||||
def test_hermes_session_key_format():
|
||||
import inspect
|
||||
import src.hermes.nl_gateway as nl
|
||||
source = inspect.getsource(nl)
|
||||
assert "awoooi:hermes:session:" in source, "hermes session key 缺少 awoooi: 前綴"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 確認測試失敗**
|
||||
|
||||
```bash
|
||||
cd apps/api && pytest tests/test_p1_redis_namespace.py::test_hermes_rate_limit_key_format tests/test_p1_redis_namespace.py::test_hermes_session_key_format -v
|
||||
```
|
||||
Expected: 2 FAILED
|
||||
|
||||
- [ ] **Step 4: 更新 nl_gateway.py 的 key 格式字串**
|
||||
|
||||
找到並更新所有 `hermes:rl:` 和 `hermes:session:` 出現處:
|
||||
|
||||
```python
|
||||
# 原: f"hermes:rl:{chat_id}"
|
||||
# 改: f"awoooi:hermes:rl:{chat_id}"
|
||||
|
||||
# 原: f"hermes:session:{chat_id}:{user_id}"
|
||||
# 改: f"awoooi:hermes:session:{chat_id}:{user_id}"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 語法確認**
|
||||
|
||||
```bash
|
||||
cd apps/api && python3 -m py_compile src/hermes/nl_gateway.py && echo "OK"
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 確認測試通過**
|
||||
|
||||
```bash
|
||||
cd apps/api && pytest tests/test_p1_redis_namespace.py -v
|
||||
```
|
||||
Expected: 全部 passed(5 個)
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/api/src/hermes/nl_gateway.py apps/api/tests/test_p1_redis_namespace.py
|
||||
git commit -m "fix(hermes): add awoooi: project prefix to nl_gateway Redis keys (ADR-106 P1)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5:Redis key namespace — ai_rate_limiter.py 和 ollama_failover_manager.py
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/api/src/services/ai_rate_limiter.py`
|
||||
- Modify: `apps/api/src/services/ollama_failover_manager.py`
|
||||
|
||||
- [ ] **Step 1: 找出 ai_rate_limiter 所有 Redis key 格式**
|
||||
|
||||
```bash
|
||||
grep -n "ai_rate:" apps/api/src/services/ai_rate_limiter.py
|
||||
```
|
||||
Expected: 顯示 5 個 key 模式(rpm, daily_req, daily_token, total_cost, gemini_count)
|
||||
|
||||
- [ ] **Step 2: 在測試檔案補充 ai_rate_limiter 驗證**
|
||||
|
||||
在 `apps/api/tests/test_p1_redis_namespace.py` 末尾追加:
|
||||
|
||||
```python
|
||||
def test_ai_rate_keys_have_project_prefix():
|
||||
"""ai_rate_limiter 的 Redis key 要有 awoooi: 前綴"""
|
||||
import inspect
|
||||
import src.services.ai_rate_limiter as arl
|
||||
source = inspect.getsource(arl)
|
||||
assert "awoooi:ai_rate:" in source, "ai_rate key 缺少 awoooi: 前綴"
|
||||
|
||||
def test_ollama_failover_key_is_global():
|
||||
"""Ollama failover 是 platform-global 資源,用 global: 前綴而非 awoooi:"""
|
||||
import inspect
|
||||
import src.services.ollama_failover_manager as ofm
|
||||
source = inspect.getsource(ofm)
|
||||
assert "global:ollama:" in source, "ollama failover key 應使用 global: 前綴"
|
||||
assert '"ollama:current_primary"' not in source, "不應有裸的 ollama:current_primary key"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 確認測試失敗**
|
||||
|
||||
```bash
|
||||
cd apps/api && pytest tests/test_p1_redis_namespace.py::test_ai_rate_keys_have_project_prefix tests/test_p1_redis_namespace.py::test_ollama_failover_key_is_global -v
|
||||
```
|
||||
Expected: 2 FAILED
|
||||
|
||||
- [ ] **Step 4: 更新 ai_rate_limiter.py 的 key 格式**
|
||||
|
||||
找到並替換所有 `ai_rate:` 開頭的 key 加上 `awoooi:` 前綴:
|
||||
|
||||
```python
|
||||
# 原: f"ai_rate:rpm:{provider}"
|
||||
# 改: f"awoooi:ai_rate:rpm:{provider}"
|
||||
|
||||
# 原: f"ai_rate:daily_req:{provider}:{date}"
|
||||
# 改: f"awoooi:ai_rate:daily_req:{provider}:{date}"
|
||||
|
||||
# 原: f"ai_rate:daily_token:{provider}:{date}"
|
||||
# 改: f"awoooi:ai_rate:daily_token:{provider}:{date}"
|
||||
|
||||
# 原: f"ai_rate:total_cost:{provider}"
|
||||
# 改: f"awoooi:ai_rate:total_cost:{provider}"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 找到並更新 ollama_failover_manager.py 的 key**
|
||||
|
||||
```bash
|
||||
grep -n "ollama:current_primary\|ollama:" apps/api/src/services/ollama_failover_manager.py
|
||||
```
|
||||
|
||||
將 `ollama:current_primary` 改為 `global:ollama:current_primary`(platform global resource)
|
||||
|
||||
- [ ] **Step 6: 語法確認**
|
||||
|
||||
```bash
|
||||
cd apps/api && python3 -m py_compile src/services/ai_rate_limiter.py src/services/ollama_failover_manager.py && echo "OK"
|
||||
```
|
||||
|
||||
- [ ] **Step 7: 執行所有 namespace 測試**
|
||||
|
||||
```bash
|
||||
cd apps/api && pytest tests/test_p1_redis_namespace.py -v
|
||||
```
|
||||
Expected: 全部 passed(9 個)
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/api/src/services/ai_rate_limiter.py apps/api/src/services/ollama_failover_manager.py apps/api/tests/test_p1_redis_namespace.py
|
||||
git commit -m "fix(ai): add project/global prefix to ai_rate_limiter and ollama_failover Redis keys (ADR-106 P1)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6:DB migration — 四張 table 加 project_id,兩張新 table
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/api/migrations/adr106_p1_project_isolation.sql`
|
||||
- Modify: `apps/api/src/db/models.py`
|
||||
|
||||
- [ ] **Step 1: 建立 migration SQL**
|
||||
|
||||
建立 `apps/api/migrations/adr106_p1_project_isolation.sql`:
|
||||
|
||||
```sql
|
||||
-- ADR-106 Phase 1: Project Isolation Foundation
|
||||
-- 2026-05-01 ogt + Claude Sonnet 4.6
|
||||
-- 操作:4 張既有 table 加 project_id 欄位 + 2 張新 table
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── 1. hermes_dispatch_log 加 project_id ──────────────────────────────────
|
||||
ALTER TABLE hermes_dispatch_log
|
||||
ADD COLUMN IF NOT EXISTS project_id VARCHAR(50) NOT NULL DEFAULT 'awoooi';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_hermes_dispatch_project_id
|
||||
ON hermes_dispatch_log (project_id);
|
||||
|
||||
-- ── 2. mcp_audit_snapshots 加 project_id ─────────────────────────────────
|
||||
ALTER TABLE mcp_audit_snapshots
|
||||
ADD COLUMN IF NOT EXISTS project_id VARCHAR(50) NOT NULL DEFAULT 'awoooi';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mcp_audit_project_id
|
||||
ON mcp_audit_snapshots (project_id);
|
||||
|
||||
-- ── 3. approval_records 加 project_id ────────────────────────────────────
|
||||
ALTER TABLE approval_records
|
||||
ADD COLUMN IF NOT EXISTS project_id VARCHAR(50) NOT NULL DEFAULT 'awoooi';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_approval_records_project_id
|
||||
ON approval_records (project_id);
|
||||
|
||||
-- ── 4. incident_records 加 project_id(如果存在)─────────────────────────
|
||||
ALTER TABLE incident_records
|
||||
ADD COLUMN IF NOT EXISTS project_id VARCHAR(50) NOT NULL DEFAULT 'awoooi';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_incident_records_project_id
|
||||
ON incident_records (project_id);
|
||||
|
||||
-- ── 5. 新建 platform_user_access ─────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS platform_user_access (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id VARCHAR(100) NOT NULL, -- e.g. "telegram:123456789"
|
||||
platform VARCHAR(20) NOT NULL, -- telegram | line | slack | api
|
||||
project_id VARCHAR(50) NOT NULL,
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'operator', -- admin | operator | viewer
|
||||
granted_by VARCHAR(100),
|
||||
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
revoked_at TIMESTAMPTZ,
|
||||
UNIQUE (user_id, platform, project_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pua_project_user
|
||||
ON platform_user_access (project_id, user_id, platform)
|
||||
WHERE revoked_at IS NULL;
|
||||
|
||||
-- ── 6. 新建 platform_budget ───────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS platform_budget (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_id VARCHAR(50) NOT NULL,
|
||||
provider VARCHAR(50) NOT NULL, -- gemini | claude | openrouter | ollama
|
||||
billing_type VARCHAR(20) NOT NULL DEFAULT 'metered', -- metered | local_fixed_cost
|
||||
monthly_limit_usd NUMERIC(10,4) NOT NULL DEFAULT 0,
|
||||
alert_threshold_usd NUMERIC(10,4) NOT NULL DEFAULT 0,
|
||||
current_month_usd NUMERIC(10,4) NOT NULL DEFAULT 0,
|
||||
period_start DATE NOT NULL DEFAULT DATE_TRUNC('month', NOW()),
|
||||
hard_stopped BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (project_id, provider, period_start)
|
||||
);
|
||||
|
||||
-- 初始化 AWOOOI 現有費用上限(對應 ai_rate_limiter.py 裡的 $5/$10 硬停)
|
||||
INSERT INTO platform_budget (project_id, provider, billing_type, monthly_limit_usd, alert_threshold_usd)
|
||||
VALUES
|
||||
('awoooi', 'gemini', 'metered', 5.0, 4.0),
|
||||
('awoooi', 'claude', 'metered', 10.0, 8.0),
|
||||
('awoooi', 'openrouter', 'metered', 10.0, 8.0),
|
||||
('awoooi', 'ollama', 'local_fixed_cost', 0.0, 0.0)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 確認 models.py 有需要的 import**
|
||||
|
||||
```bash
|
||||
grep -n "^from\|^import" apps/api/src/db/models.py | grep -E "BigInteger|Numeric|Date|Boolean|UniqueConstraint|Index|text|func" | head -10
|
||||
```
|
||||
|
||||
如果缺少任何 import,先補充到 models.py 的 import 區塊。
|
||||
|
||||
- [ ] **Step 3: 在 models.py 尾端新增 ORM models**
|
||||
|
||||
```python
|
||||
# ADR-106 P1: Platform Isolation Models
|
||||
# 2026-05-01 ogt + Claude Sonnet 4.6
|
||||
|
||||
class PlatformUserAccess(Base):
|
||||
"""統一跨專案使用者存取控制 — 取代各專案分散的 whitelist"""
|
||||
__tablename__ = "platform_user_access"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
user_id = Column(String(100), nullable=False)
|
||||
platform = Column(String(20), nullable=False)
|
||||
project_id = Column(String(50), nullable=False)
|
||||
role = Column(String(20), nullable=False, default="operator")
|
||||
granted_by = Column(String(100))
|
||||
granted_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
revoked_at = Column(DateTime(timezone=True))
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "platform", "project_id", name="uq_pua_user_platform_project"),
|
||||
Index("idx_pua_project_user", "project_id", "user_id", "platform",
|
||||
postgresql_where=text("revoked_at IS NULL")),
|
||||
)
|
||||
|
||||
class PlatformBudget(Base):
|
||||
"""統一跨專案 AI 費用預算與上限"""
|
||||
__tablename__ = "platform_budget"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
project_id = Column(String(50), nullable=False)
|
||||
provider = Column(String(50), nullable=False)
|
||||
billing_type = Column(String(20), nullable=False, default="metered")
|
||||
monthly_limit_usd = Column(Numeric(10, 4), nullable=False, default=0)
|
||||
alert_threshold_usd = Column(Numeric(10, 4), nullable=False, default=0)
|
||||
current_month_usd = Column(Numeric(10, 4), nullable=False, default=0)
|
||||
period_start = Column(Date, nullable=False)
|
||||
hard_stopped = Column(Boolean, nullable=False, default=False)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("project_id", "provider", "period_start", name="uq_budget_project_provider_period"),
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 語法確認**
|
||||
|
||||
```bash
|
||||
cd apps/api && python3 -m py_compile src/db/models.py && echo "OK"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/api/migrations/adr106_p1_project_isolation.sql apps/api/src/db/models.py
|
||||
git commit -m "feat(db): add project_id to audit tables and create platform_user_access/platform_budget (ADR-106 P1)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7:執行 migration(Dev 環境)
|
||||
|
||||
**Files:**
|
||||
- 無新檔案,執行已建立的 migration
|
||||
|
||||
- [ ] **Step 1: 確認 dev DB 連線設定**
|
||||
|
||||
```bash
|
||||
grep -n "DATABASE_URL\|POSTGRES" apps/api/.env 2>/dev/null | head -5
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 執行 migration**
|
||||
|
||||
```bash
|
||||
# 替換 $DATABASE_URL 為實際連線字串(從 .env 或 config 取得)
|
||||
psql $DATABASE_URL -f apps/api/migrations/adr106_p1_project_isolation.sql
|
||||
```
|
||||
Expected: 輸出 `ALTER TABLE` × 4、`CREATE INDEX` × 4、`CREATE TABLE` × 2、`INSERT 0 4`,最後 `COMMIT`
|
||||
|
||||
- [ ] **Step 3: 驗證欄位存在**
|
||||
|
||||
```bash
|
||||
psql $DATABASE_URL -c "\d hermes_dispatch_log" | grep project_id
|
||||
psql $DATABASE_URL -c "\d platform_budget" | grep -E "project_id|billing_type"
|
||||
```
|
||||
Expected: 顯示 project_id 欄位
|
||||
|
||||
- [ ] **Step 4: Commit 確認**
|
||||
|
||||
```bash
|
||||
git commit --allow-empty -m "chore(db): run adr106_p1 migration on dev (project isolation foundation)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8:整合測試 — 確認 project_id 隔離完整性
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/api/tests/test_p1_project_isolation.py`
|
||||
|
||||
- [ ] **Step 1: 建立整合測試**
|
||||
|
||||
建立 `apps/api/tests/test_p1_project_isolation.py`:
|
||||
|
||||
```python
|
||||
"""ADR-106 P1 整合測試:project_id 隔離完整性驗證"""
|
||||
import inspect
|
||||
import pytest
|
||||
|
||||
|
||||
class TestRedisNamespaceIsolation:
|
||||
"""驗證所有關鍵 Redis key 都有正確的 project_id 前綴"""
|
||||
|
||||
def test_telegram_keys_have_awoooi_prefix(self):
|
||||
from src.services.telegram_gateway import (
|
||||
SNOOZE_KEY_PREFIX, SILENCE_KEY_PREFIX, POLLING_LEADER_KEY
|
||||
)
|
||||
assert SNOOZE_KEY_PREFIX == "awoooi:telegram_snooze:"
|
||||
assert SILENCE_KEY_PREFIX == "awoooi:telegram_silence:"
|
||||
assert POLLING_LEADER_KEY == "awoooi:telegram:polling:leader"
|
||||
|
||||
def test_ollama_failover_key_is_global(self):
|
||||
"""Ollama failover 是 platform-global 資源,不屬於單一 project"""
|
||||
import src.services.ollama_failover_manager as ofm
|
||||
source = inspect.getsource(ofm)
|
||||
assert "global:ollama:" in source, "ollama failover key 應使用 global: 前綴"
|
||||
assert '"ollama:current_primary"' not in source
|
||||
|
||||
def test_hermes_keys_have_awoooi_prefix(self):
|
||||
import src.hermes.nl_gateway as nl
|
||||
source = inspect.getsource(nl)
|
||||
assert "awoooi:hermes:rl:" in source
|
||||
assert "awoooi:hermes:session:" in source
|
||||
|
||||
def test_ai_rate_keys_have_awoooi_prefix(self):
|
||||
import src.services.ai_rate_limiter as arl
|
||||
source = inspect.getsource(arl)
|
||||
assert "awoooi:ai_rate:" in source
|
||||
|
||||
|
||||
class TestAgentLoaderConfig:
|
||||
"""驗證 agent_loader 使用可配置路徑"""
|
||||
|
||||
def test_default_path_is_not_hardcoded_to_user_home(self):
|
||||
import src.hermes.agent_loader as loader
|
||||
source = inspect.getsource(loader)
|
||||
assert "/Users/ogt" not in source, "agent_loader 不應硬碼本機路徑"
|
||||
|
||||
def test_uses_env_var(self):
|
||||
import src.hermes.agent_loader as loader
|
||||
source = inspect.getsource(loader)
|
||||
assert "HERMES_AGENTS_DIR" in source
|
||||
|
||||
|
||||
class TestDBModels:
|
||||
"""驗證 ORM 模型存在且有正確欄位"""
|
||||
|
||||
def test_platform_user_access_model_exists(self):
|
||||
from src.db.models import PlatformUserAccess
|
||||
columns = {c.name for c in PlatformUserAccess.__table__.columns}
|
||||
assert "project_id" in columns
|
||||
assert "user_id" in columns
|
||||
assert "platform" in columns
|
||||
assert "role" in columns
|
||||
assert "revoked_at" in columns
|
||||
|
||||
def test_platform_budget_model_exists(self):
|
||||
from src.db.models import PlatformBudget
|
||||
columns = {c.name for c in PlatformBudget.__table__.columns}
|
||||
assert "project_id" in columns
|
||||
assert "provider" in columns
|
||||
assert "billing_type" in columns
|
||||
assert "monthly_limit_usd" in columns
|
||||
assert "hard_stopped" in columns
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 執行整合測試**
|
||||
|
||||
```bash
|
||||
cd apps/api && pytest tests/test_p1_project_isolation.py -v
|
||||
```
|
||||
Expected: 全部 passed(9 個)
|
||||
|
||||
- [ ] **Step 3: 執行全部 P1 測試確認無回歸**
|
||||
|
||||
```bash
|
||||
cd apps/api && pytest tests/test_p1_redis_namespace.py tests/test_p1_agent_loader.py tests/test_p1_project_isolation.py -v
|
||||
```
|
||||
Expected: 全部 passed(共約 20 個測試)
|
||||
|
||||
- [ ] **Step 4: 確認現有測試無回歸**
|
||||
|
||||
```bash
|
||||
cd apps/api && pytest tests/ -q --ignore=tests/test_p1_redis_namespace.py --ignore=tests/test_p1_agent_loader.py --ignore=tests/test_p1_project_isolation.py 2>&1 | tail -5
|
||||
```
|
||||
Expected: 通過率與改前相同,無新增 FAILED
|
||||
|
||||
- [ ] **Step 5: Final commit**
|
||||
|
||||
```bash
|
||||
git add apps/api/tests/test_p1_project_isolation.py
|
||||
git commit -m "test(platform): add P1 isolation integration tests (ADR-106 P1)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9:設定 K8s ConfigMap/Secret(Prod 準備)
|
||||
|
||||
**Files:**
|
||||
- Confirm / Modify: `apps/api/deploy/` 或 K8s manifest
|
||||
|
||||
- [ ] **Step 1: 確認 prod K8s deployment 有正確的 env var**
|
||||
|
||||
```bash
|
||||
kubectl get deployment awoooi-api -n awoooi-prod -o jsonpath='{.spec.template.spec.containers[0].env}' 2>/dev/null | python3 -m json.tool | grep -A2 "HERMES\|PLATFORM_PROJECT" || echo "需要新增 env var"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 找到 ConfigMap manifest**
|
||||
|
||||
```bash
|
||||
find apps/api/deploy -name "*.yaml" -o -name "*.yml" 2>/dev/null | head -10
|
||||
# 或
|
||||
find . -path "*/k8s/*" -name "configmap*" 2>/dev/null | head -5
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 在 ConfigMap 新增兩個 env var**
|
||||
|
||||
```yaml
|
||||
PLATFORM_PROJECT_ID: "awoooi"
|
||||
HERMES_AGENTS_DIR: "/app/agents"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 確認 /app/agents 目錄在 container 內存在**
|
||||
|
||||
```bash
|
||||
grep -rn "agents\|COPY.*agent" apps/api/Dockerfile 2>/dev/null | head -5
|
||||
```
|
||||
|
||||
如果 agents 目錄沒有被複製進 container,在 Dockerfile 加上:
|
||||
```dockerfile
|
||||
COPY .claude/agents /app/agents
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit Prod 設定**
|
||||
|
||||
```bash
|
||||
git add apps/api/deploy/ apps/api/Dockerfile
|
||||
git commit -m "chore(k8s): add PLATFORM_PROJECT_ID and HERMES_AGENTS_DIR to deployment config (ADR-106 P1)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage check(對照 ADR-106 Phase 1 要求):**
|
||||
|
||||
| ADR-106 Phase 1 要求 | 計畫中的 Task |
|
||||
|---------------------|-------------|
|
||||
| Redis keys 加 project_id | Task 3(telegram)、Task 4(hermes)、Task 5(ai_rate + ollama) |
|
||||
| platform_resource 例外(global:) | Task 5(ollama key 改 global:)|
|
||||
| AWOOOI 行為保留在 project_id=awoooi | 全部 default='awoooi' |
|
||||
| hermes_dispatch_log 加 project_id | Task 6(migration SQL)|
|
||||
| mcp_audit_snapshots 加 project_id | Task 6(migration SQL)|
|
||||
| approval_records 加 project_id | Task 6(migration SQL)|
|
||||
| 建立統一 ACL 資料模型 | Task 6(platform_user_access table)|
|
||||
| 建立統一 Budget 資料模型 | Task 6(platform_budget table)|
|
||||
| agent_loader 硬碼路徑修復 | Task 2 |
|
||||
| HERMES_AGENTS_DIR env var | Task 1 + Task 2 |
|
||||
| K8s prod 設定 | Task 9 |
|
||||
|
||||
**注意:** `IncidentRecord` ORM model 已有 project_id 欄位由 Task 6 migration SQL 新增,但 SQLAlchemy model 需人工確認後補 PR(避免破壞既有查詢)。
|
||||
Reference in New Issue
Block a user