Compare commits
41
Commits
@@ -39,32 +39,3 @@ jobs:
|
|||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: python -m pytest tests -q
|
run: python -m pytest tests -q
|
||||||
|
|
||||||
# --- CASAN Verification Gate -------------------------------------
|
|
||||||
# Ba check này là điều kiện của cổng ngày 30/08. Chạy trên MỌI PR để
|
|
||||||
# biết vi phạm ngay hôm phát sinh, thay vì dồn tới ngày cổng.
|
|
||||||
#
|
|
||||||
# Check 1 do Team Gamma sở hữu và đã có. Check 2 (Team Hoa) và Check 3
|
|
||||||
# (Team Duy) chưa viết — bước dưới bỏ qua nếu script chưa tồn tại, để
|
|
||||||
# thêm cổng không làm đỏ CI của hai team kia.
|
|
||||||
|
|
||||||
- name: "CASAN Check 1 — không có credential lộ (Team Gamma)"
|
|
||||||
run: |
|
|
||||||
python scripts/audit_security.py --self-test
|
|
||||||
python scripts/audit_security.py
|
|
||||||
|
|
||||||
- name: "CASAN Check 2 — file production ≤ 400 dòng (Team Hoa)"
|
|
||||||
run: |
|
|
||||||
if [ -f scripts/check_loc.py ]; then
|
|
||||||
python scripts/check_loc.py
|
|
||||||
else
|
|
||||||
echo "scripts/check_loc.py chưa có — Team Hoa viết, hạn 30/08. Bỏ qua."
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: "CASAN Check 3 — domain/ và application/ không import PySide6 (Team Duy)"
|
|
||||||
run: |
|
|
||||||
if [ -f scripts/check_imports.py ]; then
|
|
||||||
python scripts/check_imports.py
|
|
||||||
else
|
|
||||||
echo "scripts/check_imports.py chưa có — Team Duy viết, hạn 30/08. Bỏ qua."
|
|
||||||
fi
|
|
||||||
|
|||||||
+5
-11
@@ -28,10 +28,7 @@ bower_components/
|
|||||||
.env.preview
|
.env.preview
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
# Neo vào gốc repo: mẫu không neo nuốt MỌI thư mục tên secrets ở mọi độ
|
secrets/
|
||||||
# sâu — nó đã âm thầm chặn infrastructure/secrets/ (mã nguồn, không phải
|
|
||||||
# bí mật) khỏi repo suốt 21-22/08.
|
|
||||||
/secrets/
|
|
||||||
credentials.json
|
credentials.json
|
||||||
.npmrc
|
.npmrc
|
||||||
.yarnrc
|
.yarnrc
|
||||||
@@ -39,11 +36,9 @@ credentials.json
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Build & Distribution
|
# Build & Distribution
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Neo vao goc — mau khong neo se nuot moi thu muc trung ten o moi do sau,
|
dist/
|
||||||
# ke ca ma nguon. Da mac dung loi do voi secrets/ (xem khoi Credentials).
|
build/
|
||||||
/dist/
|
out/
|
||||||
/build/
|
|
||||||
/out/
|
|
||||||
.next/
|
.next/
|
||||||
.nuxt/
|
.nuxt/
|
||||||
.output/
|
.output/
|
||||||
@@ -78,8 +73,7 @@ desktop.ini
|
|||||||
# Logs & Debug
|
# Logs & Debug
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
*.log
|
*.log
|
||||||
# Neo vao goc: infrastructure/logs/ la ma nguon, khong phai log chay may.
|
logs/
|
||||||
/logs/
|
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
"""adapters/ — Adapter riêng cho Qt (clock, thread, timer).
|
|
||||||
|
|
||||||
Kế hoạch gốc đặt tên thư mục này là ``platform/``. Không dùng được: chạy
|
|
||||||
bất kỳ script nào từ thư mục gốc repo (``python tools/...``,
|
|
||||||
``python scripts/...``) thì ``platform/`` **che khuất module ``platform``
|
|
||||||
của thư viện chuẩn**, và ``import keyring`` chết ngay với
|
|
||||||
``AttributeError: module 'platform' has no attribute 'system'``.
|
|
||||||
Repo có 26 script chạy đúng kiểu đó.
|
|
||||||
|
|
||||||
Đổi tên là cách duy nhất chắc chắn — không thể bắt mọi người nhớ "đừng bao
|
|
||||||
giờ chạy python từ thư mục gốc".
|
|
||||||
"""
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""application/ — Điều phối use-case. KHÔNG import PySide6. Gọi domain + interface hạ tầng."""
|
|
||||||
@@ -105,7 +105,7 @@ DEFAULT_CONFIG: Dict[str, Any] = {
|
|||||||
# sandboxes agent-run shell commands) — reading a URL for info is safe and
|
# sandboxes agent-run shell commands) — reading a URL for info is safe and
|
||||||
# useful, so this defaults ON. Toggle in Settings → Security.
|
# useful, so this defaults ON. Toggle in Settings → Security.
|
||||||
"allow_url_fetch": True,
|
"allow_url_fetch": True,
|
||||||
"sandbox_pw": "", # set through COWORK_SANDBOX_PASSWORD
|
"sandbox_pw": "quandh14", # default password to unlock sandbox settings
|
||||||
"rulebase_path": "", # custom RULEBASE.md — attached to every agent execution
|
"rulebase_path": "", # custom RULEBASE.md — attached to every agent execution
|
||||||
},
|
},
|
||||||
# Legacy generic-MCP-server list. MERGED into ext_connectors["other"] as of
|
# Legacy generic-MCP-server list. MERGED into ext_connectors["other"] as of
|
||||||
@@ -173,7 +173,7 @@ DEFAULT_CONFIG: Dict[str, Any] = {
|
|||||||
# Microsoft. Real Outlook/Teams/OneDrive/SharePoint access still requires a
|
# Microsoft. Real Outlook/Teams/OneDrive/SharePoint access still requires a
|
||||||
# proper OAuth sign-in (not implemented yet) using tenant_id/client_id below.
|
# proper OAuth sign-in (not implemented yet) using tenant_id/client_id below.
|
||||||
"ms365": {
|
"ms365": {
|
||||||
"unlock_code": "", # set through COWORK_MS365_UNLOCK_CODE
|
"unlock_code": "quandh14",
|
||||||
"unlocked": False, # runtime-only — never persisted as True, see save()
|
"unlocked": False, # runtime-only — never persisted as True, see save()
|
||||||
# Auto-connect MS365/OneDrive/SharePoint: the built-in MS365 MCP server
|
# Auto-connect MS365/OneDrive/SharePoint: the built-in MS365 MCP server
|
||||||
# launches automatically once the user is signed in (OAuth tenant/client
|
# launches automatically once the user is signed in (OAuth tenant/client
|
||||||
@@ -294,10 +294,6 @@ def _apply_env_overrides(data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
data["active_provider"] = os.environ["COWORK_ACTIVE_PROVIDER"]
|
data["active_provider"] = os.environ["COWORK_ACTIVE_PROVIDER"]
|
||||||
if os.getenv("COWORK_CA_BUNDLE"):
|
if os.getenv("COWORK_CA_BUNDLE"):
|
||||||
data["tls_ca_bundle"] = os.environ["COWORK_CA_BUNDLE"]
|
data["tls_ca_bundle"] = os.environ["COWORK_CA_BUNDLE"]
|
||||||
if os.getenv("COWORK_SANDBOX_PASSWORD"):
|
|
||||||
data["agent_security"]["sandbox_pw"] = os.environ["COWORK_SANDBOX_PASSWORD"]
|
|
||||||
if os.getenv("COWORK_MS365_UNLOCK_CODE"):
|
|
||||||
data["ms365"]["unlock_code"] = os.environ["COWORK_MS365_UNLOCK_CODE"]
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,159 +0,0 @@
|
|||||||
# Mô hình chính sách an toàn — CoworkLocal
|
|
||||||
|
|
||||||
R09-T01 · Team Gamma · viết 22/08/2026
|
|
||||||
|
|
||||||
Tài liệu này mô tả **hệ thống đang chạy**, không phải hệ thống mong muốn. Mọi
|
|
||||||
khẳng định đều chỉ tới file và dòng cụ thể để đối chiếu được.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Câu hỏi quan trọng nhất: đây có phải rào chắn an ninh không
|
|
||||||
|
|
||||||
**Không.** `core/agent_security.py` nói thẳng ngay ở đầu file:
|
|
||||||
|
|
||||||
> *"this is a business productivity tool, not a hard security boundary"*
|
|
||||||
|
|
||||||
Điều đó quyết định mọi thứ còn lại. Cụ thể: **mọi tầng dùng AI đều mở khi
|
|
||||||
hỏng** (`allowed=True` khi không gọi được validator, `core/agent_security.py:150`).
|
|
||||||
Mạng chập chờn hay gateway trục trặc thì agent vẫn chạy, không bị khoá cứng.
|
|
||||||
|
|
||||||
Đánh đổi có chủ đích: chọn *dùng được* thay vì *chặn tuyệt đối*. Ai đọc tài
|
|
||||||
liệu này để đánh giá rủi ro cần hiểu đúng điều đó — đây là lớp giảm tai nạn,
|
|
||||||
không phải lớp chống kẻ tấn công có chủ đích.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Hai loại quy tắc, đừng lẫn
|
|
||||||
|
|
||||||
| | Quy tắc xác định | Quy tắc do AI phán |
|
|
||||||
|---|---|---|
|
|
||||||
| Cách hoạt động | So khớp mẫu cố định | Hỏi một model |
|
|
||||||
| Kết quả | Luôn giống nhau | Có thể khác nhau giữa hai lần |
|
|
||||||
| Khi hỏng | Vẫn chạy | **Mở** (cho qua) |
|
|
||||||
| Tắt được không | Không — luôn bật | Có, từng tầng một |
|
|
||||||
| Ở đâu | Bộ phân loại mẫu chặn + sandbox | 3 tầng validate |
|
|
||||||
|
|
||||||
Câu ở `core/agent_security.py:250` nói rõ ranh giới:
|
|
||||||
|
|
||||||
> *"always-on block-pattern classifier + sandbox still apply regardless"*
|
|
||||||
|
|
||||||
Nghĩa là **tắt hết ba tầng AI thì vẫn còn hai lớp xác định**. Đây là điểm dễ
|
|
||||||
hiểu nhầm nhất khi đọc màn Cài đặt: mấy công tắc ở đó **chỉ tắt phần AI**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Ba tầng AI
|
|
||||||
|
|
||||||
Bật/tắt độc lập trong `agent_security` của `config.json`.
|
|
||||||
|
|
||||||
| Tầng | Kiểm cái gì | Khoá cấu hình | Khi nào chạy |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Prompt | Yêu cầu của chính người dùng | `validate_prompt` | Trước khi agent làm gì |
|
|
||||||
| Attachment | Văn bản trích ra từ tệp đính kèm | `validate_attachments` | Trước khi vào ngữ cảnh model |
|
|
||||||
| Command | `run_command` / `install_package` | `validate_commands` | Trước khi thực thi |
|
|
||||||
|
|
||||||
Cả ba đọc chung một bộ luật: file cục bộ `core/security_rules.py` cộng thêm
|
|
||||||
tài liệu quản trị viên đặt trên OneDrive (nếu có cấu hình). Riêng agent Code
|
|
||||||
dùng bộ luật khác — `RULEforCode.md` thay vì `RULEBASE.md`.
|
|
||||||
|
|
||||||
Công tắc tổng `agent_security.enabled` tắt cả ba.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Chuyện gì xảy ra khi bị chặn
|
|
||||||
|
|
||||||
Theo đúng thứ tự trong `core/agent_security.py:266-273`:
|
|
||||||
|
|
||||||
1. Hiện thông báo trong khung chat — người dùng thấy ngay, kèm lý do
|
|
||||||
2. Ghi `audit_log.record("security_block", …)` — vào nhật ký kiểm toán
|
|
||||||
3. `notify_admin(...)` — gửi email quản trị viên
|
|
||||||
4. Ném `SecurityBlocked` — dừng lượt chạy
|
|
||||||
|
|
||||||
Ba bước đầu **không được phép ném lỗi**. `audit_log.record()` có ghi rõ trong
|
|
||||||
docstring: *"never raises — audit logging must never break a chat turn"*. Ghi
|
|
||||||
nhật ký hỏng không được kéo theo cả phiên làm việc.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Hỏi người dùng: trạng thái thứ ba
|
|
||||||
|
|
||||||
Ngoài cho/chặn còn một trạng thái nữa mà hệ thống hiện tại **có nhưng chưa gọi
|
|
||||||
tên**: hỏi người dùng.
|
|
||||||
|
|
||||||
`ui/chat_panel.py:1312` kiểm `ctx.project_confirm_commands()` rồi bật
|
|
||||||
`PermissionDialog`. Đó là một quyết định chính sách thật, nhưng nằm rải ở tầng
|
|
||||||
giao diện chứ không phải một kết quả chính thức.
|
|
||||||
|
|
||||||
`domain/security/tool_policy.py` (đề xuất, chờ Team Hoa xác nhận) gộp lại
|
|
||||||
thành ba trạng thái:
|
|
||||||
|
|
||||||
| | Nghĩa |
|
|
||||||
|---|---|
|
|
||||||
| `ALLOW` | Chạy |
|
|
||||||
| `DENY` | Không chạy, có lý do |
|
|
||||||
| `ASK` | Hỏi người dùng đã |
|
|
||||||
|
|
||||||
**`ASK` không phải là `allowed`.** Coi ASK như ALLOW nghĩa là tool chạy trước
|
|
||||||
khi có ai đồng ý — bẫy dễ mắc nhất, đã có test riêng chặn.
|
|
||||||
|
|
||||||
Cổng chính sách **không tự bật hộp thoại**. Nó chỉ trả lời; hỏi ai và hỏi thế
|
|
||||||
nào là việc của tầng giao diện. Nhờ vậy Co4E chạy nền mới dùng chung cổng được
|
|
||||||
với Cowork chạy tương tác — Co4E không hỏi được thì đổi `ASK` thành `DENY`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Bí mật
|
|
||||||
|
|
||||||
Từ 21/08 (R02-T05), API key **không còn nằm trong `config.json`**:
|
|
||||||
|
|
||||||
* Lưu trong kho của hệ điều hành qua `KeyringAdapter` — Windows Credential
|
|
||||||
Manager, macOS Keychain, Linux Secret Service
|
|
||||||
* `provider_conf()` đọc từ kho rồi ghép vào dict trả về, nên chỗ gọi không
|
|
||||||
đổi (đường A, `GammaTeam_decisions.md`)
|
|
||||||
* File cũ tự chuyển ở lần mở đầu tiên, có sao lưu trước khi chuyển
|
|
||||||
|
|
||||||
Máy không có kho bí mật (Linux headless, CI) thì **không chuyển** — thà để
|
|
||||||
khoá trong file còn hơn xoá đi rồi người dùng mất khoá.
|
|
||||||
|
|
||||||
Kiểm bằng `python scripts/audit_security.py`, chạy tự động trong CI.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Sandbox
|
|
||||||
|
|
||||||
`core/sandbox_manager.py` chạy lệnh trong môi trường hạn chế. Luôn bật, không
|
|
||||||
tắt được, không phụ thuộc công tắc AI nào.
|
|
||||||
|
|
||||||
Năng lực khác nhau theo hệ điều hành — ma trận đầy đủ sẽ nằm ở
|
|
||||||
`infrastructure/sandbox/sandbox_capabilities.py` (R09-T06, Hiệp phụ trách).
|
|
||||||
Chỗ này cập nhật khi task đó xong.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Những chỗ đã biết là yếu
|
|
||||||
|
|
||||||
Ghi ra để người sau khỏi tưởng đã kín:
|
|
||||||
|
|
||||||
1. **Mở khi hỏng.** Gateway chết là ba tầng AI cho qua hết. Có chủ đích, nhưng
|
|
||||||
nghĩa là không chống được kẻ tấn công biết cách làm validator ngừng trả lời.
|
|
||||||
2. **Bí mật vẫn đi trong bộ nhớ.** Đường A ghép khoá vào dict `provider_conf()`
|
|
||||||
trả về, nên khoá vẫn có thể lọt vào log gỡ lỗi hay ảnh chụp màn hình. Đường
|
|
||||||
B (bỏ hẳn khỏi dict) đã ghi vào nợ kỹ thuật.
|
|
||||||
3. **Bộ luật lấy từ OneDrive không ký số.** Ai sửa được tài liệu đó là sửa được
|
|
||||||
luật.
|
|
||||||
4. **`ASK` chưa được nối vào Co4E.** Co4E chạy nền, chưa có đường hỏi người
|
|
||||||
dùng — hiện phải chọn giữa cho qua hết hoặc chặn hết.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Đối chiếu nhanh
|
|
||||||
|
|
||||||
| Nội dung | Nguồn |
|
|
||||||
|---|---|
|
|
||||||
| Ba tầng AI, mở khi hỏng | `core/agent_security.py:1-25` |
|
|
||||||
| Phân loại mẫu + sandbox luôn bật | `core/agent_security.py:250` |
|
|
||||||
| Thứ tự khi bị chặn | `core/agent_security.py:266-273` |
|
|
||||||
| Nhật ký không được ném lỗi | `core/audit_log.py:46` |
|
|
||||||
| Hỏi người dùng | `ui/chat_panel.py:1312` |
|
|
||||||
| Ba trạng thái chính sách | `domain/security/tool_policy.py` |
|
|
||||||
| Bí mật | `infrastructure/secrets/keyring_adapter.py` |
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
# Project Context MCP — hướng dẫn làm song song
|
|
||||||
|
|
||||||
Mục tiêu: hoàn thiện ba tool trên **cùng một server** `project_context`. Không tạo server, registry,
|
|
||||||
policy hay error envelope mới. Shared skeleton đã khóa sẵn thứ tự an toàn:
|
|
||||||
|
|
||||||
```text
|
|
||||||
validate input → policy ALLOW → resolve provider → gọi upstream → validate output
|
|
||||||
```
|
|
||||||
|
|
||||||
## Chia việc
|
|
||||||
|
|
||||||
| Người | Tool | Chỉ sửa | Branch đề xuất |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Member A | `get_project_issue_context` | `tools/issue_context.py`, `providers/issue.py`, test riêng | `feat/mcp-issue-context` |
|
|
||||||
| Member B | `search_project_knowledge` | `tools/knowledge_search.py`, `providers/knowledge.py`, test riêng | `feat/mcp-knowledge-search` |
|
|
||||||
| Member C | `get_project_change_context` | `tools/change_context.py`, `providers/change.py`, test riêng | `feat/mcp-change-context` |
|
|
||||||
|
|
||||||
Trước khi gửi task, thay `Member A/B/C` bằng username thật trên ba issue. Mỗi người **không sửa**
|
|
||||||
`foundation.py`, `registry.py`, `runtime.py`, `server.py` hoặc file của người khác. Nếu shared contract
|
|
||||||
cần đổi, mở một PR nhỏ riêng và để cả ba người rebase sau khi PR đó merge.
|
|
||||||
|
|
||||||
## Bắt đầu trong 5 phút
|
|
||||||
|
|
||||||
1. Chạy `python --version` và xác nhận Python 3.11+ như baseline trong `requirements.txt`.
|
|
||||||
2. Tạo branch từ commit template chứa tài liệu này sau khi PR template merge.
|
|
||||||
3. Đọc input/output model trong module tool được giao; không thêm field riêng của Gitea/Jira/Redmine.
|
|
||||||
4. Implement provider read-only trong module `providers/<tool>.py`; credential chỉ lấy sau policy ALLOW.
|
|
||||||
5. Thêm test happy, invalid, not-found, timeout, DENIED với `resolver.calls == 0`, output sai schema,
|
|
||||||
truncation/cursor và source mở được có `revision`.
|
|
||||||
6. Chạy:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m pytest tests/test_project_context_mcp_template.py tests/test_project_context_<tool>.py -q
|
|
||||||
```
|
|
||||||
|
|
||||||
Lệnh trên chạy trực tiếp từ root repo `cowork_local`; `tests/conftest.py` đã thiết lập import path.
|
|
||||||
|
|
||||||
## Definition of Done của từng người
|
|
||||||
|
|
||||||
- Tool trả đúng schema, có `project_id` và source gồm `system`, `url`, `revision`, `retrieved_at`.
|
|
||||||
- Provider-neutral: đổi Gitea sang GitHub/Jira/Redmine không đổi schema hay tool name.
|
|
||||||
- Sai project bị `DENIED` trước khi resolve credential và trước mọi upstream call.
|
|
||||||
- Không log/return token; lỗi ngoài dự kiến không lộ exception; read không có side effect.
|
|
||||||
- Output lớn có `truncated`, `returned`, `remaining`, `next_cursor`; không cắt im lặng.
|
|
||||||
- Test riêng pass, test shared pass, PR chỉ chạm đúng vùng sở hữu trong bảng trên.
|
|
||||||
|
|
||||||
## Chạy server sau khi provider đã cấu hình
|
|
||||||
|
|
||||||
```bash
|
|
||||||
COWORK_MCP_ACTOR_ID=<actor> \
|
|
||||||
COWORK_MCP_ORG_UNIT=<org> \
|
|
||||||
COWORK_MCP_CUSTOMER=<customer> \
|
|
||||||
COWORK_MCP_PROJECT=<project> \
|
|
||||||
python -m cowork_local.mcp_servers.project_context_server
|
|
||||||
```
|
|
||||||
|
|
||||||
Không commit giá trị môi trường hoặc credential. Cowork kết nối bằng stdio với command Python và
|
|
||||||
args `-m cowork_local.mcp_servers.project_context_server`.
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
# HỆ THỐNG PROMPT KỸ SƯ TRƯỞNG PYTHON & KIẾN TRÚC SƯ TÁI CẤU TRÚC (TEAM DUY)
|
|
||||||
|
|
||||||
Bạn là một **Kỹ sư phần mềm Python Cao cấp (Senior / Staff Python Engineer) & Chuyên gia Kiến trúc Ứng dụng Desktop Local-First**, giữ vai trò Tech Lead thực thi kỹ thuật cho **🔵 Team Duy** trong dự án **Cowork Local (Cowork-Local BamBOO)**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 NHIỆM VỤ CỐT LÕI & PHẠM VI SỞ HỮU CỦA TEAM DUY
|
|
||||||
|
|
||||||
Nhiệm vụ của bạn là trực tiếp chỉ đạo và thực thi kế hoạch tái cấu trúc mã nguồn theo đúng tài liệu thiết kế kiến trúc `Feature_Architecture_Proposal.md` và cập nhật tiến độ vào file `Refactoring_Checklist.md`.
|
|
||||||
|
|
||||||
### 📦 Các Phân Hệ Thư Mục Do Team Duy Quản Lý:
|
|
||||||
- **Tầng Giao Diện (Presentation)**: `presentation/chat/` (Bóc tách từ `ui/chat_panel.py` và `ui/help_agent_widget.py`).
|
|
||||||
- **Tầng Nghiệp Vụ (Application)**: `application/conversations/`, `application/model_routing/`.
|
|
||||||
- **Tầng Miền Dữ Liệu (Domain)**: `domain/agents/`, `domain/models/`.
|
|
||||||
- **Tầng Hạ Tầng (Infrastructure)**: `infrastructure/providers/`, `infrastructure/telemetry/`.
|
|
||||||
- **Kiểm Thử & Quản Trị Hệ Thống (Testing & Governance)**: `tests/` (Unit, Contract, Integration, E2E Smoke), `scripts/` (Bộ công cụ kiểm duyệt CASAN Gate), `docs/governance/`.
|
|
||||||
- **Các EPIC Trọng Tâm**: **R01, R03, R04, R08 (Phân hệ Chat UI: R08-T01 ➔ R08-T06), R10 (Chủ trì chính Testing Pyramid & Phát hành)**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚖️ CÁC QUY TẮC KIẾN TRÚC & NGUYÊN TẮC BẤT BIẾN
|
|
||||||
|
|
||||||
1. **Kiến Trúc 4 Tầng Sạch (4-Tier Clean Architecture)**:
|
|
||||||
```text
|
|
||||||
presentation/chat/ (PySide6 UI Widgets & Qt Signals)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
application/conversations/ & application/model_routing/ (Pure Python Orchestration)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
domain/agents/ & domain/models/ (Pure Python Entities, Events, Descriptors)
|
|
||||||
▲
|
|
||||||
│
|
|
||||||
infrastructure/providers/ & infrastructure/telemetry/ (Adapters, Keyring, Network, Disk)
|
|
||||||
```
|
|
||||||
- **QUY TẮC CỐT TỬ**: Tầng `domain/` và `application/` phải là **100% Pure Python**. TUYỆT ĐỐI KHÔNG import `PySide6`, `PyQt*` hay bất kỳ UI widget nào trong 2 tầng này.
|
|
||||||
|
|
||||||
2. **Tuân Thủ Tuyệt Đối Cổng Kiểm Duyệt CASAN (CASAN Verification Gate)**:
|
|
||||||
- **C (Clean Arch)**: Chạy `python scripts/check_imports.py` phải đạt `0 Qt imports in domain and application`.
|
|
||||||
- **A (Atomic & Secret)**: 0 plaintext API Key/Token trong file cấu hình; 100% keys quản lý qua `SecretStore` (Keyring); ghi tệp an toàn qua `AtomicJsonFile`.
|
|
||||||
- **S (Single Responsibility)**: **GIỚI HẠN CỨNG: Không có file production nào vượt quá 400 dòng code (LOC)**.
|
|
||||||
- **A (Automated Tests)**: Bộ test chạy offline hoàn toàn, tốc độ siêu nhanh (< 1 giây cho unit tests), không phụ thuộc mạng hay Qt loop.
|
|
||||||
- **N (No Regression)**: 100% test pass khi chạy lệnh `pytest tests/`.
|
|
||||||
|
|
||||||
3. **Bắt Buộc Comment Code Bằng Tiếng Anh (Mandatory English Comments)**:
|
|
||||||
- Ở **mỗi dòng hoặc khối code được chỉnh sửa/tạo mới**, bạn **BẮT BUỘC phải viết comment bằng Tiếng Anh** giải thích rõ logic xử lý, cách xử lý ngoại lệ và lý do kỹ thuật/kiến trúc (rationale).
|
|
||||||
- *Ví dụ mẫu*:
|
|
||||||
```python
|
|
||||||
# Extract an immutable execution snapshot to decouple turn lifecycle from PySide6 UI state
|
|
||||||
request = ConversationExecutionRequest.from_ui_state(session_id=session_id, prompt=prompt)
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Ghi Nhận Mốc Thời Gian Thực Hiện (Start/End Timestamps)**:
|
|
||||||
- Trước khi bắt đầu code task nào, phải ghi nhận: `Start: YYYY-MM-DD HH:mm`.
|
|
||||||
- Sau khi code xong và unit test pass 100%, phải ghi nhận: `End: YYYY-MM-DD HH:mm` và đánh dấu `[x]` vào `Refactoring_Checklist.md`.
|
|
||||||
|
|
||||||
5. **An Toàn Đa Luồng (Thread-Safety) & Snapshot Bất Biến**:
|
|
||||||
- Mọi tiến trình gọi AI và thực thi Tool phải chạy bất đồng bộ trong background thread, không bao giờ làm đơ Main Thread của PySide6.
|
|
||||||
- Giao diện UI chỉ được cập nhật thông qua Qt Signals/Slots lắng nghe luồng sự kiện `AgentEvent`.
|
|
||||||
- Luôn đóng gói trạng thái đầu vào thành `ConversationExecutionRequest` bất biến trước khi gửi vào Application Service.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛠️ LỘ TRÌNH THỰC THI TỪNG BƯỚC (TEAM DUY)
|
|
||||||
|
|
||||||
Khi thực hiện nhiệm vụ, tuân thủ đúng thứ tự 5 giai đoạn sau:
|
|
||||||
|
|
||||||
### 📍 Giai Đoạn 1: Thiết Lập Nền Móng Kiến Trúc & Test Bảo Vệ (EPIC R01)
|
|
||||||
1. `R01-T01`: Soạn thảo `docs/architecture/ADR-001-layered-architecture.md` định nghĩa ranh giới 4 tầng.
|
|
||||||
2. `R01-T02`: Xây dựng `tests/fakes/fake_provider.py` & `fake_tool_executor.py` phục vụ test offline.
|
|
||||||
3. `R01-T03`: Viết script phân tích cú pháp AST `scripts/check_imports.py` chặn import Qt trái phép.
|
|
||||||
4. `R01-T04`: Viết Characterization Tests tại `tests/characterization/test_run_cowork.py` chụp snapshot hàm `core/chat_agent.py::run_cowork`.
|
|
||||||
5. `R01-T05`: Phân loại và cô lập mã nguồn cũ trong `docs/architecture/dormant-code.md`.
|
|
||||||
|
|
||||||
### 📍 Giai Đoạn 2: Chuẩn Hóa Provider & Hợp Nhất Bộ Định Tuyến (EPIC R03)
|
|
||||||
1. `R03-T01`: Xây dựng bộ Contract Tests chuẩn hóa cho các Provider trong `tests/contracts/test_providers.py`.
|
|
||||||
2. `R03-T02`: Tạo `domain/models/provider_descriptor.py` và `infrastructure/providers/provider_registry.py`.
|
|
||||||
3. `R03-T03`: Xây dựng `application/model_routing/routing_application_service.py` (Pure Python) hỗ trợ 4 chế độ: Off, Auto, Manual, Fallback.
|
|
||||||
4. `R03-T04` & `R03-T05`: Hợp nhất logic routing bị phân tán tại `ui/chat_panel.py#L638`, `ui/co4e_tab.py`, `ui/folder_tab.py` về gọi chung `RoutingApplicationService`.
|
|
||||||
5. `R03-T06`: Tách bộ ghi nhận token usage thành `infrastructure/telemetry/usage_sink.py`.
|
|
||||||
|
|
||||||
### 📍 Giai Đoạn 3: Động Cơ Hội Thoại & Vòng Đời Turn Chat (EPIC R04)
|
|
||||||
1. `R04-T01`: Định nghĩa frozen dataclass snapshot `domain/agents/conversation_execution_request.py`.
|
|
||||||
2. `R04-T02`: Định nghĩa các sự kiện có kiểu dữ liệu mạnh trong `domain/agents/agent_event.py` (`TextChunkEvent`, `ToolCallStartedEvent`, `ToolCallFinishedEvent`, `TurnCompletedEvent`, `ErrorEvent`).
|
|
||||||
3. `R04-T03`: Cài đặt `application/conversations/conversation_application_service.py` điều phối toàn bộ vòng đời turn.
|
|
||||||
4. `R04-T04` & `R04-T05`: Chuyển đổi `ui/cowork_tab.py` và `core/task_executors.py` sang dùng chung `ConversationApplicationService`.
|
|
||||||
|
|
||||||
### 📍 Giai Đoạn 4: Phân Rã God-Widget Màn Hình Chat (EPIC R08 - Phân Hệ Chat)
|
|
||||||
Bóc tách file khổng lồ `ui/chat_panel.py` (>1.800 dòng) thành 6 widget con chuyên biệt (< 400 dòng/file):
|
|
||||||
1. `R08-T01`: `presentation/chat/chat_history_widget.py` (Render bong bóng chat, markdown stream, tool cards).
|
|
||||||
2. `R08-T02`: `presentation/chat/composer_widget.py` (Ô nhập liệu text auto-resize, phím tắt Ctrl+Enter).
|
|
||||||
3. `R08-T03`: `presentation/chat/attachment_picker.py` (Bộ chọn file, folder, ảnh đính kèm).
|
|
||||||
4. `R08-T04`: `presentation/chat/audio_recorder_widget.py` (Ghi âm giọng nói & nhận diện văn bản).
|
|
||||||
5. `R08-T05`: `presentation/chat/chat_output_panel.py` (Panel hiển thị và theo dõi file output trong turn).
|
|
||||||
6. `R08-T06`: `presentation/chat/chat_panel.py` (Shell container điều phối các widget con và `Floating HelpAgent`).
|
|
||||||
|
|
||||||
### 📍 Giai Đoạn 5: Tháp Kiểm Thử, Cổng CI Quality Gate & Smoke Test (EPIC R10 - Chủ Trì Chính)
|
|
||||||
1. `R10-T01`: Cấu trúc lại thư mục test phân tầng (`tests/unit/`, `tests/contracts/`, `tests/integration/`, `tests/fakes/`).
|
|
||||||
2. `R10-T02`: Xây dựng bộ script kiểm thử tự động (`scripts/check_imports.py`, `scripts/check_loc.py`, `scripts/audit_security.py`, `scripts/run_quality_gate.py`).
|
|
||||||
3. `R10-T03`: Cập nhật tài liệu `README.md` và `START_CONTRIBUTING.md` với sơ đồ 4 tầng và hướng dẫn cấu hình Git hook.
|
|
||||||
4. `R10-T04`: Soạn thảo `docs/governance/contributor-recipes.md` (3 công thức: Thêm Provider mới, Thêm Tool/MCP mới, Thêm Màn hình UI mới).
|
|
||||||
5. `R10-T05`: Xây dựng bộ kiểm thử khói phát hành `tests/e2e/test_smoke.py` chạy qua headless Qt kiểm tra tự động 5 luồng nghiệp vụ cốt lõi.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 CHECKLIST TIÊU CHUẨN HOÀN THÀNH (DEFINITION OF DONE - DOD)
|
|
||||||
|
|
||||||
Trước khi đóng bất kỳ task nào hoặc gửi PR, bạn phải tự kiểm tra 7 tiêu chí sau:
|
|
||||||
- [ ] 1. **Kích thước file (LOC)**: Mọi file sửa đổi hoặc tạo mới đều **< 400 dòng code**.
|
|
||||||
- [ ] 2. **Kiến trúc sạch (Clean Arch)**: 0 import `PySide6`/Qt trong `domain/` và `application/` (`python scripts/check_imports.py` pass 100%).
|
|
||||||
- [ ] 3. **Comment tiếng Anh**: 100% các khối code sửa đổi/tạo mới đều có comment tiếng Anh giải thích logic và lý do kỹ thuật.
|
|
||||||
- [ ] 4. **Kiểm thử tự động**: Có unit test / contract test tương ứng với tỷ lệ pass 100% trong thời gian < 1 giây.
|
|
||||||
- [ ] 5. **Không hồi quy lỗi (No Regression)**: Toàn bộ suite test chạy xanh với lệnh `pytest tests/`.
|
|
||||||
- [ ] 6. **Cập nhật tiến độ**: Đã ghi nhận đầy đủ thời gian `Start` và `End` vào file `Refactoring_Checklist.md`.
|
|
||||||
- [ ] 7. **Cổng CASAN**: Lệnh `python scripts/run_quality_gate.py` chạy thành công không có bất kỳ cảnh báo vi phạm nào.
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,768 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="vi">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Phân Việc Refactor Team Gamma</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--ground: #FAF9FC; --surface: #FFFFFF; --surface-2: #F3F0F8;
|
|
||||||
--ink: #191325; --muted: #665E7C; --line: #E4DEEE;
|
|
||||||
--accent: #6D3A9E;
|
|
||||||
--lead: #6D3A9E; --m1: #14707F; --m2: #A65418;
|
|
||||||
--warn: #A81F1A; --ok: #1B6B40;
|
|
||||||
--lead-wash: #F1E9F9; --m1-wash: #E2F1F3; --m2-wash: #F8EDE2;
|
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root:not([data-theme="light"]) {
|
|
||||||
--ground: #121020; --surface: #1B1830; --surface-2: #241F3D;
|
|
||||||
--ink: #EFEBF7; --muted: #A69EBD; --line: #2F2848;
|
|
||||||
--accent: #C08CF0;
|
|
||||||
--lead: #C08CF0; --m1: #56C6D8; --m2: #E29A56;
|
|
||||||
--warn: #F08A84; --ok: #6FD69C;
|
|
||||||
--lead-wash: #2A1F42; --m1-wash: #133038; --m2-wash: #38270F;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
:root[data-theme="dark"] {
|
|
||||||
--ground: #121020; --surface: #1B1830; --surface-2: #241F3D;
|
|
||||||
--ink: #EFEBF7; --muted: #A69EBD; --line: #2F2848;
|
|
||||||
--accent: #C08CF0;
|
|
||||||
--lead: #C08CF0; --m1: #56C6D8; --m2: #E29A56;
|
|
||||||
--warn: #F08A84; --ok: #6FD69C;
|
|
||||||
--lead-wash: #2A1F42; --m1-wash: #133038; --m2-wash: #38270F;
|
|
||||||
}
|
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
margin: 0; background: var(--ground); color: var(--ink);
|
|
||||||
font-family: "Segoe UI", -apple-system, system-ui, "Helvetica Neue", sans-serif;
|
|
||||||
font-size: 15px; line-height: 1.62; -webkit-font-smoothing: antialiased;
|
|
||||||
}
|
|
||||||
.wrap { max-width: 1120px; margin: 0 auto; padding: 0 28px 96px; }
|
|
||||||
code, .mono, td.day, th.day, .num {
|
|
||||||
font-family: Consolas, "Cascadia Mono", "SF Mono", ui-monospace, monospace;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
header.top { border-bottom: 2px solid var(--ink); padding: 56px 0 22px; margin-bottom: 34px; }
|
|
||||||
.eyebrow { font-size: 12px; letter-spacing: .16em; text-transform: uppercase;
|
|
||||||
color: var(--accent); font-weight: 700; margin: 0 0 14px; }
|
|
||||||
h1 { font-size: clamp(30px, 4.4vw, 44px); line-height: 1.1; margin: 0 0 16px;
|
|
||||||
font-weight: 700; letter-spacing: -.02em; text-wrap: balance; }
|
|
||||||
.lede { font-size: 17px; color: var(--muted); margin: 0; max-width: 64ch; }
|
|
||||||
.alias { margin: 18px 0 0; padding: 12px 16px; max-width: 74ch;
|
|
||||||
background: var(--surface-2); border-left: 3px solid var(--accent);
|
|
||||||
border-radius: 3px; font-size: 14px; color: var(--muted); }
|
|
||||||
.alias b { color: var(--ink); }
|
|
||||||
.facts { display: flex; flex-wrap: wrap; gap: 28px; margin-top: 26px;
|
|
||||||
padding-top: 20px; border-top: 1px solid var(--line); }
|
|
||||||
.fact .k { font-size: 11px; letter-spacing: .13em; text-transform: uppercase;
|
|
||||||
color: var(--muted); display: block; margin-bottom: 3px; }
|
|
||||||
.fact .v { font-size: 15px; font-weight: 600; }
|
|
||||||
|
|
||||||
h2 { font-size: 23px; margin: 52px 0 6px; letter-spacing: -.01em; font-weight: 700; text-wrap: balance; }
|
|
||||||
h2 + .sub { color: var(--muted); margin: 0 0 22px; max-width: 70ch; }
|
|
||||||
h3 { font-size: 17px; margin: 30px 0 10px; font-weight: 700; }
|
|
||||||
|
|
||||||
/* gate = việc phải xong trước khi chia nhánh */
|
|
||||||
.gatebox { background: var(--surface); border: 1px solid var(--line);
|
|
||||||
border-left: 4px solid var(--warn); border-radius: 3px; padding: 4px 26px 22px; }
|
|
||||||
.gatebox h2 { margin-top: 22px; }
|
|
||||||
|
|
||||||
.steps { list-style: none; counter-reset: s; padding: 0; margin: 0; }
|
|
||||||
.steps > li { counter-increment: s; position: relative; padding: 14px 0 14px 46px;
|
|
||||||
border-bottom: 1px solid var(--line); }
|
|
||||||
.steps > li:last-child { border-bottom: none; }
|
|
||||||
.steps > li::before {
|
|
||||||
content: counter(s); position: absolute; left: 0; top: 14px;
|
|
||||||
width: 26px; height: 26px; border-radius: 50%; background: var(--accent);
|
|
||||||
color: #fff; font-size: 13px; font-weight: 700; display: flex;
|
|
||||||
align-items: center; justify-content: center;
|
|
||||||
font-family: Consolas, ui-monospace, monospace;
|
|
||||||
}
|
|
||||||
.steps b { display: block; margin-bottom: 2px; }
|
|
||||||
.steps small { color: var(--muted); font-size: 13.5px; display: block; }
|
|
||||||
.est { float: right; font-size: 12px; color: var(--muted); font-weight: 600;
|
|
||||||
font-family: Consolas, ui-monospace, monospace; }
|
|
||||||
|
|
||||||
.cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
|
|
||||||
@media (max-width: 940px) { .cards { grid-template-columns: 1fr; } }
|
|
||||||
.card { background: var(--surface); border: 1px solid var(--line);
|
|
||||||
border-top: 3px solid var(--c); border-radius: 3px; padding: 20px;
|
|
||||||
display: flex; flex-direction: column; }
|
|
||||||
.card.lead { --c: var(--lead); --w: var(--lead-wash); }
|
|
||||||
.card.one { --c: var(--m1); --w: var(--m1-wash); }
|
|
||||||
.card.two { --c: var(--m2); --w: var(--m2-wash); }
|
|
||||||
.card .tag { font-size: 11px; letter-spacing: .13em; text-transform: uppercase;
|
|
||||||
font-weight: 700; color: var(--c); margin-bottom: 6px; }
|
|
||||||
.card h3 { margin: 0 0 4px; font-size: 18px; }
|
|
||||||
.card .who { font-size: 13px; color: var(--muted); margin-bottom: 14px; }
|
|
||||||
.card .branch { font-size: 12.5px; background: var(--w); color: var(--c);
|
|
||||||
padding: 5px 9px; border-radius: 3px; display: inline-block;
|
|
||||||
margin-bottom: 16px; word-break: break-all; font-weight: 600; }
|
|
||||||
.card h4 { font-size: 11px; letter-spacing: .12em; text-transform: uppercase;
|
|
||||||
color: var(--muted); margin: 16px 0 7px; font-weight: 700; }
|
|
||||||
.card ul { margin: 0; padding-left: 17px; font-size: 14px; }
|
|
||||||
.card li { margin-bottom: 6px; }
|
|
||||||
.tid { font-size: 12px; font-weight: 700; color: var(--c);
|
|
||||||
font-family: Consolas, ui-monospace, monospace; }
|
|
||||||
.paths { list-style: none; padding: 0; margin: 0; font-size: 12.5px; }
|
|
||||||
.paths li { padding: 3px 0; border-bottom: 1px dotted var(--line);
|
|
||||||
font-family: Consolas, ui-monospace, monospace; color: var(--muted); word-break: break-all; }
|
|
||||||
.paths li:last-child { border-bottom: none; }
|
|
||||||
.weight { margin-top: auto; padding-top: 16px; font-size: 12.5px; color: var(--muted); }
|
|
||||||
.weight b { color: var(--ink); font-size: 15px; }
|
|
||||||
|
|
||||||
.scroll { overflow-x: auto; border: 1px solid var(--line); border-radius: 3px; }
|
|
||||||
table { border-collapse: collapse; width: 100%; font-size: 13.5px; background: var(--surface); }
|
|
||||||
th, td { text-align: left; padding: 11px 14px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
|
||||||
thead th { background: var(--surface-2); font-size: 11px; letter-spacing: .1em;
|
|
||||||
text-transform: uppercase; color: var(--muted); font-weight: 700; white-space: nowrap; }
|
|
||||||
tbody tr:last-child td { border-bottom: none; }
|
|
||||||
td.day, th.day { white-space: nowrap; font-weight: 700; font-size: 13px; }
|
|
||||||
td.cl { border-left: 3px solid var(--lead); }
|
|
||||||
td.c1 { border-left: 3px solid var(--m1); }
|
|
||||||
td.c2 { border-left: 3px solid var(--m2); }
|
|
||||||
tr.mark td { background: var(--surface-2); font-weight: 600; }
|
|
||||||
td small { color: var(--muted); display: block; font-size: 12.5px; }
|
|
||||||
.pill { display: inline-block; font-size: 11px; font-weight: 700; padding: 2px 7px;
|
|
||||||
border-radius: 2px; letter-spacing: .04em; white-space: nowrap; }
|
|
||||||
.pill.cp { background: var(--m1-wash); color: var(--m1); }
|
|
||||||
.pill.gate { background: var(--m2-wash); color: var(--m2); }
|
|
||||||
.pill.ship { background: var(--lead-wash); color: var(--lead); }
|
|
||||||
|
|
||||||
.rules { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
|
|
||||||
@media (max-width: 760px) { .rules { grid-template-columns: 1fr; } }
|
|
||||||
.rule { background: var(--surface); border: 1px solid var(--line);
|
|
||||||
border-radius: 3px; padding: 18px 20px; border-left: 3px solid var(--c, var(--line)); }
|
|
||||||
.rule.hard { --c: var(--warn); }
|
|
||||||
.rule.soft { --c: var(--ok); }
|
|
||||||
.rule h3 { margin: 0 0 8px; font-size: 15px; }
|
|
||||||
.rule p { margin: 0; font-size: 14px; color: var(--muted); }
|
|
||||||
.rule code { color: var(--ink); }
|
|
||||||
|
|
||||||
|
|
||||||
/* tóm tắt: đọc 30 giây là nắm được, trước khi vào chi tiết */
|
|
||||||
.tldr {
|
|
||||||
display: grid; grid-template-columns: 1.35fr 1fr; gap: 0;
|
|
||||||
border: 1px solid var(--line); border-radius: 3px; overflow: hidden;
|
|
||||||
margin-bottom: 8px; background: var(--surface);
|
|
||||||
}
|
|
||||||
@media (max-width: 820px) { .tldr { grid-template-columns: 1fr; } }
|
|
||||||
.tldr > div { padding: 20px 24px; }
|
|
||||||
.tldr .right { background: var(--surface-2); border-left: 1px solid var(--line); }
|
|
||||||
@media (max-width: 820px) { .tldr .right { border-left: none; border-top: 1px solid var(--line); } }
|
|
||||||
.tldr .cap {
|
|
||||||
font-size: 11px; letter-spacing: .14em; text-transform: uppercase;
|
|
||||||
color: var(--muted); font-weight: 700; margin: 0 0 12px;
|
|
||||||
}
|
|
||||||
.flow { list-style: none; padding: 0; margin: 0; font-size: 14px; }
|
|
||||||
.flow li { padding: 7px 0; border-bottom: 1px dotted var(--line); display: flex; gap: 10px; }
|
|
||||||
.flow li:last-child { border-bottom: none; }
|
|
||||||
.flow .b {
|
|
||||||
flex: 0 0 auto; font-size: 11.5px; font-weight: 700; padding: 1px 7px; border-radius: 2px;
|
|
||||||
background: var(--w2); color: var(--c2); height: fit-content; margin-top: 2px;
|
|
||||||
font-family: Consolas, ui-monospace, monospace;
|
|
||||||
}
|
|
||||||
.flow li.f0 { --c2: var(--warn); --w2: var(--surface-2); }
|
|
||||||
.flow li.f1 { --c2: var(--lead); --w2: var(--lead-wash); }
|
|
||||||
.flow li.f2 { --c2: var(--m1); --w2: var(--m1-wash); }
|
|
||||||
.flow li.f3 { --c2: var(--m2); --w2: var(--m2-wash); }
|
|
||||||
.flow .t { flex: 1; }
|
|
||||||
.flow .t b { display: block; }
|
|
||||||
.flow .t small { color: var(--muted); font-size: 12.5px; }
|
|
||||||
.must { margin: 0; padding-left: 18px; font-size: 14px; }
|
|
||||||
.must li { margin-bottom: 8px; }
|
|
||||||
.must li:last-child { margin-bottom: 0; }
|
|
||||||
.must b { color: var(--ink); }
|
|
||||||
|
|
||||||
/* input / output từng người */
|
|
||||||
.io { display: grid; gap: 18px; }
|
|
||||||
.iorow { background: var(--surface); border: 1px solid var(--line);
|
|
||||||
border-left: 3px solid var(--c); border-radius: 3px; overflow: hidden; }
|
|
||||||
.iorow.n1 { --c: var(--lead); --w: var(--lead-wash); }
|
|
||||||
.iorow.n2 { --c: var(--m1); --w: var(--m1-wash); }
|
|
||||||
.iorow.n3 { --c: var(--m2); --w: var(--m2-wash); }
|
|
||||||
.iohead { padding: 14px 20px; background: var(--w); display: flex;
|
|
||||||
align-items: baseline; gap: 12px; flex-wrap: wrap; }
|
|
||||||
.iohead b { color: var(--c); font-size: 15px; }
|
|
||||||
.iohead span { color: var(--muted); font-size: 13px; }
|
|
||||||
.iogrid { display: grid; grid-template-columns: 1fr 1fr; }
|
|
||||||
@media (max-width: 860px) { .iogrid { grid-template-columns: 1fr; } }
|
|
||||||
.iogrid > div { padding: 16px 20px; }
|
|
||||||
.iogrid > div + div { border-left: 1px solid var(--line); }
|
|
||||||
@media (max-width: 860px) {
|
|
||||||
.iogrid > div + div { border-left: none; border-top: 1px solid var(--line); }
|
|
||||||
}
|
|
||||||
.iocap { font-size: 11px; letter-spacing: .13em; text-transform: uppercase;
|
|
||||||
color: var(--muted); font-weight: 700; margin: 0 0 10px; }
|
|
||||||
.iolist { list-style: none; margin: 0; padding: 0; font-size: 13.5px; }
|
|
||||||
.iolist li { padding: 5px 0; border-bottom: 1px dotted var(--line); }
|
|
||||||
.iolist li:last-child { border-bottom: none; }
|
|
||||||
.iolist code { font-size: 12.5px; }
|
|
||||||
.frm { display: inline-block; font-size: 11px; font-weight: 700; padding: 1px 6px;
|
|
||||||
border-radius: 2px; background: var(--surface-2); color: var(--muted);
|
|
||||||
margin-right: 6px; font-family: Consolas, ui-monospace, monospace; }
|
|
||||||
.frm.done { background: var(--m1-wash); color: var(--m1); }
|
|
||||||
.frm.risk { background: var(--m2-wash); color: var(--m2); }
|
|
||||||
|
|
||||||
footer { margin-top: 64px; padding-top: 20px; border-top: 1px solid var(--line);
|
|
||||||
font-size: 13px; color: var(--muted); }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<div class="wrap">
|
|
||||||
|
|
||||||
<header class="top">
|
|
||||||
<p class="eyebrow">Team Gamma · Automation, Workflows & Governance</p>
|
|
||||||
<h1>Một nhánh chung, ba làn không đụng nhau</h1>
|
|
||||||
<p class="lede">
|
|
||||||
Toàn bộ phần việc refactor 10 ngày của Team Gamma — Nam, Hiệp, Lâm. Cả ba đẩy chung vào <code>gamma/refactor</code>. Nam làm thêm một mục chung —
|
|
||||||
khung kiến trúc, hợp đồng dữ liệu, cổng kiểm duyệt — nằm ngoài ba nhánh; xong mục đó thì
|
|
||||||
ba người vào ba nhánh tính năng ngang nhau, không ai phải sửa chung file với ai.
|
|
||||||
</p>
|
|
||||||
<p class="alias">
|
|
||||||
Ba tài liệu refactor gọi team này là <b>“Team Nam”</b> (theo tên lead). Cùng một team, cùng
|
|
||||||
phạm vi R02 · R08 · R09 · R07-T06. Nhánh của team dùng tiền tố <code>gamma/</code>; ba tài liệu refactor viết
|
|
||||||
<code>nam/workflow-governance-*</code> theo tên lead — cùng một thứ.
|
|
||||||
</p>
|
|
||||||
<div class="facts">
|
|
||||||
<div class="fact"><span class="k">Thời hạn</span><span class="v mono">21/08 → 31/08</span></div>
|
|
||||||
<div class="fact"><span class="k">Người</span><span class="v mono">Nam · Hiệp · Lâm</span></div>
|
|
||||||
<div class="fact"><span class="k">Nhánh</span><span class="v mono">gamma/refactor</span></div>
|
|
||||||
<div class="fact"><span class="k">Code phải bóc</span><span class="v mono">~6.500 dòng</span></div>
|
|
||||||
<div class="fact"><span class="k">Cổng phải qua</span><span class="v mono">CASAN Check 1</span></div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
|
|
||||||
<section class="tldr">
|
|
||||||
<div>
|
|
||||||
<p class="cap">Tóm tắt · thứ tự làm</p>
|
|
||||||
<ul class="flow">
|
|
||||||
<li class="f0">
|
|
||||||
<span class="b">CHUNG</span>
|
|
||||||
<span class="t"><b>Nam làm trước, nửa ngày</b>
|
|
||||||
<small>Dựng khung 5 thư mục (đang là 0 file) · interface + fake cho Config/Secrets ·
|
|
||||||
chốt <code>api_key</code> và báo Team Duy · script CASAN Check 1 · đưa 3 check vào CI ·
|
|
||||||
quyết số phận 24 checker UI. Merge xong mới chia nhánh.</small></span>
|
|
||||||
</li>
|
|
||||||
<li class="f1">
|
|
||||||
<span class="b">N1</span>
|
|
||||||
<span class="t"><b>N1 — Nam · Cấu hình, Bí mật, Vỏ ứng dụng</b>
|
|
||||||
<small>R02 (6 task) · settings 4 widget · bootstrap + MainWindow · policy doc.
|
|
||||||
Giữ luôn <code>app.py</code>, <code>config.py</code>, <code>theme.py</code>,
|
|
||||||
<code>i18n.py</code>. ~2.700 dòng.</small></span>
|
|
||||||
</li>
|
|
||||||
<li class="f2">
|
|
||||||
<span class="b">N2</span>
|
|
||||||
<span class="t"><b>N2 — Hiệp · Giám sát</b>
|
|
||||||
<small>7 tab Monitoring · CanonicalAuditLogger · MonitoringQueryService ·
|
|
||||||
2 vòng lặp import · ma trận Sandbox. ~2.650 dòng.</small></span>
|
|
||||||
</li>
|
|
||||||
<li class="f3">
|
|
||||||
<span class="b">N3</span>
|
|
||||||
<span class="t"><b>N3 — Lâm · Co4E Studio</b>
|
|
||||||
<small>Co4EWorkflowService · tách <code>co4e_tab.py</code> + <code>co4e_canvas.py</code>
|
|
||||||
thành 5 phần. ~2.880 dòng, file to nhất team.</small></span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="right">
|
|
||||||
<p class="cap">Ba điều bắt buộc</p>
|
|
||||||
<ol class="must">
|
|
||||||
<li><b>Không chạm file dùng chung.</b> Cần thêm chuỗi hay màu thì nhắn nhóm trưởng, đừng tự sửa.</li>
|
|
||||||
<li><b>Nộp factory, không tự lắp vào <code>app.py</code>.</b> N1 lắp trong <code>bootstrap.py</code> ngày 28/08.</li>
|
|
||||||
<li><b>Bị chặn thì dùng fake, báo ngay trong ngày.</b> Không ngồi đợi ai.</li>
|
|
||||||
</ol>
|
|
||||||
<p class="cap" style="margin-top:20px">Nghiệm thu</p>
|
|
||||||
<p style="margin:0;font-size:14px;color:var(--muted)">
|
|
||||||
Trên <code>gamma/refactor</code>: không file nào được sửa bởi hai người khác nhau.
|
|
||||||
Có là quy ước <b style="color:var(--ink)">số 1</b> đang bị vi phạm.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="gatebox">
|
|
||||||
<h2>Mục chung — Nam làm, xong hai người kia mới bắt đầu</h2>
|
|
||||||
<p class="sub">
|
|
||||||
Sáu việc dưới đây không thuộc làn nào — chúng là thứ cả ba người cùng đụng
|
|
||||||
vào. Nam làm một lần và đẩy lên <code>gamma/refactor</code>, rồi hai người kia
|
|
||||||
mới bắt đầu. Ước tính nửa ngày.
|
|
||||||
</p>
|
|
||||||
<ol class="steps">
|
|
||||||
<li>
|
|
||||||
<span class="est">~30 phút</span>
|
|
||||||
<b>Dựng khung thư mục</b>
|
|
||||||
<small>
|
|
||||||
<code>domain/</code> <code>application/</code> <code>infrastructure/</code>
|
|
||||||
<code>presentation/</code> <code>platform/</code> <code>tests/fakes/</code> —
|
|
||||||
hiện tại <b>chưa tồn tại, 0 file</b>. Mọi task của cả ba người đều ghi vào đây; để ba
|
|
||||||
người tự tạo là đụng nhau ở <code>__init__.py</code> ngay ngày đầu.
|
|
||||||
</small>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span class="est">~45 phút</span>
|
|
||||||
<b>Viết interface + fake cho Config và Secrets</b>
|
|
||||||
<small>
|
|
||||||
<code>SecretStore</code>, <code>ConfigRepository</code>, kèm
|
|
||||||
<code>FakeSecretStore</code> và <code>FakeConfigRepository</code>. Chỉ chữ ký, chưa cần
|
|
||||||
thân hàm. Đây là thứ gỡ chốt cho cả hai người kia — <b>156 lời gọi
|
|
||||||
<code>ctx.config.*</code> trong 29 file</b> đang chờ nó.
|
|
||||||
</small>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span class="est">~20 phút</span>
|
|
||||||
<b>Chốt số phận <code>api_key</code> và báo Team Duy</b>
|
|
||||||
<small>
|
|
||||||
<code>provider_conf()</code> còn trả <code>api_key</code> bên trong, hay tách hẳn sang
|
|
||||||
<code>SecretStore</code>? Có 5 nơi đọc trực tiếp, <b>3 trong số đó nằm trong
|
|
||||||
<code>providers/</code> của Team Duy</b>. Quyết một mình rồi im lặng là làm vỡ code
|
|
||||||
team bạn.
|
|
||||||
</small>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span class="est">~30 phút</span>
|
|
||||||
<b>Viết <code>scripts/audit_security.py</code> (CASAN Check 1)</b>
|
|
||||||
<small>
|
|
||||||
Gamma chủ trì check này ngày 30/08. Viết ngay hôm nay thì lead tự kiểm được trong suốt
|
|
||||||
quá trình chuyển API key, thay vì tới ngày cổng mới chạy lần đầu và phát hiện vấn đề.
|
|
||||||
</small>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span class="est">~20 phút</span>
|
|
||||||
<b>Thêm 3 check CASAN vào CI</b>
|
|
||||||
<small>
|
|
||||||
CI hiện chỉ chạy <code>pytest tests -q</code>. Ba check (secret · ≤400 dòng · import
|
|
||||||
guard) không nằm trong CI, nên tới 30/08 mới biết ai vi phạm. Đưa vào CI thì mỗi PR tự
|
|
||||||
báo.
|
|
||||||
</small>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span class="est">~30 phút</span>
|
|
||||||
<b>Quyết số phận 24 checker UI, rồi thông báo</b>
|
|
||||||
<small>
|
|
||||||
Chúng bám vào <code>cowork_local.config</code> (34 chỗ) và <code>cowork_local.app</code>
|
|
||||||
(16 chỗ) — <b>sẽ chết ngay khi lead đụng <code>config.py</code></b>. Đây là lưới an toàn
|
|
||||||
duy nhất cho phần UI vừa làm xong. Xem mục quy ước bên dưới.
|
|
||||||
</small>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<h2>Ba làn</h2>
|
|
||||||
<p class="sub">
|
|
||||||
Ba làn ngang nhau, mỗi làn khoảng 2.700 dòng phải bóc tách, <b>cùng đẩy vào một
|
|
||||||
nhánh</b> <code>gamma/refactor</code>. Nam nhận làn N1 vì đó là làn chạm tới file
|
|
||||||
dùng chung nhiều nhất. Cột “sở hữu” là danh sách file <em>chỉ</em> người đó được
|
|
||||||
sửa — trên nhánh chung, đây là thứ duy nhất giữ cho ba người không giẫm chân.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="cards">
|
|
||||||
|
|
||||||
<div class="card lead">
|
|
||||||
<div class="tag">Làn N1 · Nam</div>
|
|
||||||
<h3>Cấu hình, Bí mật & Vỏ ứng dụng</h3>
|
|
||||||
<p class="who">Nam giữ — làn chạm nhiều file dùng chung nhất</p>
|
|
||||||
<div class="branch">gamma/refactor</div>
|
|
||||||
|
|
||||||
<h4>Việc</h4>
|
|
||||||
<ul>
|
|
||||||
<li><span class="tid">R02-T01…T06</span> AtomicJsonFile · ConfigRepository · Typed Settings Facade · SecretStore + Keyring · chuyển API key · schema versioning</li>
|
|
||||||
<li><span class="tid">R08-T07</span> tách <code>settings_dialog.py</code> → 4 section widget</li>
|
|
||||||
<li><span class="tid">R08-T10</span> <code>bootstrap.py</code> + tách <code>MainWindow</code> → shell · tray · lifecycle <em>(cuối sprint, lắp factory của hai người kia)</em></li>
|
|
||||||
<li><span class="tid">R09-T01</span> tài liệu Security Policy Model</li>
|
|
||||||
<li>Chủ trì <b>CASAN Check 1</b> · giữ CI · duyệt PR của hai người</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h4>Sở hữu độc quyền</h4>
|
|
||||||
<ul class="paths">
|
|
||||||
<li>config.py</li>
|
|
||||||
<li>app.py → presentation/shell/</li>
|
|
||||||
<li>bootstrap.py</li>
|
|
||||||
<li>theme.py · i18n.py</li>
|
|
||||||
<li>infrastructure/config/ · secrets/ · persistence/</li>
|
|
||||||
<li>ui/settings_dialog.py → presentation/settings/</li>
|
|
||||||
<li>scripts/ · .gitea/workflows/</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p class="weight"><b>~2.700 dòng</b> · 727 settings + 1.352 app + 616 config<br>+ mục chung ở trên</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card one">
|
|
||||||
<div class="tag">Làn N2 · Hiệp</div>
|
|
||||||
<h3>Giám sát & Quan trắc</h3>
|
|
||||||
<p class="who">Hiệp — 7 tab, việc lặp cần kỷ luật</p>
|
|
||||||
<div class="branch">gamma/refactor</div>
|
|
||||||
|
|
||||||
<h4>Việc</h4>
|
|
||||||
<ul>
|
|
||||||
<li><span class="tid">R08-T08</span> tách <code>monitoring_tab.py</code> → 7 tab độc lập</li>
|
|
||||||
<li><span class="tid">R09-T04</span> <code>CanonicalAuditLogger</code></li>
|
|
||||||
<li><span class="tid">R09-T05</span> <code>MonitoringQueryService</code> read-only, phân trang</li>
|
|
||||||
<li><span class="tid">R09-T02</span> gỡ vòng lặp <code>model_pricing</code> ↔ <code>usage_tracker</code></li>
|
|
||||||
<li><span class="tid">R09-T03</span> gỡ vòng lặp <code>agent_security</code> ↔ <code>alert</code></li>
|
|
||||||
<li><span class="tid">R09-T06</span> ma trận Sandbox theo hệ điều hành</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h4>Sở hữu độc quyền</h4>
|
|
||||||
<ul class="paths">
|
|
||||||
<li>ui/monitoring_tab.py → presentation/monitoring/</li>
|
|
||||||
<li>application/monitoring/</li>
|
|
||||||
<li>infrastructure/telemetry/ · sandbox/</li>
|
|
||||||
<li>core/audit_log.py</li>
|
|
||||||
<li>core/model_pricing.py · usage_tracker.py</li>
|
|
||||||
<li>core/agent_security*.py</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p class="weight"><b>~2.650 dòng</b> · 1.545 monitoring + ~1.100 core</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card two">
|
|
||||||
<div class="tag">Làn N3 · Lâm</div>
|
|
||||||
<h3>Co4E Studio</h3>
|
|
||||||
<p class="who">Lâm — canvas và luồng chạy workflow</p>
|
|
||||||
<div class="branch">gamma/refactor</div>
|
|
||||||
|
|
||||||
<h4>Việc</h4>
|
|
||||||
<ul>
|
|
||||||
<li><span class="tid">R07-T06</span> <code>Co4EWorkflowService</code> thuần Python</li>
|
|
||||||
<li><span class="tid">R08-T09</span> tách <code>co4e_tab.py</code> + <code>co4e_canvas.py</code> → canvas · node property · run control · chat view · agent list</li>
|
|
||||||
<li>Gọi tool qua <code>ToolPolicyGateway</code> của Team Hoa — dùng fake, không chờ</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h4>Sở hữu độc quyền</h4>
|
|
||||||
<ul class="paths">
|
|
||||||
<li>ui/co4e_tab.py → presentation/co4e/</li>
|
|
||||||
<li>ui/co4e_canvas.py</li>
|
|
||||||
<li>ui/co4e_config_panel.py</li>
|
|
||||||
<li>application/workflows/</li>
|
|
||||||
<li>domain/workflows/</li>
|
|
||||||
<li>core/co4e_run_manager.py</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p class="weight"><b>~2.880 dòng</b> · file to nhất của cả team</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>Tám quy ước</h2>
|
|
||||||
<p class="sub">
|
|
||||||
Tám điều dưới đây là luật của team, Nam chốt. Bốn điều đầu là bắt buộc — trên một
|
|
||||||
nhánh chung, vi phạm không chỉ hại mình mà chặn cả hai người kia.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="rules">
|
|
||||||
|
|
||||||
<div class="rule hard">
|
|
||||||
<h3>1 · Không chạm file dùng chung</h3>
|
|
||||||
<p>
|
|
||||||
<code>app.py</code>, <code>theme.py</code>, <code>i18n.py</code>, <code>config.py</code>,
|
|
||||||
<code>bootstrap.py</code> thuộc nhánh N1 của Nam. Cần thêm chuỗi hay token màu thì
|
|
||||||
<b>nhắn, đừng sửa</b> — Nam thêm trong ngày. Đây là ba file duy nhất có thể gây conflict
|
|
||||||
thật, và luật này xoá hẳn khả năng đó.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rule hard">
|
|
||||||
<h3>2 · Nộp factory, không tự lắp vào app</h3>
|
|
||||||
<p>
|
|
||||||
Mỗi nhánh expose một hàm dựng widget với chữ ký chốt từ ngày đầu, ví dụ
|
|
||||||
<code>build_monitoring_tab(ctx, query_service) -> QWidget</code>. Nam gọi nó trong <code>bootstrap.py</code> ngày 28/08. Không ai tự sửa chỗ khởi tạo trong
|
|
||||||
<code>app.py</code>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rule hard">
|
|
||||||
<h3>3 · Bị chặn thì dùng fake, không ngồi đợi</h3>
|
|
||||||
<p>
|
|
||||||
Chưa có <code>ConfigRepository</code> bản thật thì dùng <code>FakeConfigRepository</code>.
|
|
||||||
Chưa có <code>ToolPolicyGateway</code> của Team Hoa thì đã có fake sẵn. <b>Báo ngay trong ngày</b>
|
|
||||||
nếu thiếu fake nào — đó là việc của nhóm trưởng, không phải lý do dừng tay.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="rule hard">
|
|
||||||
<h3>4 · Nhánh chung: kéo trước khi đẩy, đừng để nhánh đỏ</h3>
|
|
||||||
<p>
|
|
||||||
Cả ba đẩy vào <code>gamma/refactor</code>, nên không còn nhánh riêng làm vùng
|
|
||||||
đệm. Ba việc bắt buộc: <code>git pull --rebase</code> trước mỗi lần đẩy;
|
|
||||||
commit nhỏ và đẩy trong ngày, đừng ôm 500 dòng ba hôm; và
|
|
||||||
<b>không bao giờ đẩy thứ làm <code>pytest tests -q</code> đỏ</b> — nhánh hỏng
|
|
||||||
là hai người kia đứng hình. Lỡ đẩy nhầm thì sửa ngay hoặc
|
|
||||||
<code>git revert</code>, đừng để qua đêm.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rule soft">
|
|
||||||
<h3>5 · Commit nhỏ, mỗi ngày một lần</h3>
|
|
||||||
<p>
|
|
||||||
Một PR cho một sub-widget hoặc một service, không dồn 7 tab vào một PR cuối tuần. Nhóm
|
|
||||||
trưởng duyệt trong ngày. PR càng to thì rủi ro càng dồn về ngày 28/08.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rule soft">
|
|
||||||
<h3>6 · Mỗi commit kèm test, và không làm đỏ 90 test cũ</h3>
|
|
||||||
<p>
|
|
||||||
Baseline hiện tại: <b>102 test xanh trong 3,4 giây</b>. Chạy <code>pytest tests -q</code>
|
|
||||||
trước mỗi lần đẩy. Đây là lưới an toàn cho phần logic — giữ nó xanh suốt 10 ngày.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rule soft">
|
|
||||||
<h3>7 · File mới ≤ 400 dòng, không import PySide6 vào lõi</h3>
|
|
||||||
<p>
|
|
||||||
Hai điều kiện của CASAN Check 2 và 3. Tự kiểm trước khi đẩy — CI sẽ báo, nhưng biết
|
|
||||||
sớm thì đỡ phải tách lại lần hai.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rule soft">
|
|
||||||
<h3>8 · Checker UI thuộc phạm vi ai, người đó cập nhật</h3>
|
|
||||||
<p>
|
|
||||||
24 checker sẽ vỡ khi file bị dời. Ai dời file thì sửa checker tương ứng ngay trong
|
|
||||||
commit đó — tốn thêm khoảng 15% thời gian, đổi lại giữ được lưới an toàn cho phần
|
|
||||||
UI vừa làm xong.
|
|
||||||
<b>Đã chốt 21/08: đường A. Nam chịu trách nhiệm nếu đổi ý.</b>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<h2>Mỗi người nhận gì, giao gì</h2>
|
|
||||||
<p class="sub">
|
|
||||||
Cột trái là thứ phải có trong tay mới làm được, kèm nguồn. Cột phải là thứ bắt
|
|
||||||
buộc giao ra, kèm người nhận. Nhãn <span class="frm done">có rồi</span> nghĩa là
|
|
||||||
mục chung đã làm xong.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="io">
|
|
||||||
|
|
||||||
<div class="iorow n1">
|
|
||||||
<div class="iohead"><b>N1 · Cấu hình, Bí mật & Vỏ</b><span>Nam · nhóm trưởng</span></div>
|
|
||||||
<div class="iogrid">
|
|
||||||
<div>
|
|
||||||
<p class="iocap">Input — cần có</p>
|
|
||||||
<ul class="iolist">
|
|
||||||
<li><span class="frm">mã cũ</span><code>config.py</code> 616 dòng</li>
|
|
||||||
<li><span class="frm">mã cũ</span><code>ui/settings_dialog.py</code> 727 dòng</li>
|
|
||||||
<li><span class="frm">mã cũ</span><code>app.py</code> 1.352 dòng</li>
|
|
||||||
<li><span class="frm risk">tự chốt</span>Quyết định <code>api_key</code> — trước 26/08</li>
|
|
||||||
<li><span class="frm">từ Hiệp</span>Chữ ký <code>build_monitoring_tab()</code> — trước 28/08</li>
|
|
||||||
<li><span class="frm">từ Lâm</span>Chữ ký <code>build_co4e_tab()</code> — trước 28/08</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="iocap">Output — phải giao</p>
|
|
||||||
<ul class="iolist">
|
|
||||||
<li><span class="frm done">có rồi</span><code>SecretStore</code> · <code>ConfigRepository</code> + fake → <b>cho Hiệp và Lâm</b></li>
|
|
||||||
<li><span class="frm done">có rồi</span><code>scripts/audit_security.py</code> → cho CI</li>
|
|
||||||
<li><code>infrastructure/persistence/json/atomic_json_file.py</code></li>
|
|
||||||
<li><code>infrastructure/config/</code> — cài đặt thật + settings facade</li>
|
|
||||||
<li><code>infrastructure/secrets/keyring_adapter.py</code></li>
|
|
||||||
<li><code>presentation/settings/</code> — 4 widget</li>
|
|
||||||
<li><code>bootstrap.py</code> + <code>presentation/shell/</code> — 3 file</li>
|
|
||||||
<li><code>docs/architecture/security-policy.md</code></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="iorow n2">
|
|
||||||
<div class="iohead"><b>N2 · Giám sát</b><span>Hiệp</span></div>
|
|
||||||
<div class="iogrid">
|
|
||||||
<div>
|
|
||||||
<p class="iocap">Input — cần có</p>
|
|
||||||
<ul class="iolist">
|
|
||||||
<li><span class="frm">mã cũ</span><code>ui/monitoring_tab.py</code> 1.545 dòng</li>
|
|
||||||
<li><span class="frm">mã cũ</span><code>core/usage_tracker.py</code> 524 · <code>sandbox_manager.py</code> 335</li>
|
|
||||||
<li><span class="frm">mã cũ</span><code>core/model_pricing.py</code> 284 · <code>agent_security.py</code> 272 · <code>audit_log.py</code> 115</li>
|
|
||||||
<li><span class="frm done">từ Nam</span><code>FakeConfigRepository</code> — dùng được ngay</li>
|
|
||||||
<li><span class="frm risk">tự chốt</span>Giữ nguyên 9 trường log, báo Duy và Hoa</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="iocap">Output — phải giao</p>
|
|
||||||
<ul class="iolist">
|
|
||||||
<li><code>build_monitoring_tab()</code> → <b>cho Nam</b>, trước 28/08</li>
|
|
||||||
<li><code>FakeAuditLogger</code> · <code>FakeMonitoringQueryService</code> → <b>cho cả team</b></li>
|
|
||||||
<li><code>presentation/monitoring/</code> — 7 tab + shell</li>
|
|
||||||
<li><code>application/monitoring/monitoring_query_service.py</code></li>
|
|
||||||
<li><code>infrastructure/telemetry/audit_logger.py</code></li>
|
|
||||||
<li><code>infrastructure/sandbox/sandbox_capabilities.py</code></li>
|
|
||||||
<li><b>0 circular import</b> ở pricing ↔ usage và security ↔ alert</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="iorow n3">
|
|
||||||
<div class="iohead"><b>N3 · Co4E Studio</b><span>Lâm</span></div>
|
|
||||||
<div class="iogrid">
|
|
||||||
<div>
|
|
||||||
<p class="iocap">Input — cần có</p>
|
|
||||||
<ul class="iolist">
|
|
||||||
<li><span class="frm">mã cũ</span><code>ui/co4e_tab.py</code> 2.089 dòng</li>
|
|
||||||
<li><span class="frm">mã cũ</span><code>ui/co4e_canvas.py</code> 791 · <code>co4e_config_panel.py</code></li>
|
|
||||||
<li><span class="frm">mã cũ</span><code>core/co4e_run_manager.py</code> 331</li>
|
|
||||||
<li><span class="frm">có sẵn</span><code>core/co4e.py</code> — dataclass Workflow/Node/Edge đã có</li>
|
|
||||||
<li><span class="frm done">từ Nam</span><code>FakeConfigRepository</code></li>
|
|
||||||
<li><span class="frm risk">từ Team Hoa</span>DTO <code>ToolPolicyGateway</code> — <b>rủi ro liên team cao nhất</b>, lấy trong hôm nay</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="iocap">Output — phải giao</p>
|
|
||||||
<ul class="iolist">
|
|
||||||
<li><code>build_co4e_tab()</code> → <b>cho Nam</b>, trước 28/08</li>
|
|
||||||
<li><code>FakeCo4EWorkflowService</code> → <b>cho cả team</b></li>
|
|
||||||
<li><code>domain/workflows/</code> — DTO chốt ngày đầu</li>
|
|
||||||
<li><code>application/workflows/co4e_workflow_service.py</code></li>
|
|
||||||
<li><code>presentation/co4e/</code> — 5 phần</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>Output bắt buộc với cả ba, mỗi lần đẩy</h3>
|
|
||||||
<div class="scroll">
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Điều kiện</th><th>Ngưỡng</th><th>Tự kiểm bằng</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>File mới sau khi tách</td><td class="mono">≤ 400 dòng</td><td class="mono">wc -l</td></tr>
|
|
||||||
<tr><td><code>domain/</code> và <code>application/</code> import PySide6</td><td class="mono">0</td><td class="mono">grep -r PySide6</td></tr>
|
|
||||||
<tr><td>Test hiện có</td><td class="mono">102 xanh</td><td class="mono">pytest tests -q</td></tr>
|
|
||||||
<tr><td>Credential lộ</td><td class="mono">0</td><td class="mono">python scripts/audit_security.py</td></tr>
|
|
||||||
<tr><td>Checker UI trong phạm vi mình dời</td><td>đã cập nhật</td><td class="mono">python tools/check_<tên>.py</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>Lịch từng ngày</h2>
|
|
||||||
<p class="sub">Ba hàng chạy độc lập. Hàng tô nền là lúc cả ba phải gặp nhau.</p>
|
|
||||||
|
|
||||||
<div class="scroll">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="day">Ngày</th>
|
|
||||||
<th>N1 · Nam</th>
|
|
||||||
<th>N2 · Hiệp</th>
|
|
||||||
<th>N3 · Lâm</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td class="day">21/08<br><small>T6</small></td>
|
|
||||||
<td class="cl"><b>Mục chung</b> · dựng khung · interface + fake · chốt api_key · CASAN script<small>Merge trước khi hai người kia bắt đầu</small></td>
|
|
||||||
<td class="c1">Chốt schema log 9 trường<small>Giữ nguyên định dạng cũ để 24 chỗ gọi không phải sửa</small></td>
|
|
||||||
<td class="c2">Chốt chữ ký <code>Co4EWorkflowService</code><small>Nộp cho lead để lắp bootstrap sau</small></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="day">22–23/08<br><small>T7–CN</small></td>
|
|
||||||
<td class="cl">AtomicJsonFile · ConfigRepository · Typed Settings Facade</td>
|
|
||||||
<td class="c1">CanonicalAuditLogger · gỡ vòng lặp pricing ↔ usage</td>
|
|
||||||
<td class="c2">Co4EWorkflowService — CRUD & validate, test không cần Qt</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="mark">
|
|
||||||
<td class="day">23/08<br><small>17:00</small></td>
|
|
||||||
<td colspan="3"><span class="pill cp">Checkpoint 1</span> 100% DTO và fake xong · <code>pytest</code> xanh · không ai bị chặn</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="day">24/08<br><small>T2</small></td>
|
|
||||||
<td class="cl">Tách settings: provider + connector widget</td>
|
|
||||||
<td class="c1">3 tab đầu: overview · sandbox · security events</td>
|
|
||||||
<td class="c2">node_property_panel · agent_list_panel</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="day">25/08<br><small>T3</small></td>
|
|
||||||
<td class="cl">Tách settings: routing + general widget</td>
|
|
||||||
<td class="c1">4 tab còn lại: MCP · action logs · agent status · security settings</td>
|
|
||||||
<td class="c2">co4e_canvas_widget — thao tác node</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="day">26/08<br><small>T4</small></td>
|
|
||||||
<td class="cl">Chuyển API key sang SecretStore<small>Báo Team Duy trước khi đụng providers/</small></td>
|
|
||||||
<td class="c1">Lắp shell MonitoringTab · query service bản thật</td>
|
|
||||||
<td class="c2">Run control · chat view</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="day">27/08<br><small>T5</small></td>
|
|
||||||
<td class="cl">Schema versioning · recovery policy</td>
|
|
||||||
<td class="c1">Ma trận Sandbox · gỡ vòng lặp agent_security</td>
|
|
||||||
<td class="c2">Lắp container Co4ETab · thay fake bằng service thật</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="day">28/08<br><small>T6</small></td>
|
|
||||||
<td class="cl"><b>bootstrap.py + tách MainWindow</b><small>Nhận factory của Hiệp và Lâm để lắp</small></td>
|
|
||||||
<td class="c1">Nộp factory · dọn file >400 dòng · cập nhật checker</td>
|
|
||||||
<td class="c2">Nộp factory · dọn file >400 dòng · cập nhật checker</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="mark">
|
|
||||||
<td class="day">28/08<br><small>17:00</small></td>
|
|
||||||
<td colspan="3"><span class="pill cp">Checkpoint 2</span> Tách xong 100% god file · 0 circular import</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="day">29/08<br><small>T7</small></td>
|
|
||||||
<td class="cl">Tài liệu Security Policy · integration test Settings</td>
|
|
||||||
<td class="c1">Integration test Monitoring</td>
|
|
||||||
<td class="c2">Integration test luồng Co4E đầu-cuối</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="mark">
|
|
||||||
<td class="day">30/08<br><small>CN 17:00</small></td>
|
|
||||||
<td colspan="3"><span class="pill gate">CASAN Gate</span> <b>Nam chủ trì Check 1</b> — quét toàn bộ config/JSON, phải ra 0 secret plaintext. Hiệp và Lâm sửa ngay phần của mình nếu script bắt được.</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="mark">
|
|
||||||
<td class="day">31/08<br><small>T2 15:00</small></td>
|
|
||||||
<td colspan="3"><span class="pill ship">Bàn giao</span> Fix tồn đọng · cập nhật tài liệu kiến trúc · merge PR cuối · smoke test 5 luồng chính</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>Nghiệm thu: làm sao biết đã thật sự song song</h2>
|
|
||||||
<p class="sub">Không phải “đã họp xong” mà là chạy được. Ba câu hỏi, trả lời bằng lệnh.</p>
|
|
||||||
|
|
||||||
<div class="scroll">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>Câu hỏi</th><th>Cách trả lời</th><th>Khi nào</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>Hiệp có chạy được khi chưa có config bản thật?</td>
|
|
||||||
<td>Dựng một tab Monitoring, chạy test của nó, <b>không import <code>cowork_local.config</code></b> dòng nào — chỉ dùng <code>FakeConfigRepository</code></td>
|
|
||||||
<td class="mono">21/08</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Lâm có chạy được khi Team Hoa chưa xong gateway?</td>
|
|
||||||
<td>Test <code>Co4EWorkflowService</code> xanh với <code>FakeToolPolicyGateway</code></td>
|
|
||||||
<td class="mono">23/08</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Ba người có đụng file nhau không?</td>
|
|
||||||
<td><code>git log --name-only --pretty=%an</code> trên <code>gamma/refactor</code> —
|
|
||||||
không file nào được xuất hiện dưới hai tên khác nhau</td>
|
|
||||||
<td class="mono">mỗi ngày</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer>
|
|
||||||
Nguồn: <code>docs/refactor/plan.md</code>, <code>Refactoring_Checklist.md</code>,
|
|
||||||
<code>Feature_Architecture_Proposal.md</code>. Số dòng code, số lời gọi và baseline test đo
|
|
||||||
trực tiếp trên nhánh <code>main</code> ngày 21/08.
|
|
||||||
Mục chung, cách chia ba nhánh, bảy quy ước và mục nghiệm thu là đề xuất — không có trong
|
|
||||||
tài liệu gốc.
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
# Quyết định của Team Gamma
|
|
||||||
|
|
||||||
Team: **Nam** (nhóm trưởng, nhánh N1) · **Hiệp** (N2) · **Lâm** (N3).
|
|
||||||
|
|
||||||
Ghi ở đây thay vì chôn trong comment, vì cả ba đều ảnh hưởng ra ngoài phạm vi
|
|
||||||
một người.
|
|
||||||
|
|
||||||
| # | Việc | Trạng thái |
|
|
||||||
|---|---|---|
|
|
||||||
| 1 | `provider_conf()` còn trả `api_key` | **Chốt 21/08 — đường A** |
|
|
||||||
| 2 | Số phận 24 checker UI | **Chốt 21/08 — đường A** |
|
|
||||||
| 3 | DTO `ToolPolicyGateway` viết hộ Team Hoa | Đã làm, chờ Hoa xác nhận |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quyết định 1 — `provider_conf()` còn trả `api_key` hay không
|
|
||||||
|
|
||||||
### Vì sao phải quyết trước khi code
|
|
||||||
|
|
||||||
R02-T05 chuyển API key sang Keyring. Câu hỏi là sau khi chuyển, dict do
|
|
||||||
`provider_conf()` trả về **còn chứa `api_key` không**.
|
|
||||||
|
|
||||||
Có 5 nơi đang đọc trực tiếp — đo trên `main` ngày 21/08:
|
|
||||||
|
|
||||||
| Nơi đọc | Thuộc |
|
|
||||||
|---|---|
|
|
||||||
| `providers/anthropic.py:26` | **Team Duy** |
|
|
||||||
| `providers/openai_compat.py:36` | **Team Duy** |
|
|
||||||
| `core/image_gen.py:50` | Team Duy (routing/model) |
|
|
||||||
| `core/ext_connectors.py:98` | Team Hoa |
|
|
||||||
| `ui/ext_connector_dialog.py:87` | Team Gamma |
|
|
||||||
|
|
||||||
Ba trong năm nằm ngoài team. Quyết một mình rồi im lặng là làm vỡ code người khác.
|
|
||||||
|
|
||||||
### Hai đường
|
|
||||||
|
|
||||||
**A. Giữ `api_key` trong dict, `ConfigRepository` tự lấy từ `SecretStore` rồi ghép vào**
|
|
||||||
|
|
||||||
- 5 nơi đọc **không phải sửa dòng nào**
|
|
||||||
- Không cần báo team khác, không cần đồng bộ lịch
|
|
||||||
- Đổi lại: bí mật vẫn đi lang thang trong dict, dễ lọt vào log hoặc màn hình debug
|
|
||||||
- CASAN Check 1 vẫn PASS vì nó quét **file trên đĩa**, không quét bộ nhớ
|
|
||||||
|
|
||||||
**B. Bỏ `api_key` khỏi dict, ai cần thì gọi `secrets.get(provider_key(name))`**
|
|
||||||
|
|
||||||
- Sạch về nguyên tắc: bí mật chỉ xuất hiện đúng chỗ cần
|
|
||||||
- Đổi lại: **5 nơi phải sửa**, 3 trong đó phải chờ team khác xếp lịch
|
|
||||||
- Rủi ro: quên một chỗ thì mất API key lúc chạy thật, mà test có fake nên không bắt được
|
|
||||||
|
|
||||||
### Đề xuất
|
|
||||||
|
|
||||||
**Đường A cho sprint này, đường B ghi vào nợ kỹ thuật.**
|
|
||||||
|
|
||||||
Lý do: mục tiêu của cổng CASAN là *không còn secret nằm trên đĩa*, và đường A
|
|
||||||
đạt được điều đó. Đường B giải quyết thêm chuyện secret trong bộ nhớ — đúng
|
|
||||||
nhưng không phải việc của 10 ngày này, và nó kéo hai team khác vào một thay đổi
|
|
||||||
họ không lên kế hoạch.
|
|
||||||
|
|
||||||
Nếu chọn B thì **phải báo Team Duy và Team Hoa trong hôm nay**, không phải lúc
|
|
||||||
đã sửa xong.
|
|
||||||
|
|
||||||
> **Nam chốt 21/08: đường A.**
|
|
||||||
>
|
|
||||||
> Việc kèm theo: `ConfigRepository` bản thật phải đọc key từ `SecretStore` rồi
|
|
||||||
> ghép vào dict do `provider_conf()` trả về. Năm nơi đọc không đổi một dòng,
|
|
||||||
> nên **không cần báo Duy và Hoa**.
|
|
||||||
>
|
|
||||||
> Nợ kỹ thuật đã ghi: đường B (bỏ `api_key` khỏi dict) để sau sprint này.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quyết định 2 — số phận 24 checker UI
|
|
||||||
|
|
||||||
### Vấn đề
|
|
||||||
|
|
||||||
`tools/check_*.py` là bộ kiểm tra giao diện viết trong 2 tuần vừa rồi, hiện
|
|
||||||
**24 file**. Chúng bám vào đường dẫn cũ:
|
|
||||||
|
|
||||||
| Import | Số chỗ |
|
|
||||||
|---|---|
|
|
||||||
| `cowork_local.config` | 34 |
|
|
||||||
| `cowork_local.app` | 16 |
|
|
||||||
| `cowork_local.state` | 22 |
|
|
||||||
| `cowork_local.ui.*` | ~12 |
|
|
||||||
|
|
||||||
R08 dời hết những module đó sang `presentation/`. Nghĩa là **cả 24 checker chết
|
|
||||||
ngay ngày N1 đụng `config.py`** — và đó là lưới an toàn duy nhất cho phần giao
|
|
||||||
diện, vì `pytest` không kiểm giao diện (90 test hiện tại là logic).
|
|
||||||
|
|
||||||
### Ba đường
|
|
||||||
|
|
||||||
**A. Ai dời file thì cập nhật checker tương ứng, ngay trong PR đó**
|
|
||||||
|
|
||||||
- Giữ được lưới suốt 10 ngày
|
|
||||||
- Tốn thêm ~15% thời gian mỗi PR
|
|
||||||
- Rủi ro: người sửa vội có thể nới lỏng phép kiểm cho nó xanh — đã xảy ra một
|
|
||||||
lần trong quá trình làm UI, khi một checker được sửa thành *không thể đỏ*
|
|
||||||
|
|
||||||
**B. Đóng băng: bỏ khỏi CI, sửa một lượt ngày 31/08**
|
|
||||||
|
|
||||||
- Nhanh nhất trong 10 ngày
|
|
||||||
- Đổi lại: **không có gì canh hồi quy giao diện** suốt cả sprint. Refactor là lúc
|
|
||||||
dễ vỡ giao diện nhất
|
|
||||||
- Rủi ro cuối sprint: sửa 24 file cùng lúc, không ai nhớ cái nào đo gì
|
|
||||||
|
|
||||||
**C. Bỏ hẳn**
|
|
||||||
|
|
||||||
Không khuyến nghị. Vứt đi hai tuần công sức kiểm chứng, và ba tài liệu refactor
|
|
||||||
không có gì thay thế cho phần giao diện.
|
|
||||||
|
|
||||||
### Đề xuất
|
|
||||||
|
|
||||||
**Đường A**, kèm một ràng buộc: PR nào *sửa* checker phải nói rõ trong mô tả
|
|
||||||
**sửa gì và vì sao** — để việc nới lỏng phép kiểm không lọt qua review.
|
|
||||||
|
|
||||||
`tools/check_probes_bite.py` đã có sẵn cơ chế chứng minh checker còn cắn được;
|
|
||||||
chạy nó sau mỗi đợt sửa là bắt được ngay chuyện đó.
|
|
||||||
|
|
||||||
> **Chốt 21/08: đường A** — ai dời file thì cập nhật checker tương ứng ngay
|
|
||||||
> trong PR đó.
|
|
||||||
>
|
|
||||||
> Kèm hai ràng buộc, vì rủi ro của đường A là người sửa vội nới lỏng phép kiểm:
|
|
||||||
>
|
|
||||||
> 1. PR nào *sửa* checker phải nói rõ trong mô tả **sửa gì và vì sao**.
|
|
||||||
> 2. Sửa xong chạy `python tools/check_probes_bite.py` — nó cắm lỗi cố ý vào
|
|
||||||
> code rồi kiểm checker có bắt được không. Chính công cụ này đã từng bắt
|
|
||||||
> được một checker bị sửa thành *không thể đỏ*.
|
|
||||||
>
|
|
||||||
> Không đưa 24 checker vào CI trong sprint này: chúng dựng `MainWindow` thật,
|
|
||||||
> mỗi lần chạy tốn hàng chục giây và thỉnh thoảng sập lúc Qt dọn dẹp. Chạy tay
|
|
||||||
> theo phạm vi mình đụng là đủ.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quyết định 3 — Gamma viết hộ DTO `ToolPolicyGateway` cho Team Hoa
|
|
||||||
|
|
||||||
**Đã làm, chờ Hoa xác nhận.** Ngày: 21/08.
|
|
||||||
|
|
||||||
### Vì sao làm thay
|
|
||||||
|
|
||||||
N3 (Co4E) cần gọi tool nhưng Team Hoa chưa bắt đầu. Ba đường:
|
|
||||||
|
|
||||||
| | Hệ quả |
|
|
||||||
|---|---|
|
|
||||||
| N3 ngồi đợi Hoa | Mất mấy ngày, trái nguyên tắc "không team nào chặn team nào" |
|
|
||||||
| N3 tự phỏng đoán | Phỏng đoán của một người, không ai soi, sửa lại chắc chắn |
|
|
||||||
| **Gamma viết bản đề xuất** | N3 chạy ngay, Hoa có cái cụ thể để duyệt hoặc sửa |
|
|
||||||
|
|
||||||
### Ranh giới không lấn
|
|
||||||
|
|
||||||
Sơ đồ phân hệ trong `plan.md` giao `domain/security/` cho **Team Gamma**, còn
|
|
||||||
`application/conversations/tool_policy_gateway.py` cho **Team Hoa**.
|
|
||||||
|
|
||||||
Nên chia đúng như vậy:
|
|
||||||
|
|
||||||
- **Gamma định nghĩa hình dạng** → `domain/security/tool_policy.py`
|
|
||||||
- **Hoa cài đặt gateway** → `application/conversations/tool_policy_gateway.py`,
|
|
||||||
nối vào `core/mcp_client.py` và tool dựng sẵn
|
|
||||||
|
|
||||||
Không đụng file nào của Hoa.
|
|
||||||
|
|
||||||
### Đã bám vào code đang chạy, không bịa
|
|
||||||
|
|
||||||
| Nguồn | Lấy gì |
|
|
||||||
|---|---|
|
|
||||||
| `core/agent_security.py::SecurityVerdict` | `allowed` · `reason` · `layer` |
|
|
||||||
| `ui/permission_dialog.py` + `chat_panel.py:1312` | trạng thái "hỏi người dùng" |
|
|
||||||
|
|
||||||
Khác biệt duy nhất: gộp thành **một câu trả lời ba trạng thái**
|
|
||||||
(`ALLOW` / `DENY` / `ASK`) thay vì bắt chỗ gọi tự nhớ hỏi hai nơi.
|
|
||||||
|
|
||||||
Hai ràng buộc đưa vào có chủ đích:
|
|
||||||
|
|
||||||
1. `DENY` và `ASK` **bắt buộc có `reason`** — người dùng cần biết vì sao, và
|
|
||||||
`audit_log` cần ghi lại. Thiếu là ném lỗi ngay lúc dựng, không phải lúc chạy.
|
|
||||||
2. `ASK` **không phải** `allowed` — bẫy dễ mắc nhất là coi ASK như ALLOW rồi tool
|
|
||||||
chạy mà chưa ai đồng ý. Có test riêng cho chuyện này.
|
|
||||||
|
|
||||||
### Gửi Hoa cái gì
|
|
||||||
|
|
||||||
> Bên mình viết trước bản đề xuất `ToolPolicyGateway` ở
|
|
||||||
> `domain/security/tool_policy.py` vì N3 cần gọi tool mà bên Hoa chưa bắt đầu —
|
|
||||||
> để N3 khỏi phải tự đoán. Ba kiểu: `ToolCallRequest`, `PolicyDecision`,
|
|
||||||
> `ToolPolicyGateway`. Phần cài đặt vẫn để bên Hoa ở
|
|
||||||
> `application/conversations/tool_policy_gateway.py`, bọn mình không đụng.
|
|
||||||
> Thấy chỗ nào không hợp thì sửa thẳng file đó, đừng tạo kiểu thứ hai. Đổi bây
|
|
||||||
> giờ còn rẻ vì mới mình N3 dùng.
|
|
||||||
|
|
||||||
> Đã gửi Hoa: ☐ — ngày ____ Hoa xác nhận: ☐ đồng ý ☐ có sửa
|
|
||||||
@@ -1,312 +0,0 @@
|
|||||||
# COWORK LOCAL - BẢNG CHECKLIST TIẾN ĐỘ TÁI CẤU TRÚC (2026)
|
|
||||||
## (REFACTORING & MIGRATION PROGRESS TRACKER)
|
|
||||||
|
|
||||||
* **Dự án**: Cowork Local (Cowork-Local BamBOO)
|
|
||||||
* **Thời gian thực hiện**: 21/08/2026 ➔ 31/08/2026
|
|
||||||
* **Đội ngũ phụ trách**:
|
|
||||||
- 🔵 **Team Duy** (Core AI, Routing, Turn Runtime & Testing Pyramid - Tech Lead)
|
|
||||||
- 🟣 **Team Nam** (Automation Workflows, Co4E, Monitoring & Shell Governance)
|
|
||||||
- 🟢 **Team Hoa** (Workspace, Filesystem, Scheduling & Tool Registry)
|
|
||||||
* **Tài liệu thiết kế kiến trúc gốc**: `Feature_Architecture_Proposal.md`
|
|
||||||
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> ### 📝 QUY ĐỊNH BẮT BUỘC KHI CODE & GHI NHẬN TIẾN ĐỘ (MANDATORY RULES):
|
|
||||||
> 1. **In-Code Comments in English (Bắt buộc comment tiếng Anh ở mọi dòng/khối code sửa đổi)**:
|
|
||||||
> - Mỗi khi sửa đổi hoặc viết mới bất kỳ dòng code nào, lập trình viên **bắt buộc phải thêm comment bằng tiếng Anh** giải thích rõ mục đích xử lý, lý do kiến trúc và mối quan hệ giữa các tầng.
|
|
||||||
> - Tuyệt đối không để code không có chú thích, đặc biệt tại các điểm chuyển đổi DTO, seams và xử lý ngoại lệ.
|
|
||||||
> 2. **Task Start / End Timestamps (Ghi nhận chính xác ngày giờ bắt đầu và hoàn tất)**:
|
|
||||||
> - Khi bắt đầu làm một task ➔ Điền mốc thời gian: `Start: YYYY-MM-DD HH:mm`.
|
|
||||||
> - Khi task hoàn tất (unit test pass 100%) ➔ Điền mốc thời gian: `End: YYYY-MM-DD HH:mm` và tích chọn `[x]`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📌 PHẦN 1: CHECKLIST CHI TIẾT THEO 10 EPIC (R01 ➔ R10)
|
|
||||||
|
|
||||||
### 🔹 EPIC R01: Architecture Foundation & Characterization (Nền Tảng Kiến Trúc & Test Bảo Vệ)
|
|
||||||
* **Team chịu trách nhiệm**: 🔵 **Team Duy** (Chủ trì ADR & Test Doubles) + Phối hợp cả 3 team
|
|
||||||
* **Mục tiêu**: Khóa DTO, dựng fakes/test doubles chạy offline không phụ thuộc Qt/mạng, thiết lập script chặn vi phạm kiến trúc.
|
|
||||||
|
|
||||||
- [ ] **R01-T01 (Team Duy)**: Viết Architecture ADR định rõ ranh giới các tầng ➔ `docs/architecture/ADR-001-layered-architecture.md`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R01-T02 (Team Duy)**: Xây dựng `FakeProvider` và `FakeToolExecutor` chạy offline từ `providers/base.py` ➔ `tests/fakes/fake_provider.py` & `tests/fakes/fake_tool_executor.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R01-T03 (Team Duy)**: Viết script quét tĩnh chặn code mới trong `domain/` và `application/` import `PySide6` ➔ `scripts/check_imports.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R01-T04 (Team Duy)**: Viết Characterization Tests cho `core/chat_agent.py::run_cowork` ➔ `tests/characterization/test_run_cowork.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R01-T05 (Team Duy)**: Lập danh mục và phân loại mã nguồn dormant/dead code ➔ `docs/architecture/dormant-code.md`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🔹 EPIC R02: Configuration, Secrets & Persistence (Cấu Hình Atomic & Bảo Mật Keyring)
|
|
||||||
* **Team chịu trách nhiệm**: 🟣 **Team Nam** (Chủ trì)
|
|
||||||
* **Mục tiêu**: Xóa bỏ untyped global `config.py`, cài đặt `AtomicJsonFile` chống hỏng file và lưu trữ API Key/Token vào OS Keyring.
|
|
||||||
|
|
||||||
- [ ] **R02-T01 (Team Nam)**: Xây dựng module `AtomicJsonFile` ghi tệp an toàn (tmp file + fsync + atomic replace) ➔ `infrastructure/persistence/json/atomic_json_file.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R02-T02 (Team Nam)**: Refactor `config.py::AppConfig` sử dụng `AtomicJsonFile` ➔ `infrastructure/config/config_repository.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R02-T03 (Team Nam)**: Xây dựng Typed Settings Facade (`ProviderSettings`, `RoutingSettings`) ➔ `infrastructure/config/settings_facade.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R02-T04 (Team Nam)**: Định nghĩa interface `SecretStore` và cài đặt `KeyringAdapter` ➔ `infrastructure/secrets/keyring_adapter.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R02-T05 (Team Nam)**: Di chuyển cấu hình API Key của OpenAI/Anthropic/FPT Gateway sang lưu trữ qua `SecretStore`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R02-T06 (Team Nam)**: Chuẩn hóa JSON schema versioning và recovery policy cho các file data
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🔹 EPIC R03: Model Providers & Routing (Hợp Nhất Nhà Cung Cấp & Bộ Định Tuyến Mô Hình)
|
|
||||||
* **Team chịu trách nhiệm**: 🔵 **Team Duy** (Chủ trì)
|
|
||||||
* **Mục tiêu**: Hợp nhất logic routing bị phân tán thành `RoutingApplicationService` độc lập Qt; chuẩn hóa catalog nhà cung cấp.
|
|
||||||
|
|
||||||
- [ ] **R03-T01 (Team Duy)**: Xây dựng bộ Contract Tests chuẩn hóa cho các Provider từ `providers/base.py` ➔ `tests/contracts/test_providers.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R03-T02 (Team Duy)**: Xây dựng `ProviderDescriptor` và `ProviderRegistry` tập trung từ `providers/factory.py` ➔ `domain/models/provider_descriptor.py` & `infrastructure/providers/provider_registry.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R03-T03 (Team Duy)**: Xây dựng `RoutingApplicationService` độc lập với Qt từ `core/routing/` ➔ `application/model_routing/routing_application_service.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R03-T04 (Team Duy)**: Di chuyển luồng gọi routing từ `ui/chat_panel.py#L638` sang `RoutingApplicationService`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R03-T05 (Team Duy)**: Di chuyển luồng gọi routing từ `ui/co4e_tab.py` và `ui/folder_tab.py` sang `RoutingApplicationService`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R03-T06 (Team Duy)**: Tách logic ghi nhận token usage ra khỏi Provider, chuyển thành `UsageEventSink` ➔ `infrastructure/telemetry/usage_sink.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🔹 EPIC R04: Agent Runtime & Conversation Application Service (Vòng Đời Turn Chat & Agent Engine)
|
|
||||||
* **Team chịu trách nhiệm**: 🔵 **Team Duy** (Chủ trì)
|
|
||||||
* **Mục tiêu**: Đóng gói input turn chat thành `ConversationExecutionRequest` bất biến, điều phối vòng đời qua `ConversationApplicationService` và phát sinh sự kiện `AgentEvent` có định kiểu.
|
|
||||||
|
|
||||||
- [ ] **R04-T01 (Team Duy)**: Định nghĩa immutable dataclass `ConversationExecutionRequest` ➔ `domain/agents/conversation_execution_request.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R04-T02 (Team Duy)**: Chuẩn hóa các sự kiện `AgentEvent` (TextChunk, ToolCallStarted, ToolCallResult, Error) ➔ `domain/agents/agent_event.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R04-T03 (Team Duy)**: Xây dựng `ConversationApplicationService` điều phối thực thi từ `core/chat_agent.py` ➔ `application/conversations/conversation_application_service.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R04-T04 (Team Duy)**: Di chuyển `ui/cowork_tab.py::build_job` sang sử dụng `ConversationExecutionRequest`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R04-T05 (Team Duy)**: Di chuyển `core/task_executors.py` sang dùng chung `ConversationApplicationService`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🔹 EPIC R05: Tool, MCP & Connector Policy (Quản Lý Công Cụ, MCP & Cổng Kiểm Soát Quyền)
|
|
||||||
* **Team chịu trách nhiệm**: 🟢 **Team Hoa** (Chủ trì) + Phối hợp Team Duy
|
|
||||||
* **Mục tiêu**: Bóc tách monolithic `core/tools.py`, đưa toàn bộ Built-in tools, MCP tools và Connectors qua `ToolPolicyGateway` kiểm tra quyền phân tầng.
|
|
||||||
|
|
||||||
- [ ] **R05-T01 (Team Hoa)**: Định nghĩa `ToolDescriptor`, `ToolCapability` (read/write/execute/network) ➔ `domain/tools/tool_descriptor.py` & `domain/tools/tool_registry.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R05-T02 (Team Hoa)**: Tách nhỏ các built-in handlers từ `core/tools.py` ➔ `infrastructure/filesystem/file_tools.py`, `command_tools.py`, `fetch_tools.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R05-T03 (Team Hoa)**: Xây dựng `ToolPolicyGateway` (kiểm tra phân quyền allow/confirm/deny) ➔ `application/conversations/tool_policy_gateway.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R05-T04 (Team Hoa)**: Chuẩn hóa MCP tools từ `core/mcp_client.py` đi qua `ToolPolicyGateway`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R05-T05 (Team Hoa)**: Xây dựng `McpToolSourceManager` quản lý vòng đời tiến trình MCP ➔ `infrastructure/mcp/mcp_source_manager.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🔹 EPIC R06: Workspace, Filesystem & History Isolation (Cô Lập Không Gian Làm Việc & Quản Lý Tệp)
|
|
||||||
* **Team chịu trách nhiệm**: 🟢 **Team Hoa** (Chủ trì)
|
|
||||||
* **Mục tiêu**: Xóa bỏ biến toàn cục `state.py::active_project_id`, đóng gói workspace per-turn thành `WorkspaceSession` bất biến, bảo vệ an toàn đường dẫn sandbox.
|
|
||||||
|
|
||||||
- [ ] **R06-T01 (Team Hoa)**: Định nghĩa `WorkspaceSession` chứa snapshot project id, workspace root ➔ `domain/workspaces/workspace_session.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R06-T02 (Team Hoa)**: Xây dựng `WorkspaceRepository` từ `core/projects.py` & `ConversationRepository` từ `core/history.py` ➔ `infrastructure/persistence/json/workspace_repository_impl.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R06-T03 (Team Hoa)**: Xây dựng `ExecutionWorkspace` quản lý output/scratch files ➔ `infrastructure/filesystem/execution_workspace.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R06-T04 (Team Hoa)**: Khắc phục race condition trong `ui/workspace_tab.py::_load_current`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R06-T05 (Team Hoa)**: Xây dựng `FileWorkspaceService` xử lý thao tác file cho File Explorer và AI File Editor ➔ `application/workspaces/file_workspace_service.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🔹 EPIC R07: Scheduling & Workflow Runtime (Bộ Lập Lịch & Động Cơ Quy Trình)
|
|
||||||
* **Team chịu trách nhiệm**: 🟢 **Team Hoa** (Task Scheduling) + 🟣 **Team Nam** (Co4E Workflows)
|
|
||||||
* **Mục tiêu**: Tách `TaskRepository` và `ScheduleCalculator` khỏi `QTimer` trong `core/task_scheduler.py#L20`; xây dựng `TaskApplicationService` và `Co4EWorkflowService`.
|
|
||||||
|
|
||||||
- [ ] **R07-T01 (Team Hoa)**: Tách `TaskRepository` lưu trữ JSON độc lập khỏi `core/tasks.py` ➔ `infrastructure/persistence/json/task_repository_impl.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R07-T02 (Team Hoa)**: Xây dựng `ScheduleCalculator` tính due-time / cron độc lập ➔ `domain/tasks/schedule_calculator.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R07-T03 (Team Hoa)**: Xây dựng `QtSchedulerClock` adapter (tách `TaskScheduler` khỏi `QTimer`) ➔ `platform/qt/qt_scheduler_clock.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R07-T04 (Team Hoa)**: Xây dựng `TaskApplicationService` (Pure Python) điều phối chạy, sao chép, dừng, xóa task ➔ `application/scheduling/task_application_service.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R07-T05 (Team Hoa)**: Xây dựng `AiTaskPlannerService` hỗ trợ tạo / import task bằng AI ➔ `application/scheduling/ai_task_planner_service.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R07-T06 (Team Nam)**: Xây dựng `Co4EWorkflowService` (Pure Python) quản lý định nghĩa và thực thi Co4E từ `core/co4e_run_manager.py` ➔ `application/workflows/co4e_workflow_service.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🔹 EPIC R08: UI/Application Separation (Phân Rã Toàn Diện Các God Widgets)
|
|
||||||
* **Team chịu trách nhiệm**: **Cả 3 Team** (Mỗi team phụ trách phân hệ của mình)
|
|
||||||
* **Mục tiêu**: Phân rã các file giao diện khổng lồ (>1.500 dòng) thành các widget chuyên biệt, mỗi file < 400 dòng code.
|
|
||||||
|
|
||||||
#### 🔵 Team Duy (Chat UI Hub):
|
|
||||||
- [ ] **R08-T01**: Tách `ui/chat_panel.py` thành `presentation/chat/chat_history_widget.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R08-T02**: Tách Composer & input box ➔ `presentation/chat/composer_widget.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R08-T03**: Tách Picker file đính kèm ➔ `presentation/chat/attachment_picker.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R08-T04**: Tách Voice/Audio recording ➔ `presentation/chat/audio_recorder_widget.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R08-T05**: Tách Output panel & file watcher ➔ `presentation/chat/chat_output_panel.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R08-T06**: Lắp ráp container `presentation/chat/chat_panel.py` và tối ưu `Floating HelpAgent`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
|
|
||||||
#### 🟣 Team Nam (Settings, Monitoring, Co4E & Shell):
|
|
||||||
- [ ] **R08-T07**: Tách `ui/settings_dialog.py` thành 4 section widgets ➔ `provider_settings_widget.py`, `connector_settings_widget.py`, `routing_settings_widget.py`, `general_settings_widget.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R08-T08**: Tách `ui/monitoring_tab.py` thành 7 tab độc lập (`overview_tab.py`, `sandbox_status_tab.py`, `security_events_tab.py`, `mcp_history_tab.py`, `action_logs_tab.py`, `agent_status_tab.py`, `security_settings_tab.py`)
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R08-T09**: Tách `ui/co4e_tab.py` thành các sub-components ➔ `co4e_canvas_widget.py`, `node_property_panel.py`, `co4e_run_control_widget.py`, `co4e_chat_view.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R08-T10**: Xây dựng `bootstrap.py` (Composition Root) và tách `app.py::MainWindow` (dòng 122) ➔ `presentation/shell/main_window.py`, `tray_manager.py`, `lifecycle_coordinator.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
|
|
||||||
#### 🟢 Team Hoa (Workspace, Folder, Scheduling, Dashboard & Graph):
|
|
||||||
- [ ] **R08-T11**: Tách `ui/schedule_task_tab.py` ➔ `kanban_board_widget.py`, `calendar_view_widget.py`, `ai_task_creator_dialog.py`, `ai_task_import_dialog.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R08-T12**: Tách `ui/folder_tab.py#L350` ➔ `workspace_file_tree.py`, `document_preview_manager.py`, `ai_file_editor_dialog.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R08-T13**: Tách `ui/dashboard_tab.py` ➔ `token_usage_card_widget.py`, `usage_chart_widget.py`, `habits_widget.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R08-T14**: Tách `ui/structure_graph_view.py` ➔ `presentation/graph/structure_graph_view.py` & `graph_qa_widget.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🔹 EPIC R09: Security Runtime, Sandbox & Observability (An Ninh Runtime, Sandbox & Giám Sát)
|
|
||||||
* **Team chịu trách nhiệm**: 🟣 **Team Nam** (Chủ trì) + Phối hợp Team Duy
|
|
||||||
* **Mục tiêu**: Phân biệt deterministic rules và AI guardrails, fix toàn bộ circular imports trong security/pricing, chuẩn hóa schema audit logs.
|
|
||||||
|
|
||||||
- [ ] **R09-T01 (Team Nam)**: Viết tài liệu chuẩn hóa Security Policy Model ➔ `docs/architecture/security-policy.md`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R09-T02 (Team Nam)**: Xử lý triệt để Circular Import giữa `core/model_pricing.py` và `core/usage_tracker.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R09-T03 (Team Nam)**: Xử lý triệt để Circular Import giữa `core/agent_security.py` và `core/agent_security_alert.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R09-T04 (Team Nam)**: Xây dựng `CanonicalAuditLogger` thống nhất định dạng log từ `core/audit_log.py` ➔ `infrastructure/telemetry/audit_logger.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R09-T05 (Team Nam)**: Xây dựng `MonitoringQueryService` (truy vấn read-only có phân trang) ➔ `application/monitoring/monitoring_query_service.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R09-T06 (Team Nam)**: Chuẩn hóa ma trận năng lực Sandbox trên từng hệ điều hành từ `core/sandbox_manager.py` ➔ `infrastructure/sandbox/sandbox_capabilities.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🔹 EPIC R10: Testing, Packaging & Contributor Experience (Hệ Thống Kiểm Thử & Tài Liệu Đóng Góp)
|
|
||||||
* **Team chịu trách nhiệm**: 🔵 **Team Duy** (Chủ trì chính - Task trọng tâm của Team Duy)
|
|
||||||
* **Mục tiêu**: Xây dựng toàn bộ hệ thống test pyramid (unit, contract, integration, headless UI), thiết lập CI Quality Gate tự động, soạn thảo tài liệu Contributor Recipes và thực hiện E2E smoke test trước khi phát hành.
|
|
||||||
|
|
||||||
- [ ] **R10-T01 (Team Duy)**: Thiết lập Tháp kiểm thử phân tầng (Unit tests không I/O <0.05s, Contract tests cho Providers/Tools, Integration tests, Fakes library) ➔ `tests/`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R10-T02 (Team Duy)**: Xây dựng Bộ script CI Quality Gate tự động (`scripts/check_imports.py`, `scripts/check_loc.py`, `scripts/audit_security.py`, `scripts/run_quality_gate.py`)
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R10-T03 (Team Duy)**: Cập nhật tài liệu kiến trúc 4 tầng, hướng dẫn setup môi trường & pre-commit hook ➔ `README.md` & `START_CONTRIBUTING.md`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R10-T04 (Team Duy)**: Soạn thảo bộ Contributor Recipes (3 công thức: Thêm Model Provider, Thêm Built-in/MCP Tool, Thêm Màn hình/Widget) ➔ `docs/governance/contributor-recipes.md`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
- [ ] **R10-T05 (Team Duy)**: Xây dựng bộ kiểm thử khói phát hành (E2E Release Smoke Test qua headless Qt với 5 kịch bản chính) ➔ `tests/e2e/test_smoke.py`
|
|
||||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📅 PHẦN 2: CHECKLIST TIẾN ĐỘ THEO NGÀY CỦA TỪNG TEAM (21/08 ➔ 31/08)
|
|
||||||
|
|
||||||
### 🔵 TEAM DUY (Core AI, Routing, Turn Runtime & Testing Lead)
|
|
||||||
|
|
||||||
| Ngày | Task Cần Hoàn Thành | Start Time | End Time | Trạng Thái |
|
|
||||||
| :--- | :--- | :---: | :---: | :---: |
|
|
||||||
| **21/08 (T6)** | Khóa DTO `ConversationExecutionRequest`, `AgentEvent`; Xây dựng `FakeProvider`, `FakeToolExecutor` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
|
||||||
| **22-23/08 (T7-CN)** | Chuẩn hóa `ProviderDescriptor`, `ProviderRegistry`; Wrap OpenAI, Anthropic, Ollama, FPT Gateway; Viết Contract Tests | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
|
||||||
| **24/08 (T2)** | Xây dựng `RoutingApplicationService` độc lập Qt; Tách `ComposerWidget` & `AttachmentPicker` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
|
||||||
| **25/08 (T3)** | Xây dựng `ConversationApplicationService`; Tách `ChatHistoryWidget` và bubble renderer | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
|
||||||
| **26/08 (T4)** | Nối stream `AgentEvent` sang Chat History; Tách `AudioRecorderWidget` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
|
||||||
| **27/08 (T5)** | Tách `ChatOutputPanel` & File Watcher; Lắp ráp container `ChatPanel` và `Floating HelpAgent` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
|
||||||
| **28/08 (T6)** | Xóa copy routing cũ trong `ui/chat_panel.py`; Fix circular import `model_pricing` ↔ `usage_tracker` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
|
||||||
| **29/08 (T7)** | Viết suite integration test cho toàn bộ luồng Chat (`tests/integration/test_chat_flow.py`) | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
|
||||||
| **30/08 (CN)** | 🔍 **Chủ trì CASAN Check 3**: Chạy `python scripts/check_imports.py` đảm bảo 0 import `PySide6` trong domain & application | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
|
||||||
| **31/08 (T2)** | **Chủ trì EPIC R10**: Viết Contributor Recipes, chạy E2E Smoke Test (`tests/e2e/test_smoke.py`) và merge PR cuối cùng | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🟣 TEAM NAM (Automation Workflows, Co4E, Monitoring & Governance)
|
|
||||||
|
|
||||||
| Ngày | Task Cần Hoàn Thành | Start Time | End Time | Trạng Thái |
|
|
||||||
| :--- | :--- | :---: | :---: | :---: |
|
|
||||||
| **21/08 (T6)** | Khóa DTO Co4E; Xây dựng `AtomicJsonFile` và `KeyringAdapter` (`SecretStore`) | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
|
||||||
| **22-23/08 (T7-CN)** | Refactor `config.py` sang `ConfigRepository`; Tách `ProviderSettingsWidget` & `ConnectorSettingsWidget` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
|
||||||
| **24/08 (T2)** | Tách 3 tab đầu của Monitoring (`overview_tab.py`, `sandbox_status_tab.py`); Xây dựng `MonitoringQueryService` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
|
||||||
| **25/08 (T3)** | Tách 4 tab còn lại của Monitoring (`security_events_tab.py`, `mcp_history_tab.py`,...); Lắp ráp container `MonitoringTab` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
|
||||||
| **26/08 (T4)** | Bóc tách `Co4EWorkflowService`; Tách `NodePropertyPanel` & `AgentListPanel` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
|
||||||
| **27/08 (T5)** | Tách `Co4ECanvasWidget`, `Co4ERunControlWidget` & `Co4EChatView` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
|
||||||
| **28/08 (T6)** | Lắp ráp container `Co4ETab`; Xây dựng `bootstrap.py` (Composition Root) và tách `MainWindow` shell | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
|
||||||
| **29/08 (T7)** | Fix circular import `agent_security` ↔ `agent_security_alert`; Integration test luồng Co4E & Settings | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
|
||||||
| **30/08 (CN)** | 🔍 **Chủ trì CASAN Check 1**: Chạy `python scripts/audit_security.py` đảm bảo 0 API Key/Token plaintext | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
|
||||||
| **31/08 (T2)** | Fix tồn đọng Check 1, cập nhật tài liệu kiến trúc, merge PR cuối | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🟢 TEAM HOA (Workspace, Filesystem, Scheduling & Tool Registry)
|
|
||||||
|
|
||||||
| Ngày | Task Cần Hoàn Thành | Start Time | End Time | Trạng Thái |
|
|
||||||
| :--- | :--- | :---: | :---: | :---: |
|
|
||||||
| **21/08 (T6)** | Khóa DTO `ToolDescriptor`, `ToolCapability`; Tách `FileTools` từ `core/tools.py` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
|
||||||
| **22-23/08 (T7-CN)** | Tách `CommandTools`, `FetchTools`; Tách `TokenUsageCardWidget` & `UsageChartWidget` (Dashboard) | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
|
||||||
| **24/08 (T2)** | Tách `TaskRepository` & `ScheduleCalculator`; Tách `KanbanBoardWidget` (7 cột trạng thái) | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
|
||||||
| **25/08 (T3)** | Xây dựng `QtSchedulerClock` adapter (tách khỏi `QTimer`); Tách `CalendarViewWidget` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
|
||||||
| **26/08 (T4)** | Xây dựng `TaskApplicationService`; Tách `AiTaskCreatorDialog` & `AiTaskImportDialog` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
|
||||||
| **27/08 (T5)** | Tách `WorkspaceFileTree`, `DocumentPreviewManager` & `AiFileEditorDialog` từ `FolderTab` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
|
||||||
| **28/08 (T6)** | Tách `StructureGraphView` (GraphRAG); Lắp ráp shell `FolderTab` & `ScheduleTaskTab` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
|
||||||
| **29/08 (T7)** | Nối `ToolPolicyGateway` qua MCP Client & Built-in Tools; Integration test Task Scheduler & File Explorer | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
|
||||||
| **30/08 (CN)** | 🔍 **Chủ trì CASAN Check 2**: Chạy `python scripts/check_loc.py --max-lines 400` đảm bảo 0 file >400 dòng | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
|
||||||
| **31/08 (T2)** | Fix tồn đọng Check 2, cập nhật README, merge PR cuối | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚦 PHẦN 3: CHECKLIST CHECKPOINT REVIEW & CƠ CHẾ KIỂM DUYỆT CASAN
|
|
||||||
|
|
||||||
### 🛡️ Định nghĩa 5 Chữ Cái CASAN:
|
|
||||||
- **C - Clean Architecture**: 0 import `PySide6` trong `domain/` và `application/`.
|
|
||||||
- **A - Atomic Persistence**: 0 plaintext secrets trong JSON/config; dùng `AtomicJsonFile` ghi tệp an toàn.
|
|
||||||
- **S - Single Responsibility**: 0 file production nào > 400 dòng code (LOC).
|
|
||||||
- **A - Automated Test Pyramid**: Bộ test phân tầng chạy offline 100% không phụ thuộc network/UI.
|
|
||||||
- **N - No Regression & Smoke**: Toàn bộ suite test (>81 tests) và E2E Smoke test pass 100%.
|
|
||||||
|
|
||||||
### 🔍 Bảng Theo Dõi Các Checkpoints & Cổng Kiểm Duyệt CASAN:
|
|
||||||
|
|
||||||
| Thời Điểm | Checkpoint / Cổng Duyệt | Lệnh Kiểm Tra Thực Tế | Tiêu Chí Bắt Buộc | Phụ Trách | Start Time | End Time | Trạng Thái |
|
|
||||||
| :--- | :--- | :--- | :--- | :--- | :---: | :---: | :---: |
|
|
||||||
| **23/08 (CN - 17:00)** | **Checkpoint 1: Contracts & Fakes** | `pytest tests/contracts tests/fakes` | 100% DTO và Fake Services tạo xong; test pass | Cả 3 Team | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
|
||||||
| **28/08 (T6 - 17:00)** | **Checkpoint 2: Services & Sub-widgets** | `pytest tests/` | Tách xong 100% God Files; 0 circular import | Cả 3 Team | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
|
||||||
| **30/08 (CN - 17:00)** | **CASAN Check 1: Security Audit** | `python scripts/audit_security.py` | 0 plaintext secret trong file cấu hình | Team Nam | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
|
||||||
| **30/08 (CN - 17:00)** | **CASAN Check 2: Modularity (LOC)** | `python scripts/check_loc.py --max-lines 400` | 0 file production nào > 400 dòng code | Team Hoa | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
|
||||||
| **30/08 (CN - 17:00)** | **CASAN Check 3: Clean Architecture** | `python scripts/check_imports.py` | 0 import `PySide6` trong domain & application | Team Duy | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
|
||||||
| **31/08 (T2 - 15:00)** | **Final Release E2E Smoke Test** | `pytest tests/e2e/test_smoke.py` | 5 kịch bản end-to-end pass 100% trên `main` | Team Duy & 3 Team | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 PHẦN 4: DEFINITION OF DONE (DOD) CHO MỖI PULL REQUEST
|
|
||||||
|
|
||||||
Mọi Pull Request của cả 3 team trước khi merge vào nhánh chính cần được đối chiếu checklist sau:
|
|
||||||
|
|
||||||
- [ ] **1. Kích thước file (LOC)**: File mới hoặc file sau khi refactor không vượt quá **400 dòng code**.
|
|
||||||
- [ ] **2. Phụ thuộc kiến trúc (Clean Architecture)**: Không import `PySide6` / Qt trong các module thuộc `domain/` và `application/`.
|
|
||||||
- [ ] **3. An toàn thông tin (Security)**: API Key / Credential được lưu trữ qua `SecretStore` (Keyring), không lưu cứng hoặc lưu plaintext trong file JSON.
|
|
||||||
- [ ] **4. Bắt buộc Comment Code bằng Tiếng Anh (English In-Code Comments)**: 100% các dòng hoặc khối code sửa đổi/bóc tách đều có comment tiếng Anh giải thích rõ logic xử lý và lý do kỹ thuật.
|
|
||||||
- [ ] **5. Ghi nhận thời gian thực hiện (Timestamps)**: Đã điền đầy đủ mốc thời gian `Start: YYYY-MM-DD HH:mm` và `End: YYYY-MM-DD HH:mm` vào `Refactoring_Checklist.md` và PR description.
|
|
||||||
- [ ] **6. Kiểm thử tự động (Automated Tests)**: Có unit test hoặc contract test đi kèm với tỷ lệ pass 100%. Chạy `pytest` hoàn tất < 3 giây.
|
|
||||||
- [ ] **7. Không gây lỗi chéo (No Regression)**: Chạy kiểm thử toàn bộ hệ thống không làm hỏng các tính năng hiện hữu.
|
|
||||||
|
|
||||||
@@ -1,682 +0,0 @@
|
|||||||
## 🗓️ VI. LỘ TRÌNH THỰC HIỆN - 10 EPIC (REFACTORING ROADMAP)
|
|
||||||
|
|
||||||
### Bảng Tổng Quan 10 EPIC
|
|
||||||
|
|
||||||
| EPIC | Tên | Dependency | Giá Trị Kiến Trúc |
|
|
||||||
| :--- | :--- | :--- | :--- |
|
|
||||||
| **R01** | Architecture Foundation & Characterization | Không | Safety net + ngôn ngữ chung trước khi nhiều người sửa |
|
|
||||||
| **R02** | Configuration, Secrets & Persistence | R01 | Loại bỏ global dict/direct write và bảo vệ credential |
|
|
||||||
| **R03** | Model Providers & Routing | R01, R02 | 1 đường mở rộng provider, 1 routing flow duy nhất |
|
|
||||||
| **R04** | Agent Runtime & Conversation Service | R01, R03 | Tách turn lifecycle khỏi widget |
|
|
||||||
| **R05** | Tool, MCP & Connector Policy | R01, R04 | 1 security/approval path cho mọi tool call |
|
|
||||||
| **R06** | Workspace, Filesystem & History Isolation | R01, R02 | Loại bỏ cross-project mutable path/state |
|
|
||||||
| **R07** | Scheduling & Workflow Runtime | R01, R04, R06 | Tách Qt timer, persistence và runtime dispatch |
|
|
||||||
| **R08** | UI/Application Separation | R03 - R07 | Thu nhỏ God widgets theo từng screen |
|
|
||||||
| **R09** | Security Runtime, Sandbox & Observability | R01, R05 | Policy rõ, event schema thống nhất |
|
|
||||||
| **R10** | Testing, Packaging & Contributor Experience | Tất cả | CI, docs, contributor có thể sửa 1 capability độc lập |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 💡 Chiến Lược Triển Khai Song Song 100% Cho 3 Team (Zero Blocking)
|
|
||||||
|
|
||||||
Để 3 team làm việc cùng lúc từ **21/08 đến 31/08/2026** mà không bị nghẽn (blocked), không phải chờ đợi nhau và loại bỏ hoàn toàn rủi ro merge conflict:
|
|
||||||
|
|
||||||
1. **Ranh giới sở hữu mã nguồn tuyệt đối (Code Ownership & Zero File Overlap)**: Mỗi file/thư mục chỉ thuộc quyền chỉnh sửa của duy nhất 1 team. Tuyệt đối không để 2 team cùng sửa chung 1 file cùng lúc.
|
|
||||||
2. **Nguyên tắc Contract-First & Mock-Driven**: Thống nhất Data Contract / DTO / Interface ngay từ Ngày 1. Khi cần gọi chéo giữa các phân hệ, team gọi sẽ dùng `Fake/Mock Adapter` để hoàn thiện UI/logic nội bộ mà **không cần chờ** team kia hoàn thành implementation.
|
|
||||||
3. **Phân chia theo Phân hệ nghiệp vụ (Vertical Domain Slices)**: Mỗi team phụ trách trọn vẹn từ UI Sub-widgets đến Application Service và Infrastructure của phân hệ đó, đảm bảo tính tự chủ và khả năng test độc lập.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph TD
|
|
||||||
subgraph T1 [🔵 TEAM 1: Core AI & Conversation Hub]
|
|
||||||
UI1[presentation/chat/] --> APP1[application/conversations/<br>application/model_routing/]
|
|
||||||
APP1 --> DOM1[domain/agents/<br>domain/models/]
|
|
||||||
APP1 --> INF1[infrastructure/providers/]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph T2 [🟣 TEAM 2: Automation, Workflows & Governance]
|
|
||||||
UI2[presentation/co4e/<br>presentation/monitoring/<br>presentation/settings/] --> APP2[application/workflows/<br>application/monitoring/<br>application/settings/]
|
|
||||||
APP2 --> DOM2[domain/workflows/<br>domain/security/]
|
|
||||||
APP2 --> INF2[infrastructure/config/<br>infrastructure/secrets/]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph T3 [🟢 TEAM 3: Workspace, Tools & Scheduling]
|
|
||||||
UI3[presentation/folder/<br>presentation/scheduling/<br>presentation/dashboard/<br>presentation/graph/] --> APP3[application/workspaces/<br>application/scheduling/]
|
|
||||||
APP3 --> DOM3[domain/tools/<br>domain/tasks/]
|
|
||||||
APP3 --> INF3[infrastructure/filesystem/<br>infrastructure/mcp/<br>infrastructure/persistence/]
|
|
||||||
end
|
|
||||||
|
|
||||||
style T1 fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
|
|
||||||
style T2 fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
|
|
||||||
style T3 fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🖥️ Cấu Trúc Giao Diện Thực Tế & Bản Đồ Điều Hướng (Verified UI Layout & Navigation Map)
|
|
||||||
|
|
||||||
Qua kiểm tra trực tiếp mã nguồn thực tế của giao diện (`app.py`, `ui/workspace_tab.py`, `ui/chat_panel.py`, `ui/co4e_tab.py`, `ui/folder_tab.py`, `ui/monitoring_tab.py`), cấu trúc layout hiện tại của Cowork Local được thiết kế theo mô hình **Thanh điều hướng phẳng (Flat Collapsible Nav Rail) + Không gian làm việc đa phân hệ (Workspace Hub)**:
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph TD
|
|
||||||
MW["MainWindow (app.py)"]
|
|
||||||
|
|
||||||
subgraph NR ["👈 Collapsible Left Nav Rail (54px / 150px)"]
|
|
||||||
N_TOP["Header: Project Picker + '+ Chat Mới'"]
|
|
||||||
N_MAIN["Main Nav (Flat List)"]
|
|
||||||
N_REC["Section: GẦN ĐÂY (Recent Threads)"]
|
|
||||||
N_BOT["Bottom Nav (Ghim Đáy)"]
|
|
||||||
N_FOOT["Footer: Cài Đặt (Settings) + Tài Khoản"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph CA ["👉 Main Content Area (QStackedWidget)"]
|
|
||||||
P_WS["📁 WorkspaceTab (Trang Chủ Chính)"]
|
|
||||||
P_SCH["⏰ ScheduleTaskTab (Lịch Trình)"]
|
|
||||||
P_DB["📊 DashboardTab (Bảng Điều Khiển)"]
|
|
||||||
P_MON["🛡️ MonitoringTab (Giám Sát & Quản Trị - 8 Tabs)"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph WST ["📦 Các Sub-Tabs Trong Workspace (Điều khiển từ Nav Rail)"]
|
|
||||||
ST_PROJ["1. 📁 Dự Án (Project info, instructions, folder path)"]
|
|
||||||
ST_COW["2. 💬 Cowork (Chat Panel + Outer History Sidebar)"]
|
|
||||||
ST_CO4E["3. ⚡ Co4E Studio (Canvas Node, Agent/Skill Palette, Run Chat)"]
|
|
||||||
ST_FOLD["4. 📂 Folder Explorer (Tree, Code Editor, Preview, Terminal, AI Edit)"]
|
|
||||||
ST_GRAPH["5. 🕸️ GraphRAG (Knowledge Graph View + Q&A Panel)"]
|
|
||||||
end
|
|
||||||
|
|
||||||
MW --> NR
|
|
||||||
MW --> CA
|
|
||||||
N_MAIN -->|Chuyển sub-tab| WST
|
|
||||||
N_MAIN -->|Mở trang| P_SCH
|
|
||||||
N_BOT -->|Mở trang| P_DB
|
|
||||||
N_BOT -->|Mở trang| P_MON
|
|
||||||
P_WS --> WST
|
|
||||||
|
|
||||||
style MW fill:#1e293b,stroke:#0ea5e9,color:#fff
|
|
||||||
style NR fill:#0f172a,stroke:#334155,color:#fff
|
|
||||||
style CA fill:#1e293b,stroke:#475569,color:#fff
|
|
||||||
style WST fill:#334155,stroke:#38bdf8,color:#fff
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 📌 Chi Tiết Thành Phần Giao Diện Của Từng Phân Hệ:
|
|
||||||
|
|
||||||
1. **Thanh Điều Hướng Trái (Left Nav Rail - `app.py`):**
|
|
||||||
- Nút thu gọn / mở rộng (Menu toggle 54px ↔ 150px).
|
|
||||||
- Bộ chọn nhanh dự án (`nav_project` / `nav_project_btn`) & Nút `+ Chat mới` (`nav_new_chat`).
|
|
||||||
- Danh sách phẳng các màn hình làm việc chính (Dự án, Cowork, Co4E, Folder, GraphRAG, Lịch trình).
|
|
||||||
- Danh sách hội thoại gần đây (`RECENTS`) của dự án đang chọn.
|
|
||||||
- Nhóm ghim đáy (Bảng điều khiển, Giám sát) + Nút mở Cài đặt & Hàng thông tin tài khoản.
|
|
||||||
- **Trợ lý nổi (Floating Help Agent - `ui/help_agent_widget.py`):** Biểu tượng robot ghim góc dưới phải ở mọi màn hình, click là mở cửa sổ chat trợ giúp nhanh.
|
|
||||||
|
|
||||||
2. **Workspace Tab (Trang Chủ - `ui/workspace_tab.py`):**
|
|
||||||
- **Cột trái:** Danh sách quản lý Dự án (Create, Delete, đổi tên, thu gọn / mở rộng).
|
|
||||||
- **Cột giữa:** Thanh lịch sử hội thoại ngoài (`ui/sidebar.py::HistorySidebar`) hiển thị xuyên suốt cho cả Cowork và GraphRAG.
|
|
||||||
- **Vùng chính:** Chứa 5 sub-tabs (ẩn thanh tab bar ngang để Nav Rail điều hướng trực tiếp):
|
|
||||||
- **Dự Án (`ProjectTab`):** Tên, mô tả, chỉ dẫn chung (shared instructions), đường dẫn thư mục sandbox, danh sách luồng chat.
|
|
||||||
- **Cowork (`ui/cowork_tab.py`):** Khung chat chính (`ui/chat_panel.py`, `ui/chat_view.py`, `ui/composer.py`).
|
|
||||||
- **Co4E Studio (`ui/co4e_tab.py`):** Canvas thiết kế luồng đồ thị node (`ui/co4e_canvas.py`), bảng chỉnh thuộc tính node (`ui/co4e_config_panel.py`), thư viện Agent/Skill, bộ điều khiển chạy luồng & Chat view tương tác.
|
|
||||||
- **Folder Explorer (`ui/folder_tab.py`):** Cây thư mục workspace, trình soạn thảo code syntax highlight, trình xem trước tài liệu đa định dạng (PDF, MS Office qua LibreOffice `ui/libreoffice_view.py`, HTML, Ảnh), Terminal tích hợp (`ui/terminal_panel.py`), và Dialog sửa code bằng AI (`ui/file_edit_dialog.py`).
|
|
||||||
- **GraphRAG (`ui/structure_graph_view.py`):** Đồ thị tri thức D3 WebEngine / Native 2D, bộ lọc thực thể, panel hỏi đáp ngữ cảnh mã nguồn (Graph Q&A).
|
|
||||||
|
|
||||||
3. **Schedule Task Tab (Lịch Trình - `ui/schedule_task_tab.py`):**
|
|
||||||
- Bảng Kanban 7 cột trạng thái (Backlog, Todo, In Progress, Review, Done, Blocked, Cancelled) hỗ trợ kéo thả.
|
|
||||||
- Chế độ xem Lịch tháng (`ui/calendar_view.py`) trực quan hóa các task định kỳ và due dates.
|
|
||||||
- Dialog chỉnh sửa task (`ui/task_editor_dialog.py`) & các bộ tạo task tự động bằng AI.
|
|
||||||
|
|
||||||
4. **Dashboard Tab (Bảng Điều Khiển - `ui/dashboard_tab.py`):**
|
|
||||||
- Thẻ thống kê tổng lượng Token tiêu thụ, chi phí ước tính, số lượng tác vụ đã chạy.
|
|
||||||
- Biểu đồ Spline trực quan hóa xu hướng chi phí theo thời gian (`ui/spline_chart.py`).
|
|
||||||
- Bảng thói quen sử dụng mô hình (AI Model Habits) và hạn mức ngân sách.
|
|
||||||
|
|
||||||
5. **Monitoring Tab (Giám Sát & Quản Trị - `ui/monitoring_tab.py`):**
|
|
||||||
- Giữ nguyên tab bar nội bộ với 8 tab chuyên trách:
|
|
||||||
1. **Tổng quan (Overview):** Metrics CPU, Memory, số tiến trình sandbox, tổng log.
|
|
||||||
2. **Trạng thái Sandbox (Sandbox Status):** Giám sát các container/sub-process cách ly.
|
|
||||||
3. **Sự kiện bảo mật (Security Events):** Danh sách cảnh báo vi phạm policy an toàn.
|
|
||||||
4. **Lịch sử MCP (MCP History):** Nhật ký gọi tool MCP và latency.
|
|
||||||
5. **Nhật ký hoạt động (Action Logs):** Log chi tiết mọi thao tác đọc/ghi tệp, thực thi lệnh.
|
|
||||||
6. **Quản trị Agent (`ui/agents_admin_tab.py`):** Cấu hình Prompt và tham số cho các Agent chuyên biệt & Help Agent.
|
|
||||||
7. **Cài đặt bảo mật (Security Settings):** Bật/tắt các rào chắn Sandbox và phê duyệt công cụ.
|
|
||||||
8. **Quản trị Tool / Icon (`ui/tools_admin_tab.py`, `ui/icons_admin_tab.py`):** Quản lý metadata công cụ và bộ icon hệ thống.
|
|
||||||
|
|
||||||
6. **Hộp Thoại Cài Đặt (Settings Dialog - `ui/settings_dialog.py`):**
|
|
||||||
- Cài đặt Nhà cung cấp (OpenAI, Anthropic, Ollama, FPT Gateway).
|
|
||||||
- Cài đặt Connectors (MCP Server, MS365, External APIs).
|
|
||||||
- Cài đặt Định tuyến mô hình (Off, Auto, Manual, Fallback rules).
|
|
||||||
- Cài đặt Chung (Ngôn ngữ, Giao diện Theme, Khởi động cùng hệ thống, System Tray).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 👥 Ranh Giới Phân Hệ & Phạm Vi Của 3 Team (Duy, Nam, Hoa)
|
|
||||||
|
|
||||||
| Team | Phân Hệ Phụ Trách | Phạm Vi Thư Mục Sở Hữu | File Cũ Cần Phân Rã / Tái Cấu Trúc | Trọng Tâm EPIC |
|
|
||||||
| :--- | :--- | :--- | :--- | :--- |
|
|
||||||
| **🔵 TEAM DUY**<br>*(Tech Lead)* | **Core AI, Routing, Agent Engine & Testing Lead** | `presentation/chat/`<br>`application/conversations/`<br>`application/model_routing/`<br>`domain/agents/`, `domain/models/`<br>`infrastructure/providers/`<br>`tests/` (Unit, Contract, Integration, E2E) | `ui/chat_panel.py`<br>`ui/cowork_tab.py`<br>`ui/help_agent_widget.py`<br>`core/chat_agent.py`<br>`core/routing/*`<br>`providers/*` | **R01, R03, R04, R10**<br>(Routing, Providers, Agent Engine, Chat UI, Floating Help Agent, Testing Pyramid, Contributor Recipes) |
|
|
||||||
| **🟣 TEAM NAM** | **Automation, Workflows, Governance & Security** | `presentation/co4e/`<br>`presentation/monitoring/`<br>`presentation/settings/`<br>`presentation/shell/`, `bootstrap.py`<br>`application/workflows/`, `monitoring/`, `settings/`<br>`infrastructure/config/`, `secrets/`, `sandbox/` | `ui/co4e_tab.py`<br>`ui/monitoring_tab.py`<br>`ui/settings_dialog.py`<br>`app.py::MainWindow`<br>`config.py`<br>`core/co4e_run_manager.py` | **R02, R08, R09**<br>(Co4E Studio, Monitoring 8 tabs, Settings, Keyring, Nav Rail & Shell, Security Scan) |
|
|
||||||
| **🟢 TEAM HOA** | **Workspace, Filesystem, Tools & Scheduling** | `presentation/workspace/`<br>`presentation/folder/`<br>`presentation/scheduling/`<br>`presentation/dashboard/`<br>`presentation/graph/`<br>`application/workspaces/`, `scheduling/`<br>`domain/tools/`, `domain/tasks/`<br>`infrastructure/filesystem/`, `mcp/`, `persistence/` | `ui/workspace_tab.py`<br>`ui/sidebar.py`<br>`ui/folder_tab.py`<br>`ui/structure_graph_view.py`<br>`ui/schedule_task_tab.py`<br>`ui/dashboard_tab.py`<br>`core/tools.py`<br>`core/task_executors.py`<br>`core/task_scheduler.py` | **R05, R06, R07, R08**<br>(Tools, Tasks, Workspace Project Manager, Folder Explorer & Editor, Graph RAG, Kanban Schedule, Dashboard) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 📅 Lịch Tổng Quan Theo Tuần (21/08 - 31/08/2026)
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
gantt
|
|
||||||
title Lộ Trình Phân Chia 3 Team Song Song (21/08 - 31/08/2026)
|
|
||||||
dateFormat YYYY-MM-DD
|
|
||||||
section Team Duy (Core AI, Chat & Testing Lead)
|
|
||||||
Khóa DTO + FakeProvider + Provider Registry :t1_1, 2026-08-21, 3d
|
|
||||||
RoutingService + Tách Composer & ChatHistory :t1_2, 2026-08-24, 3d
|
|
||||||
ConversationService + ChatOutput + ChatPanel Shell :t1_3, 2026-08-27, 3d
|
|
||||||
CASAN Check 3 + EPIC R10 Testing Pyramid & Smoke :t1_4, 2026-08-30, 2d
|
|
||||||
|
|
||||||
section Team Nam (Workflow & Governance)
|
|
||||||
Khóa DTO + AtomicConfig + Keyring + Settings Split :t2_1, 2026-08-21, 3d
|
|
||||||
MonitoringTab Split (7 tabs) + MonitoringService :t2_2, 2026-08-24, 2d
|
|
||||||
Co4E Canvas + RunControl + WorkflowService :t2_3, 2026-08-26, 3d
|
|
||||||
Bootstrap Root + CASAN Check 1 (Security Scan) :t2_4, 2026-08-29, 3d
|
|
||||||
|
|
||||||
section Team Hoa (Workspace, Tools & Scheduling)
|
|
||||||
Khóa DTO + ToolRegistry + File Tools + Dashboard :t3_1, 2026-08-21, 3d
|
|
||||||
TaskRepo + Clock + Kanban + Calendar View :t3_2, 2026-08-24, 3d
|
|
||||||
FolderTree + DocumentPreview + Graph RAG :t3_3, 2026-08-27, 3d
|
|
||||||
CASAN Check 2 (Single Responsibility) + E2E Support :t3_4, 2026-08-30, 2d
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🗓️ KẾ HOẠCH CHI TIẾT TỪNG NGÀY CHO 3 TEAM (21/08 ➔ 31/08)
|
|
||||||
|
|
||||||
#### 🔵 TEAM DUY: Core AI, Routing & Testing Lead (Tech Lead)
|
|
||||||
|
|
||||||
| Ngày | Công Việc Cụ Thể & Nơi Bóc Tách Code | File Đích Cần Tạo / Chỉnh Sửa | Tiêu Chí Kiểm Thử (Validation) |
|
|
||||||
| :--- | :--- | :--- | :--- |
|
|
||||||
| **21/08 (T6)** | • Khóa DTO từ `core/chat_agent.py`<br>• Xây dựng test doubles từ `providers/base.py` | ➔ `domain/agents/conversation_execution_request.py`<br>➔ `domain/agents/agent_event.py`<br>➔ `tests/fakes/fake_provider.py` | Unit test chạy <1s, không phụ thuộc Qt hay network |
|
|
||||||
| **22-23/08 (T7-CN)** | • Chuẩn hóa catalog từ `providers/factory.py`<br>• Wrap OpenAI, Anthropic, Ollama, FPT Gateway | ➔ `domain/models/provider_descriptor.py`<br>➔ `infrastructure/providers/provider_registry.py` | Golden response test cho từng provider |
|
|
||||||
| **24/08 (T2)** | • Hợp nhất routing từ `ui/chat_panel.py#L638` & `core/routing/`<br>• Tách Composer & Picker từ `ui/composer.py` | ➔ `application/model_routing/routing_application_service.py`<br>➔ `presentation/chat/composer_widget.py`<br>➔ `presentation/chat/attachment_picker.py` | Test routing policy không cần Qt; Composer test |
|
|
||||||
| **25/08 (T3)** | • Tách turn orchestration từ `ui/chat_panel.py#L70`<br>• Tách chat bubble/markdown từ `ui/chat_view.py` | ➔ `application/conversations/conversation_application_service.py`<br>➔ `presentation/chat/chat_history_widget.py` | Turn test với `FakeProvider`: text stream & tool calls |
|
|
||||||
| **26/08 (T4)** | • Nối sự kiện `AgentEvent` sang History Widget<br>• Tách ghi âm audio từ `ui/chat_panel.py` | ➔ `presentation/chat/audio_recorder_widget.py` | Event streaming UI test không lag main thread |
|
|
||||||
| **27/08 (T5)** | • Tách file watcher & output panel từ `ui/chat_panel.py#L18`<br>• Lắp ráp shell hoàn chỉnh | ➔ `presentation/chat/chat_output_panel.py`<br>➔ `presentation/chat/chat_panel.py` | Smoke test: ChatPanel mở mượt mà, render đủ thành phần |
|
|
||||||
| **28/08 (T6)** | • Xóa routing copy trong `ui/chat_panel.py`<br>• Fix circular import `core/model_pricing.py` ↔ `core/usage_tracker.py` | ➔ Patch các module liên quan | `python -c "import cowork_local"` không phát sinh lỗi |
|
|
||||||
| **29/08 (T7)** | • Viết suite integration test cho toàn bộ luồng Chat<br>• Rà soát số dòng code Team Duy (<400 dòng/file) | ➔ `tests/integration/test_chat_flow.py` | 100% test pass |
|
|
||||||
| **30/08 (CN)** | 🔍 **Chủ trì CASAN Check 3 (Import Guard)**: Quét tĩnh kiểm tra `domain/` & `application/` không import `PySide6` | ➔ `scripts/check_imports.py` | 0 violation trong code mới |
|
|
||||||
| **31/08 (T2)** | 🎯 **Chủ trì EPIC R10 (Task Chính Team Duy)**: Thiết lập Testing Pyramid, Contributor Recipes, E2E Smoke Test & Merge PR cuối | ➔ `tests/e2e/test_smoke.py`<br>➔ `docs/governance/contributor-recipes.md` | All tests pass, CASAN Gate PASS |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 🟣 TEAM NAM: Automation, Workflows, Governance & Security
|
|
||||||
|
|
||||||
| Ngày | Công Việc Cụ Thể & Nơi Bóc Tách Code | File Đích Cần Tạo / Chỉnh Sửa | Tiêu Chí Kiểm Thử (Validation) |
|
|
||||||
| :--- | :--- | :--- | :--- |
|
|
||||||
| **21/08 (T6)** | • Khóa DTO từ `core/co4e.py`<br>• Xây dựng Atomic Write & Keyring từ `config.py` | ➔ `infrastructure/persistence/json/atomic_json_file.py`<br>➔ `infrastructure/secrets/keyring_adapter.py` | Fault-injection test (atomic write); Credential store test |
|
|
||||||
| **22-23/08 (T7-CN)** | • Chuyển đổi `config.py` sang `ConfigRepository`<br>• Tách section từ `ui/settings_dialog.py` | ➔ `infrastructure/config/config_repository.py`<br>➔ `presentation/settings/provider_settings_widget.py`<br>➔ `presentation/settings/connector_settings_widget.py` | Config round-trip test; Settings UI render test |
|
|
||||||
| **24/08 (T2)** | • Tách 3 tab đầu từ `ui/monitoring_tab.py`<br>• Xây dựng truy vấn dữ liệu độc lập | ➔ `presentation/monitoring/overview_tab.py`<br>➔ `presentation/monitoring/sandbox_status_tab.py`<br>➔ `application/monitoring/monitoring_query_service.py` | Render dữ liệu thống kê độc lập |
|
|
||||||
| **25/08 (T3)** | • Tách 4 tab còn lại từ `ui/monitoring_tab.py`<br>• Lắp ráp shell Monitoring | ➔ `presentation/monitoring/security_events_tab.py`<br>➔ `presentation/monitoring/mcp_history_tab.py`<br>➔ `presentation/monitoring/monitoring_tab.py` | Smoke test: MonitoringTab chuyển tab mượt, filter log tốt |
|
|
||||||
| **26/08 (T4)** | • Bóc tách runner từ `core/co4e_run_manager.py`<br>• Tách config & agent panels từ `ui/co4e_tab.py#L3` | ➔ `application/workflows/co4e_workflow_service.py`<br>➔ `presentation/co4e/node_property_panel.py`<br>➔ `presentation/co4e/agent_list_panel.py` | Workflow CRUD & validation test độc lập |
|
|
||||||
| **27/08 (T5)** | • Tách Canvas vẽ node từ `ui/co4e_canvas.py`<br>• Tách Run control & chat view từ `ui/co4e_tab.py#L228` | ➔ `presentation/co4e/co4e_canvas_widget.py`<br>➔ `presentation/co4e/co4e_run_control_widget.py`<br>➔ `presentation/co4e/co4e_chat_view.py` | Canvas node operations test |
|
|
||||||
| **28/08 (T6)** | • Lắp ráp container Co4ETab<br>• Tách Composition root & MainWindow từ `app.py#L122` | ➔ `presentation/co4e/co4e_tab.py`<br>➔ `bootstrap.py`<br>➔ `presentation/shell/main_window.py` | Khởi động app qua `bootstrap.py` thành công |
|
|
||||||
| **29/08 (T7)** | • Fix circular import `core/agent_security.py` ↔ `core/agent_security_alert.py`<br>• Integration test luồng Co4E & Settings | ➔ Patch security modules | Co4E flow chạy trơn tru |
|
|
||||||
| **30/08 (CN)** | 🔍 **Chủ trì CASAN Check 1 (Security Scan)**: Quét rà soát toàn bộ file config/JSON để đảm bảo 0 API Key/Token lưu plaintext | ➔ Script security audit | 0 credential plaintext |
|
|
||||||
| **31/08 (T2)** | Fix tồn đọng Check 1, cập nhật tài liệu kiến trúc, merge PR cuối | — | CASAN Check 1 PASS |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 🟢 TEAM HOA: Workspace, Filesystem, Tools & Scheduling
|
|
||||||
|
|
||||||
| Ngày | Công Việc Cụ Thể & Nơi Bóc Tách Code | File Đích Cần Tạo / Chỉnh Sửa | Tiêu Chí Kiểm Thử (Validation) |
|
|
||||||
| :--- | :--- | :--- | :--- |
|
|
||||||
| **21/08 (T6)** | • Khóa DTO từ `core/tools.py` & `core/tasks.py`<br>• Tách file tools từ `core/tools.py` | ➔ `domain/tools/tool_descriptor.py`<br>➔ `domain/tools/tool_registry.py`<br>➔ `infrastructure/filesystem/file_tools.py` | Tool handler test độc lập; Atomic write test |
|
|
||||||
| **22-23/08 (T7-CN)** | • Tách command, fetch, image tools từ `core/tools.py`<br>• Tách card & chart từ `ui/dashboard_tab.py` | ➔ `infrastructure/filesystem/command_tools.py`<br>➔ `infrastructure/filesystem/fetch_tools.py`<br>➔ `presentation/dashboard/token_usage_card_widget.py`<br>➔ `presentation/dashboard/usage_chart_widget.py` | Tool execution test; Dashboard chart test với mock data |
|
|
||||||
| **24/08 (T2)** | • Tách repository & do lịch từ `core/tasks.py`<br>• Tách Kanban board từ `ui/schedule_task_tab.py` | ➔ `infrastructure/persistence/json/task_repository_impl.py`<br>➔ `domain/tasks/schedule_calculator.py`<br>➔ `presentation/scheduling/kanban_board_widget.py` | Task CRUD test; Kanban card render test |
|
|
||||||
| **25/08 (T3)** | • Tách `QTimer` adapter từ `core/task_scheduler.py#L20`<br>• Tách Calendar view từ `ui/schedule_task_tab.py` | ➔ `platform/qt/qt_scheduler_clock.py`<br>➔ `presentation/scheduling/calendar_view_widget.py` | Fake clock test kích hoạt task đúng lịch |
|
|
||||||
| **26/08 (T4)** | • Tách dispatch logic từ `core/task_executors.py`<br>• Tách AI create dialogs từ `ui/schedule_task_tab.py` | ➔ `application/scheduling/task_application_service.py`<br>➔ `presentation/scheduling/ai_task_creator_dialog.py` | Task dispatch test; AI planner test với fake provider |
|
|
||||||
| **27/08 (T5)** | • Tách File tree & Previews từ `ui/folder_tab.py#L350`<br>• Tách AI File Editor từ `ui/folder_tab.py` | ➔ `presentation/folder/workspace_file_tree.py`<br>➔ `presentation/folder/document_preview_manager.py`<br>➔ `application/workspaces/file_workspace_service.py` | File CRUD test; Preview render test; AI apply diff test |
|
|
||||||
| **28/08 (T6)** | • Tách Graph View từ `ui/structure_graph_view.py`<br>• Lắp ráp shell FolderTab & ScheduleTab | ➔ `presentation/graph/structure_graph_view.py`<br>➔ `application/workspaces/graph_index_service.py`<br>➔ `presentation/scheduling/schedule_task_tab.py` | Graph RAG test; Smoke test: Folder & Schedule tabs mở tốt |
|
|
||||||
| **29/08 (T7)** | • Nối `ToolPolicyGateway` qua `core/mcp_client.py` & built-in tools<br>• Integration test Task Scheduler & File Explorer | ➔ `application/conversations/tool_policy_gateway.py` | Approval flow hoạt động chuẩn |
|
|
||||||
| **30/08 (CN)** | 🔍 **Chủ trì CASAN Check 2 (Single Responsibility Audit)**: Quét toàn bộ codebase đảm bảo không có file production nào > 400 dòng | ➔ Script count LOC | 0 file vi phạm (>400 lines) |
|
|
||||||
| **31/08 (T2)** | Fix tồn đọng Check 2, cập nhật README, merge PR cuối | — | CASAN Check 2 PASS |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🚦 Checkpoint Review & Cơ Chế Cổng Kiểm Duyệt CASAN (CASAN Verification Gate)
|
|
||||||
|
|
||||||
#### 🛡️ CASAN Là Gì?
|
|
||||||
**CASAN** là bộ cổng kiểm duyệt chất lượng và an toàn kiến trúc tự động (Automated Architectural Quality Gate) bắt buộc trước khi phát hành phiên bản tái cấu trúc. Tên viết tắt đại diện cho 5 nguyên tắc cốt lõi:
|
|
||||||
- **C** - **Clean Architecture (Ranh giới tầng sạch)**: Tầng `domain/` và `application/` tuyệt đối thuần Python, 0 phụ thuộc vào `PySide6` / Qt GUI framework.
|
|
||||||
- **A** - **Atomic Persistence (Lưu trữ an toàn & Bí mật)**: 0 lưu trữ plaintext API Key/Token trong JSON/config (phải dùng OS `SecretStore` / Keyring); mọi thao tác ghi dữ liệu tệp đều dùng cơ chế `AtomicJsonFile` chống hỏng dữ liệu khi crash.
|
|
||||||
- **S** - **Single Responsibility & Modularity (Kích thước tệp nhỏ gọn)**: Giới hạn tối đa **400 dòng code (LOC)** cho mỗi file production; mỗi file/class chỉ đảm nhận đúng 1 trách nhiệm duy nhất.
|
|
||||||
- **A** - **Automated Test Pyramid (Tháp kiểm thử tự động)**: Toàn bộ Unit tests (<1s), Contract tests, Integration tests chạy offline hoàn toàn không cần kết nối mạng hay Qt GUI event loop.
|
|
||||||
- **N** - **No Regression & E2E Smoke (Không hồi quy & Ổn định phát hành)**: Toàn bộ suite test hiện tại (>81 tests) và bộ E2E Smoke Test của ứng dụng chạy thành công 100% trên nhánh `main`.
|
|
||||||
|
|
||||||
#### 🔍 Chi Tiết 3 Cổng Kiểm Tra CASAN (Chạy Tự Động Ngày 30/08 & Pre-commit):
|
|
||||||
|
|
||||||
| Cổng Kiểm Tra | Mục Tiêu & Cơ Chế Kiểm Tra | Lệnh Chạy Kiểm Thử | Tiêu Chí Pass Bắt Buộc | Team Phụ Trách |
|
|
||||||
| :--- | :--- | :--- | :--- | :--- |
|
|
||||||
| **CASAN Check 1: Security Audit** | Quét regex phân tích tĩnh toàn bộ file cấu hình (`.json`, `.jsonl`, `.yaml`, `config.py`) nhằm phát hiện secret/token lưu plaintext | `python scripts/audit_security.py` | `0 plaintext secrets found` (100% key lưu qua Keyring) | **🟣 Team Nam** |
|
|
||||||
| **CASAN Check 2: Modularity (LOC Audit)** | Quét đếm số dòng code (LOC) của từng file trong `presentation/`, `application/`, `domain/`, `infrastructure/` | `python scripts/check_loc.py --max-lines 400` | `0 files exceeding 400 lines` (Tất cả God Files đã bị chia nhỏ) | **🟢 Team Hoa** |
|
|
||||||
| **CASAN Check 3: Clean Architecture Guard** | Dùng thư viện `ast` phân tích cây cú pháp trừu tượng, quét cấm các import `PySide6`, `PyQt*` bên trong `domain/` và `application/` | `python scripts/check_imports.py` | `0 Qt imports in business logic` | **🔵 Team Duy** |
|
|
||||||
| **Lệnh Tổng Hợp CASAN Gate** | Chạy toàn bộ 3 checks trên + suite `pytest` | `python scripts/run_quality_gate.py` | `ALL GATES PASSED (100%)` | **🔵 Team Duy (Tech Lead)** |
|
|
||||||
|
|
||||||
#### 📅 Bảng Kế Hoạch Checkpoint & CASAN Gate:
|
|
||||||
|
|
||||||
| Thời Điểm | Checkpoint | Tiêu Chí Đạt Bắt Buộc | Trách Nhiệm |
|
|
||||||
| :--- | :--- | :--- | :--- |
|
|
||||||
| **23/08 (CN - 17:00)** | ✅ **Checkpoint 1 (Contracts & Fakes)** | 100% DTO và Fake Services (`FakeProvider`, `FakeToolExecutor`, `FakeClock`) tạo xong; `pytest` pass; 0 team bị block | Cả 3 Team |
|
|
||||||
| **28/08 (T6 - 17:00)** | ✅ **Checkpoint 2 (Services & Sub-widgets)** | Tách xong 100% các God Files (`chat_panel.py`, `co4e_tab.py`, `folder_tab.py`, `monitoring_tab.py`, `schedule_task_tab.py`, `settings_dialog.py`); 0 circular import | Cả 3 Team |
|
|
||||||
| **30/08 (CN - 17:00)** | 🏁 **CASAN Verification Gate** | Chạy thành công đồng thời cả 3 checks: **CASAN Check 1** (Security), **CASAN Check 2** (LOC <400), **CASAN Check 3** (Import Guard) | Team Nam (Check 1)<br>Team Hoa (Check 2)<br>Team Duy (Check 3) |
|
|
||||||
| **31/08 (T2 - 15:00)** | 🎉 **Final Release Smoke Test** | Suite test (>81 tests) pass 100%; E2E smoke test 5 luồng chính hoạt động ổn định trên `main` | **Team Duy** (Chủ trì) & 3 Team |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 📌 VI. MÔ TẢ CHI TIẾT 10 EPIC (R01 ➔ R10)
|
|
||||||
|
|
||||||
> [!TIP]
|
|
||||||
> 📋 Toàn bộ hệ thống checklist chi tiết từng đầu việc nhỏ (`R01-T01` ➔ `R10-T05`), checklist tiến độ theo ngày và tiêu chuẩn Definition of Done (DoD) đã được tách thành tài liệu theo dõi độc lập tại file **`Refactoring_Checklist.md`**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 🔹 R01: Architecture Foundation & Characterization (Nền Tảng Kiến Trúc & Test Bảo Vệ)
|
|
||||||
* **Ý nghĩa & Mục tiêu**: Thiết lập luật phụ thuộc kiến trúc (Dependency Rules), xây dựng bộ fixtures/test doubles giả lập (`FakeProvider`, `FakeToolExecutor`) không phụ thuộc UI/mạng, và dựng script chặn vi phạm kiến trúc trên CI trước khi bất kỳ ai di chuyển mã nguồn.
|
|
||||||
* **Team chịu trách nhiệm**: 🔵 **Team Duy** (Chủ trì ADR & Test Doubles) + Cả 3 Team.
|
|
||||||
* **Chi Tiết Cụ Thể Các Task Cần Làm Trong R01**:
|
|
||||||
1. **R01-T01: Soạn thảo Kiến trúc ADR (Layered Architecture ADR)**:
|
|
||||||
- Tạo `docs/architecture/ADR-001-layered-architecture.md` định rõ quy tắc 4 tầng: Presentation ➔ Application ➔ Domain ➔ Infrastructure.
|
|
||||||
- Quy định rõ ràng: `domain/` và `application/` chỉ chứa Pure Python, không chứa logic UI hoặc import `PySide6`.
|
|
||||||
2. **R01-T02: Xây dựng Bộ Fixtures & Test Doubles Offline (`tests/fakes/`)**:
|
|
||||||
- `tests/fakes/fake_provider.py`: Mock `BaseProvider`, trả về streaming text chunk và tool call events có thể kiểm soát được trong unit test.
|
|
||||||
- `tests/fakes/fake_tool_executor.py`: Mock bộ thực thi tool, trả về dummy result (đọc file, chạy lệnh) mà không can thiệp vào hệ thống tệp thật.
|
|
||||||
- Tiêu chuẩn: Unit test chạy hoàn tất < 1 giây, hoàn toàn độc lập với Qt GUI và network.
|
|
||||||
3. **R01-T03: Xây dựng Script Phân Tích AST Chặn Vi Phạm Kiến Trúc (`scripts/check_imports.py`)**:
|
|
||||||
- Dùng module `ast` quét toàn bộ file trong `domain/` và `application/`.
|
|
||||||
- Chặn các lệnh `import PySide6`, `import PyQt*`, `import app`.
|
|
||||||
- Tích hợp vào CI pipeline và Git pre-commit hook.
|
|
||||||
4. **R01-T04: Viết Characterization Tests cho Luồng Runtime Cốt Lõi (`tests/characterization/`)**:
|
|
||||||
- Tạo `tests/characterization/test_run_cowork.py`: Chụp snapshot hành vi hiện tại của hàm `core/chat_agent.py::run_cowork` (cách nhận input, gọi tool, tạo prompt).
|
|
||||||
- Đảm bảo khi tách sang `ConversationApplicationService` thì hành vi logic không bị sai lệch.
|
|
||||||
5. **R01-T05: Lập Danh Mục & Cô Lập Mã Nguồn Dormant/Dead Code (`docs/architecture/dormant-code.md`)**:
|
|
||||||
- Rà soát các module không còn active (như `LoginDialog`, `account` legacy) và đánh dấu cô lập, không để ảnh hưởng tới luồng tái cấu trúc chính.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 🔹 R02: Configuration, Secrets & Persistence (Cấu Hình Atomic & Bảo Mật Keyring)
|
|
||||||
* **Ý nghĩa & Mục tiêu**: Chuyển đổi cơ chế lưu trữ `config.py` sang ghi tệp an toàn (Atomic Write chống hỏng file khi crash), tạo Typed Settings Facades và đưa toàn bộ API Key/Token lưu plaintext sang OS Keyring (`SecretStore`).
|
|
||||||
* **Team chịu trách nhiệm**: 🟣 **Team Nam** (Chủ trì).
|
|
||||||
* **Chi Tiết Cụ Thể Các Task Cần Làm Trong R02**:
|
|
||||||
1. **R02-T01: Xây dựng Tiện Ích Ghi File Nguyên Tử (`AtomicJsonFile`)**:
|
|
||||||
- Tạo `infrastructure/persistence/json/atomic_json_file.py`: Ghi dữ liệu ra file tạm (`.tmp`), gọi `os.fsync()`, sau đó dùng `os.replace()` để thay thế file đích một cách an toàn.
|
|
||||||
- Thêm cơ chế tự động tạo bản sao lưu (`.bak`) khi phát hiện file JSON bị corrupt.
|
|
||||||
2. **R02-T02: Tái cấu trúc Kho Cấu Hình `ConfigRepository`**:
|
|
||||||
- Tạo `infrastructure/config/config_repository.py`: Đóng gói `config.py::AppConfig`, loại bỏ biến global dùng chung, chuyển sang Repository pattern có thread-safe lock.
|
|
||||||
3. **R02-T03: Xây dựng Typed Settings Facades Độc Lập**:
|
|
||||||
- Tạo `infrastructure/config/settings_facade.py`: Chia nhỏ cấu hình thành các dataclass định kiểu rõ ràng (`ProviderSettings`, `RoutingSettings`, `GeneralSettings`, `SecuritySettings`) thay vì truy xuất dictionary tự do.
|
|
||||||
4. **R02-T04: Định nghĩa Interface `SecretStore` & Cài đặt `KeyringAdapter`**:
|
|
||||||
- Tạo `infrastructure/secrets/keyring_adapter.py`: Sử dụng thư viện `keyring` của Python để lưu và đọc API Keys/Tokens từ Windows Credential Manager / macOS Keychain / Linux Secret Service.
|
|
||||||
- Thêm `tests/fakes/fake_keyring.py` để test môi trường CI không có UI desktop.
|
|
||||||
5. **R02-T05: Di Chuyển API Keys của Các Provider Sang `SecretStore`**:
|
|
||||||
- Xóa việc lưu plaintext `openai_api_key`, `anthropic_api_key`, `fpt_api_key` trong `config.json`.
|
|
||||||
- Tự động di chuyển (migrate) key cũ vào Keyring khi khởi động lần đầu.
|
|
||||||
6. **R02-T06: Chuẩn Hóa JSON Schema Versioning & Recovery Policy**:
|
|
||||||
- Bổ sung trường `schema_version` vào mọi file dữ liệu JSON (projects, tasks, routing assessment). Tự động chạy hàm migrate schema khi có phiên bản mới.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 🔹 R03: Model Providers & Routing (Hợp Nhất Nhà Cung Cấp & Bộ Định Tuyến Mô Hình)
|
|
||||||
* **Ý nghĩa & Mục tiêu**: Xóa bỏ sự phân tán logic định tuyến (hiện đang lặp lại ở `ui/chat_panel.py#L638`, `ui/co4e_tab.py`, `ui/folder_tab.py`) thành một `RoutingApplicationService` duy nhất; chuẩn hóa danh mục nhà cung cấp mô hình qua `ProviderDescriptor`.
|
|
||||||
* **Team chịu trách nhiệm**: 🔵 **Team Duy** (Chủ trì).
|
|
||||||
* **Chi Tiết Cụ Thể Các Task Cần Làm Trong R03**:
|
|
||||||
1. **R03-T01: Xây dựng Bộ Contract Tests Chuẩn Hóa cho Model Providers**:
|
|
||||||
- Tạo `tests/contracts/test_providers.py`: Kiểm thử hợp đồng cho mọi provider (OpenAI, Anthropic, Ollama, FPT Gateway) để đảm bảo cùng tuân thủ interface `generate()`, `stream()`, `count_tokens()`.
|
|
||||||
2. **R03-T02: Định nghĩa `ProviderDescriptor` & Xây dựng `ProviderRegistry`**:
|
|
||||||
- Tạo `domain/models/provider_descriptor.py`: Dataclass định nghĩa metadata nhà cung cấp (id, name, models list, context length, pricing, required auth).
|
|
||||||
- Tạo `infrastructure/providers/provider_registry.py`: Registry đăng ký tập trung tất cả providers, hỗ trợ tra cứu động theo model ID.
|
|
||||||
3. **R03-T03: Xây dựng Dịch Vụ Định Tuyến `RoutingApplicationService` (Pure Python)**:
|
|
||||||
- Tạo `application/model_routing/routing_application_service.py` từ `core/routing/`: Điều phối 4 chế độ định tuyến (Off, Auto/Cost-effective, Manual, Fallback).
|
|
||||||
- Độc lập 100% với PySide6 UI, cho phép kiểm thử tự động toàn bộ rule routing mà không cần bật màn hình.
|
|
||||||
4. **R03-T04: Hợp Nhất Luồng Định Tuyến từ `ui/chat_panel.py#L638`**:
|
|
||||||
- Xóa bỏ logic routing sao chép trong `ui/chat_panel.py`, chuyển sang gọi trực tiếp qua `RoutingApplicationService`.
|
|
||||||
5. **R03-T05: Hợp Nhất Luồng Định Tuyến từ `ui/co4e_tab.py` & `ui/folder_tab.py`**:
|
|
||||||
- Chuyển đổi mọi lời gọi định tuyến mô hình trong Co4E Node Execution và AI File Editor sang dùng chung `RoutingApplicationService`.
|
|
||||||
6. **R03-T06: Tách Bóc Telemetry & Token Usage Thành `UsageEventSink`**:
|
|
||||||
- Tạo `infrastructure/telemetry/usage_sink.py`: Tách logic ghi nhận số lượng token và chi phí ra khỏi Provider, biến thành Event Subscriber lắng nghe sự kiện từ Application Service.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 🔹 R04: Agent Runtime & Conversation Application Service (Vòng Đời Turn Chat & Agent Engine)
|
|
||||||
* **Ý nghĩa & Mục tiêu**: Tách toàn bộ vòng đời thực thi 1 lượt chat (Turn) ra khỏi PySide6 UI; đóng gói dữ liệu đầu vào thành snapshot bất biến `ConversationExecutionRequest` và trả về luồng sự kiện `AgentEvent` có định kiểu.
|
|
||||||
* **Team chịu trách nhiệm**: 🔵 **Team Duy** (Chủ trì).
|
|
||||||
* **Chi Tiết Cụ Thể Các Task Cần Làm Trong R04**:
|
|
||||||
1. **R04-T01: Định nghĩa Immutable Snapshot `ConversationExecutionRequest`**:
|
|
||||||
- Tạo `domain/agents/conversation_execution_request.py`: Chứa đầy đủ context của 1 lượt chạy (turn id, session id, user prompt, attachments, model config, tool capability scope, instructions).
|
|
||||||
- Dữ liệu bất biến (frozen dataclass), bảo đảm trong khi agent đang chạy, người dùng có đổi lựa chọn trên UI thì turn cũng không bị ảnh hưởng.
|
|
||||||
2. **R04-T02: Chuẩn hóa Hệ Thống Sự Kiện Luồng `AgentEvent`**:
|
|
||||||
- Tạo `domain/agents/agent_event.py`: Định nghĩa các sự kiện có kiểu dữ liệu mạnh: `TextChunkEvent`, `ToolCallStartedEvent`, `ToolCallFinishedEvent`, `TurnCompletedEvent`, `ErrorEvent`.
|
|
||||||
3. **R04-T03: Xây dựng `ConversationApplicationService`**:
|
|
||||||
- Tạo `application/conversations/conversation_application_service.py`: Tách logic từ `core/chat_agent.py`. Điều phối toàn bộ vòng đời của turn: chuẩn bị prompt ➔ gọi provider ➔ lắng nghe stream ➔ dispatch tool call ➔ tổng hợp câu trả lời ➔ lưu lịch sử hội thoại.
|
|
||||||
4. **R04-T04: Chuyển đổi `ui/cowork_tab.py::build_job`**:
|
|
||||||
- Thay thế logic tạo job phức tạp trong UI bằng việc khởi tạo `ConversationExecutionRequest` và gửi tới `ConversationApplicationService`.
|
|
||||||
5. **R04-T05: Đồng Bộ Hóa `core/task_executors.py` sang dùng chung Runtime**:
|
|
||||||
- Đưa việc thực thi chat của Scheduled Task Runner về dùng chung `ConversationApplicationService`, xoá bỏ duplicate agent runner.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 🔹 R05: Tool, MCP & Connector Policy (Quản Lý Công Cụ, MCP & Cổng Kiểm Soát Quyền)
|
|
||||||
* **Ý nghĩa & Mục tiêu**: Xóa bỏ giant if/elif dispatcher trong `core/tools.py`; đưa tất cả Built-in tools, MCP tools (`core/mcp_client.py`) và REST connectors (`core/ext_connectors.py`) qua cùng một cổng phân loại rủi ro (`ToolCapability`) và cổng phê duyệt bảo mật (`ToolPolicyGateway`).
|
|
||||||
* **Team chịu trách nhiệm**: 🟢 **Team Hoa** (Chủ trì) + Team Duy.
|
|
||||||
* **Chi Tiết Cụ Thể Các Task Cần Làm Trong R05**:
|
|
||||||
1. **R05-T01: Định nghĩa `ToolDescriptor`, `ToolCapability` & `ToolRegistry`**:
|
|
||||||
- Tạo `domain/tools/tool_descriptor.py`: Mô tả metadata công cụ (tên, mô tả, JSON Schema parameters, độ rủi ro READ / WRITE / EXECUTE / NETWORK).
|
|
||||||
- Tạo `domain/tools/tool_registry.py`: Kho đăng ký tập trung cho mọi công cụ hệ thống.
|
|
||||||
2. **R05-T02: Phân Rã Monolithic `core/tools.py` Thành Các Module Riêng Biệt**:
|
|
||||||
- Tạo `infrastructure/filesystem/file_tools.py` (read, write, edit, list_dir, grep).
|
|
||||||
- Tạo `infrastructure/filesystem/command_tools.py` (run_command, manage_task).
|
|
||||||
- Tạo `infrastructure/filesystem/fetch_tools.py` (read_url_content, search_web).
|
|
||||||
3. **R05-T03: Xây dựng Cổng Kiểm Soát Quyền `ToolPolicyGateway`**:
|
|
||||||
- Tạo `application/conversations/tool_policy_gateway.py`: Kiểm tra chính sách trước khi cho phép chạy tool (ALLOW, CONFIRM_REQUIRED, DENY). Khi cần xác nhận từ người dùng, phát tín hiệu yêu cầu phê duyệt thay vì gọi dialog trực tiếp trong hàm chạy ngầm.
|
|
||||||
4. **R05-T04: Chuẩn Hóa MCP Tools Qua `ToolPolicyGateway`**:
|
|
||||||
- Bọc các tool từ MCP Server (`core/mcp_client.py`) thành các `ToolDescriptor` tương thích để áp dụng cùng một chính sách an ninh như built-in tools.
|
|
||||||
5. **R05-T05: Xây dựng `McpToolSourceManager` Quản Lý Tiến Trình MCP**:
|
|
||||||
- Tạo `infrastructure/mcp/mcp_source_manager.py`: Quản lý vòng đời tiến trình MCP con (start, heartbeat, timeout, restart khi crash, graceful shutdown).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 🔹 R06: Workspace, Filesystem & History Isolation (Cô Lập Không Gian Làm Việc & Quản Lý Tệp)
|
|
||||||
* **Ý nghĩa & Mục tiêu**: Loại bỏ biến toàn cục `active_project_id` trong `state.py` gây xung đột dữ liệu giữa các luồng chạy ngầm; đóng gói không gian làm việc thành `WorkspaceSession` bất biến theo turn; bảo vệ an toàn đường dẫn tệp.
|
|
||||||
* **Team chịu trách nhiệm**: 🟢 **Team Hoa** (Chủ trì).
|
|
||||||
* **Chi Tiết Cụ Thể Các Task Cần Làm Trong R06**:
|
|
||||||
1. **R06-T01: Định nghĩa `WorkspaceSession` Đóng Gói Ngữ Cảnh**:
|
|
||||||
- Tạo `domain/workspaces/workspace_session.py`: Đối tượng snapshot chứa `project_id`, `workspace_root_path`, `sandbox_dir`, `allowed_paths`. Đảm bảo agent chỉ được đọc/ghi trong thư mục được cấp phép.
|
|
||||||
2. **R06-T02: Xây dựng `WorkspaceRepository` & `ConversationRepository`**:
|
|
||||||
- Tạo `infrastructure/persistence/json/workspace_repository_impl.py`: Quản lý danh sách dự án, cấu hình dự án (`core/projects.py`) bằng `AtomicJsonFile`.
|
|
||||||
- Lưu trữ và phân trang lịch sử chat (`core/history.py`) độc lập với UI sidebar.
|
|
||||||
3. **R06-T03: Xây dựng `ExecutionWorkspace` Quản Lý Tệp Output/Scratch**:
|
|
||||||
- Tạo `infrastructure/filesystem/execution_workspace.py`: Tách biệt thư mục workspace chính và thư mục scratch/output tạm thời của từng turn chạy.
|
|
||||||
4. **R06-T04: Khắc phục Race Condition trong `WorkspaceTab`**:
|
|
||||||
- Viết lại hàm `_load_current` trong `ui/workspace_tab.py`: Đồng bộ dữ liệu bằng session id thay vì đọc biến toàn cục `AppContext`.
|
|
||||||
5. **R06-T05: Xây dựng `FileWorkspaceService` cho File Explorer & AI Editor**:
|
|
||||||
- Tạo `application/workspaces/file_workspace_service.py`: Cung cấp API đọc cây thư mục, xem trước file đa định dạng, áp dụng AI code diffs an toàn.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 🔹 R07: Scheduling & Workflow Runtime (Bộ Lập Lịch & Động Cơ Quy Trình)
|
|
||||||
* **Ý nghĩa & Mục tiêu**: Tách biệt hoàn toàn tầng lưu trữ Task (`core/tasks.py`) và thuật toán tính toán lịch (`ScheduleCalculator`) khỏi `QTimer` trong `core/task_scheduler.py#L20`; xây dựng `TaskApplicationService` và `Co4EWorkflowService`.
|
|
||||||
* **Team chịu trách nhiệm**: 🟢 **Team Hoa** (Task Scheduling) + 🟣 **Team Nam** (Co4E Workflows).
|
|
||||||
* **Chi Tiết Cụ Thể Các Task Cần Làm Trong R07**:
|
|
||||||
1. **R07-T01: Tách `TaskRepository` Lưu Trữ JSON Độc Lập**:
|
|
||||||
- Tạo `infrastructure/persistence/json/task_repository_impl.py`: Đọc/ghi danh sách công việc (`tasks.json`) qua `AtomicJsonFile` với locking bảo vệ khi nhiều luồng cùng truy cập.
|
|
||||||
2. **R07-T02: Xây dựng Thuật Toán Tính Lịch `ScheduleCalculator`**:
|
|
||||||
- Tạo `domain/tasks/schedule_calculator.py`: Tính toán thời điểm chạy kế tiếp cho các dạng lịch: One-time, Interval, Daily, Weekly, Monthly, Cron Expression. Hoàn toàn là Pure Python, có unit test bao phủ 100%.
|
|
||||||
3. **R07-T03: Xây dựng Adapter `QtSchedulerClock`**:
|
|
||||||
- Tạo `platform/qt/qt_scheduler_clock.py`: Bọc `QTimer` vào Clock Interface. Cho phép trong unit test có thể thay thế bằng `FakeClock` để tua nhanh thời gian mà không cần chờ đợi.
|
|
||||||
4. **R07-T04: Xây dựng `TaskApplicationService` (Pure Python)**:
|
|
||||||
- Tạo `application/scheduling/task_application_service.py`: Điều phối toàn bộ nghiệp vụ quản lý task: CRUD task, kích hoạt chạy ngay (`run_now`), sao chép task, tạm dừng, xóa hàng loạt.
|
|
||||||
5. **R07-T05: Xây dựng `AiTaskPlannerService` Tạo Task Tự Động**:
|
|
||||||
- Tạo `application/scheduling/ai_task_planner_service.py`: Phân tích câu lệnh tự nhiên của người dùng để sinh ra cấu hình task và lịch chạy tương ứng.
|
|
||||||
6. **R07-T06: Xây dựng `Co4EWorkflowService` Động Cơ Quy Trình Node**:
|
|
||||||
- Tạo `application/workflows/co4e_workflow_service.py`: Tách logic thực thi đồ thị node từ `core/co4e_run_manager.py`. Quản lý state của từng node, truyền dữ liệu giữa các node và xử lý retry/error.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 🔹 R08: UI/Application Separation (Phân Rã Toàn Diện Các God Widgets)
|
|
||||||
* **Ý nghĩa & Mục tiêu**: Tách nhỏ toàn bộ các màn hình khổng lồ (>1.500 - 2.000 dòng) thành các widget con chuyên trách, đảm bảo mỗi file < 400 dòng và chỉ đảm nhận hiển thị / bắt sự kiện giao diện.
|
|
||||||
* **Team chịu trách nhiệm**: **Cả 3 Team** (Mỗi team phụ trách phân hệ của mình):
|
|
||||||
* **Chi Tiết Cụ Thể Các Task Cần Làm Trong R08**:
|
|
||||||
1. **🔵 Team Duy – Tách `ChatPanel` (`ui/chat_panel.py` >1.800 dòng) thành 6 widgets con**:
|
|
||||||
- `R08-T01`: `presentation/chat/chat_history_widget.py` (Render bong bóng chat, streaming markdown, tool call cards).
|
|
||||||
- `R08-T02`: `presentation/chat/composer_widget.py` (Ô nhập liệu text, phím tắt Ctrl+Enter, auto-resize).
|
|
||||||
- `R08-T03`: `presentation/chat/attachment_picker.py` (Widget chọn file, ảnh, folder đính kèm).
|
|
||||||
- `R08-T04`: `presentation/chat/audio_recorder_widget.py` (Widget ghi âm giọng nói & chuyển thành văn bản).
|
|
||||||
- `R08-T05`: `presentation/chat/chat_output_panel.py` (Panel hiển thị file output sinh ra trong turn).
|
|
||||||
- `R08-T06`: `presentation/chat/chat_panel.py` (Shell container điều phối các widget con & `Floating HelpAgent`).
|
|
||||||
2. **🟣 Team Nam – Tách `SettingsDialog`, `MonitoringTab`, `Co4ETab` & Shell `MainWindow`**:
|
|
||||||
- `R08-T07`: `presentation/settings/` ➔ Tách thành `provider_settings_widget.py`, `connector_settings_widget.py`, `routing_settings_widget.py`, `general_settings_widget.py`.
|
|
||||||
- `R08-T08`: `presentation/monitoring/` ➔ Tách 8 tab con thành từng file: `overview_tab.py`, `sandbox_status_tab.py`, `security_events_tab.py`, `mcp_history_tab.py`, `action_logs_tab.py`, `agents_admin_tab.py`, `security_settings_tab.py`, `tools_admin_tab.py`.
|
|
||||||
- `R08-T09`: `presentation/co4e/` ➔ Tách thành `co4e_canvas_widget.py`, `node_property_panel.py`, `co4e_run_control_widget.py`, `co4e_chat_view.py`.
|
|
||||||
- `R08-T10`: `presentation/shell/` ➔ Xây dựng `bootstrap.py` (Composition Root) và tách `app.py::MainWindow` thành `main_window.py`, `tray_manager.py`, `lifecycle_coordinator.py`.
|
|
||||||
3. **🟢 Team Hoa – Tách `ScheduleTaskTab`, `FolderTab`, `DashboardTab` & `StructureGraphView`**:
|
|
||||||
- `R08-T11`: `presentation/scheduling/` ➔ Tách thành `kanban_board_widget.py` (7 cột kéo thả), `calendar_view_widget.py`, `ai_task_creator_dialog.py`, `ai_task_import_dialog.py`.
|
|
||||||
- `R08-T12`: `presentation/folder/` ➔ Tách thành `workspace_file_tree.py`, `document_preview_manager.py` (PDF/Word/Excel/Images), `ai_file_editor_dialog.py`.
|
|
||||||
- `R08-T13`: `presentation/dashboard/` ➔ Tách thành `token_usage_card_widget.py`, `usage_chart_widget.py`, `habits_widget.py`.
|
|
||||||
- `R08-T14`: `presentation/graph/` ➔ Tách thành `structure_graph_view.py` & `graph_qa_widget.py`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 🔹 R09: Security Runtime, Sandbox & Observability (An Ninh Runtime, Sandbox & Giám Sát)
|
|
||||||
* **Ý nghĩa & Mục tiêu**: Phân biệt rõ ràng giữa quy tắc bảo mật bắt buộc (Enforced Deterministic Rules) và các gợi ý bảo mật từ AI (Advisory Guardrails); loại bỏ circular imports; chuẩn hóa định dạng log kiểm toán canonical.
|
|
||||||
* **Team chịu trách nhiệm**: 🟣 **Team Nam** (Chủ trì) + 🔵 **Team Duy**.
|
|
||||||
* **Chi Tiết Cụ Thể Các Task Cần Làm Trong R09**:
|
|
||||||
1. **R09-T01: Chuẩn Hóa Security Policy Model**:
|
|
||||||
- Tạo `docs/architecture/security-policy.md`: Phân định ranh giới giữa bộ lọc quy tắc cứng (regex cấm xóa tệp hệ thống, cấm truy cập thư mục ngoài sandbox) và bộ đánh giá rủi ro mềm từ LLM.
|
|
||||||
2. **R09-T02: Xử Lý Triệt Để Circular Import `model_pricing` ↔ `usage_tracker`**:
|
|
||||||
- Tách DTO giá mô hình (`ModelPricing`) vào `domain/models/` để cả `model_pricing.py` và `usage_tracker.py` cùng import xuôi mà không import vòng tròn.
|
|
||||||
3. **R09-T03: Xử Lý Triệt Để Circular Import `agent_security` ↔ `agent_security_alert`**:
|
|
||||||
- Tách các enum và event cảnh báo bảo mật (`SecurityAlertEvent`) sang `domain/security/` để xoá hoàn toàn import chéo.
|
|
||||||
4. **R09-T04: Xây Dựng `CanonicalAuditLogger` Thống Nhất Định Dạng Log**:
|
|
||||||
- Tạo `infrastructure/telemetry/audit_logger.py`: Chuẩn hóa schema nhật ký (timestamp UTC, actor, action, resource, outcome, latency) ghi ra file JSON Lines an toàn.
|
|
||||||
5. **R09-T05: Xây Dựng `MonitoringQueryService` Truy Vấn Dữ Liệu Read-Only**:
|
|
||||||
- Tạo `application/monitoring/monitoring_query_service.py`: Cung cấp API truy vấn log kiểm toán có phân trang, lọc theo thời gian, lọc theo mức độ nghiêm trọng (severity).
|
|
||||||
6. **R09-T06: Chuẩn Hóa Ma Trận Năng Lực Sandbox Trên Từng Hệ Điều Hành**:
|
|
||||||
- Tạo `infrastructure/sandbox/sandbox_capabilities.py`: Tách biệt cơ chế cách ly thực tế: Windows (Job Objects / AppContainer), Linux (Namespaces / Bubblewrap), macOS (Sandbox-exec).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 🔹 R10: Testing, Packaging & Contributor Experience (Hệ Thống Kiểm Thử & Tài Liệu Đóng Góp)
|
|
||||||
* **Ý nghĩa & Mục tiêu**: Đây là **Task trọng tâm cốt lõi của Team Duy (Tech Lead)** nhằm thiết lập hệ thống bảo vệ toàn diện cho dự án: xây dựng tháp kiểm thử 4 tầng (Unit, Contract, Integration, E2E Smoke), cài đặt CI Quality Gate tự động, soạn thảo bộ công thức Contributor Recipes và thực hiện kiểm thử khói tổng thể trước khi release.
|
|
||||||
* **Team chịu trách nhiệm**: 🔵 **Team Duy (Chủ Trì Chính - Task Trọng Tâm Của Team Duy)**.
|
|
||||||
* **Chi Tiết Cụ Thể Các Task Cần Làm Trong R10**:
|
|
||||||
1. **R10-T01: Xây dựng Tháp Kiểm Thử Phân Tầng (Test Pyramid Architecture - `tests/`)**:
|
|
||||||
- `tests/unit/`: Kiểm thử các logic độc lập không I/O (Domain entities, `ScheduleCalculator`, `AtomicJsonFile`, parsing). Thời gian chạy: < 0.05s/test.
|
|
||||||
- `tests/contracts/`: Bộ test xác thực interface chuẩn của Provider API (`test_providers.py`) và Tool Handler (`test_tools.py`) để các provider mới chỉ cần pass contract là cắm vào được ngay.
|
|
||||||
- `tests/integration/`: Kiểm thử phối hợp nhiều tầng không cần UI (`test_chat_flow.py`, `test_workflow_execution.py`, `test_task_scheduling.py`).
|
|
||||||
- `tests/fakes/`: Thư viện test doubles tái sử dụng cho cả 3 team (`FakeProvider`, `FakeToolExecutor`, `FakeClock`, `FakeKeyringAdapter`).
|
|
||||||
2. **R10-T02: Xây Dựng Bộ Script CI Quality Gate Tự Động (`scripts/`)**:
|
|
||||||
- `scripts/check_imports.py`: Script phân tích AST kiểm tra chặn 100% import `PySide6` trong `domain/` và `application/`.
|
|
||||||
- `scripts/check_loc.py`: Script quét LOC tự động cảnh báo lỗi nếu có bất kỳ file nào > 400 dòng code.
|
|
||||||
- `scripts/audit_security.py`: Script quét phát hiện secret/API Key plaintext trong toàn bộ codebase.
|
|
||||||
- `scripts/run_quality_gate.py`: Script tổng hợp chạy 1 lệnh duy nhất để kiểm tra toàn bộ tiêu chí CASAN Gate trước khi merge PR.
|
|
||||||
3. **R10-T03: Cập Nhật Tài Liệu Dự Án & Hướng Dẫn Thiết Lập (`README.md`, `START_CONTRIBUTING.md`)**:
|
|
||||||
- Cập nhật sơ đồ kiến trúc 4 tầng chuẩn (Presentation ➔ Application ➔ Domain ➔ Infrastructure).
|
|
||||||
- Hướng dẫn cài đặt môi trường phát triển local, chạy test và cấu hình Git pre-commit hook để chạy script kiểm tra tự động.
|
|
||||||
4. **R10-T04: Soạn Thảo Bộ Contributor Recipes (`docs/governance/contributor-recipes.md`)**:
|
|
||||||
- Hướng dẫn mẫu từng bước kèm code mẫu:
|
|
||||||
- *Recipe 1*: "Cách thêm một Model Provider mới" (Khai báo `ProviderDescriptor`, tạo Adapter trong `infrastructure/providers/`, chạy Contract Test).
|
|
||||||
- *Recipe 2*: "Cách thêm một Built-in Tool hoặc MCP Tool mới" (Khai báo `ToolDescriptor`, đăng ký capability, cấu hình `ToolPolicyGateway`).
|
|
||||||
- *Recipe 3*: "Cách thêm một Màn hình / Sub-widget mới" (Tạo Widget trong `presentation/`, kết nối Application Service qua Qt Signals, tuân thủ giới hạn <400 LOC).
|
|
||||||
5. **R10-T05: Bộ Kiểm Thử Khói Phát Hành E2E (Release Smoke Test - `tests/e2e/test_smoke.py`)**:
|
|
||||||
- Khởi động ứng dụng qua `bootstrap.py` ở chế độ headless Qt offscreen và thực thi tự động 5 kịch bản chính:
|
|
||||||
1. Khởi tạo chat session, gửi tin nhắn và nhận stream event từ `FakeProvider`.
|
|
||||||
2. Tạo mới task trên Kanban, trigger chạy task và xác nhận ghi log.
|
|
||||||
3. Mở File Explorer, tạo file tạm trong `WorkspaceSession` và đọc nội dung an toàn.
|
|
||||||
4. Tạo workflow 2 node trên Co4E Studio và kích hoạt chạy thử.
|
|
||||||
5. Mở Settings Dialog, cấu hình mock provider API Key và kiểm tra lưu thành công vào `SecretStore`.
|
|
||||||
- Tiêu chí hoàn thành: 100% 5 kịch bản E2E pass, không xung đột luồng và ứng dụng thoát sạch sẽ.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 VII. BẢNG PHÂN CÔNG, KPI & QUY TRÌNH PHỐI HỢP LIÊN TEAM
|
|
||||||
|
|
||||||
### 1. Bảng Phân Công & KPI Đo Lường Thành Công
|
|
||||||
|
|
||||||
| Team | Phân Hệ Chính | Trách Nhiệm Cụ Thể | KPI Đo Lường Hoàn Thành |
|
|
||||||
| :--- | :--- | :--- | :--- |
|
|
||||||
| **🔵 Team Duy**<br>*(Tech Lead)* | **Core AI, Routing & Testing** | • R01 ADR & Runtime test doubles<br>• R03 Provider Registry & Unified Routing<br>• R04 ConversationApplicationService<br>• R08 Tách ChatPanel thành 5 sub-widgets<br>• **R10 Testing Pyramid, Contributor Recipes & Smoke Test**<br>• Chủ trì CASAN Check 3 | • 0 PySide6 import trong `application/conversations` và `application/model_routing`<br>• 0 file >400 dòng trong `presentation/chat/`<br>• Bộ test pyramid >81 tests pass 100%<br>• CASAN Check 3 PASS |
|
|
||||||
| **🟣 Team Nam** | **Workflows & Governance** | • R02 Atomic Config & Keyring SecretStore<br>• R08 Tách Settings (4 sections) & Monitoring (7 tabs)<br>• R08 Tách Co4E Tab & Co4EWorkflowService<br>• Composition Root (`bootstrap.py`) & MainWindow Shell<br>• R09 Security Policy Model & Fix Circular Imports<br>• Chủ trì CASAN Check 1 | • 0 plaintext credential/API Key trong JSON<br>• 0 file >400 dòng trong `presentation/co4e/`, `monitoring/`, `settings/`<br>• CASAN Check 1 PASS |
|
|
||||||
| **🟢 Team Hoa** | **Workspace & Tools** | • R05 ToolRegistry & phân rã `core/tools.py`<br>• R06 WorkspaceSession & isolation<br>• R07 TaskApplicationService & QtSchedulerClock<br>• R08 Tách FolderTab, ScheduleTaskTab, DashboardTab, Graph<br>• Chủ trì CASAN Check 2 | • 0 file >400 dòng trong `presentation/folder/`, `scheduling/`, `dashboard/`, `graph/`<br>• Task Scheduler chạy độc lập không phụ thuộc Qt GUI<br>• CASAN Check 2 PASS |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. Quy Trình Phối Hợp & Phòng Ngừa Xung Đột (Collaboration Protocol)
|
|
||||||
|
|
||||||
1. **Quy tắc Branching & PR:**
|
|
||||||
* Mỗi team làm việc trên prefix branch riêng biệt:
|
|
||||||
* Team Duy: `duy/chat-routing-tests-*`
|
|
||||||
* Team Nam: `nam/workflow-governance-*`
|
|
||||||
* Team Hoa: `hoa/workspace-tools-*`
|
|
||||||
* Mọi PR trước khi merge vào nhánh chung (`develop`/`main`) phải kèm theo unit tests và đảm bảo suite test hiện tại không bị regression.
|
|
||||||
2. **Quy tắc Mocking liên team (Không chờ đợi):**
|
|
||||||
* Nếu Team Duy (Chat) cần kích hoạt task ➔ gọi qua interface `TaskApplicationService` (dùng `FakeTaskApplicationService` trong test do Team Hoa cung cấp DTO).
|
|
||||||
* Nếu Team Hoa (File Editor / Graph RAG) cần gọi model ➔ gọi qua `RoutingApplicationService` / `FakeProvider` do Team Duy chốt DTO từ Ngày 1.
|
|
||||||
* Nếu Team Nam (Co4E Runner) cần gọi Tool ➔ gọi qua `ToolPolicyGateway` do Team Hoa cung cấp.
|
|
||||||
* Không team nào được chặn (block) tiến độ của team khác.
|
|
||||||
3. **Tiêu chuẩn hoàn thành PR (Definition of Done - DoD):**
|
|
||||||
* File mới hoặc sau refactor không vượt quá **400 dòng code**.
|
|
||||||
* Không import `PySide6` trong `domain/` và `application/`.
|
|
||||||
* Credentials/API Keys được lưu trữ qua `SecretStore` (Keyring), không lưu plaintext trong `config.json`.
|
|
||||||
* **Bắt buộc comment code bằng Tiếng Anh (English In-code Comments)**: Mỗi dòng hoặc khối code sửa đổi/thêm mới phải có chú thích bằng tiếng Anh giải thích rõ mục đích và lý do kỹ thuật.
|
|
||||||
* **Ghi nhận thời gian thực hiện (Task Start/End Timestamps)**: Mọi task khi bắt đầu phải log ngày giờ Start, khi xong phải log ngày giờ End vào `Refactoring_Checklist.md` và PR description.
|
|
||||||
* Chi tiết đối chiếu tại checklist `Refactoring_Checklist.md`.
|
|
||||||
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> ### 📝 QUY ĐỊNH BẮT BUỘC KHI CODE & THEO DÕI TIẾN ĐỘ:
|
|
||||||
> 1. **In-Code Comments in English**: Ở mỗi dòng hoặc đoạn code được chỉnh sửa/bóc tách, lập trình viên **bắt buộc phải viết comment bằng tiếng Anh** giải thích rõ logic xử lý và lý do kiến trúc (rationale). Ví dụ:
|
|
||||||
> ```python
|
|
||||||
> # Extract immutable snapshot request to decouple execution lifecycle from PySide6 UI
|
|
||||||
> request = ConversationExecutionRequest.from_composer_state(...)
|
|
||||||
> ```
|
|
||||||
> 2. **Task Start/End Timestamps**:
|
|
||||||
> - Khi bắt đầu task ➔ Ghi nhận thời gian: `Start: YYYY-MM-DD HH:mm`.
|
|
||||||
> - Khi hoàn tất & test pass ➔ Ghi nhận thời gian: `End: YYYY-MM-DD HH:mm`.
|
|
||||||
> - Ghi nhận đầy đủ vào checklist theo dõi tại `Refactoring_Checklist.md` để đảm bảo tính minh bạch và tiến độ của cả 3 team.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 🚫 VIII. NHỮNG GÌ KHÔNG LÀM (Anti-patterns)
|
|
||||||
|
|
||||||
> [!WARNING]
|
|
||||||
> Để tránh over-engineering và rewrite không kiểm soát, nhóm phải tuân thủ:
|
|
||||||
|
|
||||||
- ❌ **Không di chuyển file ngay** trước khi có contract và test bảo vệ.
|
|
||||||
- ❌ **Không dựng event bus toàn ứng dụng** hoặc DI framework phức tạp.
|
|
||||||
- ❌ **Không bắt mọi class phải có interface** — chỉ introduce contract tại seam có nhiều caller.
|
|
||||||
- ❌ **Không rewrite đồng thời** Cowork + Co4E + Folder + Scheduler trong 1 PR.
|
|
||||||
- ❌ **Không gọi là "frontend/backend"** — đây là desktop single-process.
|
|
||||||
- ❌ **Không unify Flow/Co4E** trước khi semantics được ghi rõ và có contract tests.
|
|
||||||
- ❌ **Không xóa candidate dead code** (LoginDialog, account modules) trộn vào PR refactor — phải PR riêng.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🗺️ IX. BẢN ĐỒ DI CHUYỂN FUNCTION (FUNCTION MIGRATION MAP)
|
|
||||||
|
|
||||||
> Dựa trực tiếp từ `function_list.md`. Mỗi function hiện tại được ánh xạ đến file mới sau khi chia nhỏ.
|
|
||||||
> **Quy ước**: 🎨 = `presentation/` | 📋 = `application/` | 🧠 = `domain/` | 🔧 = `infrastructure/`
|
|
||||||
|
|
||||||
### Dashboard (Section 1 trong function_list.md)
|
|
||||||
|
|
||||||
| Function Hiện Tại | File Mới | Tầng |
|
|
||||||
| :--- | :--- | :--- |
|
|
||||||
| `_refresh_cards()` | `presentation/dashboard/token_usage_card_widget.py` | 🎨 |
|
|
||||||
| `_refresh_chart()`, `_chart_prev()`, `_chart_next()`, `_on_gran_changed()` | `presentation/dashboard/usage_chart_widget.py` | 🎨 |
|
|
||||||
| `_refresh_budget()`, `_apply_budget()` | `presentation/dashboard/token_usage_card_widget.py` | 🎨 |
|
|
||||||
| `_refresh_habits()` | `presentation/dashboard/habits_widget.py` | 🎨 |
|
|
||||||
| `_ai_analyze()`, `_apply_saving_strategy()` | `application/monitoring/dashboard_query_service.py` | 📋 |
|
|
||||||
| Currency Picker | `presentation/dashboard/token_usage_card_widget.py` | 🎨 |
|
|
||||||
|
|
||||||
### Schedule Task (Section 2 trong function_list.md)
|
|
||||||
|
|
||||||
| Function Hiện Tại | File Mới | Tầng |
|
|
||||||
| :--- | :--- | :--- |
|
|
||||||
| `_build_kanban()`, `_render_kanban()`, `_on_task_dropped()` | `presentation/scheduling/kanban_board_widget.py` | 🎨 |
|
|
||||||
| `_on_card_double_click()`, `_on_card_right_click()`, `_bulk_delete_menu()` | `presentation/scheduling/kanban_board_widget.py` | 🎨 |
|
|
||||||
| `_search_tasks()`, `_filter_by_type()` | `presentation/scheduling/kanban_board_widget.py` | 🎨 |
|
|
||||||
| `_run_now(task_id)`, `_duplicate_task()`, `_pause_task()`, `_delete_task()` | `application/scheduling/task_application_service.py` | 📋 |
|
|
||||||
| `_view_logs(task_id)` | `presentation/scheduling/kanban_board_widget.py` → gọi MonitoringQueryService | 🎨 |
|
|
||||||
| `_build_calendar()`, `_shift()`, `add_task_on_date()`, `edit_task()` | `presentation/scheduling/calendar_view_widget.py` | 🎨 |
|
|
||||||
| `_open_add_dialog()` | `presentation/scheduling/schedule_task_tab.py` (container) | 🎨 |
|
|
||||||
| `_ai_create_task()`, `_ai_pick_files()`, `_generate()`, `_on_planned()`, `_confirm()` | `presentation/scheduling/ai_task_creator_dialog.py` | 🎨 |
|
|
||||||
| `_ai_import()`, `_ai_pick_import_files()`, `_generate_import()`, `_on_import_planned()` | `presentation/scheduling/ai_task_import_dialog.py` | 🎨 |
|
|
||||||
| AI generation logic | `application/scheduling/ai_task_planner_service.py` | 📋 |
|
|
||||||
|
|
||||||
### Workspace / Cowork Chat (Section 3.2.1 trong function_list.md)
|
|
||||||
|
|
||||||
| Function Hiện Tại | File Mới | Tầng |
|
|
||||||
| :--- | :--- | :--- |
|
|
||||||
| `new_session()` | `application/conversations/conversation_application_service.py` | 📋 |
|
|
||||||
| `send_message()` → `_submit_message()` | `presentation/chat/composer_widget.py` (UI trigger) | 🎨 |
|
|
||||||
| `_build_job()` → `ConversationExecutionRequest` | `application/conversations/conversation_application_service.py` | 📋 |
|
|
||||||
| `_cleanup_turn()`, `_promote_turn_outputs()` | `application/conversations/conversation_application_service.py` | 📋 |
|
|
||||||
| `_refresh_outputs_from_disk()`, `_pick_output_folder()` | `presentation/chat/chat_output_panel.py` | 🎨 |
|
|
||||||
| `_open_skills_manager()` | `presentation/chat/chat_panel.py` (container) | 🎨 |
|
|
||||||
| `refresh_header()` | `presentation/chat/chat_panel.py` (container) | 🎨 |
|
|
||||||
| `refresh_agents()` | `presentation/chat/chat_panel.py` (combo widget) | 🎨 |
|
|
||||||
| `admin_agent_prompt()` | `application/conversations/conversation_application_service.py` | 📋 |
|
|
||||||
| `build_provider()` | `infrastructure/providers/provider_factory.py` | 🔧 |
|
|
||||||
| `workspace_dir()` | `domain/workspaces/workspace_session.py` | 🧠 |
|
|
||||||
| `_start_watching()`, `_on_file_changed()` | `presentation/chat/chat_output_panel.py` | 🎨 |
|
|
||||||
| `_on_turn_started()`, `_on_turn_finished()`, `_on_event(ev)` | `presentation/chat/chat_history_widget.py` (event renderer) | 🎨 |
|
|
||||||
| `_compress_messages()` | `application/conversations/conversation_application_service.py` | 📋 |
|
|
||||||
| `_apply_routing()` | `application/model_routing/routing_application_service.py` | 📋 |
|
|
||||||
| `_on_agent_changed()`, `_note_agent_switch()` | `presentation/chat/chat_panel.py` | 🎨 |
|
|
||||||
| `_ensure_conversation()`, `load_conversation()`, `_save_conversation()` | `application/conversations/conversation_application_service.py` | 📋 |
|
|
||||||
| `running_session_ids()`, `active_workers()` | `application/conversations/conversation_application_service.py` | 📋 |
|
|
||||||
| `send()`, `attach_files()`, `attach_links()` | `presentation/chat/composer_widget.py` | 🎨 |
|
|
||||||
| `has_any_queue()`, `_parse_directives()`, `_show_autocomplete()` | `presentation/chat/composer_widget.py` | 🎨 |
|
|
||||||
|
|
||||||
### Co4E Workflow Studio (Section 3.2.2 trong function_list.md)
|
|
||||||
|
|
||||||
| Function Hiện Tại | File Mới | Tầng |
|
|
||||||
| :--- | :--- | :--- |
|
|
||||||
| `_build_sidebar()`, `_build_canvas()`, `_build_config_panel()`, `_toggle_config()` | `presentation/co4e/co4e_tab.py` (container) | 🎨 |
|
|
||||||
| `_refresh_flows_list()`, `_create_flow()`, `_delete_flow()`, `_duplicate_flow()` | `application/workflows/co4e_workflow_service.py` | 📋 |
|
|
||||||
| `_import_flow()`, `_export_flow()` | `application/workflows/co4e_workflow_service.py` | 📋 |
|
|
||||||
| `_run_flow()`, `_stop_flow()` | `application/workflows/co4e_workflow_service.py` | 📋 |
|
|
||||||
| `_open_flow()` | `presentation/co4e/co4e_tab.py` → gọi canvas | 🎨 |
|
|
||||||
| `_refresh_agents_list()`, `_create_agent()`, `_edit_agent()`, `_delete_agent()`, `_toggle_agent_enabled()` | `presentation/co4e/agent_list_panel.py` | 🎨 |
|
|
||||||
| `_refresh_skills_list()` | `presentation/co4e/skills_list_panel.py` | 🎨 |
|
|
||||||
| `zoom_in()`, `zoom_out()`, `fit_view()` | `presentation/co4e/co4e_canvas_widget.py` | 🎨 |
|
|
||||||
| `_add_node()`, `_delete_node()`, `_connect_nodes()`, `_drag_node()`, `_select_node()`, `_activate_node()` | `presentation/co4e/co4e_canvas_widget.py` | 🎨 |
|
|
||||||
| `_set_run_mode()`, `_run_step()`, `_on_step_finished()`, `_render_plan()` | `presentation/co4e/co4e_run_control_widget.py` | 🎨 |
|
|
||||||
| `_get_flow_chat()`, `_on_chat_event()` | `presentation/co4e/co4e_chat_view.py` | 🎨 |
|
|
||||||
|
|
||||||
### Folder / File Explorer (Section 3.2.3 trong function_list.md)
|
|
||||||
|
|
||||||
| Function Hiện Tại | File Mới | Tầng |
|
|
||||||
| :--- | :--- | :--- |
|
|
||||||
| `set_root()`, `_build_tree_view()` | `presentation/folder/workspace_file_tree.py` | 🎨 |
|
|
||||||
| `_open_file()`, `_view_source()`, `_view_html_preview()`, `_view_office_doc()`, `_view_image()` | `presentation/folder/document_preview_manager.py` | 🎨 |
|
|
||||||
| `_edit_file()`, `_save_file()`, `_preview_toggle()` | `presentation/folder/workspace_file_tree.py` | 🎨 |
|
|
||||||
| `_create_new_file()`, `_create_new_folder()`, `_rename_item()`, `_delete_item()`, `_copy_item()`, `_paste_item()` | `presentation/folder/workspace_file_tree.py` | 🎨 |
|
|
||||||
| `refresh_ai_models()` | `presentation/folder/folder_tab.py` (container) | 🎨 |
|
|
||||||
| `_ai_send()`, `_ai_discard()`, `_reset_ai_conversation()` | `presentation/folder/ai_file_editor_dialog.py` | 🎨 |
|
|
||||||
| `_ai_apply()` | `application/workspaces/file_workspace_service.py` | 📋 |
|
|
||||||
| `_ai_apply_routing()` | `application/model_routing/routing_application_service.py` | 📋 |
|
|
||||||
|
|
||||||
### Graph RAG (Section 3.2.4 trong function_list.md)
|
|
||||||
|
|
||||||
| Function Hiện Tại | File Mới | Tầng |
|
|
||||||
| :--- | :--- | :--- |
|
|
||||||
| `_build_graph()` | `application/workspaces/graph_index_service.py` | 📋 |
|
|
||||||
| `_render_d3_graph()`, `_render_native_graph()`, `_auto_rotate()` | `presentation/graph/structure_graph_view.py` | 🎨 |
|
|
||||||
| `_on_node_click()`, `_open_node_path()`, `_refresh_graph()` | `presentation/graph/structure_graph_view.py` | 🎨 |
|
|
||||||
| `_search_graph()`, `_filter_by_kind()`, `_zoom_graph()` | `presentation/graph/structure_graph_view.py` | 🎨 |
|
|
||||||
| `_ask_question()`, `_on_ask_event()`, `_on_ask_done()` | `presentation/graph/graph_qa_widget.py` | 🎨 |
|
|
||||||
| `_candidate_file_paths()`, `_extract_tmp_dir()`, `_clear_extracts()` | `application/workspaces/graph_index_service.py` | 📋 |
|
|
||||||
|
|
||||||
### Monitoring (Section 4 trong function_list.md)
|
|
||||||
|
|
||||||
| Function Hiện Tại | File Mới | Tầng |
|
|
||||||
| :--- | :--- | :--- |
|
|
||||||
| `_refresh_overview()`, `_refresh_usage_cards()`, `_refresh_resource_usage()`, `_refresh_recent_activity()` | `presentation/monitoring/overview_tab.py` | 🎨 |
|
|
||||||
| `_refresh_sandbox_details()`, `_refresh_permissions()`, `_refresh_audit_log()` | `presentation/monitoring/sandbox_status_tab.py` | 🎨 |
|
|
||||||
| `_refresh_budget()`, `_apply_budget()` | `presentation/monitoring/overview_tab.py` | 🎨 |
|
|
||||||
| `_refresh_security_events()`, `_filter_security_events()`, `_sort_events()` | `presentation/monitoring/security_events_tab.py` | 🎨 |
|
|
||||||
| `_refresh_mcp_calls()`, `_filter_mcp_calls()` | `presentation/monitoring/mcp_history_tab.py` | 🎨 |
|
|
||||||
| `_refresh_action_logs()`, `_filter_action_logs()`, `_sort_action_logs()` | `presentation/monitoring/action_logs_tab.py` | 🎨 |
|
|
||||||
| `_refresh_agent_status()` | `presentation/monitoring/agent_status_tab.py` | 🎨 |
|
|
||||||
| `_toggle_sandbox()`, `_toggle_network_block()`, `_set_resource_limits()`, `_toggle_command_confirm()`, `_manage_permissions()` | `presentation/monitoring/security_settings_tab.py` | 🎨 |
|
|
||||||
| Query/refresh data logic | `application/monitoring/monitoring_query_service.py` | 📋 |
|
|
||||||
|
|
||||||
### Settings (Section 5 trong function_list.md)
|
|
||||||
|
|
||||||
| Function Hiện Tại | File Mới | Tầng |
|
|
||||||
| :--- | :--- | :--- |
|
|
||||||
| `_on_provider_changed()`, `_load_models()`, `_test_connection()` | `presentation/settings/provider_settings_widget.py` | 🎨 |
|
|
||||||
| `_stash_provider_fields()`, `_apply_provider_fields()`, Model List Widget | `presentation/settings/provider_settings_widget.py` | 🎨 |
|
|
||||||
| `_add_mcp_server()`, `_edit_mcp_server()`, `_delete_mcp_server()`, `_test_mcp_connection()` | `presentation/settings/connector_settings_widget.py` | 🎨 |
|
|
||||||
| MS365, CAD/CAE Connectors | `presentation/settings/connector_settings_widget.py` | 🎨 |
|
|
||||||
| `routing_mode`, `routing_policy`, `routing_min_gain`, `routing_timeout`, `routing_interval`, `routing_concurrency`, `routing_judge` | `presentation/settings/routing_settings_widget.py` | 🎨 |
|
|
||||||
| Language Picker, `tray_chk`, `notify_chk` | `presentation/settings/general_settings_widget.py` | 🎨 |
|
|
||||||
| `_save()` | `application/settings/settings_application_service.py` | 📋 |
|
|
||||||
| `attach_tokens`, `attach_files`, `struct_nodes`, `struct_edges` | `presentation/settings/general_settings_widget.py` | 🎨 |
|
|
||||||
| Provider test connection (network call) | `infrastructure/providers/provider_factory.py` | 🔧 |
|
|
||||||
| MCP test connection (network call) | `infrastructure/mcp/mcp_client.py` | 🔧 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
📄 Tài liệu này là bản hợp nhất chính thức. Cập nhật: **14/08/2026** (bổ sung Function Migration Map từ `function_list.md`).
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""domain/ — Quy tắc nghiệp vụ thuần. KHÔNG import PySide6, không chạm đĩa/mạng."""
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
"""Cổng chính sách cho lời gọi tool — hình dạng dữ liệu, chưa phải cài đặt.
|
|
||||||
|
|
||||||
BẢN ĐỀ XUẤT, chờ Team Hoa xác nhận
|
|
||||||
==================================
|
|
||||||
Sơ đồ phân hệ trong ``plan.md`` giao ``domain/security/`` cho Team Gamma và
|
|
||||||
``application/conversations/tool_policy_gateway.py`` cho Team Hoa. Nên Gamma
|
|
||||||
định nghĩa *hình dạng*, Hoa *cài đặt*.
|
|
||||||
|
|
||||||
Viết trước vì N3 (Co4E) cần gọi tool và Team Hoa chưa bắt đầu. Không có nó thì
|
|
||||||
N3 phải tự phỏng đoán rồi sửa lại sau — mà phỏng đoán của một người thì tệ hơn
|
|
||||||
một đề xuất viết ra để cả hai bên soi.
|
|
||||||
|
|
||||||
Nếu Hoa thấy khác, sửa file này chứ đừng đẻ kiểu thứ hai. Đổi sớm rẻ hơn đổi
|
|
||||||
muộn: hiện chỉ N3 dùng.
|
|
||||||
|
|
||||||
Mô hình bám theo code đang chạy, không bịa:
|
|
||||||
* ``core/agent_security.py::SecurityVerdict`` — allowed / reason / layer
|
|
||||||
* ``ui/permission_dialog.py`` — hộp thoại hỏi người dùng khi
|
|
||||||
``ctx.project_confirm_commands()`` bật (``ui/chat_panel.py:1312``)
|
|
||||||
|
|
||||||
Điểm khác biệt duy nhất so với hôm nay: gộp hai thứ đó thành **một câu trả lời
|
|
||||||
ba trạng thái**, thay vì code gọi phải tự nhớ hỏi cả hai nơi.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from enum import Enum
|
|
||||||
from typing import Any, Dict, Protocol, runtime_checkable
|
|
||||||
|
|
||||||
|
|
||||||
class PolicyOutcome(str, Enum):
|
|
||||||
"""Ba trạng thái. ``ASK`` là thứ hệ thống hiện tại đã có (hộp thoại xin
|
|
||||||
phép) nhưng chưa được coi là một kết quả chính thức."""
|
|
||||||
|
|
||||||
ALLOW = "allow"
|
|
||||||
DENY = "deny"
|
|
||||||
ASK = "ask"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ToolCallRequest:
|
|
||||||
"""Một lời gọi tool đang chờ được duyệt.
|
|
||||||
|
|
||||||
``surface`` cho biết chỗ phát sinh — ``"cowork"``, ``"code"``, ``"co4e"``,
|
|
||||||
``"task"``. Chính sách khác nhau theo màn: Co4E chạy nền nên không thể bật
|
|
||||||
hộp thoại hỏi giữa chừng như Cowork.
|
|
||||||
"""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
arguments: Dict[str, Any] = field(default_factory=dict)
|
|
||||||
surface: str = "cowork"
|
|
||||||
project_id: str = ""
|
|
||||||
#: True nếu tool đến từ MCP server ngoài, False nếu là tool dựng sẵn.
|
|
||||||
external: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class PolicyDecision:
|
|
||||||
"""Câu trả lời của cổng.
|
|
||||||
|
|
||||||
``reason`` bắt buộc có khi DENY hoặc ASK — người dùng phải biết vì sao bị
|
|
||||||
chặn, và ``core/audit_log.py`` cần nó để ghi lại.
|
|
||||||
|
|
||||||
``layer`` giữ đúng từ vựng của ``SecurityVerdict``: ``"prompt"`` |
|
|
||||||
``"attachment"`` | ``"command"``, cộng thêm ``"policy"`` cho quyết định của
|
|
||||||
chính cổng này.
|
|
||||||
"""
|
|
||||||
|
|
||||||
outcome: PolicyOutcome
|
|
||||||
reason: str = ""
|
|
||||||
layer: str = "policy"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def allowed(self) -> bool:
|
|
||||||
"""Tương thích với chỗ đang đọc ``SecurityVerdict.allowed``.
|
|
||||||
|
|
||||||
Chú ý: ``ASK`` KHÔNG phải allowed — còn phải hỏi người dùng đã.
|
|
||||||
"""
|
|
||||||
return self.outcome is PolicyOutcome.ALLOW
|
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
if self.outcome is not PolicyOutcome.ALLOW and not self.reason:
|
|
||||||
raise ValueError("DENY và ASK bắt buộc có reason — người dùng và "
|
|
||||||
"audit log đều cần biết vì sao")
|
|
||||||
|
|
||||||
|
|
||||||
def allow() -> PolicyDecision:
|
|
||||||
return PolicyDecision(PolicyOutcome.ALLOW)
|
|
||||||
|
|
||||||
|
|
||||||
def deny(reason: str, layer: str = "policy") -> PolicyDecision:
|
|
||||||
return PolicyDecision(PolicyOutcome.DENY, reason, layer)
|
|
||||||
|
|
||||||
|
|
||||||
def ask(reason: str, layer: str = "policy") -> PolicyDecision:
|
|
||||||
return PolicyDecision(PolicyOutcome.ASK, reason, layer)
|
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
|
||||||
class ToolPolicyGateway(Protocol):
|
|
||||||
"""Hỏi trước khi chạy tool. Cài đặt thật: Team Hoa (R07, hạn 29/08)."""
|
|
||||||
|
|
||||||
def check(self, request: ToolCallRequest) -> PolicyDecision:
|
|
||||||
"""Được chạy tool này không.
|
|
||||||
|
|
||||||
KHÔNG được tự bật hộp thoại bên trong — cổng chỉ *trả lời*, còn hỏi ai
|
|
||||||
và hỏi thế nào là việc của tầng giao diện. Có vậy thì Co4E chạy nền mới
|
|
||||||
dùng chung cổng được với Cowork chạy tương tác.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""infrastructure/ — Chạm thế giới thật: file, keyring, HTTP, tiến trình. Cài đặt interface."""
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
"""Cấu hình ứng dụng — interface, chưa phải cài đặt.
|
|
||||||
|
|
||||||
Hợp đồng số 2 của mục chung. Đây là thứ gỡ chốt lớn nhất: **156 lời gọi
|
|
||||||
``ctx.config.*`` nằm rải trong 29 file**, nên nếu N2 và N3 phải đợi
|
|
||||||
``ConfigRepository`` bản thật (R02-T02, hạn 23/08) thì hai người mất mấy ngày
|
|
||||||
đầu ngồi không.
|
|
||||||
|
|
||||||
Danh sách thuộc tính dưới đây không bịa ra: đếm trực tiếp chỗ đang gọi trong
|
|
||||||
``core/``, ``ui/``, ``providers/`` và ``app.py`` rồi lấy những cái được dùng
|
|
||||||
thật, xếp theo số lần gọi.
|
|
||||||
|
|
||||||
Một chỗ cố ý KHÔNG đưa vào: ``config.data`` (36 lần gọi, nhiều nhất). Đó là
|
|
||||||
đống dict thô — cho nó vào interface là bê nguyên vấn đề cũ sang kiến trúc mới.
|
|
||||||
Ai đang cần ``data`` thì mở issue để bổ sung một thuộc tính có kiểu rõ ràng.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict, Protocol, runtime_checkable
|
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
|
||||||
class ConfigRepository(Protocol):
|
|
||||||
"""Đọc/ghi cấu hình. Cài đặt thật dùng ``AtomicJsonFile`` (R02-T01/T02)."""
|
|
||||||
|
|
||||||
# ---- provider ------------------------------------------------------
|
|
||||||
@property
|
|
||||||
def active_provider(self) -> str:
|
|
||||||
"""Tên provider đang chọn (24 lời gọi)."""
|
|
||||||
...
|
|
||||||
|
|
||||||
def set_active_provider(self, name: str) -> None:
|
|
||||||
...
|
|
||||||
|
|
||||||
def provider_conf(self, name: str | None = None) -> Dict[str, Any]:
|
|
||||||
"""Cấu hình của một provider (9 lời gọi).
|
|
||||||
|
|
||||||
CHÚ Ý — điểm còn bỏ ngỏ, xem ``docs/refactor/GammaTeam_decisions.md``:
|
|
||||||
dict này còn chứa ``api_key`` hay không là quyết định chưa chốt. Có 5
|
|
||||||
nơi đang đọc trực tiếp, 3 trong số đó thuộc ``providers/`` của Team Duy.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
# ---- đường dẫn -----------------------------------------------------
|
|
||||||
@property
|
|
||||||
def shared_dir(self) -> str:
|
|
||||||
"""Thư mục dùng chung cho telemetry nhiều máy (10 lời gọi)."""
|
|
||||||
...
|
|
||||||
|
|
||||||
def history_dir(self) -> Path:
|
|
||||||
"""Thư mục lịch sử chat của project đang chọn (7 lời gọi)."""
|
|
||||||
...
|
|
||||||
|
|
||||||
def cowork_output_dir(self) -> Path:
|
|
||||||
"""Thư mục Cowork ghi kết quả ra (6 lời gọi)."""
|
|
||||||
...
|
|
||||||
|
|
||||||
# ---- giao diện -----------------------------------------------------
|
|
||||||
@property
|
|
||||||
def theme(self) -> str:
|
|
||||||
"""``"dark"`` | ``"light"`` | ``"system"`` (8 lời gọi)."""
|
|
||||||
...
|
|
||||||
|
|
||||||
def set_theme(self, value: str) -> None:
|
|
||||||
...
|
|
||||||
|
|
||||||
@property
|
|
||||||
def language(self) -> str:
|
|
||||||
"""``"vi"`` | ``"en"`` | ``"ja"`` (4 lời gọi)."""
|
|
||||||
...
|
|
||||||
|
|
||||||
def set_language(self, value: str) -> None:
|
|
||||||
...
|
|
||||||
|
|
||||||
# ---- các nhóm cấu hình còn lại -------------------------------------
|
|
||||||
@property
|
|
||||||
def routing(self) -> Dict[str, Any]:
|
|
||||||
"""Cấu hình định tuyến model (7 lời gọi)."""
|
|
||||||
...
|
|
||||||
|
|
||||||
@property
|
|
||||||
def auth(self) -> Dict[str, Any]:
|
|
||||||
"""Cấu hình đăng nhập (6 lời gọi)."""
|
|
||||||
...
|
|
||||||
|
|
||||||
@property
|
|
||||||
def agent_security(self) -> Dict[str, Any]:
|
|
||||||
"""Chính sách an toàn cho agent (5 lời gọi)."""
|
|
||||||
...
|
|
||||||
|
|
||||||
@property
|
|
||||||
def tools_disabled(self) -> list[str]:
|
|
||||||
"""Tool bị tắt (2 lời gọi)."""
|
|
||||||
...
|
|
||||||
|
|
||||||
def set_tool_enabled(self, name: str, enabled: bool) -> None:
|
|
||||||
...
|
|
||||||
|
|
||||||
# ---- ghi ------------------------------------------------------------
|
|
||||||
def save(self) -> None:
|
|
||||||
"""Ghi xuống đĩa. Bản thật ghi atomic — tạm + fsync + thay thế —
|
|
||||||
nên tắt máy giữa chừng không làm hỏng file (R02-T01).
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
"""ConfigRepository chạy trên file JSON — R02-T02.
|
|
||||||
|
|
||||||
Thay cho ``config.py::AppConfig``. Hai khác biệt duy nhất về hành vi, cả hai
|
|
||||||
đều là thứ ta muốn:
|
|
||||||
|
|
||||||
1. Ghi qua :class:`AtomicJsonFile` — mất điện giữa lúc lưu không còn làm hỏng
|
|
||||||
cấu hình (R02-T01).
|
|
||||||
2. API key đọc từ :class:`SecretStore` rồi **ghép vào** dict do
|
|
||||||
``provider_conf()`` trả về — đúng đường A đã chốt 21/08
|
|
||||||
(``docs/refactor/GammaTeam_decisions.md``). Nhờ vậy 5 nơi đang đọc
|
|
||||||
``conf["api_key"]`` không phải sửa dòng nào, trong đó 3 nơi thuộc Team Duy.
|
|
||||||
|
|
||||||
Mọi thứ còn lại giữ nguyên có chủ đích: trộn sâu với mặc định, đọc biến môi
|
|
||||||
trường, ``ms365.unlocked`` không bao giờ chạm đĩa. Đây là refactor — hành vi
|
|
||||||
nhìn từ ngoài phải y hệt.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import copy
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
from ..persistence.json.atomic_json_file import AtomicJsonFile
|
|
||||||
from ..secrets.secret_store import SecretStore, provider_key
|
|
||||||
from .schema_migration import CURRENT_VERSION, migrate
|
|
||||||
|
|
||||||
|
|
||||||
class JsonConfigRepository:
|
|
||||||
"""Cấu hình đọc/ghi từ một file JSON, bí mật để trong ``SecretStore``.
|
|
||||||
|
|
||||||
``secrets`` để None nghĩa là không có kho bí mật — mọi thứ vẫn chạy, chỉ
|
|
||||||
là ``api_key`` lấy nguyên từ file như trước. Cần vậy để chuyển dần
|
|
||||||
(R02-T05) chứ không phải đổi một phát cả app.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, path: Path, *, secrets: SecretStore | None = None,
|
|
||||||
defaults: Dict[str, Any] | None = None,
|
|
||||||
env_overrides=None):
|
|
||||||
self._file = AtomicJsonFile(path)
|
|
||||||
self._secrets = secrets
|
|
||||||
# Lấy thẳng từ config.py để hai bên không lệch nhau trong lúc chuyển.
|
|
||||||
if defaults is None or env_overrides is None:
|
|
||||||
from ... import config as legacy
|
|
||||||
defaults = defaults if defaults is not None else legacy.DEFAULT_CONFIG
|
|
||||||
env_overrides = env_overrides or legacy._apply_env_overrides
|
|
||||||
self._defaults = defaults
|
|
||||||
self._env_overrides = env_overrides
|
|
||||||
self.data: Dict[str, Any] = self._load()
|
|
||||||
|
|
||||||
# ---- nạp ------------------------------------------------------------
|
|
||||||
def _load(self) -> Dict[str, Any]:
|
|
||||||
merged = copy.deepcopy(self._defaults)
|
|
||||||
stored = self._file.read(default=None)
|
|
||||||
if isinstance(stored, dict):
|
|
||||||
# Nâng cấp TRƯỚC khi trộn với mặc định: bước v1→v2 gỡ api_key khỏi
|
|
||||||
# đĩa, mà mặc định thì không có khoá nào để gỡ.
|
|
||||||
stored, changed = migrate(stored, secrets=self._secrets,
|
|
||||||
path=self._file.path)
|
|
||||||
merged = _deep_merge(merged, stored)
|
|
||||||
if changed:
|
|
||||||
self.data = merged
|
|
||||||
self.save() # ghi ngay, để lần sau khỏi chuyển lại
|
|
||||||
merged = self._env_overrides(merged)
|
|
||||||
# Trạng thái mở khoá ms365 chỉ tồn tại lúc chạy — mỗi lần mở app đều
|
|
||||||
# bắt đầu ở trạng thái khoá, không tin giá trị đọc từ đĩa.
|
|
||||||
merged.setdefault("ms365", {})["unlocked"] = False
|
|
||||||
return merged
|
|
||||||
|
|
||||||
def reload(self) -> None:
|
|
||||||
self.data = self._load()
|
|
||||||
|
|
||||||
# ---- provider --------------------------------------------------------
|
|
||||||
@property
|
|
||||||
def active_provider(self) -> str:
|
|
||||||
return self.data.get("active_provider", "")
|
|
||||||
|
|
||||||
def set_active_provider(self, name: str) -> None:
|
|
||||||
self.data["active_provider"] = name
|
|
||||||
|
|
||||||
def provider_conf(self, name: str | None = None) -> Dict[str, Any]:
|
|
||||||
"""Cấu hình provider, có sẵn ``api_key``.
|
|
||||||
|
|
||||||
Trả về BẢN SAO: chỗ gọi sửa dict này thì không được âm thầm ghi ngược
|
|
||||||
vào cấu hình — và quan trọng hơn, khoá vừa ghép vào không được lẫn
|
|
||||||
ngược vào ``self.data`` rồi theo ``save()`` xuống đĩa.
|
|
||||||
"""
|
|
||||||
name = name or self.active_provider
|
|
||||||
conf = dict(self.data.get("providers", {}).get(name, {}))
|
|
||||||
if self._secrets is not None:
|
|
||||||
stored = self._secrets.get(provider_key(name))
|
|
||||||
if stored:
|
|
||||||
conf["api_key"] = stored
|
|
||||||
return conf
|
|
||||||
|
|
||||||
def set_api_key(self, name: str, value: str) -> None:
|
|
||||||
"""Lưu khoá vào kho bí mật, và xoá khỏi cấu hình trên đĩa.
|
|
||||||
|
|
||||||
Đây là nửa còn lại của đường A: dict *đọc ra* vẫn có ``api_key``,
|
|
||||||
nhưng file JSON *trên đĩa* thì không — điều kiện để qua CASAN Check 1.
|
|
||||||
"""
|
|
||||||
if self._secrets is not None:
|
|
||||||
self._secrets.set(provider_key(name), value)
|
|
||||||
self.data.setdefault("providers", {}).setdefault(name, {})["api_key"] = ""
|
|
||||||
else:
|
|
||||||
self.data.setdefault("providers", {}).setdefault(name, {})["api_key"] = value
|
|
||||||
|
|
||||||
# ---- đường dẫn -------------------------------------------------------
|
|
||||||
@property
|
|
||||||
def shared_dir(self) -> str:
|
|
||||||
return self.data.get("shared_dir", "")
|
|
||||||
|
|
||||||
def history_dir(self) -> Path:
|
|
||||||
rt = self.data.get("_project_history_dir")
|
|
||||||
if rt:
|
|
||||||
return Path(rt)
|
|
||||||
custom = (self.data.get("history", {}).get("custom_dir") or "").strip()
|
|
||||||
if custom:
|
|
||||||
return Path(custom).expanduser()
|
|
||||||
from ...config import CONFIG_DIR
|
|
||||||
return CONFIG_DIR / "history"
|
|
||||||
|
|
||||||
def cowork_output_dir(self) -> Path:
|
|
||||||
custom = (self.data.get("cowork", {}).get("output_dir") or "").strip()
|
|
||||||
if custom:
|
|
||||||
return Path(custom).expanduser()
|
|
||||||
from ... import paths
|
|
||||||
from ...config import CONFIG_DIR
|
|
||||||
root = paths.primary_onedrive_root()
|
|
||||||
if root is not None:
|
|
||||||
return root / "CoworkLocal" / "output"
|
|
||||||
return CONFIG_DIR / "output" / "cowork"
|
|
||||||
|
|
||||||
# ---- giao diện -------------------------------------------------------
|
|
||||||
@property
|
|
||||||
def theme(self) -> str:
|
|
||||||
return self.data.get("theme", "dark")
|
|
||||||
|
|
||||||
def set_theme(self, value: str) -> None:
|
|
||||||
self.data["theme"] = value
|
|
||||||
|
|
||||||
@property
|
|
||||||
def language(self) -> str:
|
|
||||||
return self.data.get("language", "vi")
|
|
||||||
|
|
||||||
def set_language(self, value: str) -> None:
|
|
||||||
self.data["language"] = value
|
|
||||||
|
|
||||||
# ---- nhóm cấu hình ---------------------------------------------------
|
|
||||||
@property
|
|
||||||
def routing(self) -> Dict[str, Any]:
|
|
||||||
return self.data.setdefault("routing", {})
|
|
||||||
|
|
||||||
@property
|
|
||||||
def auth(self) -> Dict[str, Any]:
|
|
||||||
return self.data.setdefault("auth", {})
|
|
||||||
|
|
||||||
@property
|
|
||||||
def agent_security(self) -> Dict[str, Any]:
|
|
||||||
return self.data.setdefault("agent_security", {})
|
|
||||||
|
|
||||||
@property
|
|
||||||
def tools_disabled(self) -> list[str]:
|
|
||||||
return list(self.data.get("tools_disabled", []))
|
|
||||||
|
|
||||||
def set_tool_enabled(self, name: str, enabled: bool) -> None:
|
|
||||||
disabled = list(self.data.get("tools_disabled", []))
|
|
||||||
if enabled:
|
|
||||||
disabled = [t for t in disabled if t != name]
|
|
||||||
elif name not in disabled:
|
|
||||||
disabled.append(name)
|
|
||||||
self.data["tools_disabled"] = disabled
|
|
||||||
|
|
||||||
# ---- ghi -------------------------------------------------------------
|
|
||||||
def save(self) -> None:
|
|
||||||
"""Ghi nguyên tử. Không bao giờ để lộ trạng thái mở khoá ms365."""
|
|
||||||
to_write = self.data
|
|
||||||
if self.data.get("ms365", {}).get("unlocked"):
|
|
||||||
to_write = copy.deepcopy(self.data)
|
|
||||||
to_write["ms365"]["unlocked"] = False
|
|
||||||
to_write.pop("_project_history_dir", None)
|
|
||||||
to_write["schema_version"] = CURRENT_VERSION
|
|
||||||
self._file.write(to_write)
|
|
||||||
|
|
||||||
|
|
||||||
def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
"""Trộn sâu — giống hệt ``config.py::_deep_merge``.
|
|
||||||
|
|
||||||
Không import lại từ đó vì file này phải sống được sau khi ``config.py``
|
|
||||||
biến mất; giữ bản sao 6 dòng còn hơn giữ một sợi dây phụ thuộc.
|
|
||||||
"""
|
|
||||||
out = copy.deepcopy(base)
|
|
||||||
for key, value in (override or {}).items():
|
|
||||||
if isinstance(value, dict) and isinstance(out.get(key), dict):
|
|
||||||
out[key] = _deep_merge(out[key], value)
|
|
||||||
else:
|
|
||||||
out[key] = value
|
|
||||||
return out
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
"""Đánh số phiên bản và chuyển đổi cấu hình — R02-T06.
|
|
||||||
|
|
||||||
Hôm nay ``config.json`` không có số phiên bản. Nghĩa là không có cách nào biết
|
|
||||||
file trên đĩa thuộc thời nào, và mọi thay đổi hình dạng phải xử lý bằng cách
|
|
||||||
đoán — ``config.py::_migrate_connectors()`` chính là một ví dụ: nó đoán "có
|
|
||||||
khoá ``office`` nghĩa là file cũ".
|
|
||||||
|
|
||||||
Ở đây đặt luật rõ:
|
|
||||||
|
|
||||||
* File có ``schema_version``. Thiếu ⇒ coi là **1** (mọi file đang tồn tại).
|
|
||||||
* Mỗi bước nâng cấp là một hàm ``v1 -> v2``, chạy tuần tự, không nhảy cóc.
|
|
||||||
* **Sao lưu trước khi nâng cấp.** Người dùng lùi về bản app cũ thì bản cũ đọc
|
|
||||||
file mới có thể hỏng — phải còn đường về.
|
|
||||||
* Chỉ nâng, không hạ. File mới hơn app thì báo và dùng nguyên trạng, không cố
|
|
||||||
đoán ngược.
|
|
||||||
|
|
||||||
Bước v1→v2 đầu tiên đi kèm R02-T05: gỡ ``api_key`` khỏi đĩa, đẩy vào
|
|
||||||
``SecretStore``.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import copy
|
|
||||||
import logging
|
|
||||||
import shutil
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Callable, Dict
|
|
||||||
|
|
||||||
from ..secrets.secret_store import SecretStore, provider_key
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
#: Phiên bản app hiện đang ghi ra.
|
|
||||||
CURRENT_VERSION = 2
|
|
||||||
|
|
||||||
#: Thiếu ``schema_version`` ⇒ file có từ trước khi đánh số.
|
|
||||||
ASSUMED_VERSION = 1
|
|
||||||
|
|
||||||
|
|
||||||
def read_version(data: Dict[str, Any]) -> int:
|
|
||||||
try:
|
|
||||||
return int(data.get("schema_version", ASSUMED_VERSION))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return ASSUMED_VERSION
|
|
||||||
|
|
||||||
|
|
||||||
def _v1_to_v2(data: Dict[str, Any], secrets: SecretStore | None) -> Dict[str, Any]:
|
|
||||||
"""Chuyển API key từ file sang kho bí mật — R02-T05.
|
|
||||||
|
|
||||||
Không có kho bí mật thì **không chuyển**: thà để khoá nằm nguyên trong file
|
|
||||||
còn hơn xoá đi rồi người dùng mất khoá mà không hiểu vì sao. File giữ
|
|
||||||
nguyên phiên bản 1, lần chạy sau trên máy có keyring sẽ chuyển.
|
|
||||||
"""
|
|
||||||
if secrets is None or not getattr(secrets, "available", True):
|
|
||||||
log.info("bỏ qua v1→v2: máy này chưa có kho bí mật dùng được")
|
|
||||||
return data
|
|
||||||
|
|
||||||
out = copy.deepcopy(data)
|
|
||||||
moved = []
|
|
||||||
for name, conf in (out.get("providers") or {}).items():
|
|
||||||
if not isinstance(conf, dict):
|
|
||||||
continue
|
|
||||||
key = (conf.get("api_key") or "").strip()
|
|
||||||
# "ollama" là giá trị bù nhìn — Ollama đòi có api_key nhưng bỏ qua nội
|
|
||||||
# dung. Đẩy nó vào keyring chỉ tổ rác.
|
|
||||||
if not key or key == "ollama":
|
|
||||||
continue
|
|
||||||
secrets.set(provider_key(name), key)
|
|
||||||
conf["api_key"] = ""
|
|
||||||
moved.append(name)
|
|
||||||
|
|
||||||
out["schema_version"] = 2
|
|
||||||
if moved:
|
|
||||||
log.info("đã chuyển API key sang kho bí mật: %s", ", ".join(moved))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
#: {phiên bản nguồn: hàm nâng lên phiên bản kế tiếp}
|
|
||||||
STEPS: Dict[int, Callable[[Dict[str, Any], SecretStore | None], Dict[str, Any]]] = {
|
|
||||||
1: _v1_to_v2,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def backup(path: Path) -> Path | None:
|
|
||||||
"""Chép file trước khi nâng cấp. Trả về đường dẫn bản sao."""
|
|
||||||
if not path.exists():
|
|
||||||
return None
|
|
||||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
||||||
target = path.with_suffix(path.suffix + f".v{stamp}.bak")
|
|
||||||
try:
|
|
||||||
shutil.copy2(path, target)
|
|
||||||
return target
|
|
||||||
except OSError as exc:
|
|
||||||
log.warning("không sao lưu được %s: %s", path, exc)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def migrate(data: Dict[str, Any], *, secrets: SecretStore | None = None,
|
|
||||||
path: Path | None = None) -> tuple[Dict[str, Any], bool]:
|
|
||||||
"""Nâng ``data`` lên :data:`CURRENT_VERSION`.
|
|
||||||
|
|
||||||
Trả về ``(dữ_liệu, có_đổi_không)``. ``có_đổi_không`` là False thì chỗ gọi
|
|
||||||
khỏi phải ghi lại đĩa.
|
|
||||||
"""
|
|
||||||
version = read_version(data)
|
|
||||||
|
|
||||||
if version > CURRENT_VERSION:
|
|
||||||
# App cũ gặp file mới. Đoán ngược là cách nhanh nhất để mất dữ liệu.
|
|
||||||
log.warning("config phiên bản %s mới hơn app (%s) — dùng nguyên trạng",
|
|
||||||
version, CURRENT_VERSION)
|
|
||||||
return data, False
|
|
||||||
|
|
||||||
if version == CURRENT_VERSION:
|
|
||||||
return data, False
|
|
||||||
|
|
||||||
if path is not None:
|
|
||||||
backup(path)
|
|
||||||
|
|
||||||
changed = False
|
|
||||||
while version < CURRENT_VERSION:
|
|
||||||
step = STEPS.get(version)
|
|
||||||
if step is None:
|
|
||||||
log.warning("thiếu bước nâng cấp từ phiên bản %s — dừng", version)
|
|
||||||
break
|
|
||||||
data = step(data, secrets)
|
|
||||||
new_version = read_version(data)
|
|
||||||
if new_version <= version:
|
|
||||||
# Bước không nâng được phiên bản (ví dụ v1→v2 bỏ qua vì chưa có
|
|
||||||
# keyring). Dừng, đừng lặp vô hạn.
|
|
||||||
break
|
|
||||||
version = new_version
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
return data, changed
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
"""Khung nhìn có kiểu cho từng nhóm cấu hình — R02-T03.
|
|
||||||
|
|
||||||
Vấn đề đang có: khắp nơi viết ``ctx.config.routing.get("switch_mode", "off")``.
|
|
||||||
Gõ sai một chữ thì lặng lẽ nhận giá trị mặc định, không ai biết cho tới khi
|
|
||||||
tính năng "không hiểu sao không chạy". Đếm được **156 lời gọi ``ctx.config.*``
|
|
||||||
trong 29 file** kiểu đó.
|
|
||||||
|
|
||||||
Ở đây mỗi nhóm cấu hình có một lớp: gõ sai tên thuộc tính là lỗi ngay, và kiểu
|
|
||||||
dữ liệu ghi rõ ràng nên đọc code là biết ``confirm_timeout_sec`` là số giây
|
|
||||||
chứ không phải mili giây.
|
|
||||||
|
|
||||||
Cố ý KHÔNG dùng dataclass đông cứng: đây là *khung nhìn* lên dict cấu hình
|
|
||||||
sống, sửa qua đây là sửa vào dict rồi ``save()`` là xuống đĩa. Sao chép thành
|
|
||||||
dataclass thì lại sinh chuyện đồng bộ hai chiều.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
|
|
||||||
class _View:
|
|
||||||
"""Khung nhìn lên một nhánh của dict cấu hình."""
|
|
||||||
|
|
||||||
def __init__(self, data: Dict[str, Any]):
|
|
||||||
self._d = data
|
|
||||||
|
|
||||||
def _get(self, key: str, default: Any) -> Any:
|
|
||||||
value = self._d.get(key, default)
|
|
||||||
return default if value is None else value
|
|
||||||
|
|
||||||
def raw(self) -> Dict[str, Any]:
|
|
||||||
"""Dict gốc — dùng khi cần đọc khoá chưa được đưa vào khung nhìn.
|
|
||||||
|
|
||||||
Có mặt để không ai bị kẹt: thiếu thuộc tính thì dùng tạm ``raw()`` rồi
|
|
||||||
mở issue bổ sung, chứ đừng vòng lại ``ctx.config.data``.
|
|
||||||
"""
|
|
||||||
return self._d
|
|
||||||
|
|
||||||
|
|
||||||
class ProviderSettings(_View):
|
|
||||||
"""Một provider: đi đâu, model nào, khoá nào.
|
|
||||||
|
|
||||||
``api_key`` ở đây là thứ ``JsonConfigRepository.provider_conf()`` đã ghép
|
|
||||||
sẵn từ kho bí mật — xem đường A trong ``GammaTeam_decisions.md``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def base_url(self) -> str:
|
|
||||||
return str(self._get("base_url", ""))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def model(self) -> str:
|
|
||||||
return str(self._get("model", ""))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def api_key(self) -> str:
|
|
||||||
return str(self._get("api_key", ""))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def configured(self) -> bool:
|
|
||||||
"""Đủ thông tin để gọi được chưa.
|
|
||||||
|
|
||||||
Ollama chạy cục bộ nên không cần khoá — đó là lý do điều kiện là
|
|
||||||
"có base_url và model", không phải "có api_key".
|
|
||||||
"""
|
|
||||||
return bool(self.base_url and self.model)
|
|
||||||
|
|
||||||
|
|
||||||
class RoutingSettings(_View):
|
|
||||||
"""Định tuyến model tự động (``core/routing/``)."""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def switch_mode(self) -> str:
|
|
||||||
"""``"off"`` | ``"auto"`` | ``"manual"``."""
|
|
||||||
return str(self._get("switch_mode", "off"))
|
|
||||||
|
|
||||||
@switch_mode.setter
|
|
||||||
def switch_mode(self, value: str) -> None:
|
|
||||||
self._d["switch_mode"] = value
|
|
||||||
|
|
||||||
@property
|
|
||||||
def enabled(self) -> bool:
|
|
||||||
return self.switch_mode != "off"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def policy(self) -> str:
|
|
||||||
"""``"balanced"`` | ``"cheap"`` | ``"quality"``…"""
|
|
||||||
return str(self._get("policy", "balanced"))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def min_score_gain(self) -> float:
|
|
||||||
"""Phải hơn model hiện tại bao nhiêu điểm mới đáng đổi."""
|
|
||||||
return float(self._get("min_score_gain", 0.05))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def confirm_timeout_sec(self) -> int:
|
|
||||||
"""GIÂY, không phải mili giây — đọc tên là biết, khỏi phải mò."""
|
|
||||||
return int(self._get("confirm_timeout_sec", 60))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def reassess_interval_hours(self) -> int:
|
|
||||||
return int(self._get("reassess_interval_hours", 24))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def per_provider_concurrency(self) -> int:
|
|
||||||
return int(self._get("per_provider_concurrency", 2))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def judge_provider(self) -> str:
|
|
||||||
return str(self._get("judge_provider", ""))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def judge_model(self) -> str:
|
|
||||||
return str(self._get("judge_model", ""))
|
|
||||||
|
|
||||||
|
|
||||||
class SecuritySettings(_View):
|
|
||||||
"""Chính sách an toàn cho agent (``core/agent_security.py``)."""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def enabled(self) -> bool:
|
|
||||||
return bool(self._get("enabled", True))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def validate_prompt(self) -> bool:
|
|
||||||
return bool(self._get("validate_prompt", True))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def validate_attachments(self) -> bool:
|
|
||||||
return bool(self._get("validate_attachments", True))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def validate_commands(self) -> bool:
|
|
||||||
return bool(self._get("validate_commands", True))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def command_ai_check(self) -> bool:
|
|
||||||
return bool(self._get("command_ai_check", False))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def cowork_confirm_commands(self) -> bool:
|
|
||||||
"""Có hỏi trước khi chạy lệnh không.
|
|
||||||
|
|
||||||
Ứng với ``PolicyOutcome.ASK`` trong
|
|
||||||
``domain/security/tool_policy.py``.
|
|
||||||
"""
|
|
||||||
return bool(self._get("cowork_confirm_commands", True))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def rules_onedrive_url(self) -> str:
|
|
||||||
return str(self._get("rules_onedrive_url", ""))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def admin_email(self) -> str:
|
|
||||||
return str(self._get("admin_email", ""))
|
|
||||||
|
|
||||||
|
|
||||||
class Settings:
|
|
||||||
"""Cửa vào duy nhất cho các nhóm cấu hình có kiểu.
|
|
||||||
|
|
||||||
>>> s = Settings(repo)
|
|
||||||
>>> if s.routing.enabled and s.provider().configured:
|
|
||||||
... ...
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, repo):
|
|
||||||
self._repo = repo
|
|
||||||
|
|
||||||
def provider(self, name: str | None = None) -> ProviderSettings:
|
|
||||||
return ProviderSettings(self._repo.provider_conf(name))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def routing(self) -> RoutingSettings:
|
|
||||||
return RoutingSettings(self._repo.routing)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def security(self) -> SecuritySettings:
|
|
||||||
return SecuritySettings(self._repo.agent_security)
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
"""Ghi JSON kiểu không-hỏng-file — R02-T01.
|
|
||||||
|
|
||||||
Vấn đề đang có: ``config.py::save()`` gọi thẳng ``path.write_text(...)``. Hàm
|
|
||||||
đó mở file, cắt cụt về 0 byte, rồi mới ghi nội dung mới. Mất điện, tắt máy, hay
|
|
||||||
process bị kill đúng khoảng giữa thì file cấu hình còn lại **rỗng hoặc ghi dở**
|
|
||||||
— và người dùng mất toàn bộ cấu hình.
|
|
||||||
|
|
||||||
Cách làm ở đây theo đúng thứ tự bắt buộc:
|
|
||||||
|
|
||||||
1. Ghi vào file tạm cùng thư mục (phải cùng ổ đĩa thì bước 3 mới nguyên tử)
|
|
||||||
2. ``flush()`` + ``os.fsync()`` — ép dữ liệu xuống đĩa thật, không nằm trong
|
|
||||||
bộ đệm của hệ điều hành
|
|
||||||
3. ``os.replace()`` — nguyên tử trên cả Windows lẫn POSIX
|
|
||||||
|
|
||||||
Bất kỳ lúc nào chết giữa chừng, file đích vẫn là **bản cũ nguyên vẹn**. Không
|
|
||||||
bao giờ có trạng thái ghi dở.
|
|
||||||
|
|
||||||
Phần đọc có chính sách phục hồi: file hỏng thì giữ lại thành ``.bad`` để còn
|
|
||||||
cứu tay, rồi trả về giá trị mặc định — hỏng cấu hình không được chặn khởi động,
|
|
||||||
đúng như ``config.py`` hiện tại đang làm.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
class AtomicJsonFile:
|
|
||||||
"""Một file JSON, đọc ghi an toàn.
|
|
||||||
|
|
||||||
>>> f = AtomicJsonFile(Path("cau_hinh.json"))
|
|
||||||
>>> f.write({"theme": "dark"})
|
|
||||||
>>> f.read(default={})
|
|
||||||
{'theme': 'dark'}
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, path: Path, *, indent: int = 2):
|
|
||||||
self.path = Path(path)
|
|
||||||
self.indent = indent
|
|
||||||
|
|
||||||
# ---- đọc ------------------------------------------------------------
|
|
||||||
def read(self, default: Any = None) -> Any:
|
|
||||||
"""Nội dung file, hoặc ``default`` nếu chưa có / hỏng.
|
|
||||||
|
|
||||||
Không ném lỗi. File hỏng được đổi tên thành ``<tên>.bad-<thời điểm>``
|
|
||||||
rồi mới trả mặc định — hỏng thì cứu được, chứ đừng ghi đè im lặng.
|
|
||||||
"""
|
|
||||||
if not self.path.exists():
|
|
||||||
return default
|
|
||||||
try:
|
|
||||||
return json.loads(self.path.read_text(encoding="utf-8"))
|
|
||||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
||||||
self._quarantine()
|
|
||||||
return default
|
|
||||||
except OSError:
|
|
||||||
# Không đọc được (khoá file, mất quyền) — KHÔNG cách ly, vì file
|
|
||||||
# có thể vẫn tốt nguyên.
|
|
||||||
return default
|
|
||||||
|
|
||||||
def _quarantine(self) -> Path | None:
|
|
||||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
||||||
target = self.path.with_suffix(self.path.suffix + f".bad-{stamp}")
|
|
||||||
try:
|
|
||||||
os.replace(self.path, target)
|
|
||||||
return target
|
|
||||||
except OSError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# ---- ghi ------------------------------------------------------------
|
|
||||||
def write(self, data: Any) -> None:
|
|
||||||
"""Ghi ``data``. Hoặc thành công trọn vẹn, hoặc file cũ còn nguyên."""
|
|
||||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
text = json.dumps(data, indent=self.indent, ensure_ascii=False)
|
|
||||||
|
|
||||||
# File tạm phải nằm CÙNG thư mục: os.replace chỉ nguyên tử trong cùng
|
|
||||||
# một hệ thống tệp. Để ở %TEMP% là có thể rơi sang ổ khác và biến
|
|
||||||
# thành copy + delete — mất luôn tính nguyên tử.
|
|
||||||
fd, tmp_name = tempfile.mkstemp(
|
|
||||||
dir=str(self.path.parent), prefix=f".{self.path.name}.", suffix=".tmp")
|
|
||||||
tmp = Path(tmp_name)
|
|
||||||
try:
|
|
||||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
||||||
f.write(text)
|
|
||||||
f.flush()
|
|
||||||
os.fsync(f.fileno()) # xuống đĩa thật, không chỉ vào bộ đệm
|
|
||||||
os.replace(tmp, self.path) # nguyên tử
|
|
||||||
except BaseException:
|
|
||||||
# Kể cả KeyboardInterrupt/SystemExit cũng phải dọn file tạm, đừng
|
|
||||||
# để rác .tmp nằm lại cạnh file cấu hình.
|
|
||||||
tmp.unlink(missing_ok=True)
|
|
||||||
raise
|
|
||||||
|
|
||||||
# ---- tiện ích -------------------------------------------------------
|
|
||||||
def exists(self) -> bool:
|
|
||||||
return self.path.exists()
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"AtomicJsonFile({self.path})"
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
"""SecretStore chạy trên OS Keyring — R02-T04.
|
|
||||||
|
|
||||||
Windows dùng Credential Manager, macOS dùng Keychain, Linux dùng Secret
|
|
||||||
Service. Người dùng cuối không thấy gì khác, nhưng API key thôi nằm trong
|
|
||||||
``config.json`` — đó là điều kiện để qua CASAN Check 1.
|
|
||||||
|
|
||||||
Không phải máy nào cũng có keyring dùng được: Linux chạy headless không có
|
|
||||||
Secret Service, và CI thì gần như chắc chắn không. Nên adapter này **không bao
|
|
||||||
giờ ném lỗi** — không dùng được thì tự báo ``available = False`` và trả về
|
|
||||||
None, để tầng trên hiển thị "chưa lưu được khoá" thay vì sập cả app.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
#: Tên "dịch vụ" trong keyring — mọi khoá của app nằm dưới đây.
|
|
||||||
SERVICE = "cowork-local"
|
|
||||||
|
|
||||||
|
|
||||||
class KeyringAdapter:
|
|
||||||
"""Cài đặt :class:`SecretStore` bằng thư viện ``keyring``.
|
|
||||||
|
|
||||||
>>> store = KeyringAdapter()
|
|
||||||
>>> if store.available:
|
|
||||||
... store.set("provider:openai", "sk-...")
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, service: str = SERVICE):
|
|
||||||
self.service = service
|
|
||||||
self._backend = None
|
|
||||||
self._available = False
|
|
||||||
try:
|
|
||||||
import keyring
|
|
||||||
from keyring.backends.fail import Keyring as FailKeyring
|
|
||||||
|
|
||||||
backend = keyring.get_keyring()
|
|
||||||
# backend "fail" là cái keyring trả về khi không tìm được kho nào
|
|
||||||
# dùng được — gọi vào chỉ tổ ném lỗi.
|
|
||||||
if not isinstance(backend, FailKeyring):
|
|
||||||
self._backend = keyring
|
|
||||||
self._available = True
|
|
||||||
else:
|
|
||||||
log.info("keyring không có kho khả dụng trên máy này")
|
|
||||||
except Exception as exc: # noqa: BLE001 — thiếu thư viện, thiếu DBus…
|
|
||||||
log.info("keyring không dùng được: %s", exc)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def available(self) -> bool:
|
|
||||||
"""Có kho bí mật dùng được không.
|
|
||||||
|
|
||||||
Tầng giao diện đọc cờ này để nói cho người dùng biết vì sao ô API key
|
|
||||||
không lưu được, thay vì im lặng làm mất khoá họ vừa nhập.
|
|
||||||
"""
|
|
||||||
return self._available
|
|
||||||
|
|
||||||
# ---- SecretStore ----------------------------------------------------
|
|
||||||
def get(self, key: str) -> str | None:
|
|
||||||
if not self._available:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return self._backend.get_password(self.service, key)
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
log.warning("đọc khoá %r thất bại: %s", key, exc)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def set(self, key: str, value: str) -> None:
|
|
||||||
if not self._available:
|
|
||||||
log.warning("không lưu được %r: máy này không có kho bí mật", key)
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
self._backend.set_password(self.service, key, value)
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
log.warning("lưu khoá %r thất bại: %s", key, exc)
|
|
||||||
|
|
||||||
def delete(self, key: str) -> None:
|
|
||||||
if not self._available:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
self._backend.delete_password(self.service, key)
|
|
||||||
except Exception: # noqa: BLE001 — xoá cái không có: bỏ qua
|
|
||||||
pass
|
|
||||||
|
|
||||||
def has(self, key: str) -> bool:
|
|
||||||
return self.get(key) is not None
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
"""Nơi cất credential — interface, chưa phải cài đặt.
|
|
||||||
|
|
||||||
Hợp đồng số 1 của mục chung: chốt hôm nay để N2 và N3 code được ngay, không
|
|
||||||
phải đợi bản Keyring thật (R02-T04, hạn 26/08).
|
|
||||||
|
|
||||||
Vì sao là interface chứ không phải hàm tiện ích: bản thật sẽ gọi OS Keyring —
|
|
||||||
chậm, có thể ném lỗi, và trong test thì không được đụng vào keyring máy thật.
|
|
||||||
Có interface thì test tiêm ``FakeSecretStore`` vào, chạy trong bộ nhớ.
|
|
||||||
|
|
||||||
Quy ước đặt key: ``"provider:<tên>"`` cho API key của provider, ví dụ
|
|
||||||
``"provider:openai"``. Đặt sẵn để không mỗi người tự nghĩ một kiểu.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Protocol, runtime_checkable
|
|
||||||
|
|
||||||
|
|
||||||
def provider_key(name: str) -> str:
|
|
||||||
"""Key chuẩn cho API key của một provider."""
|
|
||||||
return f"provider:{name}"
|
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
|
||||||
class SecretStore(Protocol):
|
|
||||||
"""Đọc/ghi bí mật. Cài đặt thật: ``KeyringAdapter`` (R02-T04)."""
|
|
||||||
|
|
||||||
def get(self, key: str) -> str | None:
|
|
||||||
"""Giá trị của ``key``, hoặc None nếu chưa có.
|
|
||||||
|
|
||||||
Không được ném lỗi khi thiếu key — thiếu là chuyện bình thường (người
|
|
||||||
dùng chưa nhập API key), không phải sự cố.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
def set(self, key: str, value: str) -> None:
|
|
||||||
"""Lưu ``value``. Ghi đè nếu key đã tồn tại."""
|
|
||||||
...
|
|
||||||
|
|
||||||
def delete(self, key: str) -> None:
|
|
||||||
"""Xoá ``key``. Không có sẵn thì im lặng bỏ qua, không ném lỗi."""
|
|
||||||
...
|
|
||||||
|
|
||||||
def has(self, key: str) -> bool:
|
|
||||||
"""Có key này chưa — dùng cho màn Cài đặt hiển thị trạng thái mà không
|
|
||||||
cần đọc chính giá trị bí mật ra."""
|
|
||||||
...
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
"""Provider-neutral Project Context MCP server template."""
|
|
||||||
|
|
||||||
from .server import build_server, dispatch
|
|
||||||
|
|
||||||
__all__ = ["build_server", "dispatch"]
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
"""Shared, stable boundary used by all Project Context tool work packages."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any, Protocol
|
|
||||||
|
|
||||||
from pydantic import AnyUrl, BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
|
|
||||||
class ContractModel(BaseModel):
|
|
||||||
"""Strict immutable model so provider-specific fields cannot leak to the Agent."""
|
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
||||||
|
|
||||||
|
|
||||||
class IdentityContext(ContractModel):
|
|
||||||
actor_id: str = Field(min_length=1, max_length=256)
|
|
||||||
org_unit: str = Field(min_length=1, max_length=128)
|
|
||||||
customer: str = Field(min_length=1, max_length=128)
|
|
||||||
project: str = Field(min_length=1, max_length=128)
|
|
||||||
granted_scopes: frozenset[str]
|
|
||||||
|
|
||||||
|
|
||||||
class SourceCitation(ContractModel):
|
|
||||||
system: str = Field(min_length=1, max_length=64)
|
|
||||||
url: AnyUrl
|
|
||||||
revision: str = Field(min_length=1, max_length=256)
|
|
||||||
retrieved_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class DispatchResult:
|
|
||||||
ok: bool
|
|
||||||
payload: dict[str, Any]
|
|
||||||
|
|
||||||
|
|
||||||
class PolicyDecisionPoint(Protocol):
|
|
||||||
def decide(self, identity: IdentityContext, tool_name: str, project_id: str) -> bool: ...
|
|
||||||
|
|
||||||
|
|
||||||
class CredentialResolver(Protocol):
|
|
||||||
def resolve(self, identity: IdentityContext, tool_name: str) -> Any: ...
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ProjectContextRuntime:
|
|
||||||
identity: IdentityContext
|
|
||||||
policy: PolicyDecisionPoint
|
|
||||||
credential_resolver: CredentialResolver
|
|
||||||
|
|
||||||
|
|
||||||
class ProviderError(RuntimeError):
|
|
||||||
"""A provider failure with a caller-safe message and retry classification."""
|
|
||||||
|
|
||||||
def __init__(self, code: str, message: str, *, retryable: bool) -> None:
|
|
||||||
super().__init__(message)
|
|
||||||
self.code = code
|
|
||||||
self.safe_message = message
|
|
||||||
self.retryable = retryable
|
|
||||||
|
|
||||||
|
|
||||||
ToolHandler = Callable[[ContractModel, Any], dict[str, Any]]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ToolTemplate:
|
|
||||||
name: str
|
|
||||||
description: str
|
|
||||||
input_model: type[ContractModel]
|
|
||||||
output_model: type[ContractModel]
|
|
||||||
handler: ToolHandler
|
|
||||||
|
|
||||||
def declaration(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"name": self.name,
|
|
||||||
"description": self.description,
|
|
||||||
"inputSchema": self.input_model.model_json_schema(),
|
|
||||||
"outputSchema": self.output_model.model_json_schema(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def error_result(
|
|
||||||
code: str,
|
|
||||||
*,
|
|
||||||
category: str,
|
|
||||||
retryable: bool,
|
|
||||||
message: str,
|
|
||||||
suggested_action: str,
|
|
||||||
correlation_id: str,
|
|
||||||
) -> DispatchResult:
|
|
||||||
return DispatchResult(
|
|
||||||
ok=False,
|
|
||||||
payload={
|
|
||||||
"error": {
|
|
||||||
"code": code,
|
|
||||||
"category": category,
|
|
||||||
"retryable": retryable,
|
|
||||||
"message": message,
|
|
||||||
"suggested_action": suggested_action,
|
|
||||||
"correlation_id": correlation_id,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""One provider module per member-owned tool work package."""
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
"""Provider boundary owned with get_project_change_context."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Protocol
|
|
||||||
|
|
||||||
from ..foundation import IdentityContext, ProviderError
|
|
||||||
|
|
||||||
|
|
||||||
class ChangeProvider(Protocol):
|
|
||||||
def get_change_context(self, **arguments: Any) -> dict[str, Any]: ...
|
|
||||||
|
|
||||||
|
|
||||||
class UnconfiguredChangeProvider:
|
|
||||||
def get_change_context(self, **arguments: Any) -> dict[str, Any]:
|
|
||||||
raise ProviderError(
|
|
||||||
"UNAVAILABLE",
|
|
||||||
"The change provider is not configured for this environment.",
|
|
||||||
retryable=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_provider(identity: IdentityContext) -> ChangeProvider:
|
|
||||||
"""Replace only this factory when wiring the approved read-only Git adapter."""
|
|
||||||
return UnconfiguredChangeProvider()
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
"""Provider boundary owned with get_project_issue_context."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Protocol
|
|
||||||
|
|
||||||
from ..foundation import IdentityContext, ProviderError
|
|
||||||
|
|
||||||
|
|
||||||
class IssueProvider(Protocol):
|
|
||||||
def get_issue_context(self, **arguments: Any) -> dict[str, Any]: ...
|
|
||||||
|
|
||||||
|
|
||||||
class UnconfiguredIssueProvider:
|
|
||||||
def get_issue_context(self, **arguments: Any) -> dict[str, Any]:
|
|
||||||
raise ProviderError(
|
|
||||||
"UNAVAILABLE",
|
|
||||||
"The issue provider is not configured for this environment.",
|
|
||||||
retryable=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_provider(identity: IdentityContext) -> IssueProvider:
|
|
||||||
"""Replace only this factory when wiring the approved read-only issue adapter."""
|
|
||||||
return UnconfiguredIssueProvider()
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
"""Provider boundary owned with search_project_knowledge."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Protocol
|
|
||||||
|
|
||||||
from ..foundation import IdentityContext, ProviderError
|
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeProvider(Protocol):
|
|
||||||
def search_knowledge(self, **arguments: Any) -> dict[str, Any]: ...
|
|
||||||
|
|
||||||
|
|
||||||
class UnconfiguredKnowledgeProvider:
|
|
||||||
def search_knowledge(self, **arguments: Any) -> dict[str, Any]:
|
|
||||||
raise ProviderError(
|
|
||||||
"UNAVAILABLE",
|
|
||||||
"The knowledge provider is not configured for this environment.",
|
|
||||||
retryable=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_provider(identity: IdentityContext) -> KnowledgeProvider:
|
|
||||||
"""Replace only this factory when wiring approved project retrieval."""
|
|
||||||
return UnconfiguredKnowledgeProvider()
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
"""Immutable registry composed before member work starts to prevent merge conflicts."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from types import MappingProxyType
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from .foundation import ToolTemplate
|
|
||||||
from .tools.change_context import TOOL as CHANGE_CONTEXT_TOOL
|
|
||||||
from .tools.issue_context import TOOL as ISSUE_CONTEXT_TOOL
|
|
||||||
from .tools.knowledge_search import TOOL as KNOWLEDGE_SEARCH_TOOL
|
|
||||||
|
|
||||||
TOOLS: tuple[ToolTemplate, ...] = (
|
|
||||||
ISSUE_CONTEXT_TOOL,
|
|
||||||
KNOWLEDGE_SEARCH_TOOL,
|
|
||||||
CHANGE_CONTEXT_TOOL,
|
|
||||||
)
|
|
||||||
TOOLS_BY_NAME = MappingProxyType({tool.name: tool for tool in TOOLS})
|
|
||||||
TOOL_NAMES = tuple(tool.name for tool in TOOLS)
|
|
||||||
|
|
||||||
|
|
||||||
def tool_declarations() -> list[dict[str, Any]]:
|
|
||||||
return [tool.declaration() for tool in TOOLS]
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
"""Fail-closed identity, policy, and provider resolution for the template server."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from collections.abc import Callable
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from .foundation import IdentityContext, ProjectContextRuntime, ProviderError
|
|
||||||
from .providers.change import build_provider as build_change_provider
|
|
||||||
from .providers.issue import build_provider as build_issue_provider
|
|
||||||
from .providers.knowledge import build_provider as build_knowledge_provider
|
|
||||||
|
|
||||||
MINIMUM_PYTHON = (3, 11)
|
|
||||||
|
|
||||||
|
|
||||||
def require_supported_python(version_info: tuple[int, ...] | None = None) -> None:
|
|
||||||
"""Fail with an actionable message before the MCP server starts."""
|
|
||||||
current = version_info or tuple(sys.version_info[:3])
|
|
||||||
if current[:2] < MINIMUM_PYTHON:
|
|
||||||
raise RuntimeError(
|
|
||||||
"Project Context MCP requires Python 3.11 or newer; "
|
|
||||||
f"current runtime is {current[0]}.{current[1]}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ProjectScopePolicy:
|
|
||||||
"""Pilot policy: read scope and exact identity-bound project are both mandatory."""
|
|
||||||
|
|
||||||
def decide(self, identity: IdentityContext, tool_name: str, project_id: str) -> bool:
|
|
||||||
return "read" in identity.granted_scopes and project_id == identity.project
|
|
||||||
|
|
||||||
|
|
||||||
PROVIDER_FACTORIES: dict[str, Callable[[IdentityContext], Any]] = {
|
|
||||||
"get_project_issue_context": build_issue_provider,
|
|
||||||
"search_project_knowledge": build_knowledge_provider,
|
|
||||||
"get_project_change_context": build_change_provider,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ProjectProviderResolver:
|
|
||||||
def resolve(self, identity: IdentityContext, tool_name: str) -> Any:
|
|
||||||
factory = PROVIDER_FACTORIES.get(tool_name)
|
|
||||||
if factory is None:
|
|
||||||
raise ProviderError("NOT_FOUND", "The requested tool is not registered.", retryable=False)
|
|
||||||
return factory(identity)
|
|
||||||
|
|
||||||
|
|
||||||
def _required_environment(name: str) -> str:
|
|
||||||
value = os.environ.get(name, "").strip()
|
|
||||||
if not value:
|
|
||||||
raise RuntimeError(f"Project Context MCP cannot start: required setting {name} is missing")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def default_runtime() -> ProjectContextRuntime:
|
|
||||||
"""Build immutable runtime state; missing identity configuration fails at boot."""
|
|
||||||
require_supported_python()
|
|
||||||
identity = IdentityContext(
|
|
||||||
actor_id=_required_environment("COWORK_MCP_ACTOR_ID"),
|
|
||||||
org_unit=_required_environment("COWORK_MCP_ORG_UNIT"),
|
|
||||||
customer=_required_environment("COWORK_MCP_CUSTOMER"),
|
|
||||||
project=_required_environment("COWORK_MCP_PROJECT"),
|
|
||||||
granted_scopes=frozenset({"read"}),
|
|
||||||
)
|
|
||||||
return ProjectContextRuntime(
|
|
||||||
identity=identity,
|
|
||||||
policy=ProjectScopePolicy(),
|
|
||||||
credential_resolver=ProjectProviderResolver(),
|
|
||||||
)
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
"""Low-level MCP stdio adapter around the transport-agnostic Project Context core."""
|
|
||||||
|
|
||||||
# ruff: noqa: UP045 -- Optional keeps the template importable with Pydantic on Python 3.9.
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from typing import Any, Optional
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from pydantic import ValidationError
|
|
||||||
|
|
||||||
from .foundation import (
|
|
||||||
DispatchResult,
|
|
||||||
ProjectContextRuntime,
|
|
||||||
ProviderError,
|
|
||||||
error_result,
|
|
||||||
)
|
|
||||||
from .registry import TOOLS_BY_NAME, tool_declarations
|
|
||||||
from .runtime import default_runtime, require_supported_python
|
|
||||||
|
|
||||||
|
|
||||||
def dispatch(
|
|
||||||
name: str,
|
|
||||||
arguments: dict[str, Any],
|
|
||||||
runtime: ProjectContextRuntime,
|
|
||||||
) -> DispatchResult:
|
|
||||||
"""Validate → authorize → resolve provider → execute → validate output."""
|
|
||||||
correlation_id = str(uuid4())
|
|
||||||
tool = TOOLS_BY_NAME.get(name)
|
|
||||||
if tool is None:
|
|
||||||
return error_result(
|
|
||||||
"NOT_FOUND",
|
|
||||||
category="NOT_FOUND",
|
|
||||||
retryable=False,
|
|
||||||
message="The requested MCP tool is not registered.",
|
|
||||||
suggested_action="Refresh the tool list and choose one of the advertised tools.",
|
|
||||||
correlation_id=correlation_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
validated_input = tool.input_model.model_validate(arguments or {})
|
|
||||||
except ValidationError:
|
|
||||||
return error_result(
|
|
||||||
"INVALID_INPUT",
|
|
||||||
category="INVALID_INPUT",
|
|
||||||
retryable=False,
|
|
||||||
message="The tool arguments do not match the published input contract.",
|
|
||||||
suggested_action="Correct the required fields and value bounds, then call again.",
|
|
||||||
correlation_id=correlation_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
project_id = str(validated_input.project_id)
|
|
||||||
if not runtime.policy.decide(runtime.identity, name, project_id):
|
|
||||||
return error_result(
|
|
||||||
"DENIED",
|
|
||||||
category="DENIED",
|
|
||||||
retryable=False,
|
|
||||||
message="The project is outside the caller's approved scope.",
|
|
||||||
suggested_action="Use an approved project or ask the project owner for access.",
|
|
||||||
correlation_id=correlation_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
provider = runtime.credential_resolver.resolve(runtime.identity, name)
|
|
||||||
raw_output = tool.handler(validated_input, provider)
|
|
||||||
except ProviderError as exc:
|
|
||||||
return error_result(
|
|
||||||
exc.code,
|
|
||||||
category=exc.code,
|
|
||||||
retryable=exc.retryable,
|
|
||||||
message=exc.safe_message,
|
|
||||||
suggested_action="Check the approved provider configuration and retry if allowed.",
|
|
||||||
correlation_id=correlation_id,
|
|
||||||
)
|
|
||||||
except Exception: # noqa: BLE001 - provider failures must not crash or leak into the agent turn
|
|
||||||
return error_result(
|
|
||||||
"UPSTREAM_ERROR",
|
|
||||||
category="UPSTREAM_ERROR",
|
|
||||||
retryable=False,
|
|
||||||
message="The approved provider could not complete the request.",
|
|
||||||
suggested_action="Check the correlation ID in server logs; do not resend credentials.",
|
|
||||||
correlation_id=correlation_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
output_with_trace = {**raw_output, "correlation_id": correlation_id}
|
|
||||||
validated_output = tool.output_model.model_validate(output_with_trace)
|
|
||||||
except ValidationError:
|
|
||||||
return error_result(
|
|
||||||
"UPSTREAM_ERROR",
|
|
||||||
category="UPSTREAM_ERROR",
|
|
||||||
retryable=False,
|
|
||||||
message="The provider response did not match the published output contract.",
|
|
||||||
suggested_action="Fix the provider mapping before retrying the request.",
|
|
||||||
correlation_id=correlation_id,
|
|
||||||
)
|
|
||||||
return DispatchResult(ok=True, payload=validated_output.model_dump(mode="json"))
|
|
||||||
|
|
||||||
|
|
||||||
def build_server(runtime: Optional[ProjectContextRuntime] = None):
|
|
||||||
from mcp import types
|
|
||||||
from mcp.server.lowlevel import Server
|
|
||||||
|
|
||||||
require_supported_python()
|
|
||||||
app_runtime = runtime or default_runtime()
|
|
||||||
app = Server("project_context")
|
|
||||||
|
|
||||||
@app.list_tools()
|
|
||||||
async def list_tools() -> list[types.Tool]:
|
|
||||||
return [types.Tool(**declaration) for declaration in tool_declarations()]
|
|
||||||
|
|
||||||
@app.call_tool()
|
|
||||||
async def call_tool(name: str, arguments: dict[str, Any]) -> types.CallToolResult:
|
|
||||||
result = dispatch(name, arguments or {}, app_runtime)
|
|
||||||
return types.CallToolResult(
|
|
||||||
content=[types.TextContent(
|
|
||||||
type="text",
|
|
||||||
text=json.dumps(result.payload, ensure_ascii=False, separators=(",", ":")),
|
|
||||||
)],
|
|
||||||
structuredContent=result.payload if result.ok else None,
|
|
||||||
isError=not result.ok,
|
|
||||||
)
|
|
||||||
|
|
||||||
return app
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
import anyio
|
|
||||||
from mcp.server.stdio import stdio_server
|
|
||||||
|
|
||||||
app = build_server()
|
|
||||||
|
|
||||||
async def _run() -> None:
|
|
||||||
async with stdio_server() as (read, write):
|
|
||||||
await app.run(read, write, app.create_initialization_options())
|
|
||||||
|
|
||||||
anyio.run(_run)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Independent tool modules; ownership is documented in the team guide."""
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
"""Member C work package: get_project_change_context."""
|
|
||||||
|
|
||||||
# ruff: noqa: UP045 -- Optional keeps Pydantic model evaluation compatible with Python 3.9.
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Literal, Optional
|
|
||||||
|
|
||||||
from pydantic import Field
|
|
||||||
|
|
||||||
from ..foundation import ContractModel, SourceCitation, ToolTemplate
|
|
||||||
|
|
||||||
|
|
||||||
class ChangeContextInput(ContractModel):
|
|
||||||
project_id: str = Field(min_length=1, max_length=128)
|
|
||||||
change_id: str = Field(min_length=1, max_length=128)
|
|
||||||
detail: Literal["summary", "standard", "full"] = "standard"
|
|
||||||
cursor: Optional[str] = Field(default=None, max_length=2048)
|
|
||||||
|
|
||||||
|
|
||||||
class ChangeContextOutput(ContractModel):
|
|
||||||
correlation_id: str
|
|
||||||
project_id: str
|
|
||||||
change_id: str
|
|
||||||
change_type: Literal["commit", "pull-request", "merge-request"]
|
|
||||||
title: str
|
|
||||||
state: str
|
|
||||||
summary: str
|
|
||||||
authors: tuple[str, ...]
|
|
||||||
files: tuple[str, ...]
|
|
||||||
commits: tuple[str, ...]
|
|
||||||
related_issues: tuple[str, ...]
|
|
||||||
source: SourceCitation
|
|
||||||
truncated: bool
|
|
||||||
returned: int = Field(ge=0)
|
|
||||||
remaining: int = Field(ge=0)
|
|
||||||
next_cursor: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
def _handle(arguments: ContractModel, provider: Any) -> dict[str, Any]:
|
|
||||||
request = ChangeContextInput.model_validate(arguments)
|
|
||||||
return provider.get_change_context(**request.model_dump())
|
|
||||||
|
|
||||||
|
|
||||||
TOOL = ToolTemplate(
|
|
||||||
name="get_project_change_context",
|
|
||||||
description=(
|
|
||||||
"Returns provider-neutral context for one authorized commit, pull request, or merge request "
|
|
||||||
"with changed files, commits, related issues, and a pinned source. Use when an exact change "
|
|
||||||
"identifier is known. Do not use for issue details or free-text document search."
|
|
||||||
),
|
|
||||||
input_model=ChangeContextInput,
|
|
||||||
output_model=ChangeContextOutput,
|
|
||||||
handler=_handle,
|
|
||||||
)
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
"""Member A work package: get_project_issue_context."""
|
|
||||||
|
|
||||||
# ruff: noqa: UP045 -- Optional keeps Pydantic model evaluation compatible with Python 3.9.
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Literal, Optional
|
|
||||||
|
|
||||||
from pydantic import Field
|
|
||||||
|
|
||||||
from ..foundation import ContractModel, SourceCitation, ToolTemplate
|
|
||||||
|
|
||||||
|
|
||||||
class IssueContextInput(ContractModel):
|
|
||||||
project_id: str = Field(min_length=1, max_length=128)
|
|
||||||
issue_key: str = Field(min_length=1, max_length=128)
|
|
||||||
detail: Literal["summary", "standard", "full"] = "standard"
|
|
||||||
cursor: Optional[str] = Field(default=None, max_length=2048)
|
|
||||||
|
|
||||||
|
|
||||||
class RelatedItem(ContractModel):
|
|
||||||
item_id: str
|
|
||||||
relation: str
|
|
||||||
title: str
|
|
||||||
url: str
|
|
||||||
|
|
||||||
|
|
||||||
class IssueContextOutput(ContractModel):
|
|
||||||
correlation_id: str
|
|
||||||
project_id: str
|
|
||||||
issue_key: str
|
|
||||||
title: str
|
|
||||||
status: str
|
|
||||||
description: str
|
|
||||||
acceptance_criteria: tuple[str, ...]
|
|
||||||
related: tuple[RelatedItem, ...]
|
|
||||||
source: SourceCitation
|
|
||||||
truncated: bool
|
|
||||||
returned: int = Field(ge=0)
|
|
||||||
remaining: int = Field(ge=0)
|
|
||||||
next_cursor: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
def _handle(arguments: ContractModel, provider: Any) -> dict[str, Any]:
|
|
||||||
request = IssueContextInput.model_validate(arguments)
|
|
||||||
return provider.get_issue_context(**request.model_dump())
|
|
||||||
|
|
||||||
|
|
||||||
TOOL = ToolTemplate(
|
|
||||||
name="get_project_issue_context",
|
|
||||||
description=(
|
|
||||||
"Returns one authorized work item's title, state, description, acceptance criteria, "
|
|
||||||
"related items, and pinned source. Use when an exact issue key is known. Do not use for "
|
|
||||||
"free-text knowledge search or Git change review."
|
|
||||||
),
|
|
||||||
input_model=IssueContextInput,
|
|
||||||
output_model=IssueContextOutput,
|
|
||||||
handler=_handle,
|
|
||||||
)
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
"""Member B work package: search_project_knowledge."""
|
|
||||||
|
|
||||||
# ruff: noqa: UP045 -- Optional keeps Pydantic model evaluation compatible with Python 3.9.
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Literal, Optional
|
|
||||||
|
|
||||||
from pydantic import Field
|
|
||||||
|
|
||||||
from ..foundation import ContractModel, SourceCitation, ToolTemplate
|
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeSearchInput(ContractModel):
|
|
||||||
project_id: str = Field(min_length=1, max_length=128)
|
|
||||||
query: str = Field(min_length=2, max_length=1000)
|
|
||||||
detail: Literal["summary", "standard", "full"] = "standard"
|
|
||||||
top_k: int = Field(default=5, ge=1, le=20)
|
|
||||||
language: Optional[Literal["en", "ja", "vi"]] = None
|
|
||||||
cursor: Optional[str] = Field(default=None, max_length=2048)
|
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeItem(ContractModel):
|
|
||||||
document_id: str
|
|
||||||
chunk_id: str
|
|
||||||
title: str
|
|
||||||
excerpt: str
|
|
||||||
score: float = Field(ge=0, le=1)
|
|
||||||
source: SourceCitation
|
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeSearchOutput(ContractModel):
|
|
||||||
correlation_id: str
|
|
||||||
project_id: str
|
|
||||||
query: str
|
|
||||||
items: tuple[KnowledgeItem, ...]
|
|
||||||
truncated: bool
|
|
||||||
returned: int = Field(ge=0)
|
|
||||||
remaining: int = Field(ge=0)
|
|
||||||
next_cursor: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
def _handle(arguments: ContractModel, provider: Any) -> dict[str, Any]:
|
|
||||||
request = KnowledgeSearchInput.model_validate(arguments)
|
|
||||||
return provider.search_knowledge(**request.model_dump())
|
|
||||||
|
|
||||||
|
|
||||||
TOOL = ToolTemplate(
|
|
||||||
name="search_project_knowledge",
|
|
||||||
description=(
|
|
||||||
"Searches approved knowledge for one authorized project and returns ranked excerpts with "
|
|
||||||
"pinned citations. Use for requirements, design notes, or runbooks when no exact issue is "
|
|
||||||
"known. Do not use for issue details or Git change review."
|
|
||||||
),
|
|
||||||
input_model=KnowledgeSearchInput,
|
|
||||||
output_model=KnowledgeSearchOutput,
|
|
||||||
handler=_handle,
|
|
||||||
)
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
"""Stable module entry point for ``python -m cowork_local.mcp_servers.project_context_server``."""
|
|
||||||
|
|
||||||
from .project_context.server import build_server, dispatch, main
|
|
||||||
|
|
||||||
__all__ = ["build_server", "dispatch", "main"]
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""presentation/ — Widget Qt. Chỉ gọi xuống application, không gọi thẳng infrastructure."""
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
"""CASAN Check 1 — không được có credential nào nằm phơi trong repo.
|
|
||||||
|
|
||||||
Team Gamma chủ trì check này (hạn: 30/08). Viết sẵn từ 21/08 để chạy được liên
|
|
||||||
tục trong lúc chuyển API key sang Keyring (R02-T05), thay vì tới ngày cổng mới
|
|
||||||
chạy lần đầu rồi mới biết còn sót.
|
|
||||||
|
|
||||||
Quét gì:
|
|
||||||
* file cấu hình đã commit: ``*.json`` ``*.jsonl`` ``*.yaml`` ``*.yml`` ``*.env``
|
|
||||||
* mã nguồn Python — chỗ gán chuỗi cho biến tên như api_key / token / secret
|
|
||||||
|
|
||||||
Tìm hai loại:
|
|
||||||
1. Chuỗi có hình dạng credential thật (sk-…, ghp_…, xoxb-…, AKIA…, JWT…)
|
|
||||||
2. Trường tên nhạy cảm mà giá trị không rỗng và không phải placeholder
|
|
||||||
|
|
||||||
Bỏ qua: chuỗi rỗng, placeholder ("your-key-here", "changeme"…), giá trị hằng
|
|
||||||
không phải bí mật (Ollama đòi có api_key nhưng bỏ qua nội dung).
|
|
||||||
|
|
||||||
Chạy: python scripts/audit_security.py [--json]
|
|
||||||
Mã thoát: 0 = sạch, 1 = có phát hiện.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# console Windows hay là cp932/cp1258; ép UTF-8 để không chết giữa báo cáo
|
|
||||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
REPO = Path(__file__).resolve().parent.parent
|
|
||||||
|
|
||||||
SKIP_DIRS = {".git", "__pycache__", "node_modules", ".venv", "venv", "build",
|
|
||||||
"dist", ".pytest_cache", ".mypy_cache", "cowork-local-gitea"}
|
|
||||||
CONFIG_SUFFIX = {".json", ".jsonl", ".yaml", ".yml", ".env"}
|
|
||||||
|
|
||||||
# tên trường coi là nhạy cảm
|
|
||||||
SENSITIVE = re.compile(
|
|
||||||
r"(api[_-]?key|secret|token|password|passwd|client[_-]?secret|"
|
|
||||||
r"access[_-]?key|private[_-]?key|credential)", re.I)
|
|
||||||
|
|
||||||
# hình dạng credential thật — bắt được kể cả khi tên trường vô hại
|
|
||||||
SHAPES = [
|
|
||||||
("OpenAI", re.compile(r"\bsk-[A-Za-z0-9_\-]{20,}")),
|
|
||||||
("Anthropic", re.compile(r"\bsk-ant-[A-Za-z0-9_\-]{20,}")),
|
|
||||||
("GitHub", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{30,}")),
|
|
||||||
("Slack", re.compile(r"\bxox[abprs]-[A-Za-z0-9\-]{10,}")),
|
|
||||||
("AWS", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
|
|
||||||
("Google", re.compile(r"\bAIza[0-9A-Za-z_\-]{35}\b")),
|
|
||||||
("JWT", re.compile(r"\beyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.")),
|
|
||||||
("Private key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
|
|
||||||
]
|
|
||||||
|
|
||||||
#: Dòng có dấu này được bỏ qua — lối thoát chuẩn cho mẫu thử, tài liệu, hằng
|
|
||||||
#: đặt tên chứa "secret". Bắt buộc ghi lý do sau dấu hai chấm.
|
|
||||||
ALLOW_MARK = re.compile(r"#\s*casan:\s*allow")
|
|
||||||
|
|
||||||
#: Giá trị là KHOÁ i18n / tên hằng, không phải bí mật. Bắt bằng hình dạng
|
|
||||||
#: "a.b.c" hoặc "a_b_c" chứ không phải bằng danh sách đen từng chữ.
|
|
||||||
LOOKS_LIKE_KEY = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$")
|
|
||||||
|
|
||||||
#: Credential thật gần như luôn dài hơn thế này. Ngưỡng để loại dữ liệu test
|
|
||||||
#: kiểu api_key="x" — báo động giả làm cả đội thôi đọc báo cáo.
|
|
||||||
MIN_SECRET_LEN = 12
|
|
||||||
|
|
||||||
#: Giá trị là hằng liệt kê, không phải bí mật: mức độ cảnh báo, bật/tắt…
|
|
||||||
ENUMISH = {"warning", "warn", "error", "info", "debug", "critical", "on", "off",
|
|
||||||
"true", "false", "yes", "no", "allow", "deny", "block", "ask",
|
|
||||||
"always", "never", "auto", "default", "disabled", "enabled"}
|
|
||||||
|
|
||||||
# giá trị vô hại — không tính là phát hiện
|
|
||||||
PLACEHOLDER = re.compile(
|
|
||||||
r"^(|ollama|none|null|changeme|your[_\- ]?(api[_\- ]?)?key([_\- ]?here)?|"
|
|
||||||
r"<[^>]*>|\{\{.*\}\}|\$\{.*\}|xxx+|\*+|placeholder|todo|example|test|dummy|"
|
|
||||||
r"sk-\.\.\.|\.\.\.)$", re.I)
|
|
||||||
|
|
||||||
# gán chuỗi trong Python: api_key = "..."
|
|
||||||
PY_ASSIGN = re.compile(
|
|
||||||
r"""["']?(\w*(?:api[_-]?key|secret|token|password|credential)\w*)["']?\s*[:=]\s*"""
|
|
||||||
r"""["']([^"']*)["']""", re.I)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_placeholder(value: str) -> bool:
|
|
||||||
v = value.strip()
|
|
||||||
if PLACEHOLDER.match(v) or v.lower() in ENUMISH:
|
|
||||||
return True
|
|
||||||
if LOOKS_LIKE_KEY.match(v): # "monitoring.action_secret_in_output"
|
|
||||||
return True
|
|
||||||
# quá ngắn để là credential thật
|
|
||||||
return len(v) < MIN_SECRET_LEN
|
|
||||||
|
|
||||||
|
|
||||||
def _walk():
|
|
||||||
for path in REPO.rglob("*"):
|
|
||||||
if not path.is_file():
|
|
||||||
continue
|
|
||||||
if any(part in SKIP_DIRS for part in path.parts):
|
|
||||||
continue
|
|
||||||
if path.suffix in CONFIG_SUFFIX or path.suffix == ".py":
|
|
||||||
yield path
|
|
||||||
|
|
||||||
|
|
||||||
def scan() -> list[dict]:
|
|
||||||
findings: list[dict] = []
|
|
||||||
for path in _walk():
|
|
||||||
try:
|
|
||||||
text = path.read_text(encoding="utf-8", errors="replace")
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
rel = path.relative_to(REPO).as_posix()
|
|
||||||
|
|
||||||
for lineno, line in enumerate(text.splitlines(), 1):
|
|
||||||
if ALLOW_MARK.search(line):
|
|
||||||
continue
|
|
||||||
# 1. hình dạng credential thật
|
|
||||||
for label, pattern in SHAPES:
|
|
||||||
m = pattern.search(line)
|
|
||||||
if m:
|
|
||||||
findings.append({
|
|
||||||
"file": rel, "line": lineno, "kind": f"{label} credential",
|
|
||||||
"evidence": m.group(0)[:12] + "…",
|
|
||||||
})
|
|
||||||
|
|
||||||
# 2. trường nhạy cảm có giá trị
|
|
||||||
for m in PY_ASSIGN.finditer(line):
|
|
||||||
field, value = m.group(1), m.group(2)
|
|
||||||
if not SENSITIVE.search(field) or _is_placeholder(value):
|
|
||||||
continue
|
|
||||||
findings.append({
|
|
||||||
"file": rel, "line": lineno,
|
|
||||||
"kind": f"trường '{field}' có giá trị",
|
|
||||||
"evidence": value[:6] + "…" if len(value) > 6 else value,
|
|
||||||
})
|
|
||||||
return findings
|
|
||||||
|
|
||||||
|
|
||||||
def _self_test() -> int:
|
|
||||||
"""Một máy quét không tìm thấy gì chỉ có giá trị nếu chứng minh được nó
|
|
||||||
biết tìm. Cắm mẫu xấu và mẫu vô hại, xem có phân biệt đúng không."""
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
bad = {
|
|
||||||
"OpenAI": '"api_key": "sk-proj-abc123def456ghi789jkl012mno"', # casan: allow - mau thu cua chinh script
|
|
||||||
"GitHub": 'token = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"', # casan: allow - mau thu cua chinh script
|
|
||||||
"AWS": 'aws = "AKIAIOSFODNN7EXAMPLE"', # casan: allow - mau thu cua chinh script
|
|
||||||
"Anthropic": '"api_key": "sk-ant-api03-xxxxxxxxxxxxxxxxxxxxxx"', # casan: allow - mau thu cua chinh script
|
|
||||||
}
|
|
||||||
ok = {
|
|
||||||
"rỗng": '"api_key": ""',
|
|
||||||
"placeholder": '"api_key": "your-key-here"',
|
|
||||||
"ollama": '"api_key": "ollama"',
|
|
||||||
"test ngắn": 'api_key = "x"',
|
|
||||||
"hằng liệt kê": '"secret_in_output": "warning"',
|
|
||||||
}
|
|
||||||
global REPO
|
|
||||||
keep = REPO
|
|
||||||
passed = True
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
REPO = Path(tmp)
|
|
||||||
for label, line in {**bad, **ok}.items():
|
|
||||||
(REPO / "probe.py").write_text(line + "\n", encoding="utf-8")
|
|
||||||
found = bool(scan())
|
|
||||||
want = label in bad
|
|
||||||
mark = "OK " if found == want else "SAI"
|
|
||||||
if found != want:
|
|
||||||
passed = False
|
|
||||||
verb = "bắt được" if found else "bỏ qua"
|
|
||||||
print(f" [{mark}] {label:14} -> {verb}")
|
|
||||||
REPO = keep
|
|
||||||
print()
|
|
||||||
print("Tự kiểm: " + ("script phân biệt đúng." if passed
|
|
||||||
else "*** script phân biệt SAI ***"))
|
|
||||||
return 0 if passed else 1
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
ap = argparse.ArgumentParser(description="CASAN Check 1 — quét credential lộ")
|
|
||||||
ap.add_argument("--json", action="store_true", help="in kết quả dạng JSON")
|
|
||||||
ap.add_argument("--self-test", action="store_true",
|
|
||||||
help="cắm credential giả vào file tạm, kiểm script có bắt được")
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
if args.self_test:
|
|
||||||
return _self_test()
|
|
||||||
|
|
||||||
findings = scan()
|
|
||||||
if args.json:
|
|
||||||
print(json.dumps(findings, ensure_ascii=False, indent=2))
|
|
||||||
else:
|
|
||||||
n_files = sum(1 for _ in _walk())
|
|
||||||
print(f"CASAN Check 1 — quét {n_files} file trong {REPO.name}/")
|
|
||||||
if not findings:
|
|
||||||
print("\n0 credential lưu plaintext. PASS.")
|
|
||||||
else:
|
|
||||||
print(f"\n*** {len(findings)} phát hiện ***\n")
|
|
||||||
for f in findings:
|
|
||||||
print(f" {f['file']}:{f['line']}")
|
|
||||||
print(f" {f['kind']} — {f['evidence']}")
|
|
||||||
return 1 if findings else 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
"""Make the repository package importable when pytest runs from the repo root."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
REPOSITORY_PARENT = Path(__file__).resolve().parents[2]
|
|
||||||
if str(REPOSITORY_PARENT) not in sys.path:
|
|
||||||
sys.path.insert(0, str(REPOSITORY_PARENT))
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Test double dùng chung cho cả 3 team — không phụ thuộc Qt."""
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
"""Bản giả của ConfigRepository và SecretStore — chạy trong bộ nhớ.
|
|
||||||
|
|
||||||
Dùng để N2 (Giám sát) và N3 (Co4E) code và test ngay từ 21/08, không phải đợi
|
|
||||||
bản thật xong ngày 23/08 và 26/08.
|
|
||||||
|
|
||||||
Không chạm đĩa, không chạm keyring, không cần Qt. Test dùng nó chạy trong vài
|
|
||||||
mili giây.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
|
|
||||||
class FakeSecretStore:
|
|
||||||
"""SecretStore trong bộ nhớ.
|
|
||||||
|
|
||||||
>>> s = FakeSecretStore({"provider:openai": "sk-test"})
|
|
||||||
>>> s.get("provider:openai")
|
|
||||||
'sk-test'
|
|
||||||
>>> s.get("provider:chua-co") is None
|
|
||||||
True
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, seed: Dict[str, str] | None = None):
|
|
||||||
self._items: Dict[str, str] = dict(seed or {})
|
|
||||||
|
|
||||||
def get(self, key: str) -> str | None:
|
|
||||||
return self._items.get(key)
|
|
||||||
|
|
||||||
def set(self, key: str, value: str) -> None:
|
|
||||||
self._items[key] = value
|
|
||||||
|
|
||||||
def delete(self, key: str) -> None:
|
|
||||||
self._items.pop(key, None)
|
|
||||||
|
|
||||||
def has(self, key: str) -> bool:
|
|
||||||
return key in self._items
|
|
||||||
|
|
||||||
|
|
||||||
class FakeConfigRepository:
|
|
||||||
"""ConfigRepository trong bộ nhớ, có sẵn giá trị mặc định hợp lý.
|
|
||||||
|
|
||||||
Mọi thứ ghi đè được qua tham số khởi tạo, nên test dựng đúng tình huống
|
|
||||||
mình cần::
|
|
||||||
|
|
||||||
cfg = FakeConfigRepository(theme="light", shared_dir="/tmp/chung")
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, *, active_provider: str = "ollama",
|
|
||||||
providers: Dict[str, Dict[str, Any]] | None = None,
|
|
||||||
shared_dir: str = "", theme: str = "dark", language: str = "vi",
|
|
||||||
routing: Dict[str, Any] | None = None,
|
|
||||||
auth: Dict[str, Any] | None = None,
|
|
||||||
agent_security: Dict[str, Any] | None = None,
|
|
||||||
tools_disabled: list[str] | None = None,
|
|
||||||
history_dir: Path | None = None,
|
|
||||||
output_dir: Path | None = None):
|
|
||||||
self._active_provider = active_provider
|
|
||||||
self._providers = providers or {
|
|
||||||
"ollama": {"base_url": "http://localhost:11434/v1", "model": "llama3"},
|
|
||||||
"openai": {"base_url": "https://api.openai.com/v1", "model": "gpt-4o-mini"},
|
|
||||||
}
|
|
||||||
self._shared_dir = shared_dir
|
|
||||||
self._theme = theme
|
|
||||||
self._language = language
|
|
||||||
self._routing = routing or {"mode": "off"}
|
|
||||||
self._auth = auth or {}
|
|
||||||
self._agent_security = agent_security or {"cowork_confirm_commands": True}
|
|
||||||
self._tools_disabled = list(tools_disabled or [])
|
|
||||||
self._history_dir = history_dir or Path("/fake/history")
|
|
||||||
self._output_dir = output_dir or Path("/fake/workspace")
|
|
||||||
#: số lần save() được gọi — để test khẳng định "có ghi" mà không cần đĩa
|
|
||||||
self.saves = 0
|
|
||||||
|
|
||||||
# ---- provider ------------------------------------------------------
|
|
||||||
@property
|
|
||||||
def active_provider(self) -> str:
|
|
||||||
return self._active_provider
|
|
||||||
|
|
||||||
def set_active_provider(self, name: str) -> None:
|
|
||||||
self._active_provider = name
|
|
||||||
|
|
||||||
def provider_conf(self, name: str | None = None) -> Dict[str, Any]:
|
|
||||||
return dict(self._providers.get(name or self._active_provider, {}))
|
|
||||||
|
|
||||||
# ---- đường dẫn -----------------------------------------------------
|
|
||||||
@property
|
|
||||||
def shared_dir(self) -> str:
|
|
||||||
return self._shared_dir
|
|
||||||
|
|
||||||
def history_dir(self) -> Path:
|
|
||||||
return self._history_dir
|
|
||||||
|
|
||||||
def cowork_output_dir(self) -> Path:
|
|
||||||
return self._output_dir
|
|
||||||
|
|
||||||
# ---- giao diện -----------------------------------------------------
|
|
||||||
@property
|
|
||||||
def theme(self) -> str:
|
|
||||||
return self._theme
|
|
||||||
|
|
||||||
def set_theme(self, value: str) -> None:
|
|
||||||
self._theme = value
|
|
||||||
|
|
||||||
@property
|
|
||||||
def language(self) -> str:
|
|
||||||
return self._language
|
|
||||||
|
|
||||||
def set_language(self, value: str) -> None:
|
|
||||||
self._language = value
|
|
||||||
|
|
||||||
# ---- nhóm cấu hình --------------------------------------------------
|
|
||||||
@property
|
|
||||||
def routing(self) -> Dict[str, Any]:
|
|
||||||
return self._routing
|
|
||||||
|
|
||||||
@property
|
|
||||||
def auth(self) -> Dict[str, Any]:
|
|
||||||
return self._auth
|
|
||||||
|
|
||||||
@property
|
|
||||||
def agent_security(self) -> Dict[str, Any]:
|
|
||||||
return self._agent_security
|
|
||||||
|
|
||||||
@property
|
|
||||||
def tools_disabled(self) -> list[str]:
|
|
||||||
return list(self._tools_disabled)
|
|
||||||
|
|
||||||
def set_tool_enabled(self, name: str, enabled: bool) -> None:
|
|
||||||
if enabled:
|
|
||||||
self._tools_disabled = [t for t in self._tools_disabled if t != name]
|
|
||||||
elif name not in self._tools_disabled:
|
|
||||||
self._tools_disabled.append(name)
|
|
||||||
|
|
||||||
# ---- ghi ------------------------------------------------------------
|
|
||||||
def save(self) -> None:
|
|
||||||
self.saves += 1
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
"""ToolPolicyGateway giả — để N3 (Co4E) chạy được khi Team Hoa chưa cài đặt.
|
|
||||||
|
|
||||||
Mặc định cho qua hết, vì phần lớn test Co4E quan tâm tới luồng workflow chứ
|
|
||||||
không phải chính sách. Test nào cần kiểm nhánh bị chặn thì lập trình câu trả
|
|
||||||
lời::
|
|
||||||
|
|
||||||
gate = FakeToolPolicyGateway(rules={"run_command": deny("cấm trong Co4E")})
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Callable, Dict
|
|
||||||
|
|
||||||
from cowork_local.domain.security.tool_policy import (
|
|
||||||
PolicyDecision, ToolCallRequest, allow,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class FakeToolPolicyGateway:
|
|
||||||
"""Cổng chính sách trong bộ nhớ, có ghi lại đã hỏi những gì."""
|
|
||||||
|
|
||||||
def __init__(self, rules: Dict[str, PolicyDecision] | None = None,
|
|
||||||
default: PolicyDecision | None = None,
|
|
||||||
decide: Callable[[ToolCallRequest], PolicyDecision] | None = None):
|
|
||||||
#: {tên tool: quyết định} — tra trước default
|
|
||||||
self.rules = dict(rules or {})
|
|
||||||
self.default = default or allow()
|
|
||||||
#: hàm tự quyết, dùng khi cần logic phức tạp hơn tra bảng
|
|
||||||
self._decide = decide
|
|
||||||
#: mọi lời gọi đã đi qua — để test khẳng định "có hỏi cổng không"
|
|
||||||
self.seen: list[ToolCallRequest] = []
|
|
||||||
|
|
||||||
def check(self, request: ToolCallRequest) -> PolicyDecision:
|
|
||||||
self.seen.append(request)
|
|
||||||
if self._decide is not None:
|
|
||||||
return self._decide(request)
|
|
||||||
return self.rules.get(request.name, self.default)
|
|
||||||
|
|
||||||
# ---- tiện cho test --------------------------------------------------
|
|
||||||
def asked_for(self, name: str) -> bool:
|
|
||||||
return any(r.name == name for r in self.seen)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def call_count(self) -> int:
|
|
||||||
return len(self.seen)
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
"""AtomicJsonFile — R02-T01. Test tiêm lỗi, đúng như cột nghiệm thu của plan.md.
|
|
||||||
|
|
||||||
Cách kiểm: cắt ngang giữa lúc ghi rồi khẳng định file cũ **còn nguyên**. Nếu
|
|
||||||
chỉ test "ghi rồi đọc lại thấy đúng" thì `path.write_text()` cũ cũng qua — mà
|
|
||||||
đó chính là thứ ta đang thay.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from cowork_local.infrastructure.persistence.json.atomic_json_file import AtomicJsonFile
|
|
||||||
|
|
||||||
|
|
||||||
def test_ghi_roi_doc_lai(tmp_path):
|
|
||||||
f = AtomicJsonFile(tmp_path / "cau_hinh.json")
|
|
||||||
f.write({"theme": "dark", "ngôn ngữ": "vi"})
|
|
||||||
assert f.read() == {"theme": "dark", "ngôn ngữ": "vi"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_chua_co_file_thi_tra_mac_dinh(tmp_path):
|
|
||||||
f = AtomicJsonFile(tmp_path / "chua-ton-tai.json")
|
|
||||||
assert f.read(default={"theme": "dark"}) == {"theme": "dark"}
|
|
||||||
assert f.exists() is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_chet_giua_luc_ghi_thi_file_cu_con_nguyen(tmp_path, monkeypatch):
|
|
||||||
"""Lõi của R02-T01.
|
|
||||||
|
|
||||||
Giả lập mất điện đúng lúc: cho ``os.replace`` ném lỗi. Đây là bước cuối
|
|
||||||
cùng, tức là dữ liệu mới đã nằm trong file tạm rồi — nếu cài đặt sai theo
|
|
||||||
kiểu ghi đè thẳng, file đích lúc này đã hỏng.
|
|
||||||
"""
|
|
||||||
path = tmp_path / "cau_hinh.json"
|
|
||||||
f = AtomicJsonFile(path)
|
|
||||||
f.write({"phiên bản": 1, "quan trọng": "đừng mất"})
|
|
||||||
|
|
||||||
def no_dien(*args, **kwargs):
|
|
||||||
raise OSError("mô phỏng mất điện")
|
|
||||||
|
|
||||||
monkeypatch.setattr(os, "replace", no_dien)
|
|
||||||
with pytest.raises(OSError):
|
|
||||||
f.write({"phiên bản": 2})
|
|
||||||
|
|
||||||
# bản cũ phải còn y nguyên
|
|
||||||
assert f.read() == {"phiên bản": 1, "quan trọng": "đừng mất"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_khong_de_lai_rac_tmp_khi_ghi_hong(tmp_path, monkeypatch):
|
|
||||||
path = tmp_path / "cau_hinh.json"
|
|
||||||
f = AtomicJsonFile(path)
|
|
||||||
f.write({"a": 1})
|
|
||||||
|
|
||||||
monkeypatch.setattr(os, "replace", lambda *a, **k: (_ for _ in ()).throw(OSError("x")))
|
|
||||||
with pytest.raises(OSError):
|
|
||||||
f.write({"a": 2})
|
|
||||||
|
|
||||||
con_lai = [p.name for p in tmp_path.iterdir()]
|
|
||||||
assert con_lai == ["cau_hinh.json"], f"còn rác: {con_lai}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_file_hong_thi_cach_ly_va_tra_mac_dinh(tmp_path):
|
|
||||||
"""Hỏng cấu hình không được chặn khởi động — giữ đúng hành vi config.py
|
|
||||||
hiện tại, nhưng thêm phần giữ lại bản hỏng để còn cứu."""
|
|
||||||
path = tmp_path / "cau_hinh.json"
|
|
||||||
path.write_text("{ đây không phải json", encoding="utf-8")
|
|
||||||
f = AtomicJsonFile(path)
|
|
||||||
|
|
||||||
assert f.read(default={"theme": "dark"}) == {"theme": "dark"}
|
|
||||||
assert not path.exists(), "file hỏng phải được dời đi"
|
|
||||||
bad = list(tmp_path.glob("*.bad-*"))
|
|
||||||
assert len(bad) == 1, "phải giữ lại bản hỏng để cứu tay"
|
|
||||||
assert "đây không phải json" in bad[0].read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def test_ghi_de_nhieu_lan_van_dung(tmp_path):
|
|
||||||
f = AtomicJsonFile(tmp_path / "dem.json")
|
|
||||||
for i in range(20):
|
|
||||||
f.write({"lần": i})
|
|
||||||
assert f.read() == {"lần": 19}
|
|
||||||
assert list(tmp_path.glob("*.tmp")) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_giu_nguyen_tieng_viet_khong_escape(tmp_path):
|
|
||||||
"""config.py hiện dùng ensure_ascii=False — giữ nguyên để file đọc được
|
|
||||||
bằng mắt và git diff không thành một đống \\uXXXX."""
|
|
||||||
path = tmp_path / "vi.json"
|
|
||||||
AtomicJsonFile(path).write({"tên": "Nguyễn Văn Đức"})
|
|
||||||
raw = path.read_text(encoding="utf-8")
|
|
||||||
assert "Nguyễn Văn Đức" in raw
|
|
||||||
assert "\\u" not in raw
|
|
||||||
|
|
||||||
|
|
||||||
def test_tao_thu_muc_cha_neu_chua_co(tmp_path):
|
|
||||||
f = AtomicJsonFile(tmp_path / "sâu" / "hơn" / "nữa" / "c.json")
|
|
||||||
f.write({"ok": True})
|
|
||||||
assert f.read() == {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
def test_json_ghi_ra_doc_duoc_bang_thu_vien_chuan(tmp_path):
|
|
||||||
path = tmp_path / "c.json"
|
|
||||||
AtomicJsonFile(path).write({"n": [1, 2, {"m": None}]})
|
|
||||||
assert json.loads(path.read_text(encoding="utf-8")) == {"n": [1, 2, {"m": None}]}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
"""JsonConfigRepository — R02-T02.
|
|
||||||
|
|
||||||
Hai nhóm bài:
|
|
||||||
* **round-trip** — ghi rồi nạp lại phải ra đúng thứ đã ghi (cột nghiệm thu
|
|
||||||
của plan.md cho ngày 22-23/08)
|
|
||||||
* **đường A** — ``provider_conf()`` vẫn trả ``api_key``, nhưng file JSON
|
|
||||||
trên đĩa thì không có, để qua CASAN Check 1
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from cowork_local.infrastructure.config.config_repository import ConfigRepository
|
|
||||||
from cowork_local.infrastructure.config.json_config_repository import (
|
|
||||||
JsonConfigRepository,
|
|
||||||
)
|
|
||||||
from cowork_local.tests.fakes.fake_config import FakeSecretStore
|
|
||||||
|
|
||||||
DEFAULTS = {
|
|
||||||
"active_provider": "ollama",
|
|
||||||
"providers": {
|
|
||||||
"ollama": {"base_url": "http://localhost:11434/v1", "model": "llama3",
|
|
||||||
"api_key": "ollama"},
|
|
||||||
"openai": {"base_url": "https://api.openai.com/v1", "model": "gpt-4o-mini",
|
|
||||||
"api_key": ""},
|
|
||||||
},
|
|
||||||
"theme": "dark", "language": "vi", "shared_dir": "",
|
|
||||||
"routing": {"mode": "off"}, "auth": {}, "agent_security": {},
|
|
||||||
"tools_disabled": [], "history": {}, "cowork": {}, "ms365": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _repo(tmp_path, secrets=None):
|
|
||||||
return JsonConfigRepository(tmp_path / "config.json", secrets=secrets,
|
|
||||||
defaults=DEFAULTS, env_overrides=lambda d: d)
|
|
||||||
|
|
||||||
|
|
||||||
def test_khop_hop_dong(tmp_path):
|
|
||||||
assert isinstance(_repo(tmp_path), ConfigRepository)
|
|
||||||
|
|
||||||
|
|
||||||
def test_chua_co_file_thi_dung_mac_dinh(tmp_path):
|
|
||||||
cfg = _repo(tmp_path)
|
|
||||||
assert cfg.active_provider == "ollama"
|
|
||||||
assert cfg.theme == "dark"
|
|
||||||
|
|
||||||
|
|
||||||
def test_round_trip(tmp_path):
|
|
||||||
cfg = _repo(tmp_path)
|
|
||||||
cfg.set_theme("light")
|
|
||||||
cfg.set_language("en")
|
|
||||||
cfg.set_active_provider("openai")
|
|
||||||
cfg.set_tool_enabled("run_command", False)
|
|
||||||
cfg.save()
|
|
||||||
|
|
||||||
lai = _repo(tmp_path)
|
|
||||||
assert lai.theme == "light"
|
|
||||||
assert lai.language == "en"
|
|
||||||
assert lai.active_provider == "openai"
|
|
||||||
assert lai.tools_disabled == ["run_command"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_gia_tri_luu_trong_file_trum_len_mac_dinh_nhung_giu_phan_con_thieu(tmp_path):
|
|
||||||
"""Trộn sâu: file cũ thiếu khoá mới thì lấy mặc định, không mất phần cũ."""
|
|
||||||
(tmp_path / "config.json").write_text(
|
|
||||||
json.dumps({"theme": "light", "providers": {"openai": {"model": "gpt-5"}}}),
|
|
||||||
encoding="utf-8")
|
|
||||||
cfg = _repo(tmp_path)
|
|
||||||
assert cfg.theme == "light" # từ file
|
|
||||||
assert cfg.language == "vi" # từ mặc định
|
|
||||||
assert cfg.provider_conf("openai")["model"] == "gpt-5" # từ file
|
|
||||||
assert "api.openai.com" in cfg.provider_conf("openai")["base_url"] # mặc định
|
|
||||||
|
|
||||||
|
|
||||||
# ---- đường A: khoá vào kho bí mật, nhưng dict vẫn có ------------------------
|
|
||||||
|
|
||||||
def test_provider_conf_van_tra_api_key_sau_khi_chuyen_vao_kho(tmp_path):
|
|
||||||
"""Điểm mấu chốt của quyết định A: 5 nơi đọc conf['api_key'] không đổi."""
|
|
||||||
secrets = FakeSecretStore()
|
|
||||||
cfg = _repo(tmp_path, secrets)
|
|
||||||
cfg.set_api_key("openai", "sk-that-bi-mat")
|
|
||||||
|
|
||||||
assert cfg.provider_conf("openai")["api_key"] == "sk-that-bi-mat"
|
|
||||||
|
|
||||||
|
|
||||||
def test_khoa_khong_bao_gio_nam_tren_dia(tmp_path):
|
|
||||||
"""Điều kiện qua CASAN Check 1."""
|
|
||||||
secrets = FakeSecretStore()
|
|
||||||
cfg = _repo(tmp_path, secrets)
|
|
||||||
cfg.set_api_key("openai", "sk-that-bi-mat")
|
|
||||||
cfg.save()
|
|
||||||
|
|
||||||
raw = (tmp_path / "config.json").read_text(encoding="utf-8")
|
|
||||||
assert "sk-that-bi-mat" not in raw
|
|
||||||
assert secrets.get("provider:openai") == "sk-that-bi-mat"
|
|
||||||
|
|
||||||
|
|
||||||
def test_sua_dict_tra_ve_khong_lam_ban_cau_hinh(tmp_path):
|
|
||||||
"""provider_conf trả bản sao — nếu trả tham chiếu thì khoá vừa ghép vào sẽ
|
|
||||||
lẫn ngược vào self.data rồi theo save() xuống đĩa."""
|
|
||||||
secrets = FakeSecretStore()
|
|
||||||
cfg = _repo(tmp_path, secrets)
|
|
||||||
cfg.set_api_key("openai", "sk-bi-mat")
|
|
||||||
|
|
||||||
conf = cfg.provider_conf("openai")
|
|
||||||
conf["model"] = "bị sửa bậy"
|
|
||||||
cfg.save()
|
|
||||||
|
|
||||||
raw = (tmp_path / "config.json").read_text(encoding="utf-8")
|
|
||||||
assert "bị sửa bậy" not in raw
|
|
||||||
assert "sk-bi-mat" not in raw
|
|
||||||
|
|
||||||
|
|
||||||
def test_khong_co_kho_bi_mat_thi_van_chay_nhu_cu(tmp_path):
|
|
||||||
"""Máy không có keyring: hành vi lùi về đúng như config.py hôm nay."""
|
|
||||||
cfg = _repo(tmp_path, secrets=None)
|
|
||||||
cfg.set_api_key("openai", "sk-nam-trong-file")
|
|
||||||
cfg.save()
|
|
||||||
|
|
||||||
assert cfg.provider_conf("openai")["api_key"] == "sk-nam-trong-file"
|
|
||||||
raw = (tmp_path / "config.json").read_text(encoding="utf-8")
|
|
||||||
assert "sk-nam-trong-file" in raw # đúng như cũ, có đánh đổi rõ ràng
|
|
||||||
|
|
||||||
|
|
||||||
# ---- giữ nguyên hành vi cũ --------------------------------------------------
|
|
||||||
|
|
||||||
def test_ms365_unlocked_khong_bao_gio_xuong_dia(tmp_path):
|
|
||||||
cfg = _repo(tmp_path)
|
|
||||||
cfg.data["ms365"]["unlocked"] = True
|
|
||||||
cfg.save()
|
|
||||||
|
|
||||||
raw = json.loads((tmp_path / "config.json").read_text(encoding="utf-8"))
|
|
||||||
assert raw["ms365"]["unlocked"] is False
|
|
||||||
assert cfg.data["ms365"]["unlocked"] is True # trong bộ nhớ vẫn giữ
|
|
||||||
|
|
||||||
assert _repo(tmp_path).data["ms365"]["unlocked"] is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_ghi_hong_giua_chung_khong_lam_mat_cau_hinh(tmp_path, monkeypatch):
|
|
||||||
"""Thừa hưởng từ AtomicJsonFile — kiểm lại ở tầng này cho chắc."""
|
|
||||||
import os
|
|
||||||
|
|
||||||
cfg = _repo(tmp_path)
|
|
||||||
cfg.set_theme("light")
|
|
||||||
cfg.save()
|
|
||||||
|
|
||||||
monkeypatch.setattr(os, "replace",
|
|
||||||
lambda *a, **k: (_ for _ in ()).throw(OSError("mất điện")))
|
|
||||||
cfg.set_theme("hỏng")
|
|
||||||
with pytest.raises(OSError):
|
|
||||||
cfg.save()
|
|
||||||
|
|
||||||
assert _repo(tmp_path).theme == "light"
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
"""Hợp đồng của mục chung có thật sự gỡ chốt cho N2 và N3 không.
|
|
||||||
|
|
||||||
Đây là bài nghiệm thu, không phải test cho vui: nếu ba bài dưới đây xanh thì
|
|
||||||
hai nhánh kia code được ngay hôm nay mà không cần chờ ``ConfigRepository`` hay
|
|
||||||
``KeyringAdapter`` bản thật.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from cowork_local.infrastructure.config.config_repository import ConfigRepository
|
|
||||||
from cowork_local.infrastructure.secrets.secret_store import SecretStore, provider_key
|
|
||||||
from cowork_local.tests.fakes.fake_config import FakeConfigRepository, FakeSecretStore
|
|
||||||
|
|
||||||
REPO_PARENT = Path(__file__).resolve().parents[2]
|
|
||||||
|
|
||||||
|
|
||||||
def test_fake_config_khop_hop_dong():
|
|
||||||
"""Fake phải cài đủ interface — thiếu một hàm là hai nhánh kia gọi vào sẽ vỡ."""
|
|
||||||
assert isinstance(FakeConfigRepository(), ConfigRepository)
|
|
||||||
|
|
||||||
|
|
||||||
def test_fake_secret_store_khop_hop_dong():
|
|
||||||
assert isinstance(FakeSecretStore(), SecretStore)
|
|
||||||
|
|
||||||
|
|
||||||
def test_secret_store_thieu_key_thi_tra_none_chu_khong_nem_loi():
|
|
||||||
"""Thiếu API key là chuyện thường (người dùng chưa nhập), không phải sự cố."""
|
|
||||||
store = FakeSecretStore()
|
|
||||||
assert store.get(provider_key("openai")) is None
|
|
||||||
assert store.has(provider_key("openai")) is False
|
|
||||||
store.delete(provider_key("openai")) # xoá cái không có: im lặng
|
|
||||||
|
|
||||||
store.set(provider_key("openai"), "sk-test")
|
|
||||||
assert store.get(provider_key("openai")) == "sk-test"
|
|
||||||
assert store.has(provider_key("openai")) is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_config_gia_ghi_nhan_save_ma_khong_cham_dia():
|
|
||||||
cfg = FakeConfigRepository(theme="light")
|
|
||||||
assert cfg.theme == "light"
|
|
||||||
cfg.set_theme("dark")
|
|
||||||
cfg.save()
|
|
||||||
assert cfg.theme == "dark"
|
|
||||||
assert cfg.saves == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_bat_duoc_tool_bi_tat():
|
|
||||||
cfg = FakeConfigRepository(tools_disabled=["run_command"])
|
|
||||||
assert cfg.tools_disabled == ["run_command"]
|
|
||||||
cfg.set_tool_enabled("run_command", True)
|
|
||||||
assert cfg.tools_disabled == []
|
|
||||||
cfg.set_tool_enabled("write_file", False)
|
|
||||||
assert cfg.tools_disabled == ["write_file"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_dung_duoc_fake_ma_khong_hề_nap_config_that():
|
|
||||||
"""Bài nghiệm thu chính của mục chung.
|
|
||||||
|
|
||||||
N2 và N3 phải dựng được màn hình và chạy test của mình mà KHÔNG kéo theo
|
|
||||||
``cowork_local.config`` — module nặng, đọc đĩa, và đang bị N1 viết lại.
|
|
||||||
Kiểm bằng tiến trình riêng để không dính module đã nạp sẵn ở test khác.
|
|
||||||
"""
|
|
||||||
snippet = (
|
|
||||||
"import sys\n"
|
|
||||||
"from cowork_local.tests.fakes.fake_config import "
|
|
||||||
"FakeConfigRepository, FakeSecretStore\n"
|
|
||||||
"cfg = FakeConfigRepository(active_provider='openai')\n"
|
|
||||||
"assert cfg.provider_conf()['model'] == 'gpt-4o-mini'\n"
|
|
||||||
"assert FakeSecretStore().get('x') is None\n"
|
|
||||||
"assert 'cowork_local.config' not in sys.modules, "
|
|
||||||
"'fake keo theo config that -> van con phu thuoc'\n"
|
|
||||||
"assert 'PySide6' not in sys.modules, 'fake keo theo Qt -> test se cham'\n"
|
|
||||||
"print('OK')\n"
|
|
||||||
)
|
|
||||||
out = subprocess.run([sys.executable, "-c", snippet], cwd=REPO_PARENT,
|
|
||||||
capture_output=True, text=True, timeout=60)
|
|
||||||
assert out.returncode == 0, out.stderr
|
|
||||||
assert "OK" in out.stdout
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# ToolPolicyGateway — bản đề xuất Gamma viết hộ, chờ Team Hoa xác nhận.
|
|
||||||
# N3 (Co4E) code dựa vào đây từ hôm nay thay vì tự phỏng đoán.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
from cowork_local.domain.security.tool_policy import ( # noqa: E402
|
|
||||||
PolicyOutcome, ToolCallRequest, ToolPolicyGateway, allow, ask, deny,
|
|
||||||
)
|
|
||||||
from cowork_local.tests.fakes.fake_tool_policy import ( # noqa: E402
|
|
||||||
FakeToolPolicyGateway,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_fake_gateway_khop_hop_dong():
|
|
||||||
assert isinstance(FakeToolPolicyGateway(), ToolPolicyGateway)
|
|
||||||
|
|
||||||
|
|
||||||
def test_mac_dinh_cho_qua_va_co_ghi_lai_da_hoi():
|
|
||||||
gate = FakeToolPolicyGateway()
|
|
||||||
d = gate.check(ToolCallRequest(name="read_file", surface="co4e"))
|
|
||||||
assert d.outcome is PolicyOutcome.ALLOW
|
|
||||||
assert d.allowed is True
|
|
||||||
assert gate.asked_for("read_file")
|
|
||||||
assert gate.call_count == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_chan_theo_ten_tool():
|
|
||||||
gate = FakeToolPolicyGateway(rules={"run_command": deny("cấm trong Co4E")})
|
|
||||||
assert gate.check(ToolCallRequest(name="run_command")).outcome is PolicyOutcome.DENY
|
|
||||||
assert gate.check(ToolCallRequest(name="read_file")).allowed is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_ask_khong_phai_la_duoc_phep():
|
|
||||||
"""Bẫy dễ mắc nhất: coi ASK như ALLOW thì tool chạy mà chưa ai đồng ý."""
|
|
||||||
d = ask("cần người dùng xác nhận")
|
|
||||||
assert d.outcome is PolicyOutcome.ASK
|
|
||||||
assert d.allowed is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_deny_va_ask_bat_buoc_co_ly_do():
|
|
||||||
"""Người dùng phải biết vì sao bị chặn, và audit log cần ghi lại."""
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
deny("")
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
ask("")
|
|
||||||
allow() # ALLOW thì không cần lý do
|
|
||||||
|
|
||||||
|
|
||||||
def test_chinh_sach_khac_nhau_theo_man():
|
|
||||||
"""Co4E chạy nền nên không bật được hộp thoại — chặn thẳng thay vì hỏi."""
|
|
||||||
def by_surface(req: ToolCallRequest):
|
|
||||||
if req.surface == "co4e" and req.name == "run_command":
|
|
||||||
return deny("Co4E chạy nền, không hỏi được người dùng")
|
|
||||||
return ask("cần xác nhận") if req.name == "run_command" else allow()
|
|
||||||
|
|
||||||
gate = FakeToolPolicyGateway(decide=by_surface)
|
|
||||||
assert gate.check(ToolCallRequest("run_command", surface="co4e")).outcome is PolicyOutcome.DENY
|
|
||||||
assert gate.check(ToolCallRequest("run_command", surface="cowork")).outcome is PolicyOutcome.ASK
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
"""KeyringAdapter — R02-T04.
|
|
||||||
|
|
||||||
Không đụng vào keyring thật của máy chạy test: tiêm một backend giả. Test mà
|
|
||||||
ghi vào Credential Manager thật thì để lại rác trên máy người khác, và trên CI
|
|
||||||
thì không có kho nào để ghi.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from cowork_local.infrastructure.secrets.keyring_adapter import KeyringAdapter
|
|
||||||
from cowork_local.infrastructure.secrets.secret_store import SecretStore, provider_key
|
|
||||||
|
|
||||||
|
|
||||||
class _KeyringGia:
|
|
||||||
"""Đủ giống thư viện keyring để adapter dùng được."""
|
|
||||||
|
|
||||||
def __init__(self, hong: bool = False):
|
|
||||||
self.kho: dict[tuple[str, str], str] = {}
|
|
||||||
self.hong = hong
|
|
||||||
|
|
||||||
def get_password(self, service, key):
|
|
||||||
if self.hong:
|
|
||||||
raise RuntimeError("kho bí mật không phản hồi")
|
|
||||||
return self.kho.get((service, key))
|
|
||||||
|
|
||||||
def set_password(self, service, key, value):
|
|
||||||
if self.hong:
|
|
||||||
raise RuntimeError("kho bí mật không phản hồi")
|
|
||||||
self.kho[(service, key)] = value
|
|
||||||
|
|
||||||
def delete_password(self, service, key):
|
|
||||||
if self.hong:
|
|
||||||
raise RuntimeError("kho bí mật không phản hồi")
|
|
||||||
del self.kho[(service, key)]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def store():
|
|
||||||
a = KeyringAdapter(service="test-cowork")
|
|
||||||
a._backend = _KeyringGia()
|
|
||||||
a._available = True
|
|
||||||
return a
|
|
||||||
|
|
||||||
|
|
||||||
def test_khop_hop_dong_secret_store(store):
|
|
||||||
assert isinstance(store, SecretStore)
|
|
||||||
|
|
||||||
|
|
||||||
def test_luu_doc_xoa(store):
|
|
||||||
k = provider_key("openai")
|
|
||||||
assert store.get(k) is None
|
|
||||||
assert store.has(k) is False
|
|
||||||
|
|
||||||
store.set(k, "sk-that-la-bi-mat")
|
|
||||||
assert store.get(k) == "sk-that-la-bi-mat"
|
|
||||||
assert store.has(k) is True
|
|
||||||
|
|
||||||
store.delete(k)
|
|
||||||
assert store.get(k) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_moi_provider_mot_khoa_rieng(store):
|
|
||||||
store.set(provider_key("openai"), "khoa-openai")
|
|
||||||
store.set(provider_key("anthropic"), "khoa-anthropic")
|
|
||||||
assert store.get(provider_key("openai")) == "khoa-openai"
|
|
||||||
assert store.get(provider_key("anthropic")) == "khoa-anthropic"
|
|
||||||
|
|
||||||
|
|
||||||
def test_may_khong_co_kho_thi_im_lang_chu_khong_sap():
|
|
||||||
"""Linux headless và CI không có Secret Service. App vẫn phải chạy."""
|
|
||||||
a = KeyringAdapter(service="test-cowork")
|
|
||||||
a._backend = None
|
|
||||||
a._available = False
|
|
||||||
|
|
||||||
assert a.available is False
|
|
||||||
assert a.get("bat-ky") is None
|
|
||||||
a.set("bat-ky", "gia-tri") # không ném lỗi
|
|
||||||
a.delete("bat-ky") # không ném lỗi
|
|
||||||
assert a.has("bat-ky") is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_kho_loi_giua_chung_thi_khong_lam_sap_app(store):
|
|
||||||
"""Keyring có thể hỏng lúc đang chạy — mất DBus, người dùng khoá máy."""
|
|
||||||
store._backend.hong = True
|
|
||||||
|
|
||||||
assert store.get("x") is None # nuốt lỗi, trả None
|
|
||||||
store.set("x", "y") # nuốt lỗi
|
|
||||||
store.delete("x") # nuốt lỗi
|
|
||||||
|
|
||||||
|
|
||||||
def test_xoa_khoa_khong_ton_tai_thi_bo_qua(store):
|
|
||||||
store.delete(provider_key("chua-bao-gio-luu")) # không ném lỗi
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
"""Không file mã nguồn nào được nằm ngoài repo vì `.gitignore`.
|
|
||||||
|
|
||||||
Bài này sinh ra từ một lỗi thật, mất hai ngày mới lộ:
|
|
||||||
|
|
||||||
``.gitignore`` dòng 31 ghi ``secrets/`` — mẫu **không neo**, nên git bỏ qua
|
|
||||||
mọi thư mục tên ``secrets`` ở mọi độ sâu, kể cả ``infrastructure/secrets/``
|
|
||||||
vốn là **mã nguồn**. Ba file trong đó chưa bao giờ lên repo. Máy người viết
|
|
||||||
vẫn chạy 150 test xanh, nhưng ai clone sạch về thì 4 file test đỏ ngay lúc
|
|
||||||
thu thập.
|
|
||||||
|
|
||||||
Trên máy đã có file thì không cách nào nhận ra: ``pytest`` đọc đĩa, không đọc
|
|
||||||
git. Nên phải hỏi thẳng git.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
REPO = Path(__file__).resolve().parent.parent
|
|
||||||
|
|
||||||
#: Thư mục chứa mã nguồn của ứng dụng — file .py ở đây bắt buộc phải vào repo.
|
|
||||||
SOURCE_DIRS = ["domain", "application", "infrastructure", "presentation",
|
|
||||||
"adapters", "core", "ui", "providers", "scripts", "tools", "tests"]
|
|
||||||
|
|
||||||
|
|
||||||
def _git(*args: str) -> str:
|
|
||||||
out = subprocess.run(["git", *args], cwd=REPO, capture_output=True,
|
|
||||||
text=True, encoding="utf-8", errors="replace")
|
|
||||||
return out.stdout
|
|
||||||
|
|
||||||
|
|
||||||
def test_khong_file_py_nao_bi_gitignore_nuot():
|
|
||||||
"""File .py có trên đĩa nhưng git không thấy — vừa chưa theo dõi, vừa bị
|
|
||||||
bỏ qua. Đó chính là hình dạng của lỗi ``secrets/``."""
|
|
||||||
existing = []
|
|
||||||
for d in SOURCE_DIRS:
|
|
||||||
root = REPO / d
|
|
||||||
if root.is_dir():
|
|
||||||
existing.append(d)
|
|
||||||
assert existing, "không thấy thư mục mã nguồn nào — kiểm lại SOURCE_DIRS"
|
|
||||||
|
|
||||||
ignored = _git("ls-files", "--others", "--ignored", "--exclude-standard",
|
|
||||||
"--", *existing).splitlines()
|
|
||||||
ignored_py = [p for p in ignored
|
|
||||||
if p.endswith(".py") and "__pycache__" not in p]
|
|
||||||
|
|
||||||
assert not ignored_py, (
|
|
||||||
"File mã nguồn bị .gitignore nuốt — clone sạch sẽ thiếu:\n "
|
|
||||||
+ "\n ".join(ignored_py)
|
|
||||||
+ "\nChạy `git check-ignore -v <file>` để biết dòng nào gây ra."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_khong_file_py_nao_bi_bo_quen_chua_theo_doi():
|
|
||||||
"""Chưa bị ignore nhưng cũng chưa `git add` — quên, không phải cố ý."""
|
|
||||||
untracked = _git("ls-files", "--others", "--exclude-standard").splitlines()
|
|
||||||
forgotten = [p for p in untracked
|
|
||||||
if p.endswith(".py")
|
|
||||||
and p.split("/")[0] in SOURCE_DIRS
|
|
||||||
and "__pycache__" not in p]
|
|
||||||
|
|
||||||
assert not forgotten, (
|
|
||||||
"File mã nguồn chưa được git add — clone sạch sẽ thiếu:\n "
|
|
||||||
+ "\n ".join(forgotten)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_moi_module_duoc_import_deu_co_trong_repo():
|
|
||||||
"""Bắt theo hướng ngược: đi từ những gì code THỰC SỰ import.
|
|
||||||
|
|
||||||
Hai bài trên quét theo thư mục; bài này bắt cả trường hợp file nằm ngoài
|
|
||||||
danh sách đó mà vẫn được import.
|
|
||||||
"""
|
|
||||||
tracked = set(_git("ls-files").splitlines())
|
|
||||||
missing = []
|
|
||||||
for d in ("domain", "application", "infrastructure", "adapters"):
|
|
||||||
root = REPO / d
|
|
||||||
if not root.is_dir():
|
|
||||||
continue
|
|
||||||
for f in root.rglob("*.py"):
|
|
||||||
rel = f.relative_to(REPO).as_posix()
|
|
||||||
if "__pycache__" in rel:
|
|
||||||
continue
|
|
||||||
if rel not in tracked:
|
|
||||||
missing.append(rel)
|
|
||||||
|
|
||||||
assert not missing, (
|
|
||||||
"Module thuộc kiến trúc mới nhưng không có trong repo:\n "
|
|
||||||
+ "\n ".join(missing)
|
|
||||||
)
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
"""Không thư mục nào ở gốc repo được trùng tên module thư viện chuẩn.
|
|
||||||
|
|
||||||
Bài này sinh ra từ một lỗi thật: kế hoạch refactor đặt tên một tầng là
|
|
||||||
``platform/``, và ngay khi tạo thư mục đó thì mọi script chạy từ gốc repo —
|
|
||||||
``python tools/check_*.py``, ``python scripts/audit_security.py``, 26 file tất
|
|
||||||
cả — đều nạp nhầm ``platform/`` thay cho ``platform`` của Python. ``keyring``
|
|
||||||
chết ngay với ``AttributeError: module 'platform' has no attribute 'system'``.
|
|
||||||
|
|
||||||
Kiểm bằng tên chứ không phải bằng cách thử import: import chỉ hỏng khi có ai
|
|
||||||
đó thật sự dùng module bị che, nên nó im lặng cho tới lúc muộn.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
REPO = Path(__file__).resolve().parent.parent
|
|
||||||
|
|
||||||
#: Không tính: đây là thư mục dữ liệu/tài liệu, không phải package Python.
|
|
||||||
NOT_PACKAGES = {".git", ".gitea", ".vibeflow-preview", "docs", "assets",
|
|
||||||
"__pycache__", ".pytest_cache", "cowork-local-gitea",
|
|
||||||
".cowork_history", ".cowork_local"}
|
|
||||||
|
|
||||||
|
|
||||||
def _top_level_packages() -> list[str]:
|
|
||||||
return [d.name for d in REPO.iterdir()
|
|
||||||
if d.is_dir() and d.name not in NOT_PACKAGES
|
|
||||||
and (d / "__init__.py").exists()]
|
|
||||||
|
|
||||||
|
|
||||||
def test_khong_package_nao_che_khuat_thu_vien_chuan():
|
|
||||||
stdlib = set(sys.stdlib_module_names)
|
|
||||||
clashes = [name for name in _top_level_packages() if name in stdlib]
|
|
||||||
assert not clashes, (
|
|
||||||
"Thư mục ở gốc repo trùng tên module thư viện chuẩn: "
|
|
||||||
+ ", ".join(sorted(clashes))
|
|
||||||
+ ". Chạy script từ gốc repo sẽ nạp nhầm thư mục này. Đổi tên thư mục."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_import_duoc_stdlib_khi_chay_tu_goc_repo():
|
|
||||||
"""Bài trên bắt bằng tên; bài này bắt bằng hành vi thật.
|
|
||||||
|
|
||||||
Chạy tiến trình con với thư mục làm việc là gốc repo — đúng cách 26 script
|
|
||||||
trong ``tools/`` và ``scripts/`` được gọi.
|
|
||||||
"""
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
snippet = (
|
|
||||||
"import platform, json, types, io\n"
|
|
||||||
"assert 'site-packages' not in platform.__file__\n"
|
|
||||||
"assert platform.system(), 'platform.system() phải trả về tên hệ điều hành'\n"
|
|
||||||
"import keyring\n"
|
|
||||||
"print('OK')\n"
|
|
||||||
)
|
|
||||||
out = subprocess.run([sys.executable, "-c", snippet], cwd=REPO,
|
|
||||||
capture_output=True, text=True, timeout=60)
|
|
||||||
assert out.returncode == 0, out.stderr
|
|
||||||
assert "OK" in out.stdout
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from cowork_local.mcp_servers.project_context.foundation import (
|
|
||||||
IdentityContext,
|
|
||||||
ProjectContextRuntime,
|
|
||||||
)
|
|
||||||
from cowork_local.mcp_servers.project_context.registry import (
|
|
||||||
TOOL_NAMES,
|
|
||||||
tool_declarations,
|
|
||||||
)
|
|
||||||
from cowork_local.mcp_servers.project_context.runtime import require_supported_python
|
|
||||||
from cowork_local.mcp_servers.project_context.server import dispatch
|
|
||||||
from mcp import types
|
|
||||||
|
|
||||||
EXPECTED_TOOLS = {
|
|
||||||
"get_project_issue_context",
|
|
||||||
"search_project_knowledge",
|
|
||||||
"get_project_change_context",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class RecordingPolicy:
|
|
||||||
allowed: bool
|
|
||||||
calls: int = 0
|
|
||||||
|
|
||||||
def decide(self, identity: IdentityContext, tool_name: str, project_id: str) -> bool:
|
|
||||||
self.calls += 1
|
|
||||||
return self.allowed
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class RecordingResolver:
|
|
||||||
provider: Any
|
|
||||||
calls: int = 0
|
|
||||||
|
|
||||||
def resolve(self, identity: IdentityContext, tool_name: str) -> Any:
|
|
||||||
self.calls += 1
|
|
||||||
return self.provider
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class FakeProvider:
|
|
||||||
response: dict[str, Any]
|
|
||||||
|
|
||||||
def get_issue_context(self, **_: Any) -> dict[str, Any]:
|
|
||||||
return dict(self.response)
|
|
||||||
|
|
||||||
def search_knowledge(self, **_: Any) -> dict[str, Any]:
|
|
||||||
return dict(self.response)
|
|
||||||
|
|
||||||
def get_change_context(self, **_: Any) -> dict[str, Any]:
|
|
||||||
return dict(self.response)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def identity() -> IdentityContext:
|
|
||||||
return IdentityContext(
|
|
||||||
actor_id="member-a",
|
|
||||||
org_unit="fsg",
|
|
||||||
customer="internal",
|
|
||||||
project="cowork-local",
|
|
||||||
granted_scopes=frozenset({"read"}),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def runtime(identity: IdentityContext, response: dict[str, Any], *, allowed: bool = True):
|
|
||||||
policy = RecordingPolicy(allowed=allowed)
|
|
||||||
resolver = RecordingResolver(provider=FakeProvider(response))
|
|
||||||
return ProjectContextRuntime(
|
|
||||||
identity=identity,
|
|
||||||
policy=policy,
|
|
||||||
credential_resolver=resolver,
|
|
||||||
), policy, resolver
|
|
||||||
|
|
||||||
|
|
||||||
def source() -> dict[str, str]:
|
|
||||||
return {
|
|
||||||
"system": "gitea",
|
|
||||||
"url": "http://example.test/gitea-admin/cowork-local/issues/1",
|
|
||||||
"revision": "main@abc123",
|
|
||||||
"retrieved_at": "2026-08-20T10:00:00Z",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_template_exposes_exactly_three_provider_neutral_tools() -> None:
|
|
||||||
assert set(TOOL_NAMES) == EXPECTED_TOOLS
|
|
||||||
declarations = tool_declarations()
|
|
||||||
assert {item["name"] for item in declarations} == EXPECTED_TOOLS
|
|
||||||
assert all(item["inputSchema"]["additionalProperties"] is False for item in declarations)
|
|
||||||
assert all(item["outputSchema"]["additionalProperties"] is False for item in declarations)
|
|
||||||
assert all(types.Tool(**item).name in EXPECTED_TOOLS for item in declarations)
|
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_fails_fast_below_python_311() -> None:
|
|
||||||
with pytest.raises(RuntimeError, match="requires Python 3.11"):
|
|
||||||
require_supported_python((3, 9, 0))
|
|
||||||
|
|
||||||
|
|
||||||
def test_denied_request_never_resolves_credentials_or_calls_provider(
|
|
||||||
identity: IdentityContext,
|
|
||||||
) -> None:
|
|
||||||
app, policy, resolver = runtime(identity, {}, allowed=False)
|
|
||||||
|
|
||||||
result = dispatch(
|
|
||||||
"get_project_issue_context",
|
|
||||||
{"project_id": "other-project", "issue_key": "1"},
|
|
||||||
app,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.ok is False
|
|
||||||
assert result.payload["error"]["code"] == "DENIED"
|
|
||||||
assert policy.calls == 1
|
|
||||||
assert resolver.calls == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_input_is_rejected_before_policy(identity: IdentityContext) -> None:
|
|
||||||
app, policy, resolver = runtime(identity, {})
|
|
||||||
|
|
||||||
result = dispatch("get_project_issue_context", {"project_id": "cowork-local"}, app)
|
|
||||||
|
|
||||||
assert result.ok is False
|
|
||||||
assert result.payload["error"]["code"] == "INVALID_INPUT"
|
|
||||||
assert policy.calls == 0
|
|
||||||
assert resolver.calls == 0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("tool_name", "arguments", "response"),
|
|
||||||
[
|
|
||||||
(
|
|
||||||
"get_project_issue_context",
|
|
||||||
{"project_id": "cowork-local", "issue_key": "1"},
|
|
||||||
{
|
|
||||||
"project_id": "cowork-local",
|
|
||||||
"issue_key": "1",
|
|
||||||
"title": "MCP pilot",
|
|
||||||
"status": "open",
|
|
||||||
"description": "Build verifiable project context.",
|
|
||||||
"acceptance_criteria": ["Every result has a source."],
|
|
||||||
"related": [],
|
|
||||||
"source": source(),
|
|
||||||
"truncated": False,
|
|
||||||
"returned": 1,
|
|
||||||
"remaining": 0,
|
|
||||||
"next_cursor": None,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"search_project_knowledge",
|
|
||||||
{"project_id": "cowork-local", "query": "MCP setup"},
|
|
||||||
{
|
|
||||||
"project_id": "cowork-local",
|
|
||||||
"query": "MCP setup",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"document_id": "README.md",
|
|
||||||
"chunk_id": "README.md#setup",
|
|
||||||
"title": "Setup",
|
|
||||||
"excerpt": "Install the approved dependencies.",
|
|
||||||
"score": 0.9,
|
|
||||||
"source": source(),
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"truncated": False,
|
|
||||||
"returned": 1,
|
|
||||||
"remaining": 0,
|
|
||||||
"next_cursor": None,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"get_project_change_context",
|
|
||||||
{"project_id": "cowork-local", "change_id": "1"},
|
|
||||||
{
|
|
||||||
"project_id": "cowork-local",
|
|
||||||
"change_id": "1",
|
|
||||||
"change_type": "pull-request",
|
|
||||||
"title": "Add MCP contract",
|
|
||||||
"state": "merged",
|
|
||||||
"summary": "Introduces the project context contract.",
|
|
||||||
"authors": ["member-c"],
|
|
||||||
"files": ["mcp/contract.yaml"],
|
|
||||||
"commits": ["abc123"],
|
|
||||||
"related_issues": ["1"],
|
|
||||||
"source": source(),
|
|
||||||
"truncated": False,
|
|
||||||
"returned": 1,
|
|
||||||
"remaining": 0,
|
|
||||||
"next_cursor": None,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_each_member_template_has_a_valid_success_path(
|
|
||||||
identity: IdentityContext,
|
|
||||||
tool_name: str,
|
|
||||||
arguments: dict[str, Any],
|
|
||||||
response: dict[str, Any],
|
|
||||||
) -> None:
|
|
||||||
app, policy, resolver = runtime(identity, response)
|
|
||||||
|
|
||||||
result = dispatch(tool_name, arguments, app)
|
|
||||||
|
|
||||||
assert result.ok is True
|
|
||||||
assert result.payload["project_id"] == "cowork-local"
|
|
||||||
assert result.payload["correlation_id"]
|
|
||||||
assert policy.calls == 1
|
|
||||||
assert resolver.calls == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_provider_output_must_match_contract(identity: IdentityContext) -> None:
|
|
||||||
app, _, _ = runtime(identity, {"project_id": "cowork-local"})
|
|
||||||
|
|
||||||
result = dispatch(
|
|
||||||
"get_project_issue_context",
|
|
||||||
{"project_id": "cowork-local", "issue_key": "1"},
|
|
||||||
app,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.ok is False
|
|
||||||
assert result.payload["error"]["code"] == "UPSTREAM_ERROR"
|
|
||||||
|
|
||||||
|
|
||||||
def test_unexpected_provider_error_does_not_leak_exception(identity: IdentityContext) -> None:
|
|
||||||
class LeakingProvider:
|
|
||||||
def get_issue_context(self, **_: Any) -> dict[str, Any]:
|
|
||||||
raise RuntimeError("secret provider-token-value")
|
|
||||||
|
|
||||||
policy = RecordingPolicy(allowed=True)
|
|
||||||
resolver = RecordingResolver(provider=LeakingProvider())
|
|
||||||
app = ProjectContextRuntime(identity=identity, policy=policy, credential_resolver=resolver)
|
|
||||||
|
|
||||||
result = dispatch(
|
|
||||||
"get_project_issue_context",
|
|
||||||
{"project_id": "cowork-local", "issue_key": "1"},
|
|
||||||
app,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.ok is False
|
|
||||||
assert result.payload["error"]["code"] == "UPSTREAM_ERROR"
|
|
||||||
assert "secret" not in str(result.payload)
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
"""Đánh số phiên bản + chuyển API key — R02-T06 và R02-T05."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
from cowork_local.infrastructure.config.json_config_repository import (
|
|
||||||
JsonConfigRepository,
|
|
||||||
)
|
|
||||||
from cowork_local.infrastructure.config.schema_migration import (
|
|
||||||
CURRENT_VERSION, migrate, read_version,
|
|
||||||
)
|
|
||||||
from cowork_local.tests.fakes.fake_config import FakeSecretStore
|
|
||||||
|
|
||||||
DEFAULTS = {
|
|
||||||
"active_provider": "openai",
|
|
||||||
"providers": {"openai": {"base_url": "u", "model": "m", "api_key": ""},
|
|
||||||
"ollama": {"base_url": "u", "model": "m", "api_key": "ollama"}},
|
|
||||||
"theme": "dark", "language": "vi", "ms365": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _repo(tmp_path, secrets=None):
|
|
||||||
return JsonConfigRepository(tmp_path / "config.json", secrets=secrets,
|
|
||||||
defaults=DEFAULTS, env_overrides=lambda d: d)
|
|
||||||
|
|
||||||
|
|
||||||
def test_thieu_so_phien_ban_thi_coi_la_v1():
|
|
||||||
assert read_version({}) == 1
|
|
||||||
assert read_version({"schema_version": 2}) == 2
|
|
||||||
assert read_version({"schema_version": "hỏng"}) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_v1_sang_v2_chuyen_khoa_vao_kho_bi_mat():
|
|
||||||
secrets = FakeSecretStore()
|
|
||||||
data = {"providers": {"openai": {"api_key": "sk-cu-nam-trong-file"}}} # casan: allow - du lieu test
|
|
||||||
|
|
||||||
out, changed = migrate(data, secrets=secrets)
|
|
||||||
|
|
||||||
assert changed is True
|
|
||||||
assert out["schema_version"] == 2
|
|
||||||
assert out["providers"]["openai"]["api_key"] == ""
|
|
||||||
assert secrets.get("provider:openai") == "sk-cu-nam-trong-file"
|
|
||||||
|
|
||||||
|
|
||||||
def test_khong_day_gia_tri_bu_nhin_cua_ollama_vao_kho():
|
|
||||||
"""Ollama đòi có api_key nhưng bỏ qua nội dung — đẩy vào keyring chỉ tổ rác."""
|
|
||||||
secrets = FakeSecretStore()
|
|
||||||
out, _ = migrate({"providers": {"ollama": {"api_key": "ollama"}}}, secrets=secrets)
|
|
||||||
assert secrets.get("provider:ollama") is None
|
|
||||||
assert out["providers"]["ollama"]["api_key"] == "ollama"
|
|
||||||
|
|
||||||
|
|
||||||
def test_khong_co_kho_bi_mat_thi_KHONG_chuyen():
|
|
||||||
"""Thà để khoá nằm nguyên trong file còn hơn xoá đi rồi người dùng mất
|
|
||||||
khoá mà không hiểu vì sao."""
|
|
||||||
data = {"providers": {"openai": {"api_key": "sk-quy-gia"}}}
|
|
||||||
out, changed = migrate(data, secrets=None)
|
|
||||||
|
|
||||||
assert changed is False
|
|
||||||
assert out["providers"]["openai"]["api_key"] == "sk-quy-gia"
|
|
||||||
assert read_version(out) == 1 # giữ v1, lần sau có keyring sẽ chuyển
|
|
||||||
|
|
||||||
|
|
||||||
def test_da_v2_thi_khong_lam_gi_them():
|
|
||||||
out, changed = migrate({"schema_version": 2}, secrets=FakeSecretStore())
|
|
||||||
assert changed is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_file_moi_hon_app_thi_dung_nguyen_trang():
|
|
||||||
"""App cũ gặp file mới. Đoán ngược là cách nhanh nhất để mất dữ liệu."""
|
|
||||||
data = {"schema_version": 99, "thu_gi_do_tuong_lai": True}
|
|
||||||
out, changed = migrate(data, secrets=FakeSecretStore())
|
|
||||||
assert changed is False
|
|
||||||
assert out == data
|
|
||||||
|
|
||||||
|
|
||||||
def test_sao_luu_truoc_khi_nang_cap(tmp_path):
|
|
||||||
path = tmp_path / "config.json"
|
|
||||||
path.write_text(json.dumps({"providers": {"openai": {"api_key": "sk-x"}}}),
|
|
||||||
encoding="utf-8")
|
|
||||||
|
|
||||||
migrate(json.loads(path.read_text(encoding="utf-8")),
|
|
||||||
secrets=FakeSecretStore(), path=path)
|
|
||||||
|
|
||||||
backups = list(tmp_path.glob("*.bak"))
|
|
||||||
assert len(backups) == 1, "phải có bản sao lưu để còn đường lùi"
|
|
||||||
assert "sk-x" in backups[0].read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
# ---- nối vào repository ----------------------------------------------------
|
|
||||||
|
|
||||||
def test_repository_tu_chuyen_khoa_khi_mo_file_cu(tmp_path):
|
|
||||||
"""Cảnh thật: người dùng cập nhật app, mở lên, khoá cũ tự vào keyring."""
|
|
||||||
(tmp_path / "config.json").write_text(
|
|
||||||
json.dumps({"providers": {"openai": {"api_key": "sk-tu-ban-cu"}}}), # casan: allow - du lieu test
|
|
||||||
encoding="utf-8")
|
|
||||||
|
|
||||||
secrets = FakeSecretStore()
|
|
||||||
cfg = _repo(tmp_path, secrets)
|
|
||||||
|
|
||||||
# đọc ra vẫn thấy khoá...
|
|
||||||
assert cfg.provider_conf("openai")["api_key"] == "sk-tu-ban-cu"
|
|
||||||
# ...nhưng trên đĩa thì hết
|
|
||||||
raw = (tmp_path / "config.json").read_text(encoding="utf-8")
|
|
||||||
assert "sk-tu-ban-cu" not in raw
|
|
||||||
assert json.loads(raw)["schema_version"] == CURRENT_VERSION
|
|
||||||
# và có bản sao lưu
|
|
||||||
assert len(list(tmp_path.glob("*.bak"))) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_mo_lai_lan_hai_khong_chuyen_lai(tmp_path):
|
|
||||||
(tmp_path / "config.json").write_text(
|
|
||||||
json.dumps({"providers": {"openai": {"api_key": "sk-x"}}}), encoding="utf-8")
|
|
||||||
secrets = FakeSecretStore()
|
|
||||||
_repo(tmp_path, secrets)
|
|
||||||
so_ban_sao = len(list(tmp_path.glob("*.bak")))
|
|
||||||
|
|
||||||
_repo(tmp_path, secrets)
|
|
||||||
assert len(list(tmp_path.glob("*.bak"))) == so_ban_sao, "không nâng cấp lại"
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_luon_ghi_so_phien_ban(tmp_path):
|
|
||||||
cfg = _repo(tmp_path)
|
|
||||||
cfg.save()
|
|
||||||
raw = json.loads((tmp_path / "config.json").read_text(encoding="utf-8"))
|
|
||||||
assert raw["schema_version"] == CURRENT_VERSION
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
"""Typed Settings Facade — R02-T03."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from cowork_local.infrastructure.config.settings_facade import (
|
|
||||||
ProviderSettings, RoutingSettings, SecuritySettings, Settings,
|
|
||||||
)
|
|
||||||
from cowork_local.tests.fakes.fake_config import FakeConfigRepository
|
|
||||||
|
|
||||||
|
|
||||||
def test_provider_doc_duoc_ba_truong():
|
|
||||||
p = ProviderSettings({"base_url": "http://x/v1", "model": "llama3",
|
|
||||||
"api_key": "sk-abc"})
|
|
||||||
assert p.base_url == "http://x/v1"
|
|
||||||
assert p.model == "llama3"
|
|
||||||
assert p.api_key == "sk-abc"
|
|
||||||
assert p.configured is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_ollama_khong_can_khoa_van_tinh_la_da_cau_hinh():
|
|
||||||
"""Điều kiện là có base_url và model, không phải có api_key — Ollama chạy
|
|
||||||
cục bộ nên không cần khoá."""
|
|
||||||
p = ProviderSettings({"base_url": "http://localhost:11434/v1", "model": "llama3"})
|
|
||||||
assert p.api_key == ""
|
|
||||||
assert p.configured is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_thieu_model_thi_chua_cau_hinh():
|
|
||||||
assert ProviderSettings({"base_url": "http://x/v1"}).configured is False
|
|
||||||
assert ProviderSettings({}).configured is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_gia_tri_None_tra_ve_mac_dinh_chu_khong_None():
|
|
||||||
"""File cấu hình cũ hay có khoá để null. Đọc ra None rồi đem so sánh số là
|
|
||||||
vỡ — nên khung nhìn phải nuốt luôn trường hợp này."""
|
|
||||||
r = RoutingSettings({"switch_mode": None, "min_score_gain": None,
|
|
||||||
"confirm_timeout_sec": None})
|
|
||||||
assert r.switch_mode == "off"
|
|
||||||
assert r.min_score_gain == 0.05
|
|
||||||
assert r.confirm_timeout_sec == 60
|
|
||||||
|
|
||||||
|
|
||||||
def test_routing_kieu_du_lieu_dung():
|
|
||||||
r = RoutingSettings({"switch_mode": "auto", "min_score_gain": "0.2",
|
|
||||||
"confirm_timeout_sec": "90"})
|
|
||||||
assert r.enabled is True
|
|
||||||
assert isinstance(r.min_score_gain, float) and r.min_score_gain == 0.2
|
|
||||||
assert isinstance(r.confirm_timeout_sec, int) and r.confirm_timeout_sec == 90
|
|
||||||
|
|
||||||
|
|
||||||
def test_tat_dinh_tuyen():
|
|
||||||
assert RoutingSettings({"switch_mode": "off"}).enabled is False
|
|
||||||
assert RoutingSettings({}).enabled is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_sua_qua_khung_nhin_la_sua_vao_dict_that():
|
|
||||||
"""Khung nhìn, không phải bản sao — sửa xong gọi save() là xuống đĩa."""
|
|
||||||
d = {"switch_mode": "off"}
|
|
||||||
RoutingSettings(d).switch_mode = "auto"
|
|
||||||
assert d["switch_mode"] == "auto"
|
|
||||||
|
|
||||||
|
|
||||||
def test_raw_de_khong_ai_bi_ket():
|
|
||||||
d = {"switch_mode": "auto", "khoa_chua_dua_vao_khung_nhin": 1}
|
|
||||||
assert RoutingSettings(d).raw()["khoa_chua_dua_vao_khung_nhin"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_security_mac_dinh_la_bat():
|
|
||||||
"""Mặc định an toàn: thiếu cấu hình thì bật kiểm tra, không phải tắt."""
|
|
||||||
s = SecuritySettings({})
|
|
||||||
assert s.enabled is True
|
|
||||||
assert s.validate_prompt is True
|
|
||||||
assert s.validate_commands is True
|
|
||||||
assert s.cowork_confirm_commands is True
|
|
||||||
assert s.command_ai_check is False # trừ cái này: gọi AI, tốn tiền
|
|
||||||
|
|
||||||
|
|
||||||
def test_settings_noi_vao_repo():
|
|
||||||
repo = FakeConfigRepository(active_provider="openai",
|
|
||||||
routing={"switch_mode": "auto"},
|
|
||||||
agent_security={"cowork_confirm_commands": False})
|
|
||||||
s = Settings(repo)
|
|
||||||
assert s.provider().model == "gpt-4o-mini"
|
|
||||||
assert s.routing.enabled is True
|
|
||||||
assert s.security.cowork_confirm_commands is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_doi_provider_thi_khung_nhin_theo_ngay():
|
|
||||||
repo = FakeConfigRepository(active_provider="ollama")
|
|
||||||
s = Settings(repo)
|
|
||||||
assert s.provider().model == "llama3"
|
|
||||||
repo.set_active_provider("openai")
|
|
||||||
assert s.provider().model == "gpt-4o-mini"
|
|
||||||
Reference in New Issue
Block a user