feat(R01): architecture foundation, offline fakes and characterization net
EPIC R01 (Team Duy) - safety net before the parallel refactor starts.
R01-T01 docs/architecture/ADR-001-layered-architecture.md
4-tier boundaries, allowed dependency directions, invariants I1-I6 and
the strangler-fig migration strategy.
R01-T02 tests/fakes/{fake_provider,fake_tool_executor}.py
Scripted, offline Provider and extra-tool executor doubles.
R01-T03 scripts/check_imports.py
AST-based Clean Architecture Guard (CASAN Check 3). Also covers relative
imports and function-local imports; ASCII-only output for cp932 consoles.
R01-T04 tests/characterization/test_run_cowork.py
13 snapshot tests pinning run_cowork's current observable contract before
EPIC R04 moves its orchestration into application/.
R01-T05 docs/architecture/dormant-code.md
Import-graph scan: 43 unimported modules verified down to 6 genuinely
dormant items (~1887 LOC); the rest run via subprocess/CLI entry points.
tests/conftest.py binds `cowork_local` to THIS checkout by absolute path -
previously sys.path discovery could import a sibling checkout and the suite
would silently test the wrong code.
Suite: 104 passed, 1.08s (2 pre-existing failures in test_config_security.py
remain - config.py still ships a hardcoded default password, EPIC R02/Team Nam).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
# ADR-001: Kiến Trúc 4 Tầng (Layered / Clean Architecture)
|
||||
|
||||
* **Status**: Accepted
|
||||
* **Date**: 2026-08-21
|
||||
* **EPIC / Task**: R01-T01
|
||||
* **Owner**: 🔵 Team Duy (Tech Lead)
|
||||
* **Áp dụng cho**: toàn bộ mã nguồn mới của `cowork_local` (3 team)
|
||||
|
||||
---
|
||||
|
||||
## 1. Context (Bối cảnh)
|
||||
|
||||
`cowork_local` hiện là một ứng dụng PySide6 desktop local-first ~55.000 dòng Python,
|
||||
được phát triển nhanh theo hướng feature-first. Hệ quả đo được tại thời điểm viết ADR:
|
||||
|
||||
| Vấn đề | Bằng chứng cụ thể trong repo |
|
||||
| :--- | :--- |
|
||||
| **God widget** | `ui/co4e_tab.py` 2.089 dòng, `ui/chat_panel.py` 1.795 dòng, `ui/folder_tab.py` 1.590 dòng |
|
||||
| **Business logic nằm trong widget** | Vòng đời turn chat, quyết định routing, ghép prompt đều nằm trong `ui/chat_panel.py` |
|
||||
| **Logic trùng lặp 3 nơi** | `ui/chat_panel.py::_apply_routing`, `ui/co4e_tab.py::_apply_co4e_routing`, `ui/folder_tab.py::_ai_apply_routing` là ba bản sao gần như y hệt của cùng một thuật toán |
|
||||
| **Không test được nếu không có Qt** | Muốn test một quyết định routing phải dựng widget → không chạy được headless, không chạy được nhanh |
|
||||
| **Side-effect ẩn trong tầng hạ tầng** | Provider tự gọi `core.usage_tracker.record()` ngay trong vòng lặp stream (`providers/openai_compat.py::_record_usage`) |
|
||||
|
||||
Ba team (Duy / Nam / Hoa) sẽ sửa song song trên cùng codebase trong 10 ngày. Nếu
|
||||
không có một ranh giới phụ thuộc được **kiểm chứng tự động**, các thay đổi song song
|
||||
sẽ hội tụ về đúng cấu trúc rối như cũ.
|
||||
|
||||
## 2. Decision (Quyết định)
|
||||
|
||||
Mã nguồn mới được tổ chức thành **4 tầng**, với **chiều phụ thuộc một chiều** như sau:
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ presentation/ PySide6 widgets, Qt signals/slots │
|
||||
│ (chat, co4e, workspace…) Chỉ dựng UI và phát/nhận signal │
|
||||
└───────────────────────────┬─────────────────────────────────┘
|
||||
│ gọi xuống (được phép)
|
||||
┌───────────────────────────▼─────────────────────────────────┐
|
||||
│ application/ Pure Python orchestration │
|
||||
│ (conversations, Điều phối use-case, không biết Qt │
|
||||
│ model_routing…) và không biết HTTP/đĩa cụ thể │
|
||||
└───────────────────────────┬─────────────────────────────────┘
|
||||
│ gọi xuống (được phép)
|
||||
┌───────────────────────────▼─────────────────────────────────┐
|
||||
│ domain/ Pure Python entities & events │
|
||||
│ (agents, models…) Frozen dataclass, enum, quy tắc │
|
||||
│ nghiệp vụ thuần. KHÔNG import gì │
|
||||
│ từ 3 tầng còn lại. │
|
||||
└───────────────────────────▲─────────────────────────────────┘
|
||||
│ implement interface của domain
|
||||
┌───────────────────────────┴─────────────────────────────────┐
|
||||
│ infrastructure/ Adapters: network, keyring, đĩa, │
|
||||
│ (providers, telemetry…) process, Qt-free I/O │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.1 Quy tắc bất biến (Invariants)
|
||||
|
||||
| # | Quy tắc | Được kiểm bởi |
|
||||
| :--- | :--- | :--- |
|
||||
| **I1** | `domain/` và `application/` là **100% pure Python** — cấm import `PySide6`, `PyQt5`, `PyQt6`, `shiboken6` | `scripts/check_imports.py` (R01-T03) |
|
||||
| **I2** | `domain/` **không import** `application/`, `infrastructure/`, `presentation/`, `ui/` | `scripts/check_imports.py` |
|
||||
| **I3** | `application/` **không import** `presentation/` hay `ui/` | `scripts/check_imports.py` |
|
||||
| **I4** | Không file production nào vượt **400 dòng** | `scripts/check_loc.py` (R10-T02) |
|
||||
| **I5** | `presentation/` **không** gọi thẳng provider/HTTP/đĩa — phải đi qua một application service | Code review + I1–I3 |
|
||||
| **I6** | Mọi input của một use-case được đóng gói thành **snapshot bất biến** (`frozen dataclass`) trước khi rời UI thread | Code review + unit test |
|
||||
|
||||
### 2.2 Chiều phụ thuộc được phép
|
||||
|
||||
| Từ tầng | Được import | Bị cấm |
|
||||
| :--- | :--- | :--- |
|
||||
| `presentation/` | `application/`, `domain/`, PySide6 | — (nên tránh gọi thẳng `infrastructure/`) |
|
||||
| `application/` | `domain/`, interface do `domain/` định nghĩa | `presentation/`, `ui/`, PySide6 |
|
||||
| `domain/` | chỉ stdlib | tất cả các tầng khác, PySide6 |
|
||||
| `infrastructure/` | `domain/`, thư viện ngoài (requests, keyring…) | `presentation/`, `ui/`, PySide6 |
|
||||
|
||||
### 2.3 Cách tầng dưới "nói chuyện ngược" lên UI
|
||||
|
||||
`application/` **không được** giữ tham chiếu tới widget. Việc trao đổi ngược chiều
|
||||
đi qua **callback thuần Python nhận một `AgentEvent` có kiểu**
|
||||
(`domain/agents/agent_event.py`, R04-T02):
|
||||
|
||||
```python
|
||||
# application layer — pure Python, không biết Qt tồn tại
|
||||
service.run_turn(request, on_event=my_callback)
|
||||
|
||||
# presentation layer — chuyển event sang Qt signal ở ranh giới duy nhất này
|
||||
def my_callback(event: AgentEvent) -> None:
|
||||
self.agent_event.emit(event) # Qt signal → cập nhật UI trên main thread
|
||||
```
|
||||
|
||||
Đây là **seam** duy nhất giữa hai thế giới: dưới seam là Python thuần test được
|
||||
offline, trên seam là Qt. Mọi cập nhật UI phải xảy ra qua Qt signal/slot, không
|
||||
bao giờ gọi trực tiếp từ worker thread.
|
||||
|
||||
## 3. Vị trí sở hữu theo team
|
||||
|
||||
| Tầng / thư mục | Team | EPIC |
|
||||
| :--- | :--- | :--- |
|
||||
| `presentation/chat/`, `application/conversations/`, `application/model_routing/`, `domain/agents/`, `domain/models/`, `infrastructure/providers/`, `infrastructure/telemetry/`, `tests/`, `scripts/` | 🔵 Duy | R01, R03, R04, R08, R10 |
|
||||
| `presentation/co4e/`, `monitoring/`, `settings/`, `shell/`, `application/workflows/`, `infrastructure/config/`, `secrets/`, `sandbox/` | 🟣 Nam | R02, R08, R09 |
|
||||
| `presentation/workspace/`, `folder/`, `scheduling/`, `application/workspaces/`, `scheduling/`, `domain/tools/`, `domain/tasks/`, `infrastructure/filesystem/`, `mcp/`, `persistence/` | 🟢 Hoa | R05, R06, R07, R08 |
|
||||
|
||||
## 4. Chiến lược di trú (Strangler Fig, không big-bang)
|
||||
|
||||
Code cũ trong `core/`, `ui/`, `providers/` **không bị xoá ngay**. Ta bọc dần:
|
||||
|
||||
1. **Tạo seam mới** ở tầng đúng (ví dụ `RoutingApplicationService`).
|
||||
2. **Chuyển call site** cũ sang gọi seam mới (`ui/*.py` chỉ còn vài dòng adapter).
|
||||
3. **Giữ module cũ làm implementation detail** phía sau seam (ví dụ
|
||||
`application/model_routing/` vẫn gọi xuống `core/routing/` để dùng lại
|
||||
scorer/selector đã có test).
|
||||
4. Chỉ khi mọi call site đã đi qua seam mới → cân nhắc gỡ code cũ.
|
||||
|
||||
Nhờ vậy `pytest` luôn xanh giữa các bước, và một team có thể merge mà không chờ
|
||||
team khác refactor xong.
|
||||
|
||||
## 5. Consequences (Hệ quả)
|
||||
|
||||
### Tích cực
|
||||
|
||||
* Test một quyết định routing / một vòng đời turn chat **không cần Qt, không cần mạng** → suite unit chạy < 1 giây.
|
||||
* Ba bản sao logic routing hội tụ về một nơi duy nhất → sửa một lần, cả 3 màn hình cùng đúng.
|
||||
* Người mới có thể thêm một provider mà chỉ chạm `infrastructure/providers/` + `domain/models/`.
|
||||
* Vi phạm kiến trúc bị chặn ở CI thay vì phát hiện lúc review.
|
||||
|
||||
### Tiêu cực / chi phí phải chấp nhận
|
||||
|
||||
* Nhiều file nhỏ hơn thay vì vài file lớn → tăng số lần "nhảy file" khi đọc code.
|
||||
* Tồn tại **hai đường** trong giai đoạn di trú (code cũ + seam mới) cho tới khi call site cuối cùng chuyển xong.
|
||||
* Phải viết DTO/snapshot rõ ràng thay vì truyền thẳng `self` của widget — tốn thêm code, đổi lại được thread-safety.
|
||||
|
||||
## 6. Alternatives considered (Phương án đã cân nhắc)
|
||||
|
||||
| Phương án | Lý do loại |
|
||||
| :--- | :--- |
|
||||
| **Giữ nguyên, chỉ tách file cho ngắn** | Giải quyết được I4 (LOC) nhưng không giải quyết được nguyên nhân gốc: logic vẫn dính Qt nên vẫn không test được offline. |
|
||||
| **MVVM/MVP thuần Qt** | Vẫn buộc business logic phụ thuộc vòng đời Qt object; không chạy được trong scheduler headless và trong task nền. |
|
||||
| **Hexagonal đầy đủ (port/adapter cho mọi thứ)** | Đúng về lý thuyết nhưng quá tốn cho 10 ngày và cho một app desktop 1 process; 4 tầng là điểm cân bằng. |
|
||||
| **Big-bang rewrite** | Rủi ro hồi quy quá cao khi 3 team sửa song song và không có bộ test bảo vệ đầy đủ. |
|
||||
|
||||
## 7. Enforcement (Thực thi)
|
||||
|
||||
```bash
|
||||
python scripts/check_imports.py # I1, I2, I3 — quét AST
|
||||
python scripts/check_loc.py # I4 — giới hạn 400 dòng
|
||||
python scripts/run_quality_gate.py # chạy toàn bộ CASAN Gate + pytest
|
||||
```
|
||||
|
||||
CASAN Verification Gate phải PASS trước khi merge bất kỳ PR nào vào `main`.
|
||||
|
||||
## 8. Tài liệu liên quan
|
||||
|
||||
* `docs/refactor/Feature_Architecture_Proposal.md` — thiết kế tổng thể 10 EPIC
|
||||
* `docs/refactor/Refactoring_Checklist.md` — bảng tiến độ theo task
|
||||
* `docs/architecture/dormant-code.md` — danh mục code không còn hoạt động (R01-T05)
|
||||
Reference in New Issue
Block a user