Files
cowork-local/docs/architecture/ADR-001-layered-architecture.md
T
anhtnm1andClaude Opus 5 bbc09f628a 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>
2026-08-21 10:05:50 +09:00

10 KiB
Raw Blame History

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:

┌─────────────────────────────────────────────────────────────┐
│  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):

# 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)

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)