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:
2026-08-21 10:05:50 +09:00
co-authored by Claude Opus 5
parent d633dffae6
commit bbc09f628a
12 changed files with 1379 additions and 10 deletions
@@ -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)