Compare commits

..
Author SHA1 Message Date
Nam Pham Dinh ThanhandClaude Opus 5 9d6a7be31b fix(infra): AtomicJsonFile — os.replace trên Windows thỉnh thoảng bị từ chối
Bắt được nhờ merge Delta: bộ test của họ chạy lâu hơn nên lộ ra một bài
của tôi chập chờn. Truy ra không phải lỗi test mà là lỗi thật trong code
chạy máy người dùng:

    PermissionError: [WinError 5] Access is denied
      .dem.json.l7x2a8pd.tmp -> dem.json

MoveFileEx trả ERROR_ACCESS_DENIED khi tiến trình khác đang giữ handle
lên nguồn hoặc đích — trên Windows gần như luôn là Defender hoặc Search
Indexer quét file vừa tạo, giữ vài chục mili-giây rồi nhả.

Đo được: hỏng 1 trong 7 lượt chạy 20 lần ghi, tức khoảng 1 trên 140 lần
lưu. Nghĩa là người dùng thỉnh thoảng bấm Lưu là văng lỗi, và không tài
nào tái hiện được để báo.

Thêm vòng thử lại 6 lượt, nghỉ tăng dần 20ms → 640ms. Hết lượt vẫn ném
lỗi, không nuốt lỗi quyền thật, và luôn dọn file tạm.

Hai bài test mới, đã kiểm ngược: bỏ vòng thử lại thì bài thứ nhất đỏ.
Chạy lại 30 lượt sau khi vá: 0 hỏng (trước khi vá: 4).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 10:21:01 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 dcf2e8f995 merge: kéo Delta epic-R04 (gồm cả R01 và R03) vào gamma/refactor
Nam chốt: không chờ Delta merge vào main, lấy sớm để va chạm nhỏ và sửa
ngay, thay vì dồn một cục lúc cả hai cùng lên main.

R04 chứa trọn R01 và R03 nên một lần merge là đủ cả ba: 96 file, +8260
dòng. Xung đột chỉ 5 file, đều là __init__.py add/add — hai team cùng
dựng khung thư mục nên đụng docstring. Giữ docstring của Gamma (nói rõ
ràng buộc "không import PySide6"), giữ mọi phần code của Delta.

Riêng tests/fakes/__init__.py: bỏ hai dòng import háo hức của Delta
(fake_provider, fake_tool_executor). fake_provider dùng
`from providers.base import ...` — import tuyệt đối, chỉ chạy được khi
cwd là gốc repo — nên nó làm đứt bài test "dùng fake mà không nạp config
thật". Không ai import ở cấp package; test của Delta gọi thẳng module
nên bỏ đi không ảnh hưởng họ. Đã ghi lý do vào docstring của gói.

Delta cũng xoá preview-desktop và "requirements (cloud copy).txt".

430 test xanh sau merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 10:20:36 +09:00
duylh19andClaude Opus 5 3665135c38 feat(R04): run every Cowork turn through ConversationApplicationService
R04-T03 — the turn lifecycle, extracted from `core/chat_agent.py::run_cowork`
into `application/conversations/`. The 260-line body mixed the lifecycle (step
budget, cancel checks, guard -> preview -> gate -> execute ordering, sandbox
tidy-up) with the machinery doing each step, and reaching any of it meant
standing up a Qt widget and a worker thread. It is now a plain object driven
through two Protocols and six callables (`turn_runtime.py`), with the concrete
`core/*` wiring confined to `core_runtime_adapter.py` — the same shape R03 used
for routing. Faithful port, not an improvement pass: where the original had a
quirk (the step-ceiling note only merges into the answer when the last message
is the assistant's) the quirk is preserved and commented.

R04-T04 — `ui/cowork_tab.py::build_job` no longer calls run_cowork. It captures
the widget's state at submit time, builds the request via the new
`cowork_turn_request.py` and executes it. `execute(..., messages=...)` hands the
widget's own list over because `_reattach_running_turn` replays from it WHILE
the worker appends and `_finalize_turn` slices it afterwards — a private list
would break both silently.

R04-T05 — `core/task_executors.py`'s cowork branch shares the same engine. All
five unattended-run behaviours stay put (plan reminder, history_ready, History
autosave per assistant message, timeout notice, plan_incomplete_reason), and
`_unattended_prompt` now expresses the load-bearing prefix order in one
readable call instead of three successive rebindings.

Verification: 74 new tests (364 passed, 1 skipped overall; check_imports PASS).
The two that matter most:
- `test_conversation_service_parity.py` runs the same scripted turn through
  run_cowork AND the service and compares the event stream, the resulting
  conversation and the advertised tool list across 7 scenarios;
- `test_task_executor_turn.py` was written BEFORE the migration and passed 8/8
  against the old code, then unchanged against the new.

Known: `ui/cowork_tab.py` (416 -> 455) and `core/task_executors.py` (476 -> 524)
stay above the 400-LOC limit. Both were already over it before this change;
bringing them under needs the R08 / R07 decompositions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:14:15 +09:00
duylh19andClaude Opus 5 19e6b4deb2 feat(R04): add the immutable turn snapshot and typed agent event stream
R04-T01 — `domain/agents/conversation_execution_request.py`: a frozen
snapshot of everything one chat turn needs. Turn inputs previously lived in a
closure plus a 15-key ctx dict inside `ui/chat_panel.py::_start_turn`, and the
worker thread kept reading the widget back while it ran, so every later click
was visible to work already in flight. The request also owns the prompt
composition rules (instruction prefix separator, session notes, model-switch
review note) that were inline in that closure.

R04-T02 — `domain/agents/agent_event.py`: 13 frozen event types replacing the
untyped `{"type": ...}` dicts, whose only specification was the 130-line
if/elif chain in `_on_event`. Each event serialises back to the exact legacy
dict, so the presentation layer is untouched; `agent_event_codec.py` parses the
other way and is a temporary shim, isolated so R08 can delete it in one move.
`assistant_done` is deliberately NOT the end of a turn (it fires once per
provider call), so it maps to AssistantMessageCompletedEvent while the new
TurnCompletedEvent reports the turn itself.

R04-T03 (part) — `domain/agents/agent_result.py`: one named outcome for a
finished turn, replacing the message list / 3-tuple / reconstructed-from-side-
effects trio the three callers each read differently.

Verification: 66 tests. Beyond the unit tests,
`tests/integration/test_agent_event_bridge.py` runs the REAL `run_cowork` loop
offline and asserts every dict it emits is recognised and round-trips
byte-for-byte — a guard against an event type nobody modelled or a key whose
meaning silently drifted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:13:56 +09:00
duylh19andClaude Opus 5 176e6aef79 fix(ci): guard the MCP SDK import so pytest can collect the suite
`tests/test_project_context_mcp_template.py` imported `mcp` at module scope,
but the SDK is a runtime dependency (requirements.txt) and is deliberately
absent from requirements-test.txt — the only thing CI installs. Collection
therefore aborted for the ENTIRE suite before a single test ran.

The guard now sits inside the one test that touches the SDK, so the other
cases in the file (pure-Python contract checks) keep running on CI instead
of being skipped along with it.

Unrelated to the R04 refactor; kept as its own commit so it can be cherry-
picked to main on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:13:42 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 ca7ea1479d fix(infra): neo nốt logs/ build/ dist/ out/ — cùng hình dạng lỗi secrets/
Sau khi vá secrets/ thì rà cả file xem còn mẫu không neo nào sắp cắn hai
người kia. Còn hai quả đang sống:

  logs/   -> nuốt infrastructure/logs/   (Hiệp làm CanonicalAuditLogger,
                                          đây là tên rất dễ đặt)
  build/  -> nuốt application/*/build/
  dist/, out/ cùng kiểu

Chưa ai vấp, vá trước. Trong repo không có build//dist//out//logs/ lồng
nhau nào nên neo về gốc không mất gì — đã kiểm hai chiều: đường dẫn mã
nguồn qua được, còn build/x.o, dist/app.exe, logs/run.log ở gốc vẫn bị
chặn như cũ.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:15:39 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 d74c052af3 fix(infra): .gitignore nuốt infrastructure/secrets/ — nhánh đỏ với mọi máy trừ máy tôi
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 ở đó chưa bao giờ lên repo. Máy tôi vẫn 150 test xanh vì pytest
đọc đĩa chứ không đọc git; ai clone sạch thì đỏ 4 file ngay lúc thu thập:

    ModuleNotFoundError: No module named
    'cowork_local.infrastructure.secrets'

Hiệp phát hiện, không phải tôi. Đã dựng lại bằng clone sạch vào thư mục
đặt đúng tên cowork_local để tái hiện.

Neo mẫu thành /secrets/ và thêm tests/test_no_ignored_source.py — hỏi
thẳng git chứ không hỏi đĩa, nên lần sau lỗi cùng hình dạng sẽ đỏ ngay
trên máy người viết. Đã kiểm ngược: trả lại `secrets/` thì cả ba bài đỏ.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:11:42 +09:00
anhtnm1andClaude Opus 5 f61c5474b0 feat(R03): unify model routing and centralise the provider catalogue
EPIC R03 (Team Duy) — Model Providers & Routing. All six tasks done.

R03-T02 — Provider catalogue
  domain/models/provider_descriptor.py     ProviderDescriptor (frozen), WireProtocol, AuthKind
  infrastructure/providers/provider_registry.py
                                           thread-safe registry: id/alias lookup, dynamic
                                           lookup by model id, adapter selection by protocol
  providers/factory.py                     drops its own _REGISTRY table and delegates to the
                                           registry, still raising ProviderError for callers

R03-T03 — RoutingApplicationService (pure Python, 4 modes)
  application/model_routing/routing_models.py
                                           RoutingMode (off/auto/manual/fallback),
                                           RoutingRequest (immutable snapshot), RouteEvaluation,
                                           RoutingOutcome
  application/model_routing/routing_application_service.py
                                           the single decision flow, reached through two narrow
                                           ports plus a caller-supplied confirm callback, so no
                                           Qt import is needed
  application/model_routing/core_routing_adapter.py
                                           binds the ports to core/routing and AppContext

  Fallback is a new resilience mode: keep the selected model while it can serve the turn,
  re-route only when it cannot. Wired end to end through config.py, state.py,
  ui/routing_toggle.py and i18n.py (EN/JA/VI).

R03-T04 / T05 — Remove the duplicated routing flow
  ui/chat_panel.py (#L638), ui/co4e_tab.py, ui/folder_tab.py each drop ~35 lines of copied
  logic and call the shared service; the widgets now only build a RoutingRequest, host the
  Manual-mode modal and render the outcome.

R03-T06 — Token usage as an event
  infrastructure/telemetry/usage_sink.py   UsageEvent + UsageEventSink protocol, with tracker,
                                           in-memory and composite sinks
  providers/openai_compat.py, providers/anthropic.py
                                           publish a UsageEvent instead of writing to the
                                           usage tracker themselves
  core/usage_tracker.py                    adds current_context() so a sink can borrow and
                                           restore a thread's attribution

R03-T01 — Contract tests
  tests/contracts/test_providers.py parametrises over every provider in the registry: chat()
  signature, canonical assistant message, normalised tool calls, response closed, tool schema
  translation, ProviderError, list_models/test_connection, one UsageEvent per turn.

Test infrastructure fix (required to verify any of the above): tests/conftest.py used to put
the repository's PARENT directory on sys.path, so `import cowork_local.*` resolved against
whichever sibling folder happened to carry that name — on a dev machine, an unrelated older
checkout. The suite reported green while exercising different code. The conftest now binds
this checkout to the cowork_local name in sys.modules.

Verification
  pytest tests/                    236 passed in ~1.8s (102 before this change)
  scripts/check_imports.py         PASS, 0 forbidden imports in domain/ and application/
  new production files             largest is 288 lines, all under the 400 LOC ceiling
  new tests                        134 (50 contract, 70 unit, 14 integration), all offline

scripts/run_quality_gate.py does not exist yet (R10-T02), so DoD item 7 was covered by
check_imports.py plus the full suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:36:20 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 8be5ce1bab docs(arch): mô hình chính sách an toàn — R09-T01
Mô tả hệ thống ĐANG CHẠY, không phải hệ thống mong muốn. Mọi khẳng định chỉ
tới file:dòng cụ thể, và mỗi tham chiếu đã được kiểm bằng script: mở đúng file,
đọc đúng dòng, đối chiếu nội dung có khớp điều đang nói không. Lần kiểm đầu bắt
được 3 tham chiếu thiếu tiền tố core/ và 2 số dòng lệch — dòng 249 là "No-op
for any other tool", câu về bộ phân loại luôn bật nằm ở 250.

Bốn điểm đáng chú ý trong tài liệu:

  - Đây KHÔNG phải rào chắn an ninh. Chính agent_security.py nói vậy ở đầu
    file, và hệ quả là mọi tầng AI đều mở khi hỏng. Ai đọc để đánh giá rủi ro
    phải hiểu đúng chỗ này.

  - Phân biệt quy tắc xác định và quy tắc do AI phán. Tắt hết công tắc trong
    màn Cài đặt thì VẪN còn bộ phân loại mẫu và sandbox — đây là điểm dễ hiểu
    nhầm nhất, vì mấy công tắc đó chỉ tắt phần AI.

  - Trạng thái thứ ba: hỏi người dùng. Hệ thống đã có (chat_panel.py:1312) mà
    chưa gọi tên; tool_policy.py gộp thành ALLOW/DENY/ASK.

  - Mục 8 liệt kê 4 chỗ đã biết là yếu, để người sau khỏi tưởng đã kín: mở khi
    hỏng, bí mật vẫn đi trong bộ nhớ (hệ quả của đường A), bộ luật OneDrive
    không ký số, và ASK chưa nối được vào Co4E.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 16:11:01 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 ab0d26761f feat(infra): xong R02 — Settings Facade, versioning, chuyển khoá sang keyring
R02-T03 Typed Settings Facade
  Khắp nơi đang viết ctx.config.routing.get("switch_mode", "off"). Gõ sai một
  chữ thì lặng lẽ nhận mặc định, không ai biết cho tới lúc tính năng "không
  hiểu sao không chạy". ProviderSettings / RoutingSettings / SecuritySettings
  làm sai tên là lỗi ngay, và kiểu ghi rõ nên đọc là biết confirm_timeout_sec
  tính bằng giây.

  Là KHUNG NHÌN lên dict sống, không phải dataclass sao chép — sửa qua đây là
  sửa vào cấu hình, save() là xuống đĩa, khỏi sinh chuyện đồng bộ hai chiều.
  Có raw() để ai thiếu thuộc tính thì dùng tạm, đừng vòng lại config.data.

  Bắt cả trường hợp giá trị là null: file cũ hay để null, đọc ra None rồi đem
  so sánh số là vỡ.

R02-T06 Schema versioning + phục hồi
  config.json hôm nay không có số phiên bản, nên mọi thay đổi hình dạng phải
  đoán — _migrate_connectors() đoán "có khoá office nghĩa là file cũ". Giờ:
  thiếu schema_version thì coi là v1, mỗi bước là một hàm chạy tuần tự, sao
  lưu trước khi nâng, và file mới hơn app thì dùng nguyên trạng chứ không đoán
  ngược.

R02-T05 Chuyển API key sang kho bí mật
  Là bước v1→v2. Người dùng cập nhật app, mở lên, khoá cũ tự vào keyring và
  biến khỏi đĩa — có test cho đúng cảnh đó.

  Hai chỗ cố tình không làm:
    - Máy chưa có keyring: KHÔNG chuyển, giữ nguyên v1. Thà để khoá trong file
      còn hơn xoá đi rồi người dùng mất khoá mà không hiểu vì sao.
    - Giá trị "ollama" là bù nhìn (Ollama đòi có api_key nhưng bỏ qua nội
      dung), đẩy vào keyring chỉ tổ rác.

Hai chuỗi test trông giống khoá thật bị CASAN Check 1 bắt — đánh dấu
"# casan: allow" kèm lý do, đúng lối thoát đã thiết kế cho cả đội.

150 test xanh (129 + 21 mới). CASAN Check 1 sạch. File mới đều dưới 200 dòng.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 00:50:09 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 a7e369e46c feat(infra): JsonConfigRepository — R02-T02, hiện thực đường A đã chốt
Thay cho config.py::AppConfig. Hai khác biệt về hành vi, cả hai đều là thứ
muốn có; mọi thứ còn lại giữ y nguyên vì đây là refactor.

1. Ghi qua AtomicJsonFile — mất điện giữa lúc lưu không còn làm hỏng cấu hình.
   Có test riêng ở tầng này chứ không chỉ dựa vào test của AtomicJsonFile.

2. Đường A (chốt 21/08): provider_conf() đọc khoá từ SecretStore rồi ghép vào
   dict trả về, còn set_api_key() ghi khoá vào kho và để chuỗi rỗng trên đĩa.
   Kết quả: 5 nơi đang đọc conf["api_key"] không sửa dòng nào — 3 trong đó
   thuộc providers/ của Team Duy — mà file JSON vẫn sạch để qua CASAN Check 1.
   Hai test riêng cho đúng hai vế đó.

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 — đúng thứ đường A phải tránh.
Có test cho chuyện này.

secrets=None thì lùi về hành vi cũ (khoá nằm trong file). Cần vậy để chuyển
dần ở R02-T05 chứ không phải đổi một phát cả app, và để máy không có keyring
vẫn chạy.

Giữ nguyên có chủ đích: trộn sâu với mặc định, biến môi trường, và
ms365.unlocked không bao giờ chạm đĩa — mỗi thứ một test.

_deep_merge chép lại 6 dòng thay vì import từ config.py: file này phải sống
được sau khi config.py biến mất.

129 test xanh (119 + 10 mới). CASAN Check 1 sạch. File mới: 188/102/86 dòng,
đều dưới ngưỡng 400.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 00:37:43 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 d6dd6a030e feat(infra): AtomicJsonFile + KeyringAdapter, và đổi tên platform/ vì nó che stdlib
Ngày 21/08 của làn N1 (Nam): R02-T01 và R02-T04.

--- Lỗi phải sửa trước khi làm được gì ---

Kế hoạch đặt tên một tầng là platform/. Tôi dựng đúng theo đó sáng nay, có
kiểm "platform stdlib không bị che" và báo là an toàn. Kiểm đó SAI: tôi chỉ
thử từ thư mục cha. Chạy từ gốc repo — đúng cách 26 script trong tools/ và
scripts/ được gọi — thì platform/ che khuất platform của thư viện chuẩn, và
import keyring chết ngay:

    AttributeError: module 'platform' has no attribute 'system'

Nghĩa là R02-T04 không thể làm được chừng nào thư mục đó còn tên cũ. Đổi
platform/ -> adapters/. Đây là lệch khỏi plan.md và ảnh hưởng Team Hoa (họ sở
hữu platform/qt/qt_scheduler_clock.py) — đã ghi vào GammaTeam_decisions.md.

tests/test_no_stdlib_shadow.py chặn lỗi tái diễn, hai lớp: một bài so tên thư
mục gốc repo với sys.stdlib_module_names, một bài chạy tiến trình con với cwd
là gốc repo rồi import keyring thật. Dựng lại platform/ là cả hai đỏ.

--- R02-T01: AtomicJsonFile ---

config.py::save() đang gọi path.write_text(), tức là cắt file về 0 byte rồi
mới ghi. Chết giữa chừng là mất sạch cấu hình. Thay bằng: ghi file tạm cùng
thư mục -> flush + fsync -> os.replace (nguyên tử trên cả Windows và POSIX).

Test tiêm lỗi đúng như cột nghiệm thu của plan.md: cho os.replace ném lỗi
ngay bước cuối rồi khẳng định file cũ còn nguyên. Chỉ test "ghi rồi đọc lại"
thì write_text() cũ cũng qua — mà đó chính là thứ đang thay.

Phần đọc: file hỏng được dời thành .bad-<thời điểm> rồi trả mặc định. Giữ
đúng hành vi "hỏng cấu hình không chặn khởi động" của config.py, thêm phần
cứu được bản hỏng.

--- R02-T04: KeyringAdapter ---

Windows Credential Manager / macOS Keychain / Linux Secret Service. Không bao
giờ ném lỗi: máy không có kho (Linux headless, CI) thì available=False và trả
None, để tầng UI nói "chưa lưu được khoá" thay vì sập app. Test tiêm backend
giả, không đụng keyring thật của máy chạy test.

119 test xanh (102 + 17 mới). CASAN Check 1 sạch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 22:44:34 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 2627e691ce docs(refactor): cả ba đẩy chung gamma/refactor; đặt tên Nam, Hiệp, Lâm
Đổi mô hình: không còn nhánh riêng mỗi người, cả ba cùng đẩy vào
gamma/refactor. Ba "nhánh" thành ba "làn" — vẫn chia việc như cũ, nhưng ranh
giới file bây giờ là thứ DUY NHẤT giữ ba người không giẫm chân, vì không còn
nhánh riêng làm vùng đệm.

Thêm quy ước số 4 cho nhánh chung, xếp vào nhóm bắt buộc: pull --rebase trước
mỗi lần đẩy; commit nhỏ, đẩy trong ngày; không bao giờ đẩy thứ làm
pytest tests -q đỏ, vì nhánh hỏng là hai người kia đứng hình.

Phần nghiệm thu đổi theo: trước đây so file giữa ba nhánh, giờ không còn ba
nhánh để so. Thay bằng git log --name-only --pretty=%an trên gamma/refactor —
không file nào được xuất hiện dưới hai tên khác nhau.

Hai quyết định đã chốt, ghi vào GammaTeam_decisions.md:
  1. api_key: đường A — ConfigRepository ghép key từ SecretStore vào dict, 5
     nơi đọc không đổi dòng nào, không cần báo Duy và Hoa.
  2. 24 checker UI: đường A — ai dời file thì sửa checker ngay trong commit
     đó, kèm ràng buộc phải nói rõ sửa gì và chạy check_probes_bite.py sau.
     Không đưa vào CI sprint này vì chúng dựng MainWindow thật.

Baseline trong tài liệu cập nhật 90 -> 102 test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 22:24:03 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 3138856741 feat(domain): DTO ToolPolicyGateway — bản đề xuất, gỡ chốt cho N3
N3 (Co4E) cần gọi tool nhưng Team Hoa chưa bắt đầu. Ba đường: N3 ngồi đợi
(trái nguyên tắc không team nào chặn team nào), N3 tự phỏng đoán (không ai
soi, chắc chắn phải sửa), hoặc viết một bản đề xuất để Hoa duyệt. Chọn cái
thứ ba.

Ranh giới giữ đúng sơ đồ phân hệ trong plan.md: domain/security/ là của
Gamma, application/conversations/tool_policy_gateway.py là của Hoa. Nên Gamma
định nghĩa hình dạng, Hoa cài đặt. Không đụng file nào của họ.

Hình dạng bám vào code đang chạy: SecurityVerdict (allowed/reason/layer) và
hộp thoại xin phép ở chat_panel.py:1312. Khác biệt duy nhất là 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, mỗi cái một test:
  - DENY và ASK bắt buộc có reason, ném lỗi ngay lúc dựng. Người dùng cần
    biết vì sao bị chặn và audit_log cần ghi lại.
  - ASK không phải allowed. Đây là bẫy dễ mắc nhất: coi ASK như ALLOW thì
    tool chạy trước khi có ai đồng ý.

Kèm FakeToolPolicyGateway lập trình được theo tên tool hoặc theo hàm, có ghi
lại đã hỏi những gì — test khẳng định được "có hỏi cổng không", không chỉ
"kết quả đúng không".

docs/refactor/GammaTeam_decisions.md thêm quyết định 3, kèm nguyên văn tin
nhắn cần gửi Hoa và ô đánh dấu đã gửi / đã xác nhận.

102 test xanh (96 + 6 mới). CASAN Check 1 sạch. domain/ và application/ có 0
import PySide6 — kiểm bằng AST, vì grep đếm ra 4 mà cả 4 là chữ "PySide6"
nằm trong chính docstring cảnh báo. Check 3 của Team Duy nên phân tích cú
pháp chứ đừng grep.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 22:07:00 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 a164f32bfb docs(refactor): thêm mục input/output cho từng người
Ba khối, mỗi người một khối: 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 'có rồi' đánh dấu
những gì mục chung đã giao xong hôm nay (SecretStore, ConfigRepository, fake,
script CASAN).

Kèm bảng output bắt buộc với cả ba mỗi PR, mỗi dòng có lệnh tự kiểm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 21:25:20 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 8a9ee5f875 chore(refactor): mục chung của Team Gamma — khung, hợp đồng, cổng CASAN
Sáu việc trong "mục chung" của bản phân công, làm trước khi ba nhánh tính năng
tách ra.

1. Khung 5 tầng theo đúng đường dẫn plan.md: domain/ application/
   infrastructure/ presentation/ platform/ + tests/fakes/ — 38 __init__.py.
   Trước đó là 0 file, mà mọi task của cả ba người đều ghi vào đây.
   Đã kiểm platform/ không che khuất module platform của stdlib.

2. Hợp đồng SecretStore và ConfigRepository (Protocol, chưa cài đặt) + fake
   chạy trong bộ nhớ. Danh sách thuộc tính không bịa: đếm 156 lời gọi
   ctx.config.* trong 29 file rồi lấy những cái dùng thật, xếp theo số lần.
   Cố ý bỏ config.data (36 lời gọi, nhiều nhất) — bê dict thô sang kiến trúc
   mới là bê nguyên vấn đề cũ.

3. tests/test_contracts.py — bài nghiệm thu, không phải test cho vui. Bài
   chính chạy tiến trình riêng và khẳng định dùng fake KHÔNG kéo theo
   cowork_local.config lẫn PySide6; đó là điều kiện để N2 và N3 code ngay hôm
   nay thay vì đợi bản thật ngày 23 và 26/08.

4. scripts/audit_security.py — CASAN Check 1, Gamma chủ trì (hạn 30/08). Viết
   sớm để kiểm liên tục trong lúc chuyển API key, không đợi tới ngày cổng.
   Lần chạy đầu ra 3 báo động giả (secret_in_output là tên quy tắc, api_key="x"
   là dữ liệu test) nên đã siết: ngưỡng độ dài, hằng liệt kê, hình dạng khoá
   i18n, và dấu "# casan: allow" làm lối thoát chuẩn.
   --self-test cắm 4 credential thật + 5 mẫu vô hại để chứng minh nó còn cắn
   được — 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.

5. Ba check CASAN vào CI, chạy mọi PR thay vì dồn tới 30/08. Check 2 và 3
   thuộc Team Hoa và Team Duy, chưa có script — bước CI bỏ qua nếu file chưa
   tồn tại, để thêm cổng không làm đỏ CI của hai team kia.

6. docs/refactor/GammaTeam_decisions.md — hai quyết định chờ nhóm trưởng chốt:
   provider_conf() còn trả api_key hay không (ảnh hưởng 5 nơi, 3 nằm ngoài
   team), và số phận 24 checker UI sẽ vỡ khi file bị dời.

96 test xanh (90 cũ + 6 mới). CASAN Check 1: 0 credential lộ.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 20:58:35 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 09b1c93624 docs(refactor): phân việc Team Gamma thành 1 mục chung + 3 nhánh song song
Trang HTML tự đứng một mình, mở bằng trình duyệt là xem được, không cần mạng.
Chia toàn bộ phần việc của team trong plan.md (R02, R07-T06, R08-T07…T10, R09,
CASAN Check 1) cho 3 người:

  - Một mục chung nhóm trưởng làm trước, xong mới chia nhánh: dựng khung 5 thư
    mục đích (hiện là 0 file), interface + fake cho Config/Secrets, chốt số phận
    api_key, script CASAN Check 1, đưa 3 check vào CI, quyết số phận 24 checker
    UI sẽ vỡ khi file bị dời.
  - Ba nhánh tính năng ngang nhau, mỗi nhánh ~2.700 dòng: N1 cấu hình và vỏ ứng
    dụng (nhóm trưởng giữ, vì chạm app.py / config.py / theme.py / i18n.py),
    N2 giám sát, N3 Co4E.
  - Bảy quy ước cho N2 và N3, ba trong đó là bắt buộc.

Số dòng code, 156 lời gọi ctx.config, 24 lời gọi audit_log.record và baseline
90 test đều đo trực tiếp trên main ngày 21/08, không lấy từ tài liệu.

Footer ghi rõ phần nào là đề xuất, phần nào lấy từ ba tài liệu gốc — mục chung,
cách chia nhánh, quy ước và nghiệm thu là đề xuất.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 18:53:05 +09:00
huongltt35 10739f19aa breakdown folder tree for epic R01 2026-08-21 18:46:46 +09:00
gitea-admin 86c27e2e79 Merge pull request 'Feature/deltateam/refactor plan' (#5) from feature/deltateam/refactor-plan into main
CI / test (push) Canceled after 0s
Reviewed-on: #5
2026-08-21 00:46:40 +00:00
gitea-admin f1fc5bd7e7 Merge pull request 'feat(mcp): scaffold three project context tools' (#4) from codex/project-context-mcp-template into main
CI / test (push) Canceled after 0s
Reviewed-on: #4
2026-08-20 14:33:46 +00:00
thanhnv 202925e6ed feat(mcp): scaffold three project context tools
CI / test (pull_request) Canceled after 0s
2026-08-20 20:50:37 +07:00
thanhnv 3827552909 fix(security): remove shared unlock defaults 2026-08-20 20:20:04 +07:00
238 changed files with 16285 additions and 14864 deletions
+29
View File
@@ -39,3 +39,32 @@ jobs:
- name: Run tests
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
+11 -5
View File
@@ -28,7 +28,10 @@ bower_components/
.env.preview
*.pem
*.key
secrets/
# Neo vào gốc repo: mẫu không neo nuốt MỌI thư mục tên secrets ở mọi độ
# 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
.npmrc
.yarnrc
@@ -36,9 +39,11 @@ credentials.json
# =============================================================================
# Build & Distribution
# =============================================================================
dist/
build/
out/
# Neo vao goc — mau khong neo se nuot moi thu muc trung ten o moi do sau,
# ke ca ma nguon. Da mac dung loi do voi secrets/ (xem khoi Credentials).
/dist/
/build/
/out/
.next/
.nuxt/
.output/
@@ -73,7 +78,8 @@ desktop.ini
# Logs & Debug
# =============================================================================
*.log
logs/
# Neo vao goc: infrastructure/logs/ la ma nguon, khong phai log chay may.
/logs/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
+12
View File
@@ -0,0 +1,12 @@
"""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".
"""
+3 -3
View File
@@ -23,13 +23,13 @@ from .state import AppContext
from .ui.widgets import tidy_popup
from .theme import current_palette, set_active_theme, stylesheet
from .core.task_scheduler import TaskScheduler
from .presentation.dashboard.dashboard_tab import DashboardTab
from .presentation.graph.structure_graph_view import StructureGraphView
from .presentation.scheduling.schedule_task_tab import ScheduleTaskTab
from .ui.cowork_tab import CoworkTab
from .ui.dashboard_tab import DashboardTab
from .ui.monitoring_tab import MonitoringTab
from .ui.schedule_task_tab import ScheduleTaskTab
from .ui.settings_dialog import SettingsDialog
from .ui.sidebar import HistorySidebar
from .ui.structure_graph_view import StructureGraphView
from .ui.workspace_tab import WorkspaceTab
ASSETS = Path(__file__).resolve().parent / "assets"
+1 -12
View File
@@ -1,12 +1 @@
"""Application layer - pure Python use-case orchestration.
Sits between ``presentation/`` (Qt widgets) and ``domain/`` (entities). A module
here answers "what has to happen, in what order" for one use case - route a
turn, run a conversation - without knowing whether a human, a scheduler or a
test triggered it.
Hard rule (ADR-001 I1/I3, enforced by ``scripts/check_imports.py``): no
PySide6/PyQt imports and no reach into ``presentation/``/``ui/``. Results travel
back up through plain-Python callbacks; turning those into Qt signals is the
presentation layer's job.
"""
"""application/ — Điều phối use-case. KHÔNG import PySide6. Gọi domain + interface hạ tầng."""
+1 -10
View File
@@ -1,10 +1 @@
"""Conversation use case: the lifecycle of one agent turn (EPIC R04) and the
tool approval policy every turn's tool calls go through (EPIC R05)."""
from .conversation_application_service import (
ConversationApplicationService,
TurnResult,
)
from .tool_policy_gateway import ConfirmGate, ToolPolicyGateway
__all__ = ["ConversationApplicationService", "TurnResult", "ToolPolicyGateway", "ConfirmGate"]
"""Application conversations package: turn lifecycle orchestration and agent execution."""
@@ -1,328 +1,322 @@
"""ConversationApplicationService - the turn lifecycle, outside the widget (R04-T03).
"""The turn lifecycle, once, in pure Python (R04-T03).
What this replaces
------------------
The lifecycle of one Cowork turn is currently spread across a closure inside
``ui/cowork_tab.py::build_job`` and a second, near-identical assembly inside
``core/task_executors.py::_run_agent``. Both:
Extracted from ``core/chat_agent.py::run_cowork``, whose 260-line body mixed the
lifecycle (compose the prompt, call the model, dispatch tools, respect the step
ceiling, tidy the sandbox) with the concrete machinery that does each of those
things. The lifecycle is the part with rules worth testing — and the part that
was untestable, because reaching it meant standing up a Qt widget and a worker
thread.
* read live UI/config state from a worker thread,
* build the provider, the MCP tool set and the project context by hand,
* call ``core.chat_agent.run_cowork`` with a dozen positional-ish arguments,
* consume untyped event dicts.
Here it is a plain object driven through the seams in :mod:`turn_runtime`, so a
test states a rule ("the guard runs before the model", "a rejected command never
executes") in three lines. ``core/chat_agent.py`` keeps its signature and
delegates, and the presentation layer keeps receiving the same events via the
legacy codec, so nothing downstream had to change with it.
Two copies means a fix to one path (say, promoting output files on failure)
silently misses the other. This service is the single implementation: it takes
an immutable :class:`ConversationExecutionRequest`, runs the turn, and reports
typed :class:`AgentEvent` objects.
What it deliberately does NOT do
--------------------------------
It does not re-implement the agent loop. ``run_cowork`` stays the engine
(strangler fig, ADR-001 section 4) and keeps its characterization tests
(``tests/characterization/test_run_cowork.py``). This layer owns the parts that
were tangled into the UI: assembling the call, translating events, and giving a
turn a well-defined end.
Pure Python: no Qt import, no config access. Everything it needs arrives through
constructor callbacks, so the same service runs a turn from a chat panel, from
the scheduler, or from a test.
Behavioural contract: this is a faithful port, not an improvement pass. Where
the original had a quirk (the step-ceiling note only merges into the answer when
the last message is the assistant's), the quirk is preserved and commented —
changing what a user sees belongs in its own change, not smuggled into a move.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple
from typing import Any, Dict, List, Optional, Tuple
from cowork_local.domain.agents.agent_event import (
AgentEvent,
from ...domain.agents.agent_event import (
AssistantMessageCompletedEvent,
ErrorEvent,
TurnCompletedEvent,
collect_text,
event_from_dict,
OutputsAddedEvent,
OutputsRemovedEvent,
PlanStep,
PlanUpdatedEvent,
ReasoningChunkEvent,
TextChunkEvent,
ToolCallFinishedEvent,
ToolCallStartedEvent,
ToolOutputChunkEvent,
)
from cowork_local.domain.agents.conversation_execution_request import (
ConversationExecutionRequest,
from ...domain.agents.agent_result import AgentResult
from ...domain.agents.conversation_execution_request import ConversationExecutionRequest
from .turn_runtime import (
BUDGET_NOTE_TEMPLATE,
GATED_TOOLS,
PLAN_TOOL,
REASONING_ONLY_NOTE,
REJECTED_OUTPUT,
AttachmentReader,
CancelFn,
CommandGuard,
ContextCompactor,
EventSink,
ModelCallPort,
PermissionRequest,
PromptGuard,
PromptPreparer,
ToolRuntimePort,
)
logger = logging.getLogger("cowork_local.conversations")
# Presentation/scheduler supplies these. Kept as plain callables (not objects)
# so a test can wire the service with three lambdas.
EventCallback = Callable[[AgentEvent], None]
CancelFn = Callable[[], bool]
ProviderFactory = Callable[[str, str], Any] # (provider_id, model) -> Provider
ToolSourceFactory = Callable[[], Tuple[Any, Any]] # () -> (extra_tools, extra_executor)
GateFactory = Callable[[ConversationExecutionRequest], Any] # -> PermissionGate or None
@dataclass
class TurnResult:
"""What a finished turn produced.
``messages`` is the conversation AFTER the turn (system prompt inserted,
assistant and tool messages appended) - the caller persists this as the new
history. ``final_text`` is the visible answer, reasoning excluded.
"""
request: ConversationExecutionRequest
messages: List[Dict[str, Any]] = field(default_factory=list)
events: List[AgentEvent] = field(default_factory=list)
final_text: str = ""
cancelled: bool = False
error: str = ""
# The original exception, kept alongside its message so a caller that needs
# to preserve legacy failure handling can re-raise the SAME object rather
# than a lookalike (SecurityBlocked, for instance, carries context that a
# re-wrapped RuntimeError would lose).
exception: Optional[BaseException] = None
@property
def ok(self) -> bool:
"""True when the turn completed without an error and without a Stop."""
return not self.error and not self.cancelled
def raise_if_failed(self) -> None:
"""Re-raise the turn's failure, if any.
Callers that already have failure handling built around an exception
(the Qt worker turns one into its ``failed`` signal) use this to keep
that path intact while still getting a TurnResult on success."""
if self.exception is not None:
raise self.exception
def output_dir(self) -> Optional[Path]:
"""This turn's output folder, or None when it could not write files."""
return Path(self.request.output_dir) if self.request.output_dir else None
logger = logging.getLogger("cowork_local.application.conversations")
class ConversationApplicationService:
"""Runs one agent turn from an immutable request.
Args:
provider_factory: ``(provider_id, model) -> Provider``. Production passes
``AppContext.build_provider_for``; tests pass a lambda returning a
:class:`FakeProvider`.
tool_source: ``() -> (extra_tools, extra_executor)`` for MCP/connector
tools. Optional - a turn with no external tools passes nothing.
gate_factory: ``(request) -> PermissionGate | None``, consulted when the
request asks to confirm commands. Optional for the same reason.
runner: the turn engine. Defaults to ``core.chat_agent.run_cowork``,
imported lazily so this module stays importable (and testable)
without pulling in the whole legacy tool stack.
security_config: the app config the security layers read. ``None``
disables them, which is what headless callers already rely on.
"""
"""Runs one :class:`ConversationExecutionRequest` to completion."""
def __init__(
self,
provider_factory: ProviderFactory,
model: ModelCallPort,
tools: ToolRuntimePort,
*,
tool_source: Optional[ToolSourceFactory] = None,
gate_factory: Optional[GateFactory] = None,
runner: Optional[Callable[..., Any]] = None,
security_config: Any = None,
prepare_prompt: Optional[PromptPreparer] = None,
prompt_guard: Optional[PromptGuard] = None,
command_guard: Optional[CommandGuard] = None,
compact: Optional[ContextCompactor] = None,
permission_request: Optional[PermissionRequest] = None,
attachment_reader: Optional[AttachmentReader] = None,
) -> None:
self._provider_factory = provider_factory
self._tool_source = tool_source
self._gate_factory = gate_factory
self._runner = runner
self._security_config = security_config
self._model = model
self._tools = tools
# Every hook is optional so the service degrades to a plain chat turn.
# That is not only a test convenience: a headless caller legitimately has
# no guards (``security_config=None`` today) and no permission dialog.
self._prepare_prompt = prepare_prompt
self._prompt_guard = prompt_guard
self._command_guard = command_guard
self._compact = compact
self._permission_request = permission_request
self._attachment_reader = attachment_reader
# -- main entry point -------------------------------------------------- #
def run_turn(
self,
request: ConversationExecutionRequest,
on_event: Optional[EventCallback] = None,
cancel: Optional[CancelFn] = None,
) -> TurnResult:
"""Execute one turn and return everything it produced.
# -- public API ------------------------------------------------------ #
def execute(self, request: ConversationExecutionRequest, sink: EventSink,
cancel: Optional[CancelFn] = None,
messages: Optional[List[Dict[str, Any]]] = None) -> AgentResult:
"""Run the turn, streaming events to ``sink``, and report the outcome.
Never raises: a provider or tool failure becomes an :class:`ErrorEvent`
plus ``TurnResult.error``. Callers run this on a worker thread and have
no good way to handle an exception crossing that boundary - today an
escaped error kills the worker and the UI just stops updating, with no
message shown.
``messages``, when given, is a working list the caller already built —
it MUST already end with this turn's user message, and the service
appends into that very object instead of composing its own. The Cowork
widget needs this: it hands out the same list to
``_reattach_running_turn``, which replays the steps done so far while the
worker is still appending, and to ``_finalize_turn``, which slices it by
the pre-turn snapshot length. A private list would break both silently.
Passing ``None`` (every headless caller) lets the service compose the
list from the request, which is the mode the rest of this class assumes.
Exactly one :class:`TurnCompletedEvent` is always emitted last, whether
the turn succeeded, failed or was cancelled. That is the end-of-turn
signal the legacy engine never had.
Raises whatever the runtime raises (a blocked prompt, a dead gateway):
the caller already has a failure path for that — ``AgentWorker.failed``
in the UI, the artifact writer in Schedule Task — and swallowing the
exception here would silently turn a failed turn into an empty answer.
An :class:`ErrorEvent` is emitted first so subscribers see the failure
on the same stream as everything else.
"""
return self.execute_turn(self.begin_turn(request), on_event=on_event, cancel=cancel)
def begin_turn(self, request: ConversationExecutionRequest) -> TurnResult:
"""Create the (still empty) result a turn will fill in.
Exposed separately from :meth:`run_turn` because some callers need the
LIVE message list while the turn is running, not only afterwards: the
scheduler re-saves the conversation to History after every assistant
message so a long unattended run shows live progress when reopened.
Handing them ``result.messages`` - the very list the engine appends to -
is what makes that possible without leaking the engine into the caller.
"""
return TurnResult(request=request, messages=request.message_list())
def execute_turn(
self,
result: TurnResult,
on_event: Optional[EventCallback] = None,
cancel: Optional[CancelFn] = None,
) -> TurnResult:
"""Run a turn previously created by :meth:`begin_turn`. See
:meth:`run_turn` for the error/cancellation contract."""
request = result.request
emit = self._make_emitter(result, on_event)
cancel = cancel or (lambda: False)
# -- pre-flight. Runs BEFORE the output snapshot, so a turn refused here
# leaves the output folder completely untouched (tidying is not a
# read-only operation — see ToolRuntimePort.finalize).
try:
self._execute(request, result, emit, cancel)
except Exception as exc: # noqa: BLE001 - see docstring
result.error = str(exc) or exc.__class__.__name__
result.exception = exc
logger.exception("turn %s failed", request.turn_id)
emit(ErrorEvent(message=result.error,
recoverable=self._is_recoverable(exc)))
# The caller's list is used by reference on purpose (see above); only
# the self-composed path may build a fresh one.
working = messages if messages is not None else self._compose_messages(request)
tools = list(self._tools.specs(request.allowed_tools))
if self._prepare_prompt is not None:
self._prepare_prompt(working, tuple(getattr(t, "name", "") for t in tools))
if request.enforce_rules and self._prompt_guard is not None:
self._prompt_guard(working)
except Exception as exc: # noqa: BLE001 — reported, then re-raised as-is
sink(ErrorEvent(message=str(exc)))
raise
result.cancelled = bool(cancel())
result.final_text = collect_text(result.events) or self._last_assistant_text(result.messages)
emit(TurnCompletedEvent(content=result.final_text, cancelled=result.cancelled))
before = self._tools.snapshot()
steps_used = 0
plan_steps: Tuple[PlanStep, ...] = ()
completed_naturally = False
try:
for _ in range(request.effective_max_steps):
if cancel():
break
# Auto-compress when nearing the model's context budget; a no-op
# when off or when the conversation is still short.
if self._compact is not None:
self._compact(working, cancel)
assistant = self._model.call(
working, tools,
on_text=lambda piece: sink(TextChunkEvent(delta=piece)),
on_reasoning=lambda piece: sink(ReasoningChunkEvent(delta=piece)),
cancel=cancel,
)
working.append(assistant)
steps_used += 1
tool_calls = assistant.get("tool_calls") or []
if not tool_calls and not (assistant.get("content") or "").strip():
# Written into the message, not just emitted, so the stored
# conversation never ends on a blank assistant turn.
assistant["content"] = REASONING_ONLY_NOTE
sink(TextChunkEvent(delta=REASONING_ONLY_NOTE))
sink(AssistantMessageCompletedEvent(content=assistant.get("content", "")))
if not tool_calls:
completed_naturally = True
break
for call in tool_calls:
if cancel():
break
tool_message, steps = self._dispatch(request, call, sink, cancel)
working.append(tool_message)
if steps is not None:
plan_steps = steps
if not completed_naturally and not cancel():
self._announce_budget_exhausted(request, working, sink)
except Exception as exc: # noqa: BLE001 — reported, then re-raised as-is
sink(ErrorEvent(message=str(exc)))
raise
finally:
# Always tidy: the sandbox and generator scripts must not survive a
# turn that stopped abruptly. Runs on success, cancel and failure.
self._finalize_outputs(before, sink, cancelled=cancel())
result = AgentResult(
messages=working, steps_used=steps_used, cancelled=cancel(),
budget_exhausted=not completed_naturally and not cancel(),
plan_steps=plan_steps,
)
sink(result.to_turn_completed_event())
return result
# -- internals --------------------------------------------------------- #
def _execute(self, request: ConversationExecutionRequest, result: TurnResult,
emit: Callable[[AgentEvent], None], cancel: CancelFn) -> None:
"""Assemble the engine call from the request snapshot and run it."""
provider = self._provider_factory(request.provider, request.model)
extra_tools, extra_executor = self._resolve_tools()
gate = self._resolve_gate(request)
# -- internals ------------------------------------------------------- #
def _compose_messages(self, request: ConversationExecutionRequest) -> List[Dict[str, Any]]:
"""History snapshot plus this turn's user message.
# The engine speaks untyped dicts; bridge them into typed events at this
# single point rather than at every consumer.
def legacy_emit(payload: Dict[str, Any]) -> None:
event = event_from_dict(payload)
if event is not None:
emit(event)
The attachment text is read HERE rather than when the request was built,
because extraction is slow enough to freeze the UI thread; the request
deliberately carries paths only.
"""
body = request.prompt
if self._attachment_reader is not None:
body = self._attachment_reader(request.prompt, request.attachments)
messages = [dict(m) for m in request.messages]
messages.append({"role": "user", "content": request.user_content(body)})
return messages
run = self._resolve_runner()
run(
provider,
result.messages, # mutated in place by the engine, as before
self._output_dir(request),
legacy_emit,
cancel,
title=request.title,
extra_tools=extra_tools,
extra_executor=extra_executor,
project_context=request.project_context,
security_config=self._security_config,
gate=gate,
allowed_tools=list(request.allowed_tools) if request.allowed_tools is not None else None,
max_steps=request.max_steps,
run_to_completion=request.run_to_completion,
completion_max_steps=request.completion_max_steps,
enforce_rules=request.enforce_rules,
**self._role_kwargs(request),
def _dispatch(self, request: ConversationExecutionRequest, call: Dict[str, Any],
sink: EventSink, cancel: CancelFn
) -> Tuple[Dict[str, Any], Optional[Tuple[PlanStep, ...]]]:
"""Run one tool call.
Returns ``(tool_message, plan_steps)`` — the message to append to the
conversation, and the new checklist when this call was the plan tool
(``None`` otherwise, so the caller can tell "no change" from "empty
plan").
"""
call_id = str(call.get("id", ""))
name = str(call.get("name", ""))
args = call.get("arguments") or {}
# The plan tool is invisible in the transcript: it updates the Plan panel
# and nothing else, so it skips preview, guard and gate entirely.
if name == PLAN_TOOL:
outcome = self._tools.execute(name, args, on_output=None, cancel=cancel)
steps = tuple(outcome.get("plan_steps") or ())
sink(PlanUpdatedEvent(steps=steps))
return self._tool_message(call_id, name, outcome.get("output", "")), steps
# Announce first: the user sees the code/command about to run before the
# guard or the approval dialog interrupts them, which is the whole point
# of showing the step CLI-style.
preview = self._tools.preview(name, args)
sink(ToolCallStartedEvent(call_id=call_id, name=name, arguments=dict(args),
preview=preview))
if request.enforce_rules and self._command_guard is not None:
self._command_guard(name, args)
if not self._approved(request, name, args, preview, sink, call_id):
return self._tool_message(call_id, name, REJECTED_OUTPUT), None
outcome = self._tools.execute(
name, args,
on_output=lambda piece: sink(ToolOutputChunkEvent(
call_id=call_id, name=name, delta=piece)),
cancel=cancel,
)
sink(ToolCallFinishedEvent(
call_id=call_id, name=name, ok=bool(outcome.get("ok", False)),
output=str(outcome.get("output", "")), path=str(outcome.get("path", "") or ""),
produced=outcome.get("produced") or (),
))
return self._tool_message(call_id, name, outcome.get("output", "")), None
def _approved(self, request: ConversationExecutionRequest, name: str,
args: Dict[str, Any], preview: Any, sink: EventSink,
call_id: str) -> bool:
"""Whether this call may run.
Only command-shaped tools are gated, and only when the workspace asked
to confirm them: file writes stay inside the turn's own sandbox, so
prompting for those would be noise. A rejection is reported as a failed
tool result — the model needs to read back that it was refused, or it
will simply try the same call again.
"""
if not request.requires_permission_gate or name not in GATED_TOOLS:
return True
if self._permission_request is None:
# Confirm mode with nobody to ask: refusing is the safe direction,
# since auto-running is exactly what confirm mode exists to prevent.
logger.warning("turn: confirm mode without a permission callback — refusing %r", name)
approved = False
else:
approved = bool(self._permission_request({
"name": name, "args": args,
"preview": preview.to_dict() if preview is not None else {},
}))
if not approved:
sink(ToolCallFinishedEvent(call_id=call_id, name=name, ok=False,
output=REJECTED_OUTPUT))
return approved
@staticmethod
def _make_emitter(result: TurnResult,
on_event: Optional[EventCallback]) -> Callable[[AgentEvent], None]:
"""Record every event on the result AND forward it to the caller.
Recording is unconditional so a headless caller (the scheduler) can read
the full event list afterwards without having to supply a callback just
to collect it - which is exactly what task_executors does today with an
ad-hoc list.
"""
def emit(event: AgentEvent) -> None:
result.events.append(event)
if on_event is None:
return
try:
on_event(event)
except Exception: # noqa: BLE001
# A consumer that throws (a closing widget, say) must not abort
# the turn that is feeding it.
logger.debug("event consumer raised for %s", event.type, exc_info=True)
return emit
def _resolve_runner(self) -> Callable[..., Any]:
"""The turn engine, imported lazily on first use."""
if self._runner is None:
from cowork_local.core.chat_agent import run_cowork
self._runner = run_cowork
return self._runner
def _resolve_tools(self) -> Tuple[Any, Any]:
"""MCP/connector tools for this turn, or ``(None, None)``.
A failure here degrades to "no external tools" rather than failing the
turn: an MCP server that will not start must not stop the user from
chatting, which is the behaviour the chat panel already relies on.
"""
if self._tool_source is None:
return None, None
try:
return self._tool_source()
except Exception: # noqa: BLE001
logger.warning("tool source unavailable - running without external tools",
exc_info=True)
return None, None
def _resolve_gate(self, request: ConversationExecutionRequest) -> Any:
"""The permission gate, when this turn asked to confirm commands."""
if not request.confirm_commands or self._gate_factory is None:
return None
return self._gate_factory(request)
def _tool_message(call_id: str, name: str, output: Any) -> Dict[str, Any]:
"""The canonical ``role: tool`` message the model reads back."""
return {"role": "tool", "tool_call_id": call_id, "name": name,
"content": str(output or "")}
@staticmethod
def _output_dir(request: ConversationExecutionRequest) -> Path:
"""The turn's output folder as a Path.
def _announce_budget_exhausted(request: ConversationExecutionRequest,
messages: List[Dict[str, Any]], sink: EventSink) -> None:
"""Report being cut off by the step ceiling.
The request holds it as a string to stay serialisable; converting at the
single point of use keeps that decision from leaking into every caller.
The note always reaches the transcript. It is merged into the stored
answer only when the last message is the assistant's — which, when the
ceiling is hit, it never is (the turn ends on a tool result). The branch
is kept because it is what the current runtime does, and because it is
the correct behaviour the day a caller ends the loop differently.
"""
return Path(request.output_dir) if request.output_dir else Path.cwd()
note = BUDGET_NOTE_TEMPLATE.format(steps=request.effective_max_steps)
sink(TextChunkEvent(delta=note))
if messages and messages[-1].get("role") == "assistant":
messages[-1]["content"] = (messages[-1].get("content") or "") + note
@staticmethod
def _role_kwargs(request: ConversationExecutionRequest) -> Dict[str, Any]:
"""``agent_role`` only when the request set one.
def _finalize_outputs(self, before: Any, sink: EventSink, cancelled: bool) -> None:
"""Tidy the output folder and report what moved.
Omitted otherwise so the engine applies its own default (the interactive
Cowork role) instead of being handed an empty string, which would land
in the audit log as an unattributed tool call.
"""
return {"agent_role": request.agent_role} if request.agent_role else {}
@staticmethod
def _last_assistant_text(messages: List[Dict[str, Any]]) -> str:
"""Fallback answer text when no text events were seen.
A turn whose whole answer arrived in one non-streamed message still has
to report a final answer - the scheduler writes it into output.md, and
an empty string there reads as "(no output)".
"""
for message in reversed(messages):
if message.get("role") == "assistant" and (message.get("content") or "").strip():
return str(message["content"])
return ""
@staticmethod
def _is_recoverable(exc: Exception) -> bool:
"""Whether the user can act on this failure themselves.
"Model not found" is the motivating case: the chat panel restores the
typed message into the composer so the user can switch model and resend
instead of retyping it (see providers/base.py::MODEL_NOT_FOUND_HINT).
Failures are logged, never raised: this runs in a ``finally``, so an
exception here would replace the turn's real error (or its success) with
a housekeeping one.
"""
try:
from cowork_local.providers.base import is_model_not_found_error
return bool(is_model_not_found_error(str(exc)))
removed, added = self._tools.finalize(before, cancelled=cancelled)
except Exception: # noqa: BLE001
return False
logger.exception("turn: tidying the output folder failed")
return
if removed:
sink(OutputsRemovedEvent(paths=tuple(removed)))
if added:
sink(OutputsAddedEvent(paths=tuple(added)))
__all__ = ["ConversationApplicationService", "TurnResult"]
__all__ = ["ConversationApplicationService"]
@@ -0,0 +1,325 @@
"""Wires :class:`ConversationApplicationService` to the existing runtime (R04-T03).
The service is written against the narrow seams in :mod:`turn_runtime` so it can
be tested with plain fakes. This module supplies the real implementations — the
provider call with its recovery pass, the tool/sandbox runtime, the security
guards, context compaction — and is therefore the ONLY file in
``application/conversations/`` that knows ``core/*`` exists. Same shape (and
same reason) as ``application/model_routing/core_routing_adapter.py`` in R03.
Every ``core`` import is deferred into a method body: importing the tool runtime
pulls in ``requests``, ``psutil`` and the sandbox stack, and code that merely
*builds* a service must not pay for that.
Faithfulness notes — two places where this reproduces a quirk of the current
runtime rather than the behaviour one would design fresh. Both are marked
inline: the MS365 system-prompt paragraph keys off the CONFIGURED extra tools
(not the advertised subset), and the ``tool_result`` path falls back to the
call's own ``path`` argument resolved against the workdir.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
from ...domain.agents.agent_event import PlanStep, ToolPreview
from .conversation_application_service import ConversationApplicationService
from .turn_runtime import PLAN_TOOL, EventSink
# Legacy emit: the dict-based callback every current caller already owns.
LegacyEmit = Callable[[Dict[str, Any]], None]
def legacy_event_sink(emit: LegacyEmit) -> EventSink:
"""Adapt a typed :class:`EventSink` onto the legacy dict ``emit``.
This is what lets R04 land without touching the presentation layer: the
service thinks in typed events, ``ui/chat_panel.py::_on_event`` keeps
receiving exactly the dicts it already dispatches on. Deleted in R08 once
the widget consumes events directly.
"""
return lambda event: emit(event.to_legacy_dict())
class CoreModelCall:
""":class:`ModelCallPort` over ``code_agent._call_provider_with_recovery``.
Not ``provider.chat`` directly: the recovery wrapper adds the one bounded
retry that hides a dropped connection or a momentarily unreachable gateway,
and losing it would be a visible regression on flaky corporate networks.
"""
def __init__(self, provider: Any) -> None:
self._provider = provider
def call(self, messages, tools, on_text=None, on_reasoning=None, cancel=None):
from ...core.code_agent import _call_provider_with_recovery
return _call_provider_with_recovery(self._provider, messages, tools, on_text,
cancel, on_reasoning)
class CoreToolRuntime:
""":class:`ToolRuntimePort` over ``core/tools.py`` + Cowork's file tools."""
def __init__(self, output_dir: Path, *, title: str = "",
extra_tools: Optional[Sequence[Any]] = None, extra_executor=None,
security_config: Any = None, agent_role: str = "") -> None:
self._output_dir = Path(output_dir)
self._title = title
self._extra_tools = list(extra_tools or ())
self._extra_names = {getattr(t, "name", "") for t in self._extra_tools}
# The connector executor MCP/REST tools are routed to; None when the
# turn has no connectors enabled.
self._extra_executor = extra_executor
self._security_config = security_config
self._agent_role = agent_role
self._ctx: Any = None # built on first use (see _tool_context)
# -- the configured extra tools, for the system-prompt hints ---------- #
@property
def extra_names(self) -> frozenset:
return frozenset(self._extra_names)
def _tool_context(self):
"""The sandboxed ``ToolContext`` every built-in tool call runs inside.
Built once per turn and cached: it carries the resource limits and the
network policy, so re-deriving it mid-turn could let a Settings change
take effect halfway through work already in flight.
"""
if self._ctx is None:
from ...core import agent_security
from ...core.tools import ToolContext
limits, block_network = agent_security.sandbox_settings(self._security_config)
self._ctx = ToolContext(
self._output_dir, flatten_writes=True, # keep every file in the Output root
resource_limits=limits, block_network=block_network,
allow_url_fetch=agent_security.url_fetch_allowed(self._security_config),
jira=(self._security_config.data.get("jira") if self._security_config else None),
)
return self._ctx
# -- ToolRuntimePort -------------------------------------------------- #
def specs(self, allowed_tools: Optional[Sequence[str]] = None) -> List[Any]:
"""Advertised tools: Cowork's own two, the enabled built-ins, then MCP.
``allowed_tools`` restricts the list so a read-only step literally cannot
write. ``update_plan`` and the connector tools always survive the filter:
the plan tool has no side effects, and connectors are opted into
explicitly rather than governed by the built-in capability scope.
"""
from ...core.chat_agent import SAVE_FILE_SPEC
from ...core.plan import UPDATE_PLAN_SPEC
from ...core.tools import enabled_tool_specs
specs = ([SAVE_FILE_SPEC, UPDATE_PLAN_SPEC]
+ list(enabled_tool_specs(self._security_config))
+ self._extra_tools)
if allowed_tools is None:
return specs
allow = set(allowed_tools) | {PLAN_TOOL} | self._extra_names
return [t for t in specs if getattr(t, "name", "") in allow]
def preview(self, name: str, args: Dict[str, Any]) -> Optional[ToolPreview]:
"""What the user sees before the call runs."""
# A connector call has no local diff to show, so it renders as the plain
# argument dump the runtime already used.
if name in self._extra_names:
return ToolPreview(kind="info", title=name, text=str(args))
if name == "save_file":
return self._save_file_preview(args)
from ...core.tools import describe_action
raw = describe_action(self._tool_context(), name, args)
return ToolPreview.from_dict(raw)
def _save_file_preview(self, args: Dict[str, Any]) -> ToolPreview:
"""A before/after diff for the file the agent is about to write.
A brand-new file renders all-green (before is empty); an overwrite shows
the real change, so saving a file reads like editing one.
"""
import difflib
from ...core.chat_agent import _structure_summary, _titled_filename
fname = _titled_filename(self._title, args.get("filename", "output.txt"))
content = str(args.get("content", ""))
summary = _structure_summary(fname, content)
old = ""
existing = self._output_dir / fname
if existing.exists():
try:
old = existing.read_text(encoding="utf-8", errors="replace")
except OSError:
pass # unreadable existing file: show it as a fresh write
diff = "".join(difflib.unified_diff(
old.splitlines(keepends=True), content.splitlines(keepends=True),
fromfile=f"a/{fname}", tofile=f"b/{fname}",
)) or content[:4000]
return ToolPreview(kind="diff", title=f"Save {fname}",
text=f"{summary}\n\n{diff[:4000]}")
def execute(self, name: str, args: Dict[str, Any], on_output=None,
cancel=None) -> Dict[str, Any]:
"""Run one tool call and return the runtime's result mapping."""
if name == PLAN_TOOL:
return self._execute_plan(args)
if name in self._extra_names and self._extra_executor is not None:
# Connector results carry no local file, so no path/produced keys —
# matching what the runtime reports for an MCP call today.
result = self._extra_executor(name, args) or {}
return {"ok": bool(result.get("ok", False)), "output": result.get("output", "")}
if name == "save_file":
from ...core.chat_agent import _do_save_file
return dict(_do_save_file(self._output_dir, self._title, args))
from ...core.tools import execute_tool
ctx = self._tool_context()
result = dict(execute_tool(ctx, name, args, cancel=cancel, on_output=on_output,
agent_role=self._agent_role))
# Quirk preserved: a tool that wrote the file named in its OWN arguments
# (write_file/edit_file) does not report a path, so the runtime derives
# one from the argument. Dropping this would empty the Output list.
if not result.get("path") and isinstance(args, dict) and args.get("path"):
result["path"] = str(ctx.workdir / str(args["path"]))
return result
def _execute_plan(self, args: Dict[str, Any]) -> Dict[str, Any]:
"""Apply an ``update_plan`` call: validate the steps and audit them.
Produces no file and no chat bubble; the service turns the returned
steps into a single plan event.
"""
from ...core import agent_roles, audit_log
from ...core.plan import normalize_plan_steps
steps = normalize_plan_steps(args.get("steps"))
audit_log.record("tool_call", PLAN_TOOL, True, f"{len(steps)} step(s)",
agent_role=agent_roles.PLANNER)
return {"ok": True, "output": "Plan updated.",
"plan_steps": [PlanStep(title=s["title"], status=s["status"]) for s in steps]}
def snapshot(self) -> Any:
from ...core.tools import _snapshot
return _snapshot(self._output_dir)
def finalize(self, before: Any, cancelled: bool = False
) -> Tuple[List[str], List[str]]:
"""Drop the scratch sandbox and flatten deliverables into the root.
Returns ``(gone, arrived)``: a file that MOVED counts as both, because
the Output list keys entries by path and must drop the old one.
"""
from ...core.chat_agent import _cleanup_cowork_intermediates
removed, moved = _cleanup_cowork_intermediates(self._output_dir, before,
cancelled=cancelled)
gone = list(removed) + [old for old, _new in moved]
arrived = [new for _old, new in moved]
return gone, arrived
def build_cowork_conversation_service(
provider: Any,
output_dir: Path,
emit: LegacyEmit,
*,
title: str = "",
project_context: str = "",
extra_tools: Optional[Sequence[Any]] = None,
extra_executor=None,
security_config: Any = None,
gate: Any = None,
agent_role: str = "",
) -> ConversationApplicationService:
"""A service wired to the real runtime, ready to execute a Cowork turn.
``emit`` is the legacy dict callback: the guards and the compactor publish
their own notices through it directly (exactly as they do now), while the
service's typed events reach it via :func:`legacy_event_sink`.
``gate`` present means the workspace asked to confirm commands; pass the
request with ``gate_mode="confirm"`` so the two agree. A gate of ``None``
keeps the pre-existing auto-run behaviour.
"""
from ...core import agent_roles
tools = CoreToolRuntime(
output_dir, title=title, extra_tools=extra_tools, extra_executor=extra_executor,
security_config=security_config, agent_role=agent_role or agent_roles.COWORK,
)
def prepare_prompt(messages: List[Dict[str, Any]], advertised: Tuple[str, ...]) -> None:
"""Insert the system prompt, then fold in skills, rules and project text.
``advertised`` is unused on purpose: the runtime decides the MS365
paragraph from the CONFIGURED connector tools, not from the subset a
capability scope left advertised. Changing that changes the prompt the
model sees, so it stays as-is here and belongs to R05's tool-policy work.
"""
from ...core.chat_agent import (
COWORK_TOOL_PROMPT,
OPENDATALOADER_PDF_PROMPT,
_apply_project_context,
_apply_security_rules,
_apply_skills,
)
from ...core.deps import _can_pip
from ...core.java_runtime import find_java
from ...core.security_rules import load_rules
from ...core.skills import active_skills_text
if not messages or messages[0].get("role") != "system":
system = COWORK_TOOL_PROMPT
if any(n.startswith("ms365_") for n in tools.extra_names):
system += ("\nThe user has signed in to Microsoft 365 and enabled some ms365__* "
"tools (Outlook / Teams / OneDrive / SharePoint / meeting transcripts, "
"via the built-in MS365 MCP server). Use them whenever the request "
"involves that data — don't say you can't access it.")
if find_java() is not None and _can_pip():
# Only advertise the Java-backed PDF extractor when BOTH the JVM
# and pip are available, so the agent is never steered into a
# command that cannot work on this machine.
system += "\n\n" + OPENDATALOADER_PDF_PROMPT
messages.insert(0, {"role": "system", "content": system})
_apply_skills(messages, active_skills_text())
_apply_security_rules(messages, load_rules())
_apply_project_context(messages, project_context)
def prompt_guard(messages: List[Dict[str, Any]]) -> None:
from ...core import agent_security
agent_security.enforce_prompt(provider, messages, security_config, emit)
def command_guard(name: str, args: Dict[str, Any]) -> None:
from ...core import agent_security
agent_security.enforce_command(provider, name, args, security_config, emit)
def compact(messages: List[Dict[str, Any]], cancel) -> None:
from ...core import context_budget
context_budget.maybe_compact(provider, messages, security_config,
emit=emit, cancel=cancel)
return ConversationApplicationService(
CoreModelCall(provider), tools,
prepare_prompt=prepare_prompt,
prompt_guard=prompt_guard,
command_guard=command_guard,
compact=compact,
permission_request=(gate.request if gate is not None else None),
)
__all__ = [
"LegacyEmit", "legacy_event_sink", "CoreModelCall", "CoreToolRuntime",
"build_cowork_conversation_service",
]
@@ -0,0 +1,77 @@
"""Turn the Cowork widget's captured state into a request (R04-T04).
``ui/cowork_tab.py::build_job`` reads a dozen values off the widget on the UI
thread and has to translate three of them before a turn can run: which message
is this turn's prompt, which messages are its history, and whether the workspace
wants commands confirmed. Those rules lived inline in the widget, where no test
could reach them — and each fails silently when wrong (a duplicated user message,
or a command that quietly stops asking for approval).
They live here instead, as the mapping step the migration map assigns to the
application layer. The widget keeps only what is genuinely widget-specific:
reading its own state and building the provider.
Layer rules (``docs/architecture/ADR-001-layered-architecture.md``): pure Python.
Everything arrives as a plain value, so this module never sees a widget.
"""
from __future__ import annotations
from typing import Any, Dict, Optional, Sequence
from ...domain.agents.conversation_execution_request import ConversationExecutionRequest
def build_cowork_turn_request(
*,
turn_id: str,
session_id: str,
messages: Sequence[Dict[str, Any]],
surface: str = "cowork",
project_id: str = "",
title: str = "",
provider_id: str = "",
model: str = "",
instructions: str = "",
output_dir: Optional[Any] = None,
home_output_root: Optional[Any] = None,
confirm_commands: bool = False,
agent_role: str = "cowork",
) -> ConversationExecutionRequest:
"""Build one Cowork turn's immutable request.
``messages`` is the widget's working list, which ALREADY ends with this
turn's user message (the chat panel composes it — prefix, attachments,
session notes — before the job starts). So the prompt is that last message
and the history is everything before it. The request records both; the
service is handed the same working list and appends into it.
Keyword-only on purpose: a dozen positional strings in a call site is exactly
how a title ends up in the project-id slot.
"""
history = list(messages or ())
# ``pop`` rather than ``[-1]``/``[:-1]`` so the empty-list case needs no
# special branch: a turn with nothing in it yields an empty prompt instead of
# raising IndexError deep inside a worker thread.
last = history.pop() if history else {}
return ConversationExecutionRequest(
turn_id=turn_id,
session_id=session_id,
surface=surface,
project_id=project_id,
title=title,
prompt=str(last.get("content") or ""),
messages=history,
provider_id=provider_id,
model=model,
project_context=instructions,
output_dir=output_dir,
home_output_root=home_output_root,
# The workspace's Auto-run override (or the global setting) decides
# whether run_command/install_package must be approved first.
gate_mode="confirm" if confirm_commands else "auto",
agent_role=agent_role,
)
__all__ = ["build_cowork_turn_request"]
@@ -1,83 +0,0 @@
"""ToolPolicyGateway - one confirm/deny decision path for every tool call
(R05-T03).
Today "does this tool call need the user's OK first" is answered by a
different hand-written check per engine:
* ``core/chat_agent.py::run_cowork`` — ``name in ("run_command",
"install_package")``, a literal tuple.
* ``core/code_agent.py::run_code`` — ``name in (WRITE_TOOLS | MS365_WRITE_TOOLS)``,
a set built from two other hand-maintained sets.
* MCP/connector tools (``core/mcp_client.py``, ``core/ext_connectors.py``) —
no check at all; ``chat_agent.py`` calls ``extra_executor(name, args)``
directly.
Three answers to the same question, and the third one is a real gap: an MCP
tool that deletes files or calls an external API today runs with zero
confirmation even when the user turned "confirm before running commands" on.
This gateway answers the question from data (:class:`~domain.tools.tool_descriptor.ToolCapability`
via a :class:`~domain.tools.tool_registry.ToolRegistry`) instead of a literal
name list, so registering a tool with the right capability is what gates it -
nothing to remember at each new call site. R05-T04 is what actually registers
MCP/connector tools with a capability; this module only needs the mechanism
to exist.
Pure Python: no Qt, no direct dialog. The actual approval prompt stays exactly
what it is today - a ``gate`` object with a ``.request(payload) -> bool``
method, supplied by the presentation layer (Settings' "confirm before running
commands" wires it up, or None for auto-run) - this module only decides
WHEN to ask it, never how to render the question.
"""
from __future__ import annotations
from typing import Any, Dict, Optional, Protocol
from cowork_local.domain.tools import ToolCapability, ToolRegistry
class ConfirmGate(Protocol):
"""Shape of the existing ``PermissionGate`` both engines already use."""
def request(self, payload: Dict[str, Any]) -> bool: ...
class ToolPolicyGateway:
"""Decides whether a tool call needs approval, for ONE calling surface.
``gated_capabilities`` is what makes this per-surface: Cowork only ever
asked about ``run_command``/``install_package`` (capability ``EXECUTE``),
while the Code tab additionally confirms plain file writes (capability
``WRITE``). Passing the wrong set here would silently change which tools
prompt for approval - see the callers in ``core/chat_agent.py`` and
``core/code_agent.py`` for the exact sets that preserve today's behavior.
"""
def __init__(self, registry: ToolRegistry, gated_capabilities: ToolCapability) -> None:
self._registry = registry
self._gated_capabilities = gated_capabilities
def requires_confirmation(self, name: str) -> bool:
"""True when ``name``'s declared capabilities overlap this surface's
gated set. An unregistered tool never requires confirmation through
this path - callers that must fail safe on unknown tools check
``name in registry`` themselves (see R05-T04's MCP wrapping, which
registers every tool it exposes before any call can reach here)."""
return bool(self._registry.capabilities_for(name) & self._gated_capabilities)
def allow(self, name: str, gate: Optional[ConfirmGate], payload: Dict[str, Any]) -> bool:
"""True when the call may proceed.
``gate is None`` preserves each engine's existing "no gate wired -
auto-run" behavior; a tool outside ``gated_capabilities`` is never
asked about, matching read-only tools "never confirm" today.
``payload`` is whatever ``gate.request(...)`` already expects at that
call site (the two engines use slightly different dict shapes) - this
gateway only decides WHETHER to call it, never reshapes the payload.
"""
if gate is None or not self.requires_confirmation(name):
return True
return bool(gate.request(payload))
__all__ = ["ToolPolicyGateway", "ConfirmGate"]
+177
View File
@@ -0,0 +1,177 @@
"""The seams :mod:`conversation_application_service` runs a turn through (R04-T03).
Two Protocols and six callables — chosen deliberately, not by reflex. The
refactor plan forbids giving every class an interface, so a contract exists here
only where there is both a real ``core/*`` implementation AND a test double:
* :class:`ModelCallPort` — one provider round-trip *including* the app's
existing context-overflow recovery, which is why the raw ``Provider.chat``
signature is not enough.
* :class:`ToolRuntimePort` — the tool + output-folder runtime, kept as one
cohesive object because every method operates on the same sandbox.
Everything else is a single function, so it is expressed as a callable type
rather than a class with one method (the same choice R03 made for
``ConfirmationCallback``). All of them are optional: a service built with none
of them still runs a plain chat turn, which is what keeps the unit tests short.
Layer rules (``docs/architecture/ADR-001-layered-architecture.md``): application
layer — pure Python. Nothing here imports PySide6, ``core.*``, ``providers.*``
or ``ui.*``; the concrete wiring lives in :mod:`core_runtime_adapter`.
"""
from __future__ import annotations
from typing import (
Any,
Callable,
Dict,
List,
Optional,
Protocol,
Sequence,
Tuple,
runtime_checkable,
)
from ...domain.agents.agent_event import AgentEvent, ToolPreview
# The plan tool is special-cased by the loop: it drives the Plan panel and
# produces no chat bubble and no file. Named here so the check is not a bare
# string literal in the middle of the dispatch.
PLAN_TOOL = "update_plan"
# Tools that need approval before they run when the workspace is in confirm
# mode. R05 replaces this tuple with a real ``ToolPolicyGateway`` keyed on
# ToolCapability; until then it mirrors exactly what the runtime gates today.
GATED_TOOLS = ("run_command", "install_package")
# Shown when the user (or the workspace policy) rejects a proposed command. The
# exact string also becomes the tool message the model reads back, so it must
# stay stable.
REJECTED_OUTPUT = "Rejected by user."
# A reasoning model can answer with thinking only. The note is written into the
# assistant message itself, not merely emitted, so an unattended run does not
# read back an empty answer and report "(no output)".
REASONING_ONLY_NOTE = "*(model returned only its reasoning — try rephrasing)*"
# Emitted when the turn is stopped by its own safety ceiling rather than by the
# model finishing. Never silent: being cut off looks exactly like being done.
BUDGET_NOTE_TEMPLATE = (
"\n\n⚠️ Reached the {steps}-step safety limit before the task signalled "
"completion — stopping here. Re-run to continue if more work remains."
)
def combine_instructions(*blocks: Optional[str]) -> str:
"""Join the standing-instruction blocks of a turn, skipping the absent ones.
A turn's instructions arrive as several independent blocks — the project's
shared context, an Admin agent's persona, a skill's rules, the
"this runs unattended" reminder — and each caller was joining them inline
with its own ``f"{a}\\n\\n{b}" if a else b`` expression. Two call sites now
need the same rule (the Cowork widget in R04-T04 and the task runner in
R04-T05), which is the point at which it stops being an expression.
Whitespace-only blocks count as absent: they would otherwise open the system
prompt with a stray blank line.
"""
return "\n\n".join(b.strip() for b in blocks if b and b.strip())
# --------------------------------------------------------------------------- #
# Callables.
# --------------------------------------------------------------------------- #
# Receives every typed event the turn produces. The caller decides what that
# means — render it, forward it as a legacy dict, autosave on it.
EventSink = Callable[[AgentEvent], None]
# True once the user has asked to stop. Polled between steps and between tool
# calls, the same cadence the current runtime uses.
CancelFn = Callable[[], bool]
# ``(prompt, attachment_paths) -> body``. Runs on the worker thread because
# extracting a .docx may pip-install a parser or call LibreOffice.
AttachmentReader = Callable[[str, Tuple[str, ...]], str]
# ``(messages, advertised_tool_names) -> None`` — inserts the system prompt and
# folds in skills, security rules and project instructions, in place. It needs
# the tool names because the system prompt gains an MS365 paragraph only when
# ms365 tools are actually present.
PromptPreparer = Callable[[List[Dict[str, Any]], Tuple[str, ...]], None]
# Reviews the assembled request; raises to refuse the turn outright.
PromptGuard = Callable[[List[Dict[str, Any]]], None]
# Reviews one proposed tool call; raises to refuse it.
CommandGuard = Callable[[str, Dict[str, Any]], None]
# ``(messages, cancel) -> None``. Summarises old turns in place when the
# conversation nears the model's context budget; a no-op when compaction is off
# or the conversation is short. It takes the cancel signal because compacting
# calls the model itself, so Stop has to reach it too.
ContextCompactor = Callable[[List[Dict[str, Any]], "CancelFn"], None]
# ``(action) -> approved``. Blocks the worker thread while a human decides.
PermissionRequest = Callable[[Dict[str, Any]], bool]
# --------------------------------------------------------------------------- #
# Ports.
# --------------------------------------------------------------------------- #
@runtime_checkable
class ModelCallPort(Protocol):
"""One call to the model, with the app's retry/recovery behaviour applied."""
def call(self, messages: List[Dict[str, Any]], tools: Sequence[Any],
on_text: Optional[Callable[[str], None]] = None,
on_reasoning: Optional[Callable[[str], None]] = None,
cancel: Optional[CancelFn] = None) -> Dict[str, Any]:
"""Return the canonical assistant message (content plus tool calls)."""
@runtime_checkable
class ToolRuntimePort(Protocol):
"""The tools a turn may call, and the folder its files land in."""
def specs(self, allowed_tools: Optional[Sequence[str]] = None) -> Sequence[Any]:
"""Tool specs to advertise to the model, already filtered.
Returns opaque objects (the provider layer's ``ToolSpec``); the service
only ever reads ``.name`` off them, which is what keeps this layer free
of a provider import.
"""
def preview(self, name: str, args: Dict[str, Any]) -> Optional[ToolPreview]:
"""Human-readable description of a call that is about to run."""
def execute(self, name: str, args: Dict[str, Any],
on_output: Optional[Callable[[str], None]] = None,
cancel: Optional[CancelFn] = None) -> Dict[str, Any]:
"""Run one tool call.
Returns the runtime's own result mapping: ``ok``, ``output``, optionally
``path``/``produced`` for files it created, and ``plan_steps`` for the
plan tool.
"""
def snapshot(self) -> Any:
"""Opaque record of the output folder before the turn started."""
def finalize(self, before: Any, cancelled: bool = False
) -> Tuple[Sequence[str], Sequence[str]]:
"""Tidy the output folder; return ``(removed_paths, added_paths)``.
Not read-only — it deletes the scratch sandbox and flattens sub-folders —
so the service only calls it for a turn that actually started.
"""
__all__ = [
"PLAN_TOOL", "GATED_TOOLS", "REJECTED_OUTPUT", "REASONING_ONLY_NOTE",
"BUDGET_NOTE_TEMPLATE", "combine_instructions",
"EventSink", "CancelFn", "AttachmentReader", "PromptPreparer", "PromptGuard",
"CommandGuard", "ContextCompactor", "PermissionRequest",
"ModelCallPort", "ToolRuntimePort",
]
+48 -6
View File
@@ -1,12 +1,54 @@
"""Model routing use case: pick the best-fit model for one turn (EPIC R03)."""
"""Application model routing package: model route decisions and multi-provider balancing.
Public surface (R03-T03 — the single routing entry point every chat surface uses):
* :class:`RoutingApplicationService` — decides one turn's provider/model.
* :class:`RoutingRequest` / :class:`RoutingOutcome` — the immutable DTOs in and out.
* :class:`RoutingMode` — Off / Auto / Manual / Fallback.
* :func:`build_routing_application_service` — wires the service to a live
``AppContext`` (engine + per-workspace mode + confirm timeout).
Typical call site (see ``ui/chat_panel.py::_apply_routing``)::
service = build_routing_application_service(self.ctx)
outcome = service.resolve(
RoutingRequest(surface="cowork", prompt=text,
current_provider=provider, current_model=model),
confirm=lambda decision, timeout: confirm_switch(self, decision, timeout),
)
Only ``core_routing_adapter`` touches ``core/routing``; the service and the DTOs
stay pure Python so the whole rule set is testable without Qt or the engine.
"""
from .core_routing_adapter import (
AppContextModeResolver,
CoreRoutingEngine,
build_routing_application_service,
)
from .routing_application_service import (
ConfirmationCallback,
ModeResolver,
RoutingApplicationService,
RoutingDecision,
RoutingDecisionPort,
)
from .routing_models import (
RouteEvaluation,
RoutingMode,
is_valid_mode,
normalize_mode,
RoutingOutcome,
RoutingRequest,
)
__all__ = ["RoutingApplicationService", "RoutingDecision", "RoutingMode",
"normalize_mode", "is_valid_mode"]
__all__ = [
"AppContextModeResolver",
"ConfirmationCallback",
"CoreRoutingEngine",
"ModeResolver",
"RouteEvaluation",
"RoutingApplicationService",
"RoutingDecisionPort",
"RoutingMode",
"RoutingOutcome",
"RoutingRequest",
"build_routing_application_service",
]
@@ -0,0 +1,169 @@
"""Adapters that plug the existing routing engine into the application service.
:mod:`routing_application_service` is written against two narrow ports so it can
be unit-tested with plain fakes. This module supplies the real implementations —
the assessment/scoring engine in ``core/routing`` and the per-workspace mode
lookup on ``AppContext`` — and is therefore the ONLY file in
``application/model_routing/`` that knows those concrete types exist.
All engine imports are deferred into method bodies. Importing the routing stack
pulls in Pydantic models and the on-disk assessment store, and the UI must be
able to import this module during startup without paying that cost (the same
lazy-wiring reason ``state.py::AppContext.routing`` gives).
"""
from __future__ import annotations
import logging
from typing import Any, Optional
from .routing_application_service import RoutingApplicationService
from .routing_models import RouteEvaluation, RoutingMode, RoutingRequest
logger = logging.getLogger("cowork_local.application.model_routing")
class CoreRoutingEngine:
""":class:`RoutingDecisionPort` backed by ``core/routing/service.py``.
Translates in both directions: application DTOs in, and the engine's
``RouteResult``/``SwitchDecision``/``TaskType`` flattened back out into a
:class:`RouteEvaluation`, so no ``core.routing`` type ever escapes into the
application service or the UI call sites.
"""
def __init__(self, routing_service: Any) -> None:
self._routing_service = routing_service
def evaluate(self, request: RoutingRequest, mode: RoutingMode) -> RouteEvaluation:
"""Rank candidates for this turn and report the engine's verdict."""
from ...core.routing.models import TaskType, candidate_key
result = self._routing_service.route(
request.surface,
request.prompt,
request.current_provider,
request.current_model,
# The engine only knows off/auto/manual; FALLBACK was already mapped
# to AUTO upstream so the value handed over here is always valid.
mode_override=mode.value,
required_capabilities=list(request.required_capabilities) or None,
task_type=self._parse_task_type(request.task_type, TaskType),
)
decision = result.decision
target = result.target() # (provider, model_id) or None
current_key = (
candidate_key(request.current_provider, request.current_model)
if request.current_model
else ""
)
return RouteEvaluation(
task_type=self._task_type_value(result.task_type),
should_switch=bool(result.should_switch),
target_provider=target[0] if target else None,
target_model=target[1] if target else None,
score_gain=float(getattr(decision, "score_gain", 0.0) or 0.0),
reason=str(getattr(decision, "reason", "") or ""),
current_is_usable=self._current_is_usable(result, current_key),
decision=decision,
)
# -- translation helpers --------------------------------------------- #
@staticmethod
def _parse_task_type(raw: Optional[str], task_type_enum) -> Optional[Any]:
"""Coerce a task-type string to the engine's enum.
``None`` (the common case) means "let the engine classify the prompt".
An unrecognised string is also downgraded to ``None`` rather than
raising, so a stale value in a saved workspace cannot break a turn.
"""
if raw is None:
return None
if isinstance(raw, task_type_enum):
return raw
try:
return task_type_enum(str(raw).strip().lower())
except ValueError:
logger.warning("routing: unknown task type %r — classifying from the prompt", raw)
return None
@staticmethod
def _task_type_value(task_type: Any) -> str:
"""The plain string form of the engine's task type enum."""
return str(getattr(task_type, "value", task_type) or "")
@staticmethod
def _current_is_usable(result: Any, current_key: str) -> bool:
"""Whether the currently selected model can still serve this task.
This is the signal FALLBACK mode acts on. A model is usable when the
ranking scored it above zero; ``rank_models`` already drops candidates
that are unavailable, lack a probe for this task type, or failed their
last probe, so "absent from the ranking" is precisely "cannot serve it".
With no ranking (routing off, or the engine's internal error path) or no
current model, we answer True: absence of evidence must not trigger a
surprise switch in a mode whose whole promise is not to surprise.
"""
ranking = getattr(result, "ranking", None)
if ranking is None or not current_key:
return True
try:
return float(ranking.score_of(current_key)) > 0.0
except Exception: # noqa: BLE001 — defensive: never fail a turn on telemetry-ish data
logger.debug("routing: could not score current model %r", current_key, exc_info=True)
return True
class AppContextModeResolver:
""":class:`ModeResolver` backed by the active workspace's settings.
Reads through ``AppContext.project_routing_mode``, which already layers the
workspace override on top of the global default — so per-workspace routing
modes keep working unchanged now that the mode lookup moved out of the
widgets.
"""
def __init__(self, ctx: Any) -> None:
self._ctx = ctx
def mode_for(self, surface: str) -> RoutingMode:
"""Effective mode for ``surface`` in the active workspace."""
return RoutingMode.parse(self._ctx.project_routing_mode(surface))
def build_routing_application_service(ctx: Any) -> RoutingApplicationService:
"""The shared :class:`RoutingApplicationService` for this app context.
Cached on the context (like ``AppContext.routing()`` caches the engine) so
every surface talks to the same instance and a future stateful addition —
per-surface cool-down, switch history — is shared rather than duplicated per
widget. Falls back to a fresh instance if the context refuses attribute
assignment, which keeps tests using lightweight stand-ins working.
"""
cached = getattr(ctx, "_routing_app_service", None)
if cached is not None:
return cached
service = RoutingApplicationService(
CoreRoutingEngine(ctx.routing()),
AppContextModeResolver(ctx),
# Read at call time: the user can change the confirm timeout in Settings
# between two turns and the next Manual dialog should honour it.
confirm_timeout_sec=lambda: float(
(ctx.config.routing or {}).get("confirm_timeout_sec", 60) or 60
),
)
try:
ctx._routing_app_service = service
except Exception: # noqa: BLE001 — read-only/slotted stand-ins stay supported
logger.debug("routing: could not cache the application service on the context", exc_info=True)
return service
__all__ = [
"AppContextModeResolver",
"CoreRoutingEngine",
"build_routing_application_service",
]
@@ -1,353 +1,236 @@
"""RoutingApplicationService - one routing flow for every surface (R03-T03).
"""The one place that decides how a turn is routed (R03-T03).
Before this service, the same routing algorithm existed three times:
Before this service, ``ui/chat_panel.py#L638``, ``ui/co4e_tab.py`` and
``ui/folder_tab.py`` each carried their own copy of the same eight-step dance:
clear last turn's override → read the surface's mode → bail on "off" → call the
routing engine → check ``should_switch`` → resolve the target → show the Manual
confirm dialog → publish the override and a status line. Three copies meant
three chances to drift, and none of them could be tested without a Qt widget.
* ``ui/chat_panel.py::_apply_routing`` (Cowork chat)
* ``ui/co4e_tab.py::_apply_co4e_routing`` (Co4E studio)
* ``ui/folder_tab.py::_ai_apply_routing`` (AI-Edit)
The dance now lives here, once, in pure Python:
The three copies had already drifted - each one resolves the "current model"
differently and each one has its own private notion of what to do when the user
declines - and every one of them lives inside a Qt widget, so none of the logic
could be tested without building a window.
* the routing engine is reached through :class:`RoutingDecisionPort`;
* the surface's Off/Auto/Manual/Fallback mode through :class:`ModeResolver`;
* the Manual-mode confirmation through a ``confirm`` callback supplied per call,
so the Qt dialog stays in the presentation layer where it belongs.
This module is the single implementation. It is pure Python: no Qt import, no
config access, no network. The presentation layer supplies a confirm callback
and renders the notice; everything else happens here.
Modes (:class:`RoutingMode`)
----------------------------
* ``OFF`` - never switch. The user's pinned model always wins.
* ``AUTO`` - switch silently when the best candidate clears the gain threshold.
* ``MANUAL`` - propose the switch and switch only if the confirm callback approves.
* ``FALLBACK`` - never switch pre-emptively; switch only AFTER the current model
fails, to the next-best candidate. This is the mode a user wants when they
trust their own model choice but still want the turn to survive an outage.
Migration note (ADR-001 section 4): the scoring/ranking engine is NOT rewritten.
This service depends on the small :class:`RoutingPort` interface, and production
wires the existing, already-tested ``core.routing.service.RoutingService`` into
it. Tests wire a fake.
Every failure path degrades to "keep the current model": a routing problem must
never be the reason a user cannot send a message.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Any, Callable, List, Optional, Protocol, Sequence, Tuple
import logging
from typing import Any, Callable, Optional, Protocol, runtime_checkable
from .routing_models import (
RouteEvaluation,
RoutingMode,
RoutingOutcome,
RoutingRequest,
)
logger = logging.getLogger("cowork_local.application.model_routing")
# Asks the user to approve a Manual-mode switch. Receives the underlying
# decision object (for rendering) plus the timeout in seconds; returns True to
# approve. Supplied by the caller so this module never imports a UI toolkit.
ConfirmationCallback = Callable[[Any, float], bool]
class RoutingMode(str, Enum):
"""Per-surface routing behaviour.
@runtime_checkable
class RoutingDecisionPort(Protocol):
"""The routing engine, as this service needs it.
The first three values match ``core.routing.models.SwitchMode`` string for
string, so a mode read from the existing config round-trips unchanged.
Narrowed to a single method on purpose: the concrete engine
(``core/routing/service.py::RoutingService``) exposes assessment,
persistence and scheduling too, none of which a turn-time decision needs.
"""
OFF = "off"
AUTO = "auto"
MANUAL = "manual"
FALLBACK = "fallback"
@classmethod
def parse(cls, raw: Any) -> "RoutingMode":
"""Best-effort parse of a config value.
Unknown or empty values become ``OFF``: routing is an optimisation, and
the safe reading of a corrupt setting is "leave the user's model alone"
rather than "silently move their work to another model".
"""
try:
return cls(str(raw or "off").strip().lower())
except ValueError:
return cls.OFF
def evaluate(self, request: RoutingRequest, mode: RoutingMode) -> RouteEvaluation:
"""Rank candidates for ``request`` and report whether to switch."""
@dataclass(frozen=True)
class RoutingDecision:
"""The outcome of routing one turn - an immutable instruction for the caller.
@runtime_checkable
class ModeResolver(Protocol):
"""Resolves the effective routing mode for a surface.
``provider``/``model`` are ALWAYS filled with what the turn should actually
run on, switched or not, so a call site never has to re-derive the fallback
itself (the bug that made the three UI copies diverge).
In the app this reads the active workspace's per-surface override with the
global default behind it (``AppContext.project_routing_mode``); in tests it
is a two-line stub.
"""
mode: RoutingMode
provider: str
model: str
switched: bool = False
task_type: str = ""
score_gain: float = 0.0
reason: str = ""
declined: bool = False # Manual mode: a switch was offered and refused
# What the turn would have run on without routing. Carried so the Manual
# confirm dialog can show "from X to Y" without re-deriving the current
# model itself - re-deriving it differently per screen is exactly how the
# three legacy copies drifted apart.
previous_provider: str = ""
previous_model: str = ""
@property
def should_notify(self) -> bool:
"""True when the UI should show the "switched model" notice - i.e. only
when a switch really happened."""
return self.switched
def target(self) -> Tuple[str, str]:
"""``(provider, model)`` to run this turn on."""
return self.provider, self.model
@property
def from_model(self) -> str:
"""Candidate key (``provider/model``) of the model being switched away
from, or "" when nothing was selected yet.
Named to match ``core.routing.models.SwitchDecision`` so the existing
Manual-mode dialog (``ui/routing_toggle.py::confirm_switch``) accepts
this object unchanged - the dialog moves to the new shape in EPIC R08.
"""
if not self.previous_model:
return ""
return f"{self.previous_provider}/{self.previous_model}"
@property
def to_model(self) -> str:
"""Candidate key (``provider/model``) of the model to run on. See
:attr:`from_model` for why the name matches the legacy decision."""
return f"{self.provider}/{self.model}" if self.model else ""
def is_valid_mode(raw: Any) -> bool:
"""True when ``raw`` names a mode the routing service understands.
Distinct from :func:`normalize_mode` because callers need to tell "the user
chose off" apart from "this stored value is unrecognised" - the per-workspace
lookup falls back to the global setting only in the second case.
"""
try:
RoutingMode(str(raw or "").strip().lower())
except ValueError:
return False
return True
def normalize_mode(raw: Any) -> str:
"""Canonical mode string for persistence, or ``"off"`` when unrecognised.
Exists so the mode vocabulary is defined exactly once. It used to be
hard-coded as a ``("off", "auto", "manual")`` tuple in four separate places
(config.py twice, state.py twice); adding FALLBACK meant finding all four,
and missing one silently downgraded the user's choice back to "off".
"""
return RoutingMode.parse(raw).value
class RoutingPort(Protocol):
"""The slice of the routing engine this service needs.
Declared as a Protocol so the application layer states its requirement
without importing the implementation - which is what lets the whole service
be tested against a 20-line fake, and lets ``core.routing`` be replaced later
without touching this file.
"""
def route(self, surface: str, prompt: str, current_provider: str, current_model: str,
*, mode_override: Optional[str] = None,
required_capabilities: Optional[List[str]] = None,
task_type: Optional[Any] = None) -> Any:
"""Return a route result exposing ``should_switch``, ``target()``,
``task_type`` and ``decision``."""
# Presentation supplies this to ask the human. Receives the proposal so the
# dialog can explain it; returns True to approve. Manual mode only.
ConfirmFn = Callable[[RoutingDecision], bool]
def mode_for(self, surface: str) -> RoutingMode:
"""Effective mode for ``surface``."""
class RoutingApplicationService:
"""Decides which provider/model one turn runs on.
"""Turn-time routing decisions for every chat surface."""
Args:
router: the scoring engine (see :class:`RoutingPort`).
mode_reader: ``surface -> mode string``; production passes the per-workspace
lookup ``AppContext.project_routing_mode``. Injected rather than read
from config here so this layer stays free of config plumbing that
EPIC R02 is rewriting in parallel.
"""
# Matches DEFAULT_CONFIG["routing"]["confirm_timeout_sec"]; used only when
# no timeout provider is wired, so a bare service is still usable in tests.
DEFAULT_CONFIRM_TIMEOUT_SEC = 60.0
def __init__(self, router: RoutingPort,
mode_reader: Optional[Callable[[str], str]] = None) -> None:
self._router = router
self._mode_reader = mode_reader
# -- main entry point -------------------------------------------------- #
def route_turn(
def __init__(
self,
surface: str,
prompt: str,
current_provider: str,
current_model: str,
decision_port: RoutingDecisionPort,
mode_resolver: Optional[ModeResolver] = None,
*,
mode: Optional[str] = None,
confirm: Optional[ConfirmFn] = None,
required_capabilities: Optional[Sequence[str]] = None,
task_type: Optional[Any] = None,
) -> RoutingDecision:
"""Decide what to run this turn on. Never raises.
confirm_timeout_sec: Optional[Callable[[], float]] = None,
) -> None:
self._decision_port = decision_port
self._mode_resolver = mode_resolver
# A callable rather than a number: the timeout lives in mutable config
# the user can change in Settings between two turns.
self._confirm_timeout_sec = confirm_timeout_sec
A routing failure must never block a message: any unexpected error
degrades to "keep the current model", which is exactly what all three
legacy copies did with a bare ``except`` - made explicit and testable here.
"""
resolved_mode = RoutingMode.parse(mode if mode is not None else self._read_mode(surface))
keep = self._keep(resolved_mode, current_provider, current_model,
reason="routing off - keeping current model")
# An empty prompt carries no signal to classify, so routing cannot make a
# meaningful choice; the same guard exists in all three legacy copies.
if resolved_mode is RoutingMode.OFF or not (prompt or "").strip():
return keep
# FALLBACK never switches up front - it only reacts to a failure, which
# the caller reports through fallback_after_failure().
if resolved_mode is RoutingMode.FALLBACK:
return self._keep(resolved_mode, current_provider, current_model,
reason="fallback mode - switching only after a failure")
try:
result = self._router.route(
surface, prompt, current_provider, current_model,
mode_override=resolved_mode.value,
required_capabilities=list(required_capabilities) if required_capabilities else None,
task_type=task_type,
)
except Exception: # noqa: BLE001 - routing must never break a turn
return self._keep(resolved_mode, current_provider, current_model,
reason="routing engine failed - keeping current model")
proposal = self._to_decision(result, resolved_mode, current_provider, current_model)
if not proposal.switched:
return proposal
# Manual mode: the proposal only becomes a switch once a human approves.
if resolved_mode is RoutingMode.MANUAL:
if confirm is None or not self._ask(confirm, proposal):
return self._keep(resolved_mode, current_provider, current_model,
reason="switch declined - keeping current model",
task_type=proposal.task_type, declined=True)
return proposal
# -- failure recovery -------------------------------------------------- #
def fallback_after_failure(
# -- public API ------------------------------------------------------ #
def resolve(
self,
surface: str,
prompt: str,
failed_provider: str,
failed_model: str,
*,
mode: Optional[str] = None,
required_capabilities: Optional[Sequence[str]] = None,
task_type: Optional[Any] = None,
) -> Optional[RoutingDecision]:
"""Pick a replacement after ``failed_provider/failed_model`` failed.
request: RoutingRequest,
confirm: Optional[ConfirmationCallback] = None,
) -> RoutingOutcome:
"""Decide this turn's provider/model.
Returns None when there is nothing to fall back to, so the caller can
surface the original error instead of retrying forever. Available in
AUTO and FALLBACK; OFF and MANUAL keep the user's model on failure too,
because silently moving work to another model is exactly what those two
modes exist to prevent.
Returns a :class:`RoutingOutcome`; ``provider``/``model`` are ``None``
whenever the surface should keep its own selection. Never raises — an
unexpected failure is logged and reported as "keep current", because a
broken assessment store must not block chatting.
"""
resolved_mode = RoutingMode.parse(mode if mode is not None else self._read_mode(surface))
if resolved_mode not in (RoutingMode.AUTO, RoutingMode.FALLBACK):
return None
mode = request.mode or self._resolve_mode(request.surface)
try:
# Asked in AUTO so the engine ranks candidates rather than short-
# circuiting on FALLBACK's "never switch up front" rule; the failed
# model is passed as current so any positive gain beats it.
result = self._router.route(
surface, prompt, failed_provider, failed_model,
mode_override=RoutingMode.AUTO.value,
required_capabilities=list(required_capabilities) if required_capabilities else None,
task_type=task_type,
)
except Exception: # noqa: BLE001 - a broken router must not mask the real error
return None
return self._resolve_unguarded(request, mode, confirm)
except Exception: # noqa: BLE001 — routing must never break a turn
logger.exception("routing.resolve failed — keeping the current model")
return RoutingOutcome.keep_current(mode, reason="routing error — keeping current model")
decision = self._to_decision(result, resolved_mode, failed_provider, failed_model)
# A "switch" back to the model that just failed would retry the outage.
if not decision.switched or (decision.provider, decision.model) == (failed_provider, failed_model):
return None
return RoutingDecision(
mode=resolved_mode, provider=decision.provider, model=decision.model,
switched=True, task_type=decision.task_type, score_gain=decision.score_gain,
reason=f"{failed_provider}/{failed_model} failed - falling back to "
f"{decision.provider}/{decision.model}",
previous_provider=failed_provider, previous_model=failed_model,
def confirm_timeout(self) -> float:
"""Seconds to wait for a Manual-mode confirmation.
Falls back to the built-in default when the provider is missing or
returns something unusable, so a corrupted config value cannot produce a
zero-second dialog that instantly declines every switch.
"""
if self._confirm_timeout_sec is None:
return self.DEFAULT_CONFIRM_TIMEOUT_SEC
try:
value = float(self._confirm_timeout_sec())
except (TypeError, ValueError):
return self.DEFAULT_CONFIRM_TIMEOUT_SEC
return value if value > 0 else self.DEFAULT_CONFIRM_TIMEOUT_SEC
# -- internals ------------------------------------------------------- #
def _resolve_mode(self, surface: str) -> RoutingMode:
"""The surface's configured mode, defaulting to OFF when unresolvable —
routing stays opt-in, so "we don't know" must mean "don't switch"."""
if self._mode_resolver is None:
return RoutingMode.OFF
try:
return RoutingMode.parse(self._mode_resolver.mode_for(surface))
except Exception: # noqa: BLE001 — a config read must not break a turn
logger.exception("routing: could not resolve mode for surface %r", surface)
return RoutingMode.OFF
def _resolve_unguarded(
self,
request: RoutingRequest,
mode: RoutingMode,
confirm: Optional[ConfirmationCallback],
) -> RoutingOutcome:
"""The decision flow proper; :meth:`resolve` owns the safety net."""
# 1. Routing disabled, or nothing to classify -> keep the selection.
if mode is RoutingMode.OFF:
return RoutingOutcome.keep_current(mode, reason="routing off")
if not request.has_prompt:
return RoutingOutcome.keep_current(mode, reason="empty prompt — nothing to route")
# 2. Ask the engine. FALLBACK is evaluated with AUTO's ranking because
# it needs the same candidate list; only the accept/reject rule below
# differs, so the engine stays unaware of the extra mode.
engine_mode = RoutingMode.AUTO if mode is RoutingMode.FALLBACK else mode
evaluation = self._decision_port.evaluate(request, engine_mode)
# 3. Apply the mode's own accept rule to the engine's verdict.
if mode is RoutingMode.FALLBACK:
accepted, reason = self._fallback_verdict(evaluation)
else:
accepted, reason = evaluation.should_switch, evaluation.reason
if not accepted or not evaluation.has_target:
return RoutingOutcome.keep_current(
mode,
reason=reason or evaluation.reason,
task_type=evaluation.task_type,
decision=evaluation.decision,
)
# 4. Manual mode asks first; a decline or a timeout keeps the current
# model (and is reported as such, so the surface can tell the two
# cases apart from "nothing better was found").
if mode is RoutingMode.MANUAL and not self._approved(evaluation, confirm):
return RoutingOutcome.keep_current(
mode,
reason="switch declined by user or confirmation timed out",
task_type=evaluation.task_type,
declined=True,
decision=evaluation.decision,
)
# 5. Publish the override for THIS turn only. The provider falls back to
# the request's current provider when the engine named a model but no
# provider (same-provider switch).
return RoutingOutcome(
mode=mode,
switched=True,
provider=evaluation.target_provider or request.current_provider,
model=evaluation.target_model or "",
task_type=evaluation.task_type,
score_gain=evaluation.score_gain,
reason=reason or evaluation.reason,
decision=evaluation.decision,
)
# -- internals --------------------------------------------------------- #
def _read_mode(self, surface: str) -> str:
"""Per-surface mode from the injected reader ('off' when none supplied)."""
if self._mode_reader is None:
return RoutingMode.OFF.value
try:
return self._mode_reader(surface) or RoutingMode.OFF.value
except Exception: # noqa: BLE001 - a config read must not break a turn
return RoutingMode.OFF.value
@staticmethod
def _keep(mode: RoutingMode, provider: str, model: str, *, reason: str,
task_type: str = "", declined: bool = False) -> RoutingDecision:
"""A no-switch decision that still names the model to run on."""
return RoutingDecision(mode=mode, provider=provider, model=model, switched=False,
task_type=task_type, reason=reason, declined=declined,
previous_provider=provider, previous_model=model)
def _fallback_verdict(evaluation: RouteEvaluation) -> tuple:
"""FALLBACK's accept rule: switch ONLY to rescue an unusable selection.
@staticmethod
def _ask(confirm: ConfirmFn, proposal: RoutingDecision) -> bool:
"""Run the confirm callback, treating any failure as "declined".
The callback opens a modal dialog in production; if that raises (window
already closing, for instance) the safe answer is to keep the user's own
model rather than to switch without consent.
The user's pinned model wins as long as it can serve the turn, even when
a higher-scoring candidate exists — that is the whole point of the mode.
A switch happens only when the current model is not a usable candidate
(never assessed, marked unavailable, or its last probe failed) and the
engine has something to move to.
"""
if evaluation.current_is_usable:
return False, "fallback mode — current model is healthy, keeping it"
if not evaluation.has_target:
return False, "fallback mode — current model unusable and no replacement available"
return True, "fallback mode — current model unavailable, switching to the best alternative"
def _approved(
self,
evaluation: RouteEvaluation,
confirm: Optional[ConfirmationCallback],
) -> bool:
"""Run the Manual-mode confirmation callback.
No callback means no way to ask, and silently switching in Manual mode
would violate the mode's contract — so a missing callback is treated as
"not approved". A callback that raises is treated the same way, since a
broken dialog must not auto-approve a model change.
"""
if confirm is None:
logger.warning("routing: manual mode without a confirmation callback — keeping current model")
return False
try:
return bool(confirm(proposal))
return bool(confirm(evaluation.decision, self.confirm_timeout()))
except Exception: # noqa: BLE001
logger.exception("routing: confirmation callback failed — keeping current model")
return False
@staticmethod
def _to_decision(result: Any, mode: RoutingMode,
current_provider: str, current_model: str) -> RoutingDecision:
"""Translate the engine's route result into a :class:`RoutingDecision`.
Defensive about the result shape on purpose: this is the seam between the
new layer and a legacy module still under refactor, and a missing
attribute must degrade to "keep current model" instead of raising into
the middle of a chat turn.
"""
inner = getattr(result, "decision", None)
task_type = getattr(getattr(result, "task_type", None), "value", "") or ""
gain = float(getattr(inner, "score_gain", 0.0) or 0.0)
reason = str(getattr(inner, "reason", "") or "")
target = None
if getattr(result, "should_switch", False):
getter = getattr(result, "target", None)
target = getter() if callable(getter) else None
if not target:
return RoutingDecision(mode=mode, provider=current_provider, model=current_model,
switched=False, task_type=task_type, score_gain=gain,
reason=reason or "no better model - keeping current",
previous_provider=current_provider,
previous_model=current_model)
provider, model = target
return RoutingDecision(mode=mode, provider=provider or current_provider, model=model,
switched=True, task_type=task_type, score_gain=gain, reason=reason,
previous_provider=current_provider, previous_model=current_model)
__all__ = ["RoutingApplicationService", "RoutingDecision", "RoutingMode",
"RoutingPort", "normalize_mode", "is_valid_mode"]
__all__ = [
"ConfirmationCallback",
"ModeResolver",
"RoutingApplicationService",
"RoutingDecisionPort",
]
+158
View File
@@ -0,0 +1,158 @@
"""Pure-Python DTOs exchanged with :mod:`routing_application_service`.
These types are the vocabulary the chat surfaces (Cowork chat, Co4E, AI-Edit)
now speak instead of each re-deriving routing state from raw config lookups and
``core/routing`` internals.
Layer rules (``docs/architecture/ADR-001-layered-architecture.md``): application
code is 100% pure Python. Nothing here imports PySide6, and nothing here imports
``core.routing`` either — the concrete routing engine is reached only through
the adapter in :mod:`core_routing_adapter`, which keeps this module trivially
testable with plain fakes.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Optional, Tuple
class RoutingMode(str, Enum):
"""The four routing behaviours a surface can be in (R03-T03).
``OFF``/``AUTO``/``MANUAL`` map 1:1 onto the existing per-surface toggle and
onto ``core/routing/models.py::SwitchMode``. ``FALLBACK`` is new and
deliberately NOT an optimisation mode: it keeps whatever model the user
chose and only re-routes when that model cannot serve the turn, which is the
behaviour a resilience-minded workspace wants (never surprise me, but never
leave me stuck either).
"""
OFF = "off"
AUTO = "auto"
MANUAL = "manual"
FALLBACK = "fallback"
@classmethod
def parse(cls, raw: Any, default: "RoutingMode" = None) -> "RoutingMode":
"""Best-effort coercion from config/UI strings.
Routing must never break a turn, so an unrecognised value degrades to
``default`` (``OFF`` unless told otherwise) instead of raising — the same
defensive posture ``config.routing_mode_for`` already takes.
"""
fallback = default if default is not None else cls.OFF
if isinstance(raw, cls):
return raw
try:
return cls(str(raw or "").strip().lower())
except ValueError:
return fallback
@dataclass(frozen=True)
class RoutingRequest:
"""Everything needed to decide how ONE turn should be routed.
Frozen: the request is captured from live UI state (the selected model, the
typed prompt) and then handed to code that may run on a worker thread. An
immutable snapshot means the user changing the model picker mid-turn cannot
retroactively alter the decision that was already made — the same rationale
behind R04's ``ConversationExecutionRequest``.
"""
surface: str # "cowork" | "co4e" | "ai_edit" | ...
prompt: str # the user's text; drives task classification
current_provider: str # provider the surface would use as-is
current_model: str = "" # model the surface would use ("" = provider default)
mode: Optional[RoutingMode] = None # explicit override; None -> resolve per surface
# Pre-classified task type ("coding", "qa", ...). AI-Edit always knows its
# turns are coding work, so it pins this and skips prompt classification.
task_type: Optional[str] = None
required_capabilities: Tuple[str, ...] = () # e.g. ("vision",)
@property
def has_prompt(self) -> bool:
"""Whether there is anything to classify. An empty prompt cannot be
routed meaningfully, so every surface short-circuits on it."""
return bool((self.prompt or "").strip())
@dataclass(frozen=True)
class RouteEvaluation:
"""A routing engine's verdict, normalised away from ``core/routing`` types.
The adapter flattens ``RouteResult``/``SwitchDecision`` into these plain
fields so the application service never touches Pydantic models or enums
owned by another layer. ``decision`` still carries the original object
because the Manual-mode confirm dialog renders its ``reason``.
"""
task_type: str
should_switch: bool
target_provider: Optional[str] = None
target_model: Optional[str] = None
score_gain: float = 0.0
reason: str = ""
# False when the currently selected model is not a usable candidate for this
# task (unranked, unavailable, or failed its last probe) — the single signal
# FALLBACK mode acts on.
current_is_usable: bool = True
decision: Any = None # original SwitchDecision, for the UI dialog
@property
def has_target(self) -> bool:
"""A switch is only actionable when the engine named a model to move to."""
return bool(self.target_model or self.target_provider)
@dataclass(frozen=True)
class RoutingOutcome:
"""What the calling surface should actually do for this turn.
A surface needs exactly three things from routing — "which provider/model do
I build?", "do I tell the user?" and "was I told to stand down?" — so those
are the fields here, and nothing else. ``provider``/``model`` are ``None``
when the surface should keep its own selection untouched.
"""
mode: RoutingMode
switched: bool = False
provider: Optional[str] = None
model: Optional[str] = None
task_type: str = ""
score_gain: float = 0.0
reason: str = ""
# True when Manual mode proposed a switch and the user declined or the
# confirmation timed out. Distinct from "no switch proposed" so a surface
# can tell "routing had nothing to offer" from "the user said no".
declined: bool = False
decision: Any = field(default=None, repr=False)
@property
def should_notify(self) -> bool:
"""Whether the surface should post the "switched model" status bubble.
Only an executed switch is worth interrupting the transcript for."""
return self.switched
@classmethod
def keep_current(
cls,
mode: RoutingMode,
*,
reason: str = "",
task_type: str = "",
declined: bool = False,
decision: Any = None,
) -> "RoutingOutcome":
"""The no-change outcome — the single constructor for every path that
leaves the surface's own model selection in place (routing off, empty
prompt, no better candidate, user declined, internal error)."""
return cls(
mode=mode, switched=False, provider=None, model=None,
task_type=task_type, reason=reason, declined=declined, decision=decision,
)
__all__ = ["RoutingMode", "RoutingRequest", "RouteEvaluation", "RoutingOutcome"]
+1 -18
View File
@@ -1,18 +1 @@
"""Read-only query services for monitoring/dashboard screens (EPIC R08).
⚠️ Ownership note (R08-T13): per ``docs/refactor/Feature_Architecture_
Proposal.md``'s file-split diagram, ``dashboard_query_service.py`` lives
under ``application/monitoring/`` alongside the Dashboard split — but the
SAME document's "Ranh giới phân hệ" table assigns ``application/monitoring/``
to Team Nam (R08-T07→T10, Monitoring's own 8-tab split). This directory did
not exist yet when Team Hoa reached R08-T13, so creating it here does not
collide with any file Team Nam has written — same situation R06-T02 flagged
for ``infrastructure/persistence/json/atomic_write.py`` vs. Team Nam's
planned ``atomic_json_file.py``. Team Nam should confirm when they start
R08-T07→T10 whether ``DashboardQueryService`` belongs here permanently or
should move once Monitoring's own query service exists.
"""
from .dashboard_query_service import DashboardQueryService
__all__ = ["DashboardQueryService"]
"""Application monitoring package: Monitoring query service for audit and metrics."""
@@ -1,110 +0,0 @@
"""DashboardQueryService - read-only usage/cost queries for the Dashboard
screen (R08-T13, extracted from ``ui/dashboard_tab.py::DashboardTab``, lines
196-199/256-261/284-322 of the original 437-line file: ``_pricing``,
``_period_range``'s date-math, and the ``period_totals``/``period_breakdown``
calls ``_refresh_chart`` made directly).
``ui/dashboard_tab.py`` called ``core/usage_tracker.py``/``core/model_
pricing.py`` directly from FIVE different methods spread across what is now
three widgets (``token_usage_card_widget.py``, ``usage_chart_widget.py``,
``habits_widget.py``) — each recomputing the same merged pricing dict. This
service is the one place that merge happens now; the three widgets share it
instead of each calling ``core.usage_tracker``/``core.model_pricing`` on
their own.
Pure Python: no Qt. Wraps ``core/usage_tracker.py`` (a plain-Python module
already) rather than reimplementing any of its date/cost math.
"""
from __future__ import annotations
from datetime import date, timedelta
from typing import Any, Dict, List, Tuple
class DashboardQueryService:
"""Usage/cost queries scoped to one ``AppContext``.
Args:
ctx: ``AppContext`` — read for ``ctx.config`` (pricing table,
currency, budget) and nothing else; this class does no I/O of
its own beyond what ``core.usage_tracker`` already does.
"""
def __init__(self, ctx: Any) -> None:
self.ctx = ctx
def pricing(self) -> Dict[str, Any]:
"""The merged price table (defaults + user overrides), synced from
Monitoring's model-pricing table first so cost figures always agree
between the two screens."""
from cowork_local.core import model_pricing as mp
from cowork_local.core import usage_tracker as ut
mp.sync_to_usage(self.ctx.config)
return {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
def period_range(self, granularity: str, offset: int) -> Tuple[date, date]:
"""The SELECTED period as an inclusive ``(start, end)`` date range —
drives every widget on the screen (cards, chart, habits)."""
from cowork_local.core import usage_tracker as ut
start, end = ut.period_bounds(granularity, offset)
return start, end - timedelta(days=1) # load_events end is inclusive
def summary(self, start: date, end: date) -> Dict[str, Any]:
"""Everything the stat cards + habits panel need for one period:
the raw events, ``usage_tracker.summarize``'s aggregate stats, the
per-bucket costs, and their total — computed once so both widgets
read the same numbers instead of loading events twice."""
from cowork_local.core import usage_tracker as ut
events = ut.load_events(start, end)
pricing = self.pricing()
stats = ut.summarize(events)
costs = ut.cost_usd_events(events, pricing)
return {
"events": events,
"pricing": pricing,
"stats": stats,
"costs": costs,
"total_cost": sum(costs.values()),
}
def chart_series(self, granularity: str, offset: int, metric: str
) -> List[Tuple[str, float]]:
"""``(label, value)`` points for the spline chart — WEEK -> 7 days,
MONTH -> weeks, YEAR -> 12 months, in whichever ``metric``
("tokens" | "cost") was selected."""
from cowork_local.core import usage_tracker as ut
events = ut.load_events() # all events; breakdown slices by period
pricing = self.pricing()
parts = ut.period_breakdown(events, granularity, pricing, offset=offset)
mi = 0 if metric == "tokens" else 1 # (label, tokens, cost) -> +1 for the value
return [(row[0], float(row[mi + 1])) for row in parts]
def period_totals(self, granularity: str, offset: int) -> Tuple[float, float]:
"""``(tokens, cost)`` totals for one period — used to compute the
vs-previous-period delta the chart's reference line shows."""
from cowork_local.core import usage_tracker as ut
events = ut.load_events()
return ut.period_totals(events, granularity, self.pricing(), offset)
def period_range_label(self, granularity: str, offset: int) -> str:
from cowork_local.core import usage_tracker as ut
return ut.period_range_label(granularity, offset)
def budget_status(self):
from cowork_local.core import usage_tracker as ut
return ut.budget_status(self.ctx.config)
def set_budget(self, amount: float, currency: str) -> None:
from cowork_local.core import usage_tracker as ut
ut.set_budget(self.ctx.config, amount, currency)
__all__ = ["DashboardQueryService"]
+1 -6
View File
@@ -1,6 +1 @@
"""Application services for Schedule Task (EPIC R07)."""
from .ai_task_planner_service import AiTaskPlannerService
from .task_application_service import MoveResult, RunNowResult, TaskApplicationService
__all__ = ["TaskApplicationService", "RunNowResult", "MoveResult", "AiTaskPlannerService"]
"""Application scheduling package: TaskApplicationService and AI task planning."""
@@ -1,94 +0,0 @@
"""AiTaskPlannerService - AI-generate / import task lists, outside the widget
(R07-T05).
``ui/schedule_task_tab.py``'s ``_AiCreateDialog`` already delegates the
actual planning to two existing pure functions —
``core/ai_task_planner.py::plan_tasks`` (natural-language description ->
task dicts, via the active provider) and
``core/task_import.py::import_tasks`` (Excel/CSV/JSON -> task dicts) — so
this service does not reimplement either. What it DOES own is one small
piece of business logic that currently only exists inside the dialog's
``AgentWorker`` job closure (``_generate``'s ``job()``): every AI-generated
task must carry the SAME file/link attachments the user attached to the
request, so they're available again at run time, not just visible to the
planner while it drafts the task list. Leaving that step trapped in a Qt
worker closure means it can only be exercised by driving the real dialog;
here it's a plain, independently testable method.
Pure Python: no Qt import. The provider is a constructor-injected factory
(``() -> Provider``, no arguments — matches ``AppContext.build_active_
provider``), the same dependency-inversion shape
``application/conversations/conversation_application_service.py`` (R04-T03)
uses for ITS provider factory.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Sequence, Union
ProviderFactory = Callable[[], Any]
CancelFn = Callable[[], bool]
class AiTaskPlannerService:
"""AI task generation + file/Excel/CSV/JSON import, for
``presentation/scheduling/ai_task_creator_dialog.py`` and
``ai_task_import_dialog.py`` (R08-T11) to call instead of importing
``core.ai_task_planner``/``core.task_import`` directly.
Args:
provider_factory: ``() -> Provider``. Production passes
``AppContext.build_active_provider``; tests pass a lambda
returning a :class:`FakeProvider`.
"""
def __init__(self, provider_factory: Optional[ProviderFactory] = None) -> None:
self._provider_factory = provider_factory
def plan(
self,
description: str,
*,
file_paths: Sequence[str] = (),
links: Sequence[str] = (),
provider: Any = None,
cancel: Optional[CancelFn] = None,
) -> List[Dict[str, Any]]:
"""Turn ``description`` into a list of NOT-yet-saved task dicts.
``provider`` overrides the constructor's factory for this one call
(useful for tests, or a caller that already resolved a provider);
omit it to use the injected factory. Raises ``RuntimeError`` when
no provider is available at all, or when the model's reply had no
parseable task list (same error ``core.ai_task_planner.plan_tasks``
already raises).
"""
resolved = provider if provider is not None else self._resolve_provider()
from cowork_local.core.ai_task_planner import plan_tasks
planned = plan_tasks(resolved, description, cancel=cancel)
# Attachments apply to EVERY generated task so they're still there
# when the task actually runs, not just while the planner drafts it
# (see module docstring — this used to only happen inside the
# dialog's worker closure).
for task in planned:
task["input"]["file_paths"] = list(file_paths)
task["input"]["links"] = list(links)
return planned
def import_file(self, path: Union[str, Path]) -> List[Dict[str, Any]]:
"""Excel/CSV/JSON -> NOT-yet-saved task dicts, auto-chained in file
order. Raises ``ValueError`` with a human-readable message on an
unusable/unsupported file (same contract
``core.task_import.import_tasks`` already has)."""
from cowork_local.core.task_import import import_tasks
return import_tasks(path)
def _resolve_provider(self) -> Any:
if self._provider_factory is None:
raise RuntimeError("No provider available to plan tasks.")
return self._provider_factory()
__all__ = ["AiTaskPlannerService"]
@@ -1,172 +0,0 @@
"""TaskApplicationService - task CRUD + dispatch, outside the widget (R07-T04).
``ui/schedule_task_tab.py`` currently does all of this by importing
``core/tasks.py`` module functions directly and calling
``self.scheduler.run_now(...)`` inline inside Qt slot methods
(``_run_now``, ``_context_menu``'s duplicate/pause/delete branches,
``_on_task_dropped``'s per-lane business rules). None of it is Qt — it's
plain CRUD plus a few small rules ("a manual task never auto-runs",
"dropping a card on Done disables its schedule so it won't re-fire",
"dropping on Scheduled with no time set needs the editor, not a silent
no-op") — but it can only be exercised today by driving the real widget.
This service is the seam ``presentation/scheduling/kanban_board_widget.py``
(R08-T11) calls instead: same rules, same
:class:`~infrastructure.persistence.json.task_repository_impl.TaskRepository`
underneath, testable with no Qt at all.
Pure Python: no Qt import. ``run_now`` dispatch is a plain injected callable
(production wires ``TaskScheduler.run_now``; tests inject a stub), the same
constructor-injection shape ``application/conversations/conversation_
application_service.py`` (R04-T03) uses for its provider factory.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional
# "TaskRepository" here is a Protocol-shaped name, not an import: this module
# only calls .get/.save/.delete/.duplicate, so any object with that shape
# (the real infrastructure.persistence.json.task_repository_impl.TaskRepository,
# or a test double) works without this file importing infrastructure/ at
# module scope.
RunNowFn = Callable[[str], bool]
@dataclass
class RunNowResult:
"""Outcome of asking a task to run immediately.
``reason`` is one of ``""`` (ok), ``"not_found"``, ``"manual_task"``
(manual tasks never auto-run — spec: they exist to be run by a human),
``"no_scheduler"`` (no ``run_now`` callable was wired in), or
``"already_running"`` (the scheduler's own dedupe rejected it).
"""
ok: bool
reason: str = ""
@dataclass
class MoveResult:
"""Outcome of dropping a task card onto a Kanban lane
(``move_to_status``). The caller (kanban widget) uses the flags to decide
what to show — a full re-render, a "task is running" toast, or opening
the task editor — without re-deriving the business rule itself."""
task: Optional[Dict[str, Any]]
blocked: bool = False # dropped while already running — ignored
ran_now: bool = False # dropped on the Running lane — dispatched
run_now_result: Optional[RunNowResult] = None
needs_schedule: bool = False # dropped on Scheduled with no run_at set — needs editing
class TaskApplicationService:
"""CRUD + dispatch for Schedule Task, backed by a ``TaskRepository``.
Args:
repository: a ``TaskRepository``-shaped object (``.get``, ``.save``,
``.delete``, ``.duplicate``). Production passes
``infrastructure.persistence.json.task_repository_impl.
TaskRepository()``; tests pass one scoped to a ``tmp_path``.
run_now: ``(task_id) -> bool``. Production passes
``TaskScheduler.run_now``; ``None`` means no scheduler is wired
(matches the widget's own "no scheduler" guard today).
"""
def __init__(self, repository: Any, run_now: Optional[RunNowFn] = None) -> None:
self._repository = repository
self._run_now = run_now
# -- single-task actions ------------------------------------------------ #
def run_now(self, task_id: str) -> RunNowResult:
"""Dispatch ``task_id`` immediately. A "Run now" always counts as
manual approval (spec §13) — this is the ONE path that bypasses
``execution.requires_approval``, same as the scheduler's own
``run_now`` already does."""
task = self._repository.get(task_id)
if task is None:
return RunNowResult(False, "not_found")
if task.get("task_type") == "manual":
return RunNowResult(False, "manual_task")
if self._run_now is None:
return RunNowResult(False, "no_scheduler")
ok = self._run_now(task_id)
return RunNowResult(ok, "" if ok else "already_running")
def duplicate(self, task_id: str) -> Optional[Dict[str, Any]]:
"""A saved copy with a fresh identity — see
``core/tasks.py::duplicate_task`` for what's preserved/reset."""
task = self._repository.get(task_id)
if task is None:
return None
dup = self._repository.duplicate(task)
self._repository.save(dup)
return dup
def toggle_pause(self, task_id: str) -> Optional[Dict[str, Any]]:
"""Pause a task, or resume a paused one back to Backlog (matches
``ui/schedule_task_tab.py``'s context-menu action exactly — resuming
does NOT restore whatever status the task had before pausing, only
Backlog, so the user re-schedules explicitly rather than a stale
schedule silently re-firing)."""
task = self._repository.get(task_id)
if task is None:
return None
task["status"] = "backlog" if task.get("status") == "paused" else "paused"
self._repository.save(task)
return task
def delete(self, task_id: str) -> bool:
if self._repository.get(task_id) is None:
return False
self._repository.delete(task_id)
return True
def bulk_delete(self, task_ids: List[str]) -> int:
"""Delete every id in ``task_ids``; returns how many actually
existed (mirrors ``_confirm_and_delete_selected``'s best-effort
loop — a stale id in the selection doesn't abort the rest)."""
return sum(1 for tid in task_ids if self.delete(tid))
# -- Kanban drag/drop ----------------------------------------------------- #
def move_to_status(self, task_id: str, new_status: str) -> Optional[MoveResult]:
"""Apply the business rule behind dropping a card into a lane
(``ui/schedule_task_tab.py::_on_task_dropped``, moved here so it's
testable without a real ``QListWidget`` drag gesture):
* already running -> the drop is ignored (a running task can't be
re-filed by dragging it).
* dropped on Running -> runs it now (counts as manual approval).
* dropped on Done -> marks it done AND disables its schedule, so a
repeating task marked done by hand doesn't quietly re-fire later.
* dropped on Scheduled with no ``run_at`` set yet -> saved as-is but
flagged ``needs_schedule`` — the caller should open the editor
rather than leave a Scheduled card that will never actually run.
* anything else -> plain status change.
"""
task = self._repository.get(task_id)
if task is None:
return None
if task.get("status") == "running":
return MoveResult(task=task, blocked=True)
if new_status == "running":
result = self.run_now(task_id)
return MoveResult(task=self._repository.get(task_id), ran_now=True, run_now_result=result)
if new_status == "done":
task["status"] = "done"
task["schedule"]["enabled"] = False
self._repository.save(task)
return MoveResult(task=task)
task["status"] = new_status
if new_status == "scheduled" and not task["schedule"].get("enabled"):
if task["schedule"].get("run_at"):
task["schedule"]["enabled"] = True
else:
self._repository.save(task)
return MoveResult(task=task, needs_schedule=True)
self._repository.save(task)
return MoveResult(task=task)
__all__ = ["TaskApplicationService", "RunNowResult", "MoveResult"]
+1
View File
@@ -0,0 +1 @@
"""Application settings package: Settings application service."""
+1
View File
@@ -0,0 +1 @@
"""Application workflows package: Co4E graph execution orchestration."""
+1 -17
View File
@@ -1,17 +1 @@
"""Workspace file operations for non-agent-loop callers (EPIC R06, R08)."""
from .ai_edit_output import parse_ai_output, split_code_block
from .file_preview_helpers import is_probably_text, pptx_available, read_text
from .file_workspace_service import FileWorkspaceService
from .graph_index_service import extract_file_contents, pdf_to_markdown
__all__ = [
"FileWorkspaceService",
"read_text",
"is_probably_text",
"pptx_available",
"split_code_block",
"parse_ai_output",
"pdf_to_markdown",
"extract_file_contents",
]
"""Application workspaces package: File workspace and AI file editor services."""
-40
View File
@@ -1,40 +0,0 @@
"""Parse an AI file-edit reply into its parts (R08-T12, moved out of
``ui/folder_tab.py`` — that file's module-level ``_split_code_block``/
``_parse_ai_output``, lines 1536-1562 of the original 1587-line file). Pure
string parsing, no Qt — used by ``presentation/folder/ai_file_editor_dialog.py``
to turn a model's raw reply into a proposed edit.
"""
from __future__ import annotations
import re
from typing import List, Optional, Tuple
def split_code_block(text: str) -> Tuple[Optional[str], str]:
"""Split an AI reply into ``(file_content, summary)``. ``file_content``
is the first fenced code block (the edited file); ``summary`` is any
prose before it. Returns ``(None, text)`` when there's no code block."""
m = re.search(r"```[^\n]*\n(.*?)```", text or "", re.DOTALL)
if not m:
return None, (text or "")
return m.group(1), (text[:m.start()].strip())
def parse_ai_output(text: str) -> Tuple[Optional[str], Optional[str], str, List[Tuple[str, str]]]:
"""Parse an AI edit reply into ``(target, content, summary, image_gens)``.
``FILE: <path>`` names a NEW file to create; ``IMAGE_GEN: <prompt> =>
<path>`` lines request generated illustration images (relative paths)."""
content, summary = split_code_block(text)
target = None
m = re.search(r"(?mi)^\s*FILE:\s*(.+?)\s*$", text or "")
if m:
target = m.group(1).strip().strip("`\"'")
image_gens = []
for gm in re.finditer(r"(?mi)^\s*IMAGE_GEN:\s*(.+?)\s*=>\s*(\S+)\s*$", text or ""):
image_gens.append((gm.group(1).strip(), gm.group(2).strip().strip("`\"'")))
# Strip the directive lines out of the shown summary.
summary = re.sub(r"(?mi)^\s*(FILE|IMAGE_GEN):\s*.+?$", "", summary).strip()
return target, content, summary, image_gens
__all__ = ["split_code_block", "parse_ai_output"]
@@ -1,54 +0,0 @@
"""Pure helpers for previewing a file (R08-T12, moved out of
``ui/folder_tab.py`` — that file's module-level functions
``_read_text``/``_is_probably_text``/``_pptx_available``, lines 1519-1533 and
1565-1587 of the original 1587-line file). No Qt, no widget state — the
"is this file text? is pptx editing available?" questions the preview
manager asks before it decides how to render something.
"""
from __future__ import annotations
from pathlib import Path
_PPTX_READY = None # cached: pptx-editing library available (after auto-install)
def pptx_available() -> bool:
"""True when python-pptx is importable. If it's MISSING, auto-download &
install it (via deps.ensure_module) so pptx editing 'just works' — cached
so the (one-time) install is attempted only once."""
global _PPTX_READY
if _PPTX_READY is None:
try:
from cowork_local.core.deps import ensure_module
_PPTX_READY = ensure_module("pptx", "python-pptx") is not None
except Exception: # noqa: BLE001
_PPTX_READY = False
return _PPTX_READY
def read_text(path: str) -> str:
try:
return Path(path).read_text(encoding="utf-8", errors="replace")
except OSError as exc:
return f"[could not read file: {exc}]"
def is_probably_text(path: str) -> bool:
try:
with open(path, "rb") as f:
chunk = f.read(4096)
except OSError:
return False
if b"\x00" in chunk:
return False
try:
chunk.decode("utf-8")
return True
except UnicodeDecodeError:
# Latin-ish text still edits fine via errors="replace"; only reject on
# a hard binary signal (NUL above), so most source files pass.
return True
__all__ = ["pptx_available", "read_text", "is_probably_text"]
@@ -1,81 +0,0 @@
"""FileWorkspaceService - the safe file operations File Explorer and the AI
File Editor need, outside the agent tool loop (R06-T05).
``ui/folder_tab.py`` (File Explorer) and the AI File Editor dialog need the
exact same guarantees the agent's tools already have — path containment
inside the workspace, precise context-anchored edits, syntax warnings on a
bad Python write — but today that logic only exists wired to a model's tool
call (``core/tools.py::execute_tool``). A UI action that isn't a tool call
(browsing the tree, applying an AI-suggested diff from a review dialog) has
no equivalent entry point of its own.
This service IS that entry point. It reuses ``core/tools.py::execute_tool``
verbatim - same dispatch table, same ``ToolContext`` containment check, same
audit-log entry, same Python-syntax warning on write/edit - rather than
re-implementing any of it, so a fix to one path fixes both. It only adds the
:class:`~domain.workspaces.workspace_session.WorkspaceSession` seam: which
workspace root a call is scoped to is decided by the session, not by
whichever folder a widget happens to have open.
"""
from __future__ import annotations
from typing import Any, Dict
class FileWorkspaceService:
"""File operations scoped to one :class:`WorkspaceSession`.
Read-only by name (``list_tree``/``read_preview``) vs. writing
(``write_file``/``apply_edit``) mirrors the same READ/WRITE split
``domain/tools/tool_registry.py`` uses for the agent's own tools - a
caller that only wants to browse never accidentally has write access.
"""
def __init__(self, session) -> None: # WorkspaceSession - see module docstring
self._session = session
def list_tree(self, rel: str = ".") -> Dict[str, Any]:
"""Entries at ``rel`` (default: the workspace root)."""
return self._execute("list_dir", {"path": rel})
def read_preview(self, rel: str) -> Dict[str, Any]:
"""A text file's content (truncated by
``infrastructure/filesystem/file_tools.py::MAX_READ_BYTES``, same as
the agent's ``read_file`` tool)."""
return self._execute("read_file", {"path": rel})
def write_file(self, rel: str, content: str) -> Dict[str, Any]:
"""Create or fully overwrite ``rel``."""
return self._execute("write_file", {"path": rel, "content": content})
def apply_edit(self, rel: str, old_string: str, new_string: str,
replace_all: bool = False) -> Dict[str, Any]:
"""Replace an exact snippet in an existing file - the same
context-anchored algorithm the agent's ``edit_file`` tool uses, so an
AI-suggested diff applies with the same precision and the same
"old_string not found / ambiguous" failure messages either path
would give the caller."""
return self._execute("edit_file", {
"path": rel, "old_string": old_string, "new_string": new_string,
"replace_all": replace_all,
})
# -- internals --------------------------------------------------------- #
def _tool_context(self):
"""A ``ToolContext`` scoped to this session's workspace root.
``flatten_writes=False`` (unlike Cowork's agent context) - File
Explorer must preserve whatever subfolder structure the user is
actually browsing, not collapse every write into the root."""
from cowork_local.infrastructure.filesystem.tool_context import ToolContext
return ToolContext(self._session.workspace_root, flatten_writes=False)
def _execute(self, name: str, args: Dict[str, Any]) -> Dict[str, Any]:
"""Dispatch through ``core/tools.py::execute_tool`` - see the module
docstring for why this delegates instead of reimplementing."""
from cowork_local.core.tools import execute_tool
return execute_tool(self._tool_context(), name, args)
__all__ = ["FileWorkspaceService"]
@@ -1,91 +0,0 @@
"""Temporary file-content extraction for Graph-RAG Q&A (R08-T14, moved out
of ``ui/structure_graph_view.py`` — that file's module-level
``_pdf_to_markdown``/``_extract_file_contents``, lines 964-1034 of the
original 1035-line file). Runs inside the ask worker's job function so the
answer is synthesized from real file content, not just the graph structure.
Pure Python: no Qt. Best-effort throughout (never raises) — a failed
extraction degrades to "no content for this file", not a broken Q&A turn.
"""
from __future__ import annotations
from pathlib import Path
from typing import Dict, List, Optional, Tuple
def pdf_to_markdown(pdf_path: str, out_dir: str) -> Optional[str]:
"""Convert a PDF to Markdown with opendataloader-pdf when available
(richer structure than a plain text dump). Best-effort — returns None
if the package isn't installed or the call fails, so the caller falls
back to ``core/doc_extract.py``."""
try:
import opendataloader_pdf # optional; auto-installed elsewhere if present
except Exception: # noqa: BLE001
try:
from cowork_local.core.deps import ensure_module
if ensure_module("opendataloader_pdf", "opendataloader-pdf") is None:
return None
import opendataloader_pdf # noqa: F811
except Exception: # noqa: BLE001
return None
out = Path(out_dir)
out.mkdir(parents=True, exist_ok=True)
for call in (
lambda: opendataloader_pdf.convert(input_path=[str(pdf_path)], output_dir=str(out),
generate_markdown=True),
lambda: opendataloader_pdf.convert(input_path=str(pdf_path), output_dir=str(out)),
lambda: opendataloader_pdf.convert(str(pdf_path), str(out)),
):
try:
call()
break
except TypeError:
continue
except Exception: # noqa: BLE001
return None
mds = list(out.rglob(Path(pdf_path).stem + "*.md")) or list(out.rglob("*.md"))
for md in mds:
try:
return md.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
return None
def extract_file_contents(paths: List[str], cache: Dict[str, str], tmp_dir: str,
max_files: int = 15, max_total: int = 120_000
) -> Tuple[str, Dict[str, str]]:
"""Read the ACTUAL content of ``paths`` (PDF -> markdown via
opendataloader when available, else ``doc_extract`` for office/pdf/
text). Returns ``(block, cache)`` — ``block`` is the concatenated
content for the prompt (bounded), ``cache`` maps path -> text for
reuse. Never raises."""
from cowork_local.core import doc_extract
cache = dict(cache or {})
parts, total = [], 0
for p in paths[:max_files]:
if total >= max_total:
break
text = cache.get(p)
if text is None:
try:
if Path(p).suffix.lower() == ".pdf":
text = pdf_to_markdown(p, tmp_dir)
if not text:
text, _n = doc_extract.extract_text(p)
else:
text, _n = doc_extract.extract_text(p)
except Exception: # noqa: BLE001
text = ""
cache[p] = text or ""
text = cache.get(p) or ""
if not text:
continue
chunk = text[: max(0, max_total - total)]
total += len(chunk)
parts.append(f'--- {Path(p).name} ({p}) ---\n{chunk}')
return ("\n\n".join(parts), cache)
__all__ = ["pdf_to_markdown", "extract_file_contents"]
+18 -13
View File
@@ -105,7 +105,7 @@ DEFAULT_CONFIG: Dict[str, Any] = {
# sandboxes agent-run shell commands) — reading a URL for info is safe and
# useful, so this defaults ON. Toggle in Settings → Security.
"allow_url_fetch": True,
"sandbox_pw": "quandh14", # default password to unlock sandbox settings
"sandbox_pw": "", # set through COWORK_SANDBOX_PASSWORD
"rulebase_path": "", # custom RULEBASE.md — attached to every agent execution
},
# 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
# proper OAuth sign-in (not implemented yet) using tenant_id/client_id below.
"ms365": {
"unlock_code": "quandh14",
"unlock_code": "", # set through COWORK_MS365_UNLOCK_CODE
"unlocked": False, # runtime-only — never persisted as True, see save()
# Auto-connect MS365/OneDrive/SharePoint: the built-in MS365 MCP server
# launches automatically once the user is signed in (OAuth tenant/client
@@ -294,6 +294,10 @@ def _apply_env_overrides(data: Dict[str, Any]) -> Dict[str, Any]:
data["active_provider"] = os.environ["COWORK_ACTIVE_PROVIDER"]
if os.getenv("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
@@ -551,26 +555,27 @@ class AppConfig:
d["surface_modes"].setdefault(surface, "")
return d
# The routing modes a surface may be in. "fallback" joined the set in
# R03-T03 (keep the selected model; re-route only when it cannot serve the
# turn) — see application/model_routing/routing_models.py::RoutingMode,
# which is the authority on what each mode means.
ROUTING_MODES = ("off", "auto", "manual", "fallback")
def routing_mode_for(self, surface: str) -> str:
"""Effective Off/Auto/Manual/Fallback mode for a chat surface.
A per-surface override wins; an empty override falls back to the global
``switch_mode``. The value is validated through
``application.model_routing.normalize_mode`` so the accepted vocabulary
is defined in exactly one place (R03-T03) - it used to be a literal
tuple repeated here and in state.py, and adding a mode to one copy but
not the others silently downgraded the user's choice to "off"."""
from .application.model_routing import normalize_mode
``switch_mode``. Anything unrecognised degrades to "off" so routing
stays opt-in even with a hand-edited config."""
routing = self.routing
override = (routing.get("surface_modes", {}) or {}).get(surface, "")
return normalize_mode(override or routing.get("switch_mode", "off"))
mode = override or routing.get("switch_mode", "off")
return mode if mode in self.ROUTING_MODES else "off"
def set_routing_mode_for(self, surface: str, mode: str) -> None:
"""Persist a chat surface's routing toggle selection."""
from .application.model_routing import normalize_mode
self.routing.setdefault("surface_modes", {})[surface] = normalize_mode(mode)
mode = mode if mode in self.ROUTING_MODES else "off"
self.routing.setdefault("surface_modes", {})[surface] = mode
self.save()
@property
+10 -46
View File
@@ -11,8 +11,6 @@ import re
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
from ..application.conversations.tool_policy_gateway import ToolPolicyGateway
from ..domain.tools import ToolCapability, default_registry
from ..providers.base import Provider, ToolSpec
from . import agent_roles
from . import agent_security
@@ -29,13 +27,6 @@ from .tools import TOOL_SPECS, ToolContext, _snapshot, describe_action, execute_
# Generator / helper scripts — never a final deliverable in Cowork's output.
_SCRIPT_EXTS = {".py", ".pyw", ".js", ".mjs", ".cjs", ".ts", ".sh", ".bat", ".ps1", ".rb", ".pl"}
# R05-T03/T04: replaces the literal ``name in ("run_command",
# "install_package")`` check below with a capability lookup — EXECUTE is
# exactly the capability those two (and only those two) built-in tools carry
# (see domain/tools/tool_registry.py::BUILT_IN_CAPABILITIES). Copied per-turn
# into ``turn_tool_policy`` inside run_cowork() once extra_tools are known.
_COWORK_TOOL_REGISTRY = default_registry(TOOL_SPECS)
EmitFn = Callable[[Dict[str, Any]], None]
CancelFn = Callable[[], bool]
@@ -397,19 +388,6 @@ def run_cowork(
jira=(security_config.data.get("jira") if security_config else None))
extra_tools = extra_tools or []
extra_names = {t.name for t in extra_tools}
# R05-T04: MCP servers (core/mcp_client.py) and unified connectors
# (core/ext_connectors.py) — everything that arrives here as extra_tools —
# advertise no standard risk metadata, so each is tagged with the same
# conservative default (WRITE|EXECUTE|NETWORK) domain/tools/tool_registry.py
# uses for any unclassified tool. Copying the built-in registry per turn
# (cheap - under 20 entries) rather than mutating the shared module-level
# one keeps different turns' extra_tools from leaking into each other.
from ..domain.tools import ToolDescriptor, ToolRegistry
from ..domain.tools.tool_registry import UNKNOWN_SOURCE_CAPABILITIES
_turn_registry = ToolRegistry(_COWORK_TOOL_REGISTRY.all())
for _spec in extra_tools:
_turn_registry.register(ToolDescriptor.from_spec(_spec, UNKNOWN_SOURCE_CAPABILITIES))
turn_tool_policy = ToolPolicyGateway(_turn_registry, ToolCapability.EXECUTE)
# update_plan drives the Plan panel (above Output); it produces no file.
# Built-in tools the admin disabled (Monitoring → Tools) are filtered out.
from .tools import enabled_tool_specs
@@ -511,18 +489,6 @@ def run_cowork(
preview = {"kind": "info", "title": name, "text": str(args)}
emit({"type": "tool_proposed", "id": tc_id, "name": name, "args": args,
"preview": preview})
# R05-T04: MCP/connector tools used to run with NO permission
# check at all — this is what closes that gap. Same policy,
# same gate object as the built-in tools below.
if not turn_tool_policy.allow(
name, gate, {"name": name, "args": args, "preview": preview}
):
result = {"ok": False, "output": "Rejected by user."}
emit({"type": "tool_result", "id": tc_id, "name": name,
"ok": False, "output": result["output"]})
messages.append({"role": "tool", "tool_call_id": tc_id, "name": name,
"content": result["output"]})
continue
result = extra_executor(name, args)
emit({"type": "tool_result", "id": tc_id, "name": name,
"ok": result.get("ok", False), "output": result.get("output", "")})
@@ -562,18 +528,16 @@ def run_cowork(
# Permission Management (Sandbox Security Layer) — only when a
# gate was actually supplied (Settings: "confirm before running
# commands"); None preserves the pre-existing auto-run behavior.
# R05-T03: gating is now capability-driven (see
# turn_tool_policy above) instead of a literal name tuple.
if not turn_tool_policy.allow(
name, gate, {"name": name, "args": args, "preview": preview}
):
result = {"ok": False, "output": "Rejected by user."}
evt = {"type": "tool_result", "id": tc_id, "name": name,
"ok": False, "output": result["output"]}
emit(evt)
messages.append({"role": "tool", "tool_call_id": tc_id,
"name": name, "content": result["output"]})
continue
if gate is not None and name in ("run_command", "install_package"):
approved = gate.request({"name": name, "args": args, "preview": preview})
if not approved:
result = {"ok": False, "output": "Rejected by user."}
evt = {"type": "tool_result", "id": tc_id, "name": name,
"ok": False, "output": result["output"]}
emit(evt)
messages.append({"role": "tool", "tool_call_id": tc_id,
"name": name, "content": result["output"]})
continue
if name == "save_file":
result = _do_save_file(output_dir, title, args)
+4 -15
View File
@@ -12,8 +12,6 @@ import re
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
from ..application.conversations.tool_policy_gateway import ToolPolicyGateway
from ..domain.tools import ToolCapability, ToolDescriptor, ToolRegistry
from ..providers.base import Provider
from . import agent_roles
from . import agent_security
@@ -227,14 +225,6 @@ def run_code(
# read/list ms365 tools count as "read-only, never confirm". Names are
# the MCP-qualified "ms365__*" form the agent sees (see ms365_tools.py).
gated_tools = WRITE_TOOLS | MS365_WRITE_TOOLS
# R05-T03/T04: ``gated_tools`` stays the authoritative name set (unchanged),
# but the actual confirm decision now goes through the same
# ToolPolicyGateway class run_cowork uses, instead of a separate
# hand-rolled ``if name in gated_tools`` + direct ``gate.request(...)``.
code_tool_policy = ToolPolicyGateway(
ToolRegistry(ToolDescriptor(n, "", {}, ToolCapability.WRITE) for n in gated_tools),
ToolCapability.WRITE,
)
# In PLAN mode, don't advertise write/run tools (analysis only).
advertised = [t for t in all_tools if t.name not in gated_tools] if plan else all_tools
has_memory = any(t.name.startswith("cmem_") for t in extra_tools)
@@ -307,11 +297,10 @@ def run_code(
agent_security.enforce_command(provider, name, args, security_config, emit,
agent_kind="code")
# read-only tools (incl. codebase memory) never consult the gate —
# code_tool_policy.requires_confirmation(name) is False for them.
approved = code_tool_policy.allow(
name, gate, {"id": tc_id, "name": name, "args": args, "preview": preview}
)
if name in gated_tools:
approved = gate.request({"id": tc_id, "name": name, "args": args, "preview": preview})
else:
approved = True # read-only tools (incl. codebase memory) never confirm
if cancel():
return messages
+3 -9
View File
@@ -66,9 +66,7 @@ def save_conversation(
"outputs": list(outputs or []),
"messages": messages,
}
# R06-T02: atomic write - see infrastructure/persistence/json/atomic_write.py.
from ..infrastructure.persistence.json.atomic_write import write_json
write_json(path, payload)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
return path
@@ -80,19 +78,15 @@ def delete_conversation(path) -> None:
def rename_conversation(path, new_title: str) -> None:
from ..infrastructure.persistence.json.atomic_write import write_json
data = load_conversation(path)
data["title"] = new_title
write_json(Path(path), data)
Path(path).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
def set_pinned(path, pinned: bool) -> None:
from ..infrastructure.persistence.json.atomic_write import write_json
data = load_conversation(path)
data["pinned"] = bool(pinned)
write_json(Path(path), data)
Path(path).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
def load_conversation(path: Path) -> Dict[str, Any]:
-7
View File
@@ -106,13 +106,6 @@ class McpServerConnection:
if self._thread is not None:
self._thread.join(timeout=5)
def is_alive(self) -> bool:
"""True while the connection's background thread (and therefore its
event loop and subprocess) is still running — used by
``infrastructure/mcp/mcp_source_manager.py`` (R05-T05) to tell a live
cached connection from one whose subprocess already died."""
return self._thread is not None and self._thread.is_alive()
# ---- tools -----------------------------------------------------------
def list_tool_specs(self) -> List[ToolSpec]:
"""The server's tools, wrapped as :class:`ToolSpec` — the same shape
+3 -5
View File
@@ -116,12 +116,10 @@ def new_project(name: str, description: str = "", instructions: str = "",
def save_project(project: Project, directory: Path = None) -> Path:
directory = directory or PROJECTS_DIR
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{project.project_id}.json"
# R06-T02: atomic write — a crash/kill between truncate and write used to
# leave a half-written project.json that load_project() then silently
# treats as "missing" (see infrastructure/persistence/json/atomic_write.py).
from ..infrastructure.persistence.json.atomic_write import write_json
write_json(path, asdict(project))
path.write_text(json.dumps(asdict(project), ensure_ascii=False, indent=2),
encoding="utf-8")
return path
+70 -52
View File
@@ -2,7 +2,9 @@
``execute_task`` dispatches by ``task_type`` to the app's existing engines:
- ``cowork`` → ``chat_agent.run_cowork`` (documents/answers, real files)
- ``cowork`` → ``ConversationApplicationService`` (documents/answers, real
files) — the same turn engine the interactive Cowork chat
runs on since R04-T05
- ``co4e_code`` → ``code_agent.run_code`` (code agent with file/command tools)
- ``script`` → local subprocess with a timeout
- ``flow`` → the task's own simple step list, run sequentially, each
@@ -162,6 +164,30 @@ _TIMEOUT_NOTICE_TMPL = (
)
_UNATTENDED_PREFIX = (
"This runs unattended (Schedule Task) — no one is watching live. Use "
"update_plan to track your steps and keep it accurate: mark a step "
"'error' (not silently skip it) if it genuinely can't be completed."
)
def _unattended_prompt(prompt: str, *, skill_text: str = "",
agent_instructions: str = "") -> str:
"""Assemble the user message an unattended run sends.
The order is load-bearing and used to be encoded as three successive
rebindings of ``prompt``, each prepending its own block: the plan reminder
must lead (it is the instruction that keeps a run without a human watching
honest), then the chosen skill's rules, then the Admin agent's persona, and
the task's own words last. Routing it through ``combine_instructions`` keeps
that order in one readable expression and drops the absent blocks instead of
leaving blank lines behind.
"""
from ..application.conversations.turn_runtime import combine_instructions
return combine_instructions(_UNATTENDED_PREFIX, skill_text, agent_instructions, prompt)
def _cancel_with_timeout(cancel: CancelFn, timeout_sec: Optional[int]) -> Tuple[CancelFn, Callable[[], bool]]:
"""Wrap ``cancel`` so it also fires once ``timeout_sec`` of wall-clock time
elapses. ``timed_out()`` tells the caller whether THAT is why it stopped
@@ -218,64 +244,32 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
# default, see state.build_provider_for). A legacy Admin-agent preset
# (task.admin_agent_id), if still set on an older task, keeps working and
# takes precedence — it pins the provider/model AND prepends instructions.
agent_instructions = ""
if admin_agent is not None:
from .admin_agents import build_agent_provider
provider = build_agent_provider(ctx, admin_agent)
agent_instructions = admin_agent.effective_prompt()
if agent_instructions:
prompt = f"{agent_instructions}\n\n{prompt}"
elif provider_name or model:
# An explicit per-task provider/model override.
provider = ctx.build_provider_for(provider_name or None, model or None)
else:
# Neither overridden → the machine's own Settings default, exactly as before.
provider = ctx.build_active_provider()
# A chosen skill's instructions are prepended so this unattended run follows
# A chosen skill's instructions are applied so this unattended run follows
# them, mirroring how the interactive chat applies /skill.
skill_text = ""
if skill_slug:
from .skills import skill_prefix_for
skill_text = skill_prefix_for(skill_slug)
if skill_text:
prompt = f"{skill_text}\n\n{prompt}"
# This is an UNATTENDED run (no human watching to catch a half-finished
# job) — push the agent to actually use the Plan checklist so completion
# can be verified afterward, instead of just trusting "no exception".
prompt = (
"This runs unattended (Schedule Task) — no one is watching live. Use "
"update_plan to track your steps and keep it accurate: mark a step "
"'error' (not silently skip it) if it genuinely can't be completed.\n\n"
f"{prompt}"
)
# One immutable snapshot of this run, then the shared turn service (R04-T05).
# The Schedule Task path used to assemble the run_cowork call itself, in
# parallel with ui/cowork_tab.py doing the same thing slightly differently -
# so a fix to one path silently missed the other. Both now go through
# ConversationApplicationService.
from ..application.conversations import ConversationApplicationService
from ..domain.agents import ConversationExecutionRequest
# Assemble reminder + skill + persona + the task's own words in one place
# (see _unattended_prompt for why that order matters).
prompt = _unattended_prompt(prompt, skill_text=skill_text,
agent_instructions=agent_instructions)
messages = [{"role": "user", "content": prompt}]
session_id = new_session_id()
project_id = project.project_id if project is not None else ""
project_context = projects.project_context_text(project)
conversation_service = ConversationApplicationService(
# The provider was already resolved above (admin agent / per-task
# override / machine default), so the factory just hands it back.
lambda _provider_id, _model: provider,
security_config=ctx.config,
)
turn = conversation_service.begin_turn(ConversationExecutionRequest.create(
prompt, [{"role": "user", "content": prompt}],
output_dir=str(out_dir), session_id=session_id, surface="task",
title=title, project_id=project_id, project_context=project_context,
# Tags every tool call in the audit log as a scheduled task rather than
# as the interactive Cowork tab.
agent_role=agent_roles.TASK,
))
# The LIVE list the engine appends to - History is re-saved from it after
# every assistant message so a long run shows progress when reopened.
messages = turn.messages
_save_history_session(ctx, task_type, title, messages, session_id, project_id)
# Tell the scheduler the session now genuinely EXISTS on disk — it
# refreshes History on this, not on the earlier "task_started" signal
@@ -294,21 +288,45 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
elif ev.get("type") == "plan_set":
last_plan_steps[:] = ev.get("steps") or []
project_context = projects.project_context_text(project)
watched_cancel, timed_out = _cancel_with_timeout(cancel, timeout_sec)
try:
if task_type == "cowork":
# Typed events are rendered back into the legacy dict shape this
# module's autosave/plan tracking already consumes; it moves to
# AgentEvent directly once the scheduler UI migrates (EPIC R07/R08).
result = conversation_service.execute_turn(
turn,
on_event=lambda event: emit_and_autosave(event.to_dict()),
cancel=watched_cancel,
# R04-T05: the unattended run shares the interactive turn engine
# instead of calling run_cowork itself, so there is exactly one place
# where a turn's lifecycle is defined. Everything unattended-specific
# stays here (the plan reminder above, the History autosave in
# emit_and_autosave, the timeout notice below).
from ..application.conversations.core_runtime_adapter import (
build_cowork_conversation_service,
legacy_event_sink,
)
# This module's callers handle a failed run through an exception
# (execute_task writes error.txt from it), so re-raise the ORIGINAL
# error rather than reporting a silently empty answer.
result.raise_if_failed()
from ..domain.agents.conversation_execution_request import (
ConversationExecutionRequest,
)
# No extra_tools/extra_executor and no permission gate: a scheduled
# run gets no MCP connectors and nobody is there to approve a
# command, which is exactly what run_cowork was called with.
service = build_cowork_conversation_service(
provider, out_dir, emit_and_autosave, title=title,
project_context=project_context, security_config=ctx.config,
agent_role=agent_roles.TASK,
)
request = ConversationExecutionRequest(
# The artifact folder is named by the run id, which identifies
# this attempt in the audit log.
turn_id=out_dir.name or session_id, session_id=session_id,
surface="task", title=title, project_id=project_id,
prompt=prompt, output_dir=out_dir,
agent_role=agent_roles.TASK, unattended=True,
timeout_sec=timeout_sec,
)
# ``messages`` is handed over so the History autosave in
# emit_and_autosave (and the final save in the finally block below)
# keep reading the live conversation as it grows.
service.execute(request, legacy_event_sink(emit_and_autosave),
cancel=watched_cancel, messages=messages)
else:
from .code_agent import run_code
limits, block_network = agent_security.sandbox_settings(ctx.config)
+10 -20
View File
@@ -17,7 +17,7 @@ from datetime import datetime
from pathlib import Path
from typing import Dict, Optional
from PySide6.QtCore import QObject, Signal
from PySide6.QtCore import QCoreApplication, QObject, QTimer, Signal
from .tasks import (
advance_after_run, chain_action, dependencies_met, due_tasks, format_run_at,
@@ -39,32 +39,22 @@ class TaskScheduler(QObject):
# which fires before the worker thread has even begun).
history_ready = Signal(str) # task_id
def __init__(self, ctx, tasks_dir: Optional[Path] = None, parent=None, clock=None):
def __init__(self, ctx, tasks_dir: Optional[Path] = None, parent=None):
super().__init__(parent)
self.ctx = ctx
self.tasks_dir = tasks_dir # None → default TASKS_DIR
self._workers: Dict[str, AgentWorker] = {} # task_id → running worker
self._retries: Dict[str, int] = {}
self._session_ids: Dict[str, str] = {} # task_id → its run's History session id
# R07-T03: the QTimer this class used to own directly is now behind a
# small clock interface (start/stop/pump) — see
# platform/qt/qt_scheduler_clock.py::QtSchedulerClock. Defaulting to a
# real one here keeps every existing production call site (which
# never passes `clock=`) unchanged; tests inject
# tests/fakes/fake_clock.py::FakeClock to control ticks by hand with
# no Qt event loop running. Imported lazily so importing core.tasks/
# core.task_scheduler for the Qt-free logic doesn't require the Qt
# adapter module to even exist in a headless test context.
if clock is None:
from ..infrastructure.qt.qt_scheduler_clock import QtSchedulerClock
clock = QtSchedulerClock(self)
self._clock = clock
self._timer = QTimer(self)
self._timer.setInterval(TICK_MS)
self._timer.timeout.connect(self.tick)
# ---- lifecycle ----------------------------------------------------
def start(self) -> None:
self._recover_orphans()
self.tick() # catch up overdue tasks right at app start
self._clock.start(TICK_MS, self.tick)
self._timer.start()
def stop(self) -> None:
"""Request every running worker to stop, then WAIT (bounded) for them
@@ -77,15 +67,15 @@ class TaskScheduler(QObject):
``_on_done`` (the only place that writes the run into the task's
history) never runs. The task's real output can already be sitting on
disk while its history stays stuck on "running" forever. Pumping
the clock here lets that queued signal actually get delivered before
the app finishes quitting.
``processEvents()`` here lets that queued signal actually get
delivered before the app finishes quitting.
"""
self._clock.stop()
self._timer.stop()
deadline = time.monotonic() + STOP_WAIT_SECS
while self._workers and time.monotonic() < deadline:
for w in list(self._workers.values()):
w.request_stop()
self._clock.pump()
QCoreApplication.processEvents()
for w in list(self._workers.values()):
w.wait(50)
# Anything still alive past the deadline is abandoned here;
+71 -29
View File
@@ -151,14 +151,7 @@ def save_task(task: Dict[str, Any], directory: Path = None) -> Path:
directory.mkdir(parents=True, exist_ok=True)
task["updated_at"] = datetime.now().isoformat(timespec="seconds")
path = task_path(task["task_id"], directory)
# R07-T01: atomic write — same class of bug already fixed in
# core/projects.py and core/history.py at R06-T02 (plain write_text has a
# gap between truncate and write; a crash there leaves a half-written
# tasks/<id>.json that load_task() then silently treats as "missing",
# dropping the task). Lazy import to match the existing call sites and
# avoid a core -> infrastructure import at module load time.
from ..infrastructure.persistence.json.atomic_write import write_json
write_json(path, task)
path.write_text(json.dumps(task, ensure_ascii=False, indent=2), encoding="utf-8")
return path
@@ -294,32 +287,36 @@ def chain_error(tasks: List[Dict[str, Any]], task_id: str,
# ---- schedule math --------------------------------------------------------
# R07-T02: the actual date/cron math now lives in
# domain/tasks/schedule_calculator.py::ScheduleCalculator (pure Python, unit
# tested on its own — see tests/unit/test_schedule_calculator.py). Everything
# below is a thin wrapper kept for backward compatibility: task_scheduler.py,
# task_executors.py and ui/task_editor_dialog.py all still import these
# module-level names from core.tasks, and core/holiday_calendar.py::is_holiday
# / core/cron.py::Cron are only wired in HERE (lazily, matching the previous
# lazy-import style) — domain/ is not allowed to import core/ (ADR-001 I2).
_calculator: Optional[Any] = None
def _get_calculator():
global _calculator
if _calculator is None:
from .cron import Cron
def _is_excluded_day(dt: datetime, sched: Dict[str, Any]) -> bool:
"""True when ``dt`` falls on a day this schedule must skip: a weekend
(working_days_only) or a public holiday of the configured country."""
if sched.get("working_days_only") and dt.weekday() >= 5: # 5=Sat, 6=Sun
return True
if sched.get("skip_holidays"):
from .holiday_calendar import is_holiday
from ..domain.tasks.schedule_calculator import ScheduleCalculator
_calculator = ScheduleCalculator(is_holiday=is_holiday, make_cron=Cron)
return _calculator
if is_holiday(dt.date(), sched.get("holiday_country", "")):
return True
return False
def _add_month(dt: datetime) -> datetime:
import calendar
year = dt.year + (1 if dt.month == 12 else 0)
month = 1 if dt.month == 12 else dt.month + 1
day = min(dt.day, calendar.monthrange(year, month)[1])
return dt.replace(year=year, month=month, day=day)
def shift_off_excluded_days(dt: datetime, sched: Dict[str, Any]) -> datetime:
"""Push ``dt`` forward one day at a time until it lands on an allowed day
(same time of day) — used for one-time schedules set on a weekend/holiday."""
return _get_calculator().shift_off_excluded_days(dt, sched)
guard = 0
while _is_excluded_day(dt, sched) and guard < 400:
dt += timedelta(days=1)
guard += 1
return dt
def compute_next_run(task: Dict[str, Any], after: datetime) -> Optional[datetime]:
@@ -327,12 +324,57 @@ def compute_next_run(task: Dict[str, Any], after: datetime) -> Optional[datetime
(daily / weekly / monthly / cron), or None for one-shot schedules.
Occurrences on excluded days (weekends with working_days_only, public
holidays with skip_holidays+holiday_country) are skipped forward."""
return _get_calculator().compute_next_run(task, after)
sched = task.get("schedule", {})
repeat = sched.get("repeat_type", "none")
if repeat == "cron":
from .cron import Cron, CronError
try:
cron = Cron(sched.get("cron_expression") or "")
except CronError:
return None
nxt = cron.next_after(after)
guard = 0
while nxt is not None and _is_excluded_day(nxt, sched) and guard < 400:
nxt = cron.next_after(nxt)
guard += 1
return nxt
base = parse_run_at(sched.get("run_at"))
if base is None:
return None
if repeat == "daily":
advance = lambda d: d + timedelta(days=1) # noqa: E731
elif repeat == "weekly":
advance = lambda d: d + timedelta(weeks=1) # noqa: E731
elif repeat == "monthly":
advance = _add_month
else:
return None
nxt = base
while nxt <= after:
nxt = advance(nxt)
guard = 0
while _is_excluded_day(nxt, sched) and guard < 400:
nxt = advance(nxt)
guard += 1
return nxt
def due_tasks(tasks: List[Dict[str, Any]], now: datetime) -> List[Dict[str, Any]]:
"""Tasks that should start now: Scheduled + schedule enabled + run_at due."""
return _get_calculator().due_tasks(tasks, now)
due = []
for t in tasks:
if t.get("status") != "scheduled":
continue
sched = t.get("schedule", {})
if not sched.get("enabled"):
continue
run_at = parse_run_at(sched.get("run_at"))
if run_at is not None and run_at <= now:
due.append(t)
return due
# ---- post-run bookkeeping (pure; scheduler applies + saves) ---------------
+312 -38
View File
@@ -3,29 +3,79 @@
Every path is resolved relative to the working directory and must stay inside
it (path-traversal is rejected). ``run_command`` executes inside the workdir
with a timeout and captured output.
R05-T02: the actual handlers (``read_file``/``list_dir``/``write_file``/
``edit_file``/``run_command``/``install_package``/``fetch_url``/
``jira_search``/``jira_get_issue``) now live in
``infrastructure/filesystem/{file_tools,command_tools,fetch_tools}.py``, split
out of what used to be one big if/elif chain here. This module is the
strangler-fig shim (ADR-001 section 4): it re-exports ``ToolContext``/
``ToolError`` (actually defined in
``infrastructure/filesystem/tool_context.py`` now) so every existing
``from .tools import ToolContext`` keeps working, and ``execute_tool``
dispatches through a small ``{name: handler}`` table built from the moved
modules instead of the chain itself.
"""
from __future__ import annotations
import ast
import difflib
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
from ..infrastructure.filesystem import command_tools, fetch_tools, file_tools
from ..infrastructure.filesystem.command_tools import _snapshot # noqa: F401 - re-export, core/chat_agent.py imports this name
from ..infrastructure.filesystem.tool_context import CancelFn, ToolContext, ToolError # noqa: F401 - re-export
from ..providers.base import ToolSpec
CancelFn = Callable[[], bool]
MAX_READ_BYTES = 200_000
COMMAND_TIMEOUT = 120 # seconds
class ToolError(Exception):
pass
def _flatten_rel(rel: str) -> str:
"""Collapse a sub-folder path down to a bare filename so the file lands in the
workdir root — EXCEPT the ``.scratch`` sandbox subtree, which is preserved.
Used by the Cowork agent (flatten_writes=True) so it can never create a
per-session / per-chat / per-task output sub-folder: every deliverable stays
directly in the single configured Output folder."""
parts = Path(rel).parts
if parts and parts[0] == ".scratch":
return rel # temporary sandbox is allowed (and cleaned up afterwards)
return Path(rel).name or rel
@dataclass
class ToolContext:
workdir: Path
flatten_writes: bool = False # Cowork: force every write into the workdir root
sandbox: bool = False # Code tab: isolate run_command/install_package into <workdir>/.venv
# Sandbox Security Layer — Settings' "Resource Limits" (cpu_percent/memory_mb/
# disk_mb), applied to every run_command/install_package this context runs.
# None (default) = no limits, matching pre-existing behavior.
resource_limits: Optional[Dict[str, float]] = None
# Sandbox Security Layer — Settings' "Block network for agent commands"
# (policy-level, see deps.py::network_blocked_env). False (default) =
# unrestricted, matching pre-existing behavior.
block_network: bool = False
# Whether the fetch_url tool may read URLs — SEPARATE from block_network
# (reading a web page/share link for info is safe; running networked shell
# commands is the risk). Defaults True; set from agent_security.allow_url_fetch.
allow_url_fetch: bool = True
# Jira read connector config (base_url/email/api_token) — None disables the
# jira_* tools' ability to connect. Populated from config.data["jira"].
jira: Optional[Dict[str, Any]] = None
def resolve(self, rel: str) -> Path:
"""Resolve ``rel`` inside the workdir, rejecting escapes."""
if rel in ("", "."):
return self.workdir
candidate = (self.workdir / rel).expanduser()
try:
resolved = candidate.resolve()
except OSError as exc:
raise ToolError(f"Invalid path: {rel} ({exc})")
root = self.workdir.resolve()
if resolved != root and root not in resolved.parents:
raise ToolError(
f"Refused: '{rel}' is outside the working folder ({root})."
)
return resolved
# --------------------------------------------------------------------------
# Tool specs advertised to the model
# --------------------------------------------------------------------------
@@ -142,23 +192,6 @@ TOOL_SPECS: List[ToolSpec] = [
# Actions gated by the permission gate in confirm mode (auto-approved in Auto-run).
WRITE_TOOLS = {"write_file", "edit_file", "run_command", "install_package"}
# name -> handler(ctx, args[, cancel, on_output]) — built once from the split
# infrastructure modules. Replaces the if/elif chain execute_tool used to be.
_HANDLERS: Dict[str, Callable[..., Dict[str, Any]]] = {
"read_file": file_tools.read_file,
"list_dir": file_tools.list_dir,
"write_file": file_tools.write_file,
"edit_file": file_tools.edit_file,
"run_command": command_tools.run_command,
"install_package": command_tools.install_package,
"fetch_url": fetch_tools.fetch_url,
"jira_search": fetch_tools.jira_search,
"jira_get_issue": fetch_tools.jira_get_issue,
}
# Handlers that accept the long-running (cancel, on_output) signature — every
# other handler takes just (ctx, args).
_CANCELLABLE = {"run_command", "install_package"}
def enabled_tool_specs(security_config=None) -> List[ToolSpec]:
"""The built-in TOOL_SPECS minus any the admin turned OFF in Monitoring →
@@ -268,14 +301,27 @@ def execute_tool(ctx: ToolContext, name: str, args: Dict[str, Any],
labels WHICH agent role made it."""
from . import audit_log
handler = _HANDLERS.get(name)
try:
if handler is None:
result = {"ok": False, "output": f"Tool not found: {name}"}
elif name in _CANCELLABLE:
result = handler(ctx, args, cancel, on_output)
if name == "read_file":
result = _read_file(ctx, args)
elif name == "list_dir":
result = _list_dir(ctx, args)
elif name == "write_file":
result = _write_file(ctx, args)
elif name == "edit_file":
result = _edit_file(ctx, args)
elif name == "run_command":
result = _run_command(ctx, args, cancel, on_output)
elif name == "install_package":
result = _install_package(ctx, args, cancel, on_output)
elif name == "fetch_url":
result = _fetch_url(ctx, args)
elif name == "jira_search":
result = _jira_search(ctx, args)
elif name == "jira_get_issue":
result = _jira_get_issue(ctx, args)
else:
result = handler(ctx, args)
result = {"ok": False, "output": f"Tool not found: {name}"}
except ToolError as exc:
result = {"ok": False, "output": str(exc)}
except Exception as exc: # defensive: a tool must never crash the agent
@@ -285,6 +331,234 @@ def execute_tool(ctx: ToolContext, name: str, args: Dict[str, Any],
return result
def _fetch_url(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
"""Fetch a URL's text content (web page / online document / SharePoint-
OneDrive share link) via link_fetch — the same parser task-link attachments
use. Honors the Sandbox Security Layer's "Block network" policy."""
url = str(args.get("url", "")).strip()
if not url:
return {"ok": False, "output": "fetch_url: 'url' is required."}
if not url.lower().startswith(("http://", "https://")):
return {"ok": False, "output": f"fetch_url: not an http(s) URL: {url}"}
if not ctx.allow_url_fetch:
return {"ok": False,
"output": ("fetch_url: URL fetching is turned off in Settings → Security "
"(\"Allow the agent to fetch URLs\").")}
# A pasted Jira issue link on the CONNECTED Jira host is read via the
# authenticated API (so private issues resolve, not a login page). Public
# links / any other URL fall through to the normal fetcher below.
from . import jira_tool
if jira_tool.is_jira_issue_url(ctx.jira, url):
return {"ok": True, "output": jira_tool.get_issue_by_url(ctx.jira, url)}
from .link_fetch import fetch_link_preview
return {"ok": True, "output": fetch_link_preview(url)}
def _jira_search(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
from . import jira_tool
out = jira_tool.search(ctx.jira, str(args.get("jql", "")),
int(args.get("max_results", 25) or 25))
return {"ok": not out.lower().startswith(("jira is not configured", "jira search failed")),
"output": out}
def _jira_get_issue(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
from . import jira_tool
out = jira_tool.get_issue(ctx.jira, str(args.get("key", "")))
return {"ok": not out.lower().startswith(("jira is not configured", "could not fetch")),
"output": out}
def _read_file(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
target = ctx.resolve(str(args.get("path", "")))
if not target.exists():
return {"ok": False, "output": f"File not found: {args.get('path')}"}
data = target.read_bytes()[:MAX_READ_BYTES]
text = data.decode("utf-8", errors="replace")
return {"ok": True, "output": text}
def _list_dir(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
rel = str(args.get("path", ".") or ".")
target = ctx.resolve(rel)
# A missing/not-yet-created path is NOT a tool failure — report it as an
# ordinary result so the agent can create it or pick another path and keep
# going. Returning ok=False here surfaced a false "tool failed: list_dir" in
# Co4E flows and could stall a step on a recoverable situation.
if not target.exists():
return {"ok": True, "output": f"(path '{rel}' does not exist yet — create it or use another path)"}
if target.is_file():
return {"ok": True, "output": f"('{rel}' is a file, not a directory)"}
entries = []
for child in sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):
marker = "/" if child.is_dir() else ""
entries.append(f"{child.name}{marker}")
return {"ok": True, "output": "\n".join(entries) or "(empty folder)"}
def _check_python_syntax(target: Path, content: str) -> str:
"""Return a short warning if ``content`` is invalid Python, else ''.
Catches syntax errors the instant a .py file is written/edited — before the
agent wastes a whole run_command round-trip just to get the same error back
from a traceback."""
if target.suffix.lower() not in (".py", ".pyw"):
return ""
try:
ast.parse(content, filename=str(target))
return ""
except SyntaxError as exc:
return f"\n⚠ Syntax error at line {exc.lineno}: {exc.msg} — fix this before running the file."
def _write_file(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
rel = str(args.get("path", ""))
if ctx.flatten_writes:
rel = _flatten_rel(rel)
target = ctx.resolve(rel)
content = str(args.get("content", ""))
target.parent.mkdir(parents=True, exist_ok=True)
# A .xlsx is a binary package — build a REAL workbook from the content
# (CSV/TSV/Markdown-table/JSON) rather than writing raw text (which corrupts it).
if target.suffix.lower() in (".xlsx", ".xlsm"):
from . import xlsx_write
if xlsx_write.build_xlsx_from_text(target, content):
return {"ok": True, "path": str(target),
"output": f"Wrote spreadsheet {rel} ({target.name})."}
return {"ok": False, "output": "Could not build the .xlsx (openpyxl unavailable) — "
"write a .csv instead, or use a generator script."}
target.write_text(content, encoding="utf-8")
warning = _check_python_syntax(target, content)
return {"ok": True, "path": str(target),
"output": f"Wrote {len(content)} chars to {rel}.{warning}"}
def _edit_file(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
"""Replace an exact snippet inside an existing file (precise patch edit)."""
rel = str(args.get("path", ""))
if ctx.flatten_writes:
rel = _flatten_rel(rel)
target = ctx.resolve(rel)
if not target.exists():
return {"ok": False,
"output": f"File not found: {rel} — use write_file to create it."}
old = str(args.get("old_string", ""))
new = str(args.get("new_string", ""))
replace_all = bool(args.get("replace_all", False))
if not old:
return {"ok": False, "output": "old_string is empty — provide the exact text to replace."}
try:
text = target.read_text(encoding="utf-8", errors="replace")
except OSError as exc:
return {"ok": False, "output": f"Could not read file: {exc}"}
count = text.count(old)
if count == 0:
return {"ok": False, "output": ("old_string not found. Read the file and copy the exact "
"text to replace, including indentation/whitespace.")}
if count > 1 and not replace_all:
return {"ok": False, "output": (f"old_string appears {count} times — add surrounding "
"context to make it unique, or set replace_all=true.")}
updated = text.replace(old, new) if replace_all else text.replace(old, new, 1)
target.write_text(updated, encoding="utf-8")
n = count if replace_all else 1
warning = _check_python_syntax(target, updated)
return {"ok": True,
"output": f"Edited {args.get('path')} ({n} replacement{'' if n == 1 else 's'}).{warning}"}
def _sandbox_python(ctx: ToolContext, cancel: Optional[CancelFn] = None,
on_output: Optional[Callable[[str], None]] = None) -> Optional[str]:
"""Lazily create/reuse this ctx's project sandbox venv (Code tab only —
``ctx.sandbox``); returns its python path, or None to use the app's own."""
if not ctx.sandbox:
return None
from .deps import ensure_project_venv
py = ensure_project_venv(ctx.workdir, cancel=cancel, on_output=on_output)
return str(py) if py else None
def _install_package(ctx: ToolContext, args: Dict[str, Any], cancel: Optional[CancelFn] = None,
on_output: Optional[Callable[[str], None]] = None) -> Dict[str, Any]:
from .deps import pip_install
package = str(args.get("package", "")).strip()
if not package:
return {"ok": False, "output": "No package specified."}
python = _sandbox_python(ctx, cancel, on_output)
ok, detail = pip_install(package, cancel=cancel, on_output=on_output, python=python)
head = f"Installed {package}." if ok else f"Could not install {package}."
return {"ok": ok, "output": f"{head}\n{detail}"}
_SNAPSHOT_SKIP = {".git", "__pycache__", "node_modules", ".scratch", ".venv",
".idea", ".mypy_cache", ".pytest_cache"}
def _snapshot(workdir: Path) -> Dict[str, Any]:
"""Map of file path -> (mtime, size) under the workdir (noise dirs skipped)."""
snap: Dict[str, Any] = {}
try:
for dirpath, dirnames, filenames in os.walk(str(workdir)):
dirnames[:] = [d for d in dirnames if d not in _SNAPSHOT_SKIP]
for fn in filenames:
full = os.path.join(dirpath, fn)
try:
st = os.stat(full)
snap[full] = (st.st_mtime_ns, st.st_size)
except OSError:
pass
if len(snap) > 5000:
return snap
except OSError:
pass
return snap
def _run_command(ctx: ToolContext, args: Dict[str, Any],
cancel: Optional[CancelFn] = None,
on_output: Optional[Callable[[str], None]] = None) -> Dict[str, Any]:
from .deps import network_blocked_env, run_cancellable, sandbox_env
from .sandbox_manager import SandboxManager, ExecutionConfig
from ..security.command_risk_classifier import classify_command
command = str(args.get("command", "")).strip()
if not command:
return {"ok": False, "output": "Empty command."}
# --- Security validation pipeline ---
risk = classify_command(command, is_cowork_mode=ctx.flatten_writes)
if risk.blocked:
denial = "Command blocked by security policy: " + "; ".join(risk.reasons)
return {"ok": False, "output": denial}
# Route through SandboxManager for risk-based isolation
mgr = SandboxManager(ExecutionConfig(
enabled=True,
block_network_by_default=ctx.block_network,
is_cowork_mode=ctx.flatten_writes,
))
sandbox_result = mgr.run(
command=command,
workdir=str(ctx.workdir),
block_network=ctx.block_network,
timeout_sec=COMMAND_TIMEOUT,
cancel=cancel,
)
# Sandbox ALWAYS executes (never double-run). Return its result directly.
if sandbox_result.get("sandbox") == "blocked":
return {"ok": False, "output": sandbox_result.get("stderr", "Command blocked")}
out = sandbox_result.get("stdout", "").strip() or "(no output)"
err = sandbox_result.get("stderr", "")
rc = sandbox_result.get("returncode", -1)
if err:
out = f"{out}\n{err}" if out else err
return {"ok": sandbox_result.get("ok", False), "output": f"[exit {rc}]\n{out}"}
def _short_json(obj: Any, limit: int = 500) -> str:
import json
text = json.dumps(obj, ensure_ascii=False, indent=2)
+12
View File
@@ -49,6 +49,18 @@ def set_context(source: str, label: str = "") -> None:
_local.label = label
def current_context() -> tuple:
"""The ``(source, label)`` currently tagged on THIS thread.
Public counterpart to :func:`set_context`, added for
``infrastructure/telemetry/usage_sink.py``: a subscriber that needs to
attribute one event to a different surface must be able to save the
caller's context and put it back afterwards, instead of leaving the worker
thread permanently retagged.
"""
return getattr(_local, "source", "") or "", getattr(_local, "label", "") or ""
# ---- per-thread usage accumulator -----------------------------------------
# A step/run that wants to know its OWN token/cost (not the all-time file total)
# calls begin_accumulation(), reads accumulated() before/after a unit of work,
+77 -130
View File
@@ -1,156 +1,103 @@
# ADR-001: Kiến Trúc 4 Tầng (Layered / Clean Architecture)
# ADR-001: 4-Tier Clean Architecture for Desktop Local Application
* **Status**: Accepted
* **Status**: ACCEPTED / ENFORCED
* **Date**: 2026-08-21
* **EPIC / Task**: R01-T01
* **Owner**: 🔵 Team Duy (Tech Lead)
* **Áp dụng cho**: toàn bộ mã nguồn mới của `cowork_local` (3 team)
* **Deciders**: Team Duy (Tech Lead & AI Runtime), Team Nam (Governance & Automation), Team Hoa (Workspace & Scheduling)
* **Target Project**: Cowork Local (Cowork-Local BamBOO)
---
## 1. Context (Bối cảnh)
## 1. Context and Problem Statement
`cowork_local` hiện là một ứng dụng PySide6 desktop local-first ~55.000 dòng Python,
được phát triển nhanh theo hướng feature-first. Hệ quả đo được tại thời điểm viết ADR:
Cowork Local is a desktop application written in Python using PySide6 (Qt) and designed for local-first execution.
Historically, the codebase suffered from architectural coupling across layers:
1. **God-Widget Problem**: Monolithic UI widgets (e.g., `ui/chat_panel.py` >1,800 LOC, `ui/co4e_tab.py` >1,400 LOC) mixed UI rendering, network I/O, business rules, filesystem operations, and background worker lifecycle.
2. **Untestable Business Logic**: Core algorithms (model routing, conversation turn management, schedule calculation) were tightly coupled to `PySide6` widgets or `QTimer`, making unit testing in headless CI environments impossible without a graphical display server.
3. **Circular Dependencies & Global State Leaks**: Uncontrolled module imports (`model_pricing.py` ↔ `usage_tracker.py`, `agent_security.py` ↔ `agent_security_alert.py`) and mutable global state (`state.py::AppContext.active_project_id`) caused race conditions in background task runs.
| Vấn đề | Bằng chứng cụ thể trong repo |
| :--- | :--- |
| **God widget** | `ui/co4e_tab.py` 2.089 dòng, `ui/chat_panel.py` 1.795 dòng, `ui/folder_tab.py` 1.590 dòng |
| **Business logic nằm trong widget** | Vòng đời turn chat, quyết định routing, ghép prompt đều nằm trong `ui/chat_panel.py` |
| **Logic trùng lặp 3 nơi** | `ui/chat_panel.py::_apply_routing`, `ui/co4e_tab.py::_apply_co4e_routing`, `ui/folder_tab.py::_ai_apply_routing` là ba bản sao gần như y hệt của cùng một thuật toán |
| **Không test được nếu không có Qt** | Muốn test một quyết định routing phải dựng widget → không chạy được headless, không chạy được nhanh |
| **Side-effect ẩn trong tầng hạ tầng** | Provider tự gọi `core.usage_tracker.record()` ngay trong vòng lặp stream (`providers/openai_compat.py::_record_usage`) |
---
Ba team (Duy / Nam / Hoa) sẽ sửa song song trên cùng codebase trong 10 ngày. Nếu
không có một ranh giới phụ thuộc được **kiểm chứng tự động**, các thay đổi song song
sẽ hội tụ về đúng cấu trúc rối như cũ.
## 2. Decision: 4-Tier Clean Architecture
## 2. Decision (Quyết định)
Mã nguồn mới được tổ chức thành **4 tầng**, với **chiều phụ thuộc một chiều** như sau:
We enforce a strict **4-Tier Clean Architecture** based on the Dependency Inversion Principle:
```text
┌─────────────────────────────────────────────────────────────┐
│ presentation/ PySide6 widgets, Qt signals/slots │
│ (chat, co4e, workspace…) Chỉ dựng UI và phát/nhận signal │
└───────────────────────────┬─────────────────────────────────┘
│ gọi xuống (được phép)
┌───────────────────────────▼─────────────────────────────────┐
│ application/ Pure Python orchestration │
│ (conversations, Điều phối use-case, không biết Qt │
│ model_routing…) và không biết HTTP/đĩa cụ thể │
└───────────────────────────┬─────────────────────────────────┘
│ gọi xuống (được phép)
┌───────────────────────────▼─────────────────────────────────┐
│ domain/ Pure Python entities & events │
│ (agents, models…) Frozen dataclass, enum, quy tắc │
│ nghiệp vụ thuần. KHÔNG import gì │
│ từ 3 tầng còn lại. │
└───────────────────────────▲─────────────────────────────────┘
│ implement interface của domain
┌───────────────────────────┴─────────────────────────────────┐
│ infrastructure/ Adapters: network, keyring, đĩa, │
│ (providers, telemetry…) process, Qt-free I/O │
│ PRESENTATION │
│ (PySide6 Widgets, Dialogs, Qt Signals/Slots, View Models) │
└──────────────────────────────┬──────────────────────────────┘
│ depends on
▼
┌─────────────────────────────────────────────────────────────┐
│ APPLICATION │
│ (Use Case Services, Turn Orchestrators, Route Dispatchers) │
│ *** STRICTLY PURE PYTHON (0 Qt) *** │
└──────────────────────────────┬──────────────────────────────┘
│ depends on
▼
┌─────────────────────────────────────────────────────────────┐
│ DOMAIN & RUNTIME CORE │
│ (Entities, Value Objects, Domain Events, Tool Descriptors) │
│ *** STRICTLY PURE PYTHON (0 Qt) *** │
└──────────────────────────────▲──────────────────────────────┘
│ implemented by
┌──────────────────────────────┴──────────────────────────────┐
│ INFRASTRUCTURE │
│ (LLM Providers, Keyring Secrets, Atomic Persistence, MCP) │
└─────────────────────────────────────────────────────────────┘
```
### 2.1 Quy tắc bất biến (Invariants)
---
| # | Quy tắc | Được kiểm bởi |
| :--- | :--- | :--- |
| **I1** | `domain/` và `application/` là **100% pure Python** — cấm import `PySide6`, `PyQt5`, `PyQt6`, `shiboken6` | `scripts/check_imports.py` (R01-T03) |
| **I2** | `domain/` **không import** `application/`, `infrastructure/`, `presentation/`, `ui/` | `scripts/check_imports.py` |
| **I3** | `application/` **không import** `presentation/` hay `ui/` | `scripts/check_imports.py` |
| **I4** | Không file production nào vượt **400 dòng** | `scripts/check_loc.py` (R10-T02) |
| **I5** | `presentation/` **không** gọi thẳng provider/HTTP/đĩa — phải đi qua một application service | Code review + I1–I3 |
| **I6** | Mọi input của một use-case được đóng gói thành **snapshot bất biến** (`frozen dataclass`) trước khi rời UI thread | Code review + unit test |
## 3. Layer Definitions and Responsibilities
### 2.2 Chiều phụ thuộc được phép
### Tier 1: Presentation Layer (`presentation/`)
* **Responsibilities**: UI component layout, user event capture, progress display, visual animations, confirmation dialog triggers.
* **Allowed Imports**: `PySide6.*`, `application.*`, `domain.*`.
* **Forbidden**: Direct database queries, raw LLM API calls, disk writes outside UI cache, executing tool commands directly.
* **Constraints**: Every widget file must strictly be **under 400 lines of code (LOC)**.
| Từ tầng | Được import | Bị cấm |
| :--- | :--- | :--- |
| `presentation/` | `application/`, `domain/`, PySide6 | — (nên tránh gọi thẳng `infrastructure/`) |
| `application/` | `domain/`, interface do `domain/` định nghĩa | `presentation/`, `ui/`, PySide6 |
| `domain/` | chỉ stdlib | tất cả các tầng khác, PySide6 |
| `infrastructure/` | `domain/`, thư viện ngoài (requests, keyring…) | `presentation/`, `ui/`, PySide6 |
### Tier 2: Application Layer (`application/`)
* **Responsibilities**: Orchestrate single use cases (e.g. `ConversationApplicationService`, `RoutingApplicationService`, `TaskApplicationService`). Convert UI requests into domain requests, coordinate domain services with infrastructure adapters.
* **Allowed Imports**: `domain.*`, `infrastructure.*` interfaces/contracts, standard Python libraries.
* **Forbidden**: `PySide6`, `PyQt5`, `PyQt6`, `ui.*`, `app.*`.
* **Nature**: **100% Pure Python**. Must be executable and testable in headless CI environments without a display driver.
### 2.3 Cách tầng dưới "nói chuyện ngược" lên UI
### Tier 3: Domain Layer (`domain/`)
* **Responsibilities**: Core domain models, frozen DTO snapshots (`ConversationExecutionRequest`), typed event streams (`AgentEvent`), descriptors (`ToolDescriptor`, `ProviderDescriptor`), deterministic calculation algorithms (`ScheduleCalculator`).
* **Allowed Imports**: Standard Python library only (`dataclasses`, `typing`, `enum`, `datetime`, `pathlib`, `abc`).
* **Forbidden**: `PySide6`, `PyQt*`, `requests`, `sqlalchemy`, filesystem mutations, OS network calls.
* **Nature**: Completely isolated and zero-dependency core.
`application/` **không được** giữ tham chiếu tới widget. Việc trao đổi ngược chiều
đi qua **callback thuần Python nhận một `AgentEvent` có kiểu**
(`domain/agents/agent_event.py`, R04-T02):
### Tier 4: Infrastructure Layer (`infrastructure/`)
* **Responsibilities**: Adapters for external systems (OpenAI/Anthropic/Ollama/FPT providers, OS Keyring via `SecretStore`, `AtomicJsonFile` persistence, MCP child processes, filesystem tools).
* **Allowed Imports**: Third-party SDKs, OS libraries, `domain.*`.
* **Forbidden**: `presentation.*`, `PySide6.QtWidgets`.
```python
# application layer — pure Python, không biết Qt tồn tại
service.run_turn(request, on_event=my_callback)
---
# presentation layer — chuyển event sang Qt signal ở ranh giới duy nhất này
def my_callback(event: AgentEvent) -> None:
self.agent_event.emit(event) # Qt signal → cập nhật UI trên main thread
```
## 4. Architectural Rules and Non-Negotiable Invariants
Đây là **seam** duy nhất giữa hai thế giới: dưới seam là Python thuần test được
offline, trên seam là Qt. Mọi cập nhật UI phải xảy ra qua Qt signal/slot, không
bao giờ gọi trực tiếp từ worker thread.
1. **Zero Qt in Business Logic**:
- `domain/` and `application/` must never import `PySide6` or `PyQt*`.
- Verified via AST parser script `scripts/check_imports.py`.
2. **Immutable Request Snapshots**:
- Turns are initiated using immutable frozen dataclasses (`ConversationExecutionRequest`) to decouple runtime state from mutable UI state.
3. **Thread Safety and Signal Decoupling**:
- AI generation and tool calls run asynchronously in worker threads.
- UI updates occur strictly on the Qt main thread by consuming `AgentEvent` streams through Qt Signal bridges.
4. **Single Responsibility and Modularity**:
- Production files must stay within **400 LOC**.
5. **English In-Code Comments**:
- Every modified or created line/block must include concise English comments explaining design decisions and processing logic.
## 3. Vị trí sở hữu theo team
---
| Tầng / thư mục | Team | EPIC |
| :--- | :--- | :--- |
| `presentation/chat/`, `application/conversations/`, `application/model_routing/`, `domain/agents/`, `domain/models/`, `infrastructure/providers/`, `infrastructure/telemetry/`, `tests/`, `scripts/` | 🔵 Duy | R01, R03, R04, R08, R10 |
| `presentation/co4e/`, `monitoring/`, `settings/`, `shell/`, `application/workflows/`, `infrastructure/config/`, `secrets/`, `sandbox/` | 🟣 Nam | R02, R08, R09 |
| `presentation/workspace/`, `folder/`, `scheduling/`, `application/workspaces/`, `scheduling/`, `domain/tools/`, `domain/tasks/`, `infrastructure/filesystem/`, `mcp/`, `persistence/` | 🟢 Hoa | R05, R06, R07, R08 |
## 5. Consequences and Compliance
## 4. Chiến lược di trú (Strangler Fig, không big-bang)
Code cũ trong `core/`, `ui/`, `providers/` **không bị xoá ngay**. Ta bọc dần:
1. **Tạo seam mới** ở tầng đúng (ví dụ `RoutingApplicationService`).
2. **Chuyển call site** cũ sang gọi seam mới (`ui/*.py` chỉ còn vài dòng adapter).
3. **Giữ module cũ làm implementation detail** phía sau seam (ví dụ
`application/model_routing/` vẫn gọi xuống `core/routing/` để dùng lại
scorer/selector đã có test).
4. Chỉ khi mọi call site đã đi qua seam mới → cân nhắc gỡ code cũ.
Nhờ vậy `pytest` luôn xanh giữa các bước, và một team có thể merge mà không chờ
team khác refactor xong.
## 5. Consequences (Hệ quả)
### Tích cực
* Test một quyết định routing / một vòng đời turn chat **không cần Qt, không cần mạng** → suite unit chạy < 1 giây.
* Ba bản sao logic routing hội tụ về một nơi duy nhất → sửa một lần, cả 3 màn hình cùng đúng.
* Người mới có thể thêm một provider mà chỉ chạm `infrastructure/providers/` + `domain/models/`.
* Vi phạm kiến trúc bị chặn ở CI thay vì phát hiện lúc review.
### Tiêu cực / chi phí phải chấp nhận
* Nhiều file nhỏ hơn thay vì vài file lớn → tăng số lần "nhảy file" khi đọc code.
* Tồn tại **hai đường** trong giai đoạn di trú (code cũ + seam mới) cho tới khi call site cuối cùng chuyển xong.
* Phải viết DTO/snapshot rõ ràng thay vì truyền thẳng `self` của widget — tốn thêm code, đổi lại được thread-safety.
## 6. Alternatives considered (Phương án đã cân nhắc)
| Phương án | Lý do loại |
| :--- | :--- |
| **Giữ nguyên, chỉ tách file cho ngắn** | Giải quyết được I4 (LOC) nhưng không giải quyết được nguyên nhân gốc: logic vẫn dính Qt nên vẫn không test được offline. |
| **MVVM/MVP thuần Qt** | Vẫn buộc business logic phụ thuộc vòng đời Qt object; không chạy được trong scheduler headless và trong task nền. |
| **Hexagonal đầy đủ (port/adapter cho mọi thứ)** | Đúng về lý thuyết nhưng quá tốn cho 10 ngày và cho một app desktop 1 process; 4 tầng là điểm cân bằng. |
| **Big-bang rewrite** | Rủi ro hồi quy quá cao khi 3 team sửa song song và không có bộ test bảo vệ đầy đủ. |
## 7. Enforcement (Thực thi)
```bash
python scripts/check_imports.py # I1, I2, I3 — quét AST
python scripts/check_loc.py # I4 — giới hạn 400 dòng
python scripts/run_quality_gate.py # chạy toàn bộ CASAN Gate + pytest
```
CASAN Verification Gate phải PASS trước khi merge bất kỳ PR nào vào `main`.
## 8. Tài liệu liên quan
* `docs/refactor/Feature_Architecture_Proposal.md` — thiết kế tổng thể 10 EPIC
* `docs/refactor/Refactoring_Checklist.md` — bảng tiến độ theo task
* `docs/architecture/dormant-code.md` — danh mục code không còn hoạt động (R01-T05)
* **Positive**:
- Full testability: Unit tests run in milliseconds without GUI or network mocks.
- Zero circular dependencies: Clear top-down data flow.
- Resilience: UI crashes do not corrupt background tasks or files.
* **Verification**:
- Automated CI gate: `python scripts/check_imports.py` and `python scripts/check_loc.py`.
+27 -73
View File
@@ -1,85 +1,39 @@
# Dormant / Dead Code Inventory (R01-T05)
# Danh Mục & Kế Hoạch Cô Lập Mã Nguồn Dormant / Dead Code (Dormant Code Catalog)
* **Task**: R01-T05 — Phân loại và cô lập mã nguồn cũ
* **Owner**: 🔵 Team Duy
* **Ngày quét**: 2026-08-21
* **Phạm vi quét**: toàn bộ `*.py` production (loại trừ `tests/`, `assets/`, `docs/`, `.git/`)
* **Tài liệu**: `docs/architecture/dormant-code.md`
* **Thuộc EPIC**: `R01: Architecture Foundation & Characterization`
* **Team phụ trách**: 🔵 **Team Duy (Tech Lead)**
---
## 1. Mục đích
## 1. Mục Đích & Nguyên Tắc Quản Trị
Trước khi 3 team refactor song song, cần biết **file nào thật sự đang chạy**. Refactor
một module đã chết là lãng phí; xoá nhầm một module chỉ được gọi động là gây sự cố
runtime. Tài liệu này phân loại từng ứng viên, kèm **bằng chứng** và **hành động đề xuất**.
Trong quá trình phát triển nhanh, một số module, hàm hoặc script đã trở thành mã nguồn không hoạt động (**dormant**), mã nguồn thử nghiệm cũ (**legacy prototypes**), hoặc mã nguồn không còn được sử dụng (**dead code**).
## 2. Phương pháp
> [!IMPORTANT]
> ### 🛡️ NGUYÊN TẮC CÔ LẬP MÃ NGUỒN CŨ:
> 1. **Tuyệt đối không import vào các tầng mới**: Các tầng `domain/`, `application/`, `infrastructure/` mới được xây dựng **cấm tuyệt đối import bất kỳ module dormant nào**.
> 2. **Không xóa vội vàng khi chưa có test bảo vệ**: Giữ nguyên mã nguồn cũ trong giai đoạn tái cấu trúc R01–R08; chỉ dọn dẹp hoặc xóa sau khi bộ kiểm thử khói E2E (EPIC R10) chạy pass 100%.
> 3. **Phân loại rõ ràng trạng thái**: Mỗi module dormant phải được gắn nhãn (DEPRECATED / ISOLATED / PENDING_DELETION).
Quét AST toàn repo, dựng đồ thị import, tìm module **không có module nào khác import**.
Kết quả thô: **43 module**. Sau đó xác minh thủ công từng ứng viên, vì phân tích tĩnh
không thấy 3 kiểu tham chiếu:
---
| Kiểu tham chiếu ẩn | Ví dụ thật trong repo |
| :--- | :--- |
| Chạy như subprocess | `state.py:285` gọi `python -m cowork_local.mcp_servers.ms365_server` |
| Entry point của gói | `__main__.py` (chạy bằng `python -m cowork_local`) |
| Script chạy tay | `tools/check_*.py`, `scripts/*.py` |
## 2. Bảng Danh Mục Mã Nguồn Dormant / Dead Code Đã Rà Soát
> ⚠️ **Kết luận quan trọng**: 43 module "không ai import" **KHÔNG** đồng nghĩa 43 module chết.
> Sau xác minh, chỉ còn **6 hạng mục (~1.887 dòng)** là dormant thật.
| STT | File / Module / Ký Hiệu | Trạng Thái Hiện Tại | Lý Do Phân Loại & Phân Tích Kỹ Thuật | Kế Hoạch Xử Lý & Thời Điểm Gỡ Bỏ |
| :---: | :--- | :---: | :--- | :--- |
| **1** | `requirements (cloud copy).txt` | `PENDING_DELETION` | File sao chép dự phòng tạm thời trong quá khứ, không được tham chiếu bởi bất kỳ quy trình setup nào. | Gỡ bỏ trong EPIC R10 (Packaging & Clean-up). |
| **2** | `preview-desktop` | `ISOLATED` | Script shell rỗng/phác thảo cho môi trường dev container cũ. | Cô lập, không liên kết vào build workflow. |
| **3** | `scripts/bootstrap_gitea_repo.py` | `ISOLATED` | Script tiện ích bootstrap kho lưu trữ Gitea nội bộ; không thuộc runtime ứng dụng chính. | Di chuyển vào `docs/gitea/` làm tài liệu tham khảo ops. |
| **4** | Hàm routing sao chép tại `ui/chat_panel.py#L638` | `DEPRECATED` | Đoạn code logic chọn model lặp lại từ `core/routing/` nằm trực tiếp trong UI widget. | Thay thế hoàn toàn bằng `RoutingApplicationService` trong EPIC R03. |
| **5** | Biến toàn cục `state.py::active_project_id` | `DEPRECATED` | Biến global mutable gây race condition khi chạy background task song song. | Thay thế bằng `WorkspaceSession` trong EPIC R06. |
| **6** | Các hàm xử lý UI đồng bộ trong `core/tools.py` | `DEPRECATED` | `core/tools.py` chứa mã monolithic vừa xử lý file vừa gọi dialog xác thực trực tiếp. | Phân rã thành `file_tools.py`, `command_tools.py` và `ToolPolicyGateway` trong EPIC R05. |
## 3. Phân loại kết quả
---
### 🟥 A. DORMANT THẬT — không có đường nào chạy tới (ứng viên xoá)
## 3. Quy Trình Cô Lập & Kiểm Soát
| Module | LOC | Bằng chứng | Rủi ro khi xoá | Hành động |
| :--- | ---: | :--- | :--- | :--- |
| `ui/accounts_tab.py` | 700 | Chỉ xuất hiện trong comment của `i18n.py:92`; không widget nào khởi tạo `AccountsTab` | Thấp — panel Monitoring → Accounts hiện không có đường vào | Cô lập, chờ xác nhận PO rồi xoá |
| `ui/flow_dialog.py` | 596 | Chỉ được nhắc trong docstring `ui/agent_manager_tab.py:4` và comment `i18n.py:2124` | Trung bình — Flow Manager có thể là tính năng tạm ẩn | **Hỏi PO trước**, chưa xoá |
| `security/` (cả package) | 296 | `prompt_validator`, `action_validator`, `attachment_validator`, `audit_logger`, `command_risk_classifier` — không file nào ngoài package tự import. Chức năng **trùng** `core/agent_security.py` + `core/security_rules.py` (đang chạy thật) | Trung bình — dễ nhầm đây là lớp bảo mật đang hoạt động | ⚠️ Ưu tiên cao: xoá hoặc hợp nhất trong **R09 (Team Nam)** |
| `core/codebase_memory_ui.py` | 123 | Không nơi nào import; `core/codebase_memory.py` (bản không-UI) mới là bản đang dùng | Thấp | Xoá |
| `core/graph_server.py` | 115 | Docstring nói phục vụ build không có QtWebEngine, nhưng **không có call site nào**; `ui/structure_graph_view.py` không gọi | Trung bình — có thể là fallback cho bản .exe chưa nối dây | Xác minh với bản đóng gói PyInstaller trước khi xoá |
| `ui/mcp_servers_dialog.py` | 57 | Không import; MCP settings hiện nằm trong `ui/settings_dialog.py` | Thấp | Xoá |
**Tổng: ~1.887 dòng (≈ 3,4% codebase).**
### 🟨 B. KHÔNG CHẾT — chạy qua đường ẩn (giữ nguyên)
| Module | Vì sao phân tích tĩnh báo nhầm |
| :--- | :--- |
| `__main__.py` | Entry point `python -m cowork_local` |
| `mcp_servers/ms365_server.py` | Chạy như tiến trình con — `state.py:285` |
| `core/routing/__init__.py` | Được import qua đường dẫn con (`from .routing.service import RoutingService`), heuristic theo tên lá không thấy |
| `tools/check_*.py` (34 file, 6.608 dòng) | Bộ smoke-test UI chạy tay: `python tools/check_nav.py`. Là **dev tooling**, không phải code chết |
| `scripts/bootstrap_gitea_repo.py`, `scripts/check_imports.py` | Script CLI chạy tay / chạy trong CI |
### 🟩 C. CODE SỐNG NHƯNG "ĐÓNG BĂNG" — đụng vào phải cẩn thận
| Module | LOC | Ghi chú cho người refactor |
| :--- | ---: | :--- |
| `core/chat_agent.py::run_cowork` | 580 | Đang có **characterization test** (`tests/characterization/test_run_cowork.py`, R01-T04). Mọi thay đổi hành vi phải làm cùng lúc với cập nhật snapshot |
| `providers/base.py` | 401 | Là contract chung của mọi provider; đổi chữ ký = vỡ cả 3 team. Đã có contract test (R03-T01) |
| `core/routing/*` | 2.263 | Đã có 79 test đang xanh. R03 **bọc** chứ không viết lại: `application/model_routing/` gọi xuống đây |
## 4. Quy tắc xử lý (bắt buộc)
1. **Không xoá trong cùng PR với refactor.** Xoá code chết là một commit riêng, để `git revert` được độc lập khi có sự cố.
2. **Cô lập trước, xoá sau.** Đánh dấu module bằng docstring cảnh báo, chạy 1 vòng release; không ai báo lỗi mới xoá.
3. **Hạng mục 🟥 A cần một người xác nhận** (PO hoặc chủ tính năng) trước khi xoá — trừ khi rõ ràng là bản trùng lặp (`codebase_memory_ui`, `mcp_servers_dialog`).
4. **Không refactor code trong nhóm 🟥 A.** Nếu một file trong danh sách này >400 dòng, nó **không** tính vào CASAN Check 2 — vì đường đi đúng là xoá, không phải tách nhỏ.
## 5. Việc cần bàn giao
| Hạng mục | Team nhận | EPIC |
| :--- | :--- | :--- |
| `security/` trùng lặp với `core/agent_security.py` | 🟣 Nam | R09 |
| `ui/accounts_tab.py`, `ui/flow_dialog.py`, `ui/mcp_servers_dialog.py` | 🟣 Nam (sở hữu `presentation/shell/`, `settings/`) | R08 |
| `core/graph_server.py`, `core/codebase_memory_ui.py` | 🟢 Hoa (sở hữu `presentation/graph/`) | R06 |
## 6. Cách chạy lại lần quét này
```bash
python scripts/check_imports.py # ranh giới kiến trúc (R01-T03)
# Bản quét đồ thị import dùng cho tài liệu này sẽ được đóng gói thành
# scripts/find_dormant.py trong R10-T02 (Testing & Governance tooling).
```
1. **Kiểm tra tự động qua AST Guard**:
- Bộ script `scripts/check_imports.py` tự động quét để đảm bảo không có bất kỳ import mới nào trỏ tới các thành phần đã đánh dấu deprecated.
2. **Kế hoạch dọn dẹp cuối cùng (Release Phase - 31/08/2026)**:
- Sau khi hoàn thành EPIC R10 và pass toàn bộ bài test E2E (`tests/e2e/test_smoke.py`), các file đánh dấu `PENDING_DELETION` sẽ được gỡ bỏ khỏi nhánh `main`.
+159
View File
@@ -0,0 +1,159 @@
# 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` |
+58
View File
@@ -0,0 +1,58 @@
# 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`.
-233
View File
@@ -1,233 +0,0 @@
# BÁO CÁO KẾT QUẢ — TEAM DUY: EPIC R01, R03, R04
* **Dự án**: Cowork Local (Cowork-Local BamBOO)
* **Team**: 🔵 Team Duy — Core AI, Routing, Turn Runtime & Testing (Tech Lead)
* **Nhánh**: `feature/deltateam/refactor-plan`
* **Thời gian thực hiện**: 21/08/2026, 09:56 ➔ 10:56
* **Ngày báo cáo**: 21/08/2026
* **Tài liệu gốc**: `Feature_Architecture_Proposal.md`, `Refactoring_Checklist.md`, `DeltaTeam_prompt.md`
---
## 1. Tóm tắt điều hành
Hoàn tất **16/16 task** của 3 EPIC được giao trong đợt này: **R01** (nền tảng kiến trúc & lưới an toàn), **R03** (hợp nhất provider & routing), **R04** (vòng đời turn hội thoại). Toàn bộ đã commit và push lên nhánh.
| Chỉ số | Kết quả |
| :--- | :--- |
| Task hoàn thành | **16/16** (R01: 5, R03: 6, R04: 5) |
| Commit | 5 |
| File thay đổi | 48 (37 file mới, 11 file sửa) |
| Dòng code | +5.843 / −225 |
| Test | **243 pass** / 44s |
| Test suite nhanh (unit + contract + characterization + routing) | **218 pass / 1,22s** |
| CASAN Check 3 (`scripts/check_imports.py`) | **PASS** — 0 Qt import trong `domain/`, `application/` |
| File production > 400 dòng | **0** |
**3 lỗi thật được phát hiện và sửa trong quá trình làm** (chi tiết mục 5) — trong đó 1 lỗi deadlock sẽ làm treo ứng dụng ngay ở tin nhắn đầu tiên.
---
## 2. Kết quả theo từng EPIC
### 🔹 EPIC R01 — Architecture Foundation & Characterization (5/5)
| Task | Sản phẩm | Ghi chú |
| :--- | :--- | :--- |
| R01-T01 | `docs/architecture/ADR-001-layered-architecture.md` | Định nghĩa 4 tầng, chiều phụ thuộc, 6 quy tắc bất biến I1–I6, chiến lược di trú Strangler Fig |
| R01-T02 | `tests/fakes/fake_provider.py`, `fake_tool_executor.py` | Test double chạy offline, kịch bản hoá, ghi lại mọi lời gọi |
| R01-T03 | `scripts/check_imports.py` (239 dòng) | Quét AST, bắt cả import tương đối (`from ...ui import x`) và import trong thân hàm |
| R01-T04 | `tests/characterization/test_run_cowork.py` | **13 test** chụp snapshot hành vi hiện tại của `run_cowork` trước khi R04 đụng vào |
| R01-T05 | `docs/architecture/dormant-code.md` | Quét đồ thị import: 43 module "không ai import" ➔ xác minh còn **6 hạng mục chết thật (~1.887 dòng)** |
**Điểm đáng chú ý ở R01-T03**: dùng AST thay vì `grep` là bắt buộc — trong repo có nhiều docstring nhắc tên `PySide6` một cách hợp lệ, `grep` sẽ báo nhầm và đội sẽ học cách tắt cổng kiểm duyệt.
**Điểm đáng chú ý ở R01-T05**: 43 module không có importer **không** đồng nghĩa 43 module chết. Sau xác minh thủ công: `__main__.py` là entry point, `mcp_servers/ms365_server.py` chạy bằng subprocess (`state.py:285`), 34 file `tools/check_*.py` là dev tooling chạy tay. Chỉ 6 hạng mục là dormant thật.
### 🔹 EPIC R03 — Model Providers & Routing (6/6)
| Task | Sản phẩm | Ghi chú |
| :--- | :--- | :--- |
| R03-T01 | `tests/contracts/test_providers.py` | **29 contract test**; chạy được cả 2 adapter thật mà **không cần mạng** nhờ thay `Provider._request` bằng SSE đóng hộp |
| R03-T02 | `domain/models/provider_descriptor.py`, `infrastructure/providers/provider_registry.py` | Gom 3 nơi khai báo provider về 1 chỗ |
| R03-T03 | `application/model_routing/routing_application_service.py` | Pure Python, 4 chế độ: Off / Auto / Manual / **Fallback (mới)** |
| R03-T04, T05 | `ui/chat_panel.py`, `ui/co4e_tab.py`, `ui/folder_tab.py` | Gỡ 3 bản sao logic routing |
| R03-T06 | `infrastructure/telemetry/usage_sink.py` | Tách ghi nhận token usage khỏi provider |
**Vấn đề gốc đã giải quyết** — cùng một thuật toán routing tồn tại **3 bản gần giống nhau**:
```
ui/chat_panel.py::_apply_routing (~45 dòng)
ui/co4e_tab.py::_apply_co4e_routing (~38 dòng)
ui/folder_tab.py::_ai_apply_routing (~42 dòng)
```
Cả 3 đều nằm trong widget Qt ➔ **không thể test nếu không dựng cửa sổ**, và đã bắt đầu lệch nhau (mỗi bản xác định "model hiện tại" một kiểu). Nay cả 3 chỉ còn gọi `ctx.routing_application().route_turn(...)` + một callback xác nhận.
**Chế độ Fallback (mới)**: giữ nguyên model người dùng chọn, **chỉ đổi sau khi model đó lỗi**. Đây là chế độ người dùng cần khi họ tin lựa chọn của mình nhưng vẫn muốn lượt chat sống sót qua sự cố nhà cung cấp.
**Bộ từ vựng mode**: trước đây tuple `("off", "auto", "manual")` bị lặp ở **4 chỗ** (`config.py` × 2, `state.py` × 2). Thêm một mode mà quên một chỗ sẽ **âm thầm hạ lựa chọn của người dùng về "off"**. Nay tập trung vào `normalize_mode()` / `is_valid_mode()`.
### 🔹 EPIC R04 — Agent Runtime & Conversation Service (5/5)
| Task | Sản phẩm | Ghi chú |
| :--- | :--- | :--- |
| R04-T01 | `domain/agents/conversation_execution_request.py` | Frozen dataclass, chụp toàn bộ input của 1 turn tại thời điểm submit |
| R04-T02 | `domain/agents/agent_event.py` (370 dòng) | **13 event có kiểu** thay cho dict không kiểu, kèm cầu nối 2 chiều |
| R04-T03 | `application/conversations/conversation_application_service.py` | Điều phối vòng đời turn, không import Qt |
| R04-T04 | `ui/cowork_tab.py::build_job` | Chuyển sang snapshot + service |
| R04-T05 | `core/task_executors.py::_run_agent` | Chuyển sang **cùng** service (trước đây là bản lắp ráp thứ hai, hơi khác) |
**Vấn đề gốc đã giải quyết** — closure trong `build_job` đọc state của widget **từ trong worker thread**:
```python
def job(worker):
provider = self.build_provider() # đọc combo box
proj_ctx = project_context_text(load_project(project_id))
```
Người dùng có thể đổi model, đổi workspace, sửa chỉ dẫn project **trong lúc turn đang chạy**. Turn khi đó chạy trên hỗn hợp state cũ + mới, và hỗn hợp nào phụ thuộc vào thời điểm luồng — đúng loại bug tái hiện mỗi tuần một lần và không bao giờ tái hiện trong test.
**`TurnCompletedEvent`** là tín hiệu kết thúc turn mà engine cũ **hoàn toàn không có**: hiện tại mọi consumer suy ra "xong" từ việc worker thread kết thúc, nên **turn bị huỷ và turn thất bại trông giống hệt nhau** với giao diện.
---
## 3. Kiến trúc sau refactor
```text
presentation/ ui/chat_panel.py, ui/co4e_tab.py, ui/folder_tab.py, ui/cowork_tab.py
│ (chỉ dựng UI, mở dialog xác nhận, render thông báo)
▼
application/ model_routing/routing_application_service.py ← 4 mode routing
conversations/conversation_application_service.py ← vòng đời turn
│ (100% pure Python — cổng kiểm duyệt tự động chặn import Qt)
▼
domain/ agents/conversation_execution_request.py ← snapshot bất biến
agents/agent_event.py ← 13 event có kiểu
models/provider_descriptor.py ← catalog provider
▲
infrastructure/ providers/provider_registry.py telemetry/usage_sink.py
```
**Nguyên tắc di trú (ADR-001 mục 4)**: **không viết lại engine**. `core/chat_agent.py::run_cowork` và `core/routing/*` (2.263 dòng, 79 test đang xanh) vẫn là engine bên dưới; tầng application chỉ sở hữu phần trước đây bị trộn vào UI. Nhờ vậy `pytest` luôn xanh giữa các bước và một team có thể merge mà không phải chờ team khác.
---
## 4. Bằng chứng kiểm thử
### Phân bố test
| Suite | Số test | Thời gian | Vai trò |
| :--- | ---: | ---: | :--- |
| `tests/unit/` | 97 | | Logic thuần, không Qt/mạng |
| `tests/contracts/` | 29 | | Mọi provider phải thoả cùng bộ cam kết |
| `tests/characterization/` | 13 | | Chốt hành vi hiện tại của `run_cowork` |
| `tests/routing/` | 79 | | Có sẵn từ trước, vẫn xanh |
| **Cộng 4 suite nhanh** | **218** | **1,22s** | ✅ đạt CASAN "A — unit < 1s" |
| `tests/integration/` | 25 | 42s | Widget Qt thật (offscreen) + provider kịch bản hoá |
| **Tổng** | **243** | **44s** | |
### Đối chiếu Definition of Done (7 tiêu chí, `DeltaTeam_prompt.md`)
| # | Tiêu chí | Kết quả |
| :--- | :--- | :--- |
| 1 | Mọi file < 400 dòng | ✅ Lớn nhất: `agent_event.py` 370 dòng |
| 2 | 0 import Qt trong `domain/`, `application/` | ✅ `check_imports.py` PASS |
| 3 | Comment tiếng Anh ở mọi khối sửa/mới | ✅ Docstring + giải thích **lý do**, không chỉ mô tả code |
| 4 | Có unit/contract test, pass 100% < 1s | ✅ 218 test / 1,22s |
| 5 | Không hồi quy | ✅ 79 test routing có sẵn vẫn xanh |
| 6 | Ghi Start/End vào Checklist | ✅ 16 task đã tick kèm mốc thời gian |
| 7 | Cổng CASAN | ⚠️ `run_quality_gate.py` thuộc **R10-T02**, chưa viết. Check 3 đã có và PASS |
### Ba đường code đã sửa nhưng ban đầu chưa được thực thi
Sau khi hoàn tất 16 task, rà soát lại phát hiện 3 đường code đã bị sửa nhưng **không test nào chạy qua**. Đã bổ sung **18 test**:
| Đường code | Rủi ro nếu bỏ qua | Test bổ sung |
| :--- | :--- | ---: |
| `task_executors._run_agent` | Autosave History có thể đóng băng ở tin nhắn đầu | 7 |
| `_apply_co4e_routing` / `_ai_apply_routing` | Mới chỉ import được, chưa từng gọi hàm | 11 |
| `confirm_switch(decision)` Manual mode | Thiếu field ➔ **nổ bên trong modal**, nơi khó phát hiện nhất | (nằm trong 11 ở trên) |
---
## 5. Ba lỗi thật phát hiện trong quá trình làm
### 🔴 Lỗi 1 — Deadlock khi khởi tạo routing service
`AppContext.routing_application()` giữ `_routing_lock` rồi gọi `routing()`, vốn cũng lấy **chính lock đó**. `threading.Lock` không reentrant ➔ **treo cứng ngay ở tin nhắn đầu tiên**, không có thông báo lỗi.
*Sửa*: tách `_routing_app_lock` riêng, và resolve engine **trước khi** lấy lock.
### 🟠 Lỗi 2 — Event `notice` bị cầu nối nuốt mất
Bản đầu của `agent_event.py` liệt kê 12 loại event nhưng **thiếu `notice`**. Trong khi đó `notice` được phát ra từ 3 nơi trên đường chạy bình thường:
* `core/agent_security.py` — yêu cầu/lệnh bị Agent Security **chặn**
* `core/context_budget.py` — hội thoại vừa bị tự động nén
* Bộ đọc file đính kèm — file không xử lý được, và tiến độ "đang đọc trang X/Y"
Cầu nối bỏ qua event không nhận diện được (đúng thiết kế, để engine có thể thêm event mới) — nên **người dùng sẽ không bao giờ thấy cảnh báo bảo mật**, hoàn toàn im lặng.
*Sửa*: thêm `NoticeEvent`, **và** thêm test quét mã nguồn engine tìm mọi tag `emit({"type": ...})` rồi bắt lỗi nếu có tag nào chưa có event tương ứng — biến sự im lặng thành test đỏ.
### 🟡 Lỗi 3 — Test đang chạy trên checkout khác
`tests/routing/conftest.py` đẩy thư mục cha vào `sys.path`. Vì thư mục checkout tên là `cowork_local_gitea` (không phải `cowork_local`), lệnh `import cowork_local` **ăn nhầm sang `Desktop\cowork_local`** — một bản checkout khác. Suite báo xanh trên mã nguồn **không phải nhánh đang review**.
*Sửa*: `tests/conftest.py` nạp `__init__.py` theo đường dẫn tuyệt đối và đăng ký vào `sys.modules` trước mọi test.
---
## 6. Cải thiện phụ (không nằm trong yêu cầu task)
| Cải thiện | Ảnh hưởng |
| :--- | :--- |
| `ProviderRegistry.build()` đóng dấu `descriptor.id` lên instance | Sửa việc usage của `ollama` / `github_copilot` / `codex` bị ghi nhận nhầm thành `openai_compat` trên Dashboard. **Chưa nối vào production** — xem mục 7. |
| `ProviderRegistry.build()` copy config trước khi ghi | Trước đây một model do routing chọn có thể ghi đè lên default đã lưu của người dùng |
| `UsageTrackerSink` ghi log ở mức debug khi thất bại | Trước là `except: pass` — mất sạch lý do khi Dashboard hỏng |
| `estimate_tokens` được chốt bằng test so với `core.usage_tracker` | Bảo đảm việc tách telemetry **không làm lệch một con số nào** |
---
## 7. Còn nợ & cần quyết định
| # | Nội dung | Người quyết |
| :--- | :--- | :--- |
| 1 | **`ProviderRegistry` chưa nối vào `state.build_provider_for`** (vẫn dùng `providers/factory.py`). Nối vào sẽ sửa lỗi quy kết usage ở mục 6, **nhưng đổi cách gom dữ liệu lịch sử trên Dashboard**. | Team Duy + PO |
| 2 | **Mode `fallback` chưa có trên toggle UI** — config và service đã hỗ trợ đầy đủ; widget `RoutingToggle` thuộc R08. | Team Duy (R08) |
| 3 | **Đã sửa 2 dòng trong `config.py`** (`routing_mode_for`, `set_routing_mode_for`) để dùng chung bộ từ vựng mode. File này Team Nam đang refactor ở R02-T02. | ⚠️ **Cần báo Team Nam** |
| 4 | **Circular import** `core/model_pricing.py` ↔ `core/usage_tracker.py` chưa xử lý (task ngày 28/08). | Team Duy |
| 5 | **2 test đỏ có sẵn từ trước**: `config.py:108` hardcode `sandbox_pw = "quandh14"` ➔ `tests/test_config_security.py`. Thuộc **EPIC R02 / Team Nam**. | 🟣 Team Nam |
| 6 | `tests/integration/test_routing_surfaces.py` mất 41s do dựng `Co4ETab`/`FolderTab`. Nên gắn marker `slow` khi làm R10. | Team Duy (R10) |
---
## 8. Phạm vi chưa kiểm thử
Nêu rõ để tránh hiểu nhầm mức độ bảo đảm:
* **Chưa mở ứng dụng bằng tay** — mới chạy widget headless (`QT_QPA_PLATFORM=offscreen`), chưa có ai kiểm tra bằng mắt.
* **Chưa gọi provider thật** — toàn bộ dùng `FakeProvider`, không có lưu lượng mạng.
* **Chưa chạy 34 script `tools/check_*.py`** — các script này tự `sys.path.insert` thư mục cha nên sẽ import nhầm checkout khác (đúng lỗi 3 ở mục 5). Cần sửa chúng ở R10.
---
## 9. Việc kế tiếp của Team Duy
| EPIC | Nội dung | Điều kiện |
| :--- | :--- | :--- |
| **R08** (T01 ➔ T06) | Tách `ui/chat_panel.py` (1.795 dòng) thành 6 widget < 400 dòng | Sẵn sàng bắt đầu — `AgentEvent` (R04-T02) chính là kênh dữ liệu 6 widget con sẽ dùng thay vì đọc trực tiếp state của `ChatPanel` |
| **R10** (T01 ➔ T05) | Testing Pyramid, `run_quality_gate.py`, Contributor Recipes, E2E Smoke | Chờ cả 3 team hoàn tất |
---
## 10. Lịch sử commit
| Commit | Nội dung |
| :--- | :--- |
| `bbc09f6` | feat(R01): architecture foundation, offline fakes and characterization net |
| `96bec97` | feat(R03): unify provider catalogue, routing decisions and usage telemetry |
| `a53163e` | feat(R04): immutable turn snapshot, typed agent events, conversation service |
| `15e1d3e` | test(R03/R04): cover the three code paths that were changed but never executed |
| `67b8d2e` | docs(refactor): correct the Team Duy scope block in the checklist |
-238
View File
@@ -1,238 +0,0 @@
# BÁO CÁO KẾT QUẢ — TEAM HOA: EPIC R05, R06, R07, R08 (phần Team Hoa)
* **Dự án**: Cowork Local (Cowork-Local BamBOO)
* **Team**: 🟢 Team Hoa — Workspace, Filesystem, Scheduling & Tool Registry
* **Nhánh**: `feature/teamhoa/r05-r08` (tạo từ `origin/feature/deltateam/refactor-plan`, có sẵn nền R01/R03/R04 của Team Duy; đổi tên từ `feature/teamhoa/r05-r06` sau khi gộp thêm R07/R08)
* **Thời gian thực hiện**: 21/08/2026 21:40 → 27/08/2026 20:52
* **Ngày báo cáo**: 27/08/2026 (bản gộp, thay thế `BaoCao_TeamHoa_R05_R06.md` và `BaoCao_TeamHoa_R07_R08.md`)
* **Tài liệu gốc**: `Feature_Architecture_Proposal.md`, `Refactoring_Checklist.md`, `plan.md`
---
## 1. Tóm tắt điều hành
Hoàn tất **toàn bộ 19/19 task thuộc phạm vi Team Hoa** trên 4 EPIC: **R05** (Tool, MCP & Connector Policy), **R06** (Workspace, Filesystem & History Isolation), **R07** (Scheduling & Workflow Runtime), **R08** (UI/Application Separation — phần Team Hoa, T11→T14).
| Chỉ số | Kết quả |
| :--- | :--- |
| Task hoàn thành | **19/19** (R05: 5, R06: 5, R07: 5, R08: 4 — không tính R07-T06/R08-T01→T10 thuộc Team Duy/Team Nam) |
| File thay đổi | 92+ (phần lớn file mới) |
| Test cuối cùng | **377 pass / 4 fail** (xem tiến trình chi tiết ở mục 4) |
| CASAN Check 3 (`scripts/check_imports.py`) | **PASS** — 0 Qt import trong `domain/`, `application/` |
| File production > 400 dòng (file mới) | **0** — lớn nhất `presentation/graph/graph_renderer.py` 391 dòng |
| `python -c "import cowork_local.app"` | **OK** sau mọi task |
**3 lỗi thật phát hiện và sửa**, **1 quyết định kiến trúc đổi so với plan gốc (xác nhận bằng thực nghiệm)** — chi tiết mục 5.
---
## 2. Kết quả theo từng EPIC
### 🔹 EPIC R05 — Tool, MCP & Connector Policy (5/5)
| Task | Sản phẩm | Ghi chú |
| :--- | :--- | :--- |
| R05-T01/T02 | `domain/tools/{tool_descriptor,tool_registry}.py`, `infrastructure/filesystem/{file_tools,command_tools,fetch_tools}.py` | Tách if/elif dispatcher của `core/tools.py`; `core/tools.py` còn lại là shim strangler-fig (re-export `ToolContext`/`ToolError`, dispatch qua dict), 566 ➔ 291 dòng. |
| R05-T03 | `application/conversations/tool_policy_gateway.py::ToolPolicyGateway` | Thay 2 chỗ check hardcode riêng biệt (`chat_agent.py`, `code_agent.py`) bằng 1 lookup capability chung. |
| R05-T04 | Sửa `core/chat_agent.py`, `core/mcp_client.py` | **Thay đổi hành vi có chủ đích** — xem mục 5, Lỗi 1. |
| R05-T05 | `infrastructure/mcp/mcp_source_manager.py::McpToolSourceManager` | Tách lifecycle connection MCP khỏi `state.py::AppContext`. |
### 🔹 EPIC R06 — Workspace, Filesystem & History Isolation (5/5)
| Task | Sản phẩm | Ghi chú |
| :--- | :--- | :--- |
| R06-T01 | `domain/workspaces/workspace_session.py::WorkspaceSession` | Snapshot bất biến (project_id/workspace_root/sandbox_dir/allowed_paths) + `is_allowed(path)`. |
| R06-T02 | `infrastructure/persistence/json/{atomic_write,workspace_repository_impl,conversation_repository_impl}.py` | **Sửa bug thật** — xem mục 5, Lỗi 2. |
| R06-T03 | `infrastructure/filesystem/execution_workspace.py::ExecutionWorkspace` | Đặt tên cho quy ước `.scratch` đã có sẵn. |
| R06-T04 | Sửa `ui/chat_panel.py` | **Sửa race condition thật** — xem mục 5, Lỗi 3. |
| R06-T05 | `application/workspaces/file_workspace_service.py::FileWorkspaceService` | Seam cho File Explorer/AI Editor gọi `execute_tool` giống agent — **chưa có call site thật lúc R06 xong; đã nối dây ở R08-T12** (xem mục 5). |
### 🔹 EPIC R07 — Scheduling & Workflow Runtime (5/5, phạm vi Team Hoa)
| Task | Sản phẩm | Ghi chú |
| :--- | :--- | :--- |
| R07-T01 | `infrastructure/persistence/json/task_repository_impl.py::TaskRepository` | Bọc CRUD của `core/tasks.py`. **Sửa bug thật**: `save_task` trước đây ghi không atomic — cùng lớp bug đã sửa ở R06-T02. |
| R07-T02 | `domain/tasks/schedule_calculator.py::ScheduleCalculator` | Tách "schedule math" (cron/interval/daily/weekly/monthly + holiday exclusion) thành pure Python, trước đây **0 test**, giờ có 14 test. |
| R07-T03 | `infrastructure/qt/qt_scheduler_clock.py::QtSchedulerClock` | Bọc `QTimer` sau 1 interface nhỏ, inject qua `clock=`. **Đổi vị trí so với plan gốc** — xem mục 5. |
| R07-T04 | `application/scheduling/task_application_service.py::TaskApplicationService` | Gom CRUD + luật kéo-thả Kanban (`move_to_status`). |
| R07-T05 | `application/scheduling/ai_task_planner_service.py::AiTaskPlannerService` | Seam cho `plan_tasks`/`import_tasks`. |
*(R07-T06 `Co4EWorkflowService` là việc Team Nam — không đụng.)*
### 🔹 EPIC R08 — UI/Application Separation (4/4, phạm vi Team Hoa: T11→T14)
`presentation/` **chưa tồn tại** trước task này — Team Hoa tạo cấu trúc lần đầu.
| Task | God file gốc | Tách thành |
| :--- | :--- | :--- |
| R08-T11 | `ui/schedule_task_tab.py` (795 dòng) | `presentation/scheduling/{kanban_board_widget,calendar_view_widget,ai_task_creator_dialog,ai_task_import_dialog,run_history_dialog}.py` + shell |
| R08-T12 | `ui/folder_tab.py` (1587 dòng — lớn nhất) | `presentation/folder/{workspace_file_tree,document_preview_manager,code_editor,office_document_renderer,ai_file_editor_dialog,ai_edit_model_resolver,ai_edit_pipeline}.py` + shell |
| R08-T13 | `ui/dashboard_tab.py` (437 dòng) | `presentation/dashboard/{token_usage_card_widget,usage_chart_widget,habits_widget}.py` + shell, `application/monitoring/dashboard_query_service.py` |
| R08-T14 | `ui/structure_graph_view.py` (1035 dòng) | `presentation/graph/{graph_scene_items,graph_renderer,graph_messages_view,graph_qa_widget}.py` + shell |
*(R08-T01→T10 thuộc Team Duy/Team Nam — không đụng.)* Mỗi god-file cũ chỉ có 1-2 nơi khởi tạo thật (`app.py`, `ui/workspace_tab.py`) nên đã **sửa thẳng import site** và **xoá hẳn file `ui/*.py` cũ** thay vì giữ shim (khác `core/tools.py` ở R05, có hàng chục call site).
---
## 3. Kiến trúc sau refactor
```text
presentation/ (MỚI ở R08 — Team Hoa tạo cấu trúc lần đầu)
scheduling/ {kanban_board_widget, calendar_view_widget,
ai_task_creator_dialog, ai_task_import_dialog,
run_history_dialog, schedule_task_tab}.py
folder/ {workspace_file_tree, document_preview_manager, code_editor,
office_document_renderer, ai_file_editor_dialog,
ai_edit_model_resolver, ai_edit_pipeline, folder_tab}.py
dashboard/ {token_usage_card_widget, usage_chart_widget,
habits_widget, dashboard_tab}.py
graph/ {graph_scene_items, graph_renderer, graph_messages_view,
graph_qa_widget, structure_graph_view}.py
shared/ web_engine_support.py (HAS_WEB_ENGINE dùng chung)
│
▼
application/ conversations/tool_policy_gateway.py ← ALLOW/CONFIRM cho mọi tool call (R05)
workspaces/{file_workspace_service, file_preview_helpers,
ai_edit_output, graph_index_service}.py (R06, R08)
scheduling/{task_application_service, ai_task_planner_service}.py (R07)
monitoring/dashboard_query_service.py (R08)
│ (100% pure Python — check_imports.py chặn import Qt)
▼
domain/ tools/{tool_descriptor,tool_registry}.py ← capability + catalogue (R05)
workspaces/workspace_session.py ← snapshot workspace bất biến (R06)
tasks/schedule_calculator.py ← due-time/cron math thuần Python (R07)
▲
infrastructure/ filesystem/{file_tools,command_tools,fetch_tools,tool_context,execution_workspace}.py (R05/R06)
mcp/mcp_source_manager.py ← lifecycle connection MCP (R05)
persistence/json/{atomic_write,workspace_repository_impl,
conversation_repository_impl,task_repository_impl}.py (R06/R07)
qt/qt_scheduler_clock.py ← QTimer đằng sau 1 interface nhỏ (R07)
```
**Nguyên tắc di trú xuyên suốt cả 4 EPIC (ADR-001 mục 4, tiếp nối cách Team Duy làm ở R04)**: **không viết lại engine**. `core/tools.py::execute_tool`, `core/chat_agent.py::run_cowork`, `core/tasks.py`, `core/task_scheduler.py`, `core/task_executors.py` vẫn là engine bên dưới — tầng mới chỉ sở hữu phần từng nằm rải rác/hardcode trong widget hoặc dispatcher. `pytest` xanh liên tục giữa các bước, chạy full suite sau MỖI task.
**Riêng ở R08**: khi 1 file bị tách vượt 400 dòng dù đã theo đúng mapping gốc, đã tách thêm file phụ theo kiểu **composition** (class phụ nhận `owner` là widget chính) thay vì service riêng — ví dụ `run_history_dialog.py`, `office_document_renderer.py`, `ai_edit_pipeline.py`, `ai_edit_model_resolver.py`, `graph_messages_view.py`. Đây là split kỹ thuật để đạt giới hạn LOC, không phải ranh giới tầng kiến trúc.
---
## 4. Bằng chứng kiểm thử
### Tiến trình test qua từng EPIC
| Mốc | Tổng pass | Ghi chú |
| :--- | ---: | :--- |
| Sau R05+R06 | 283 (/287, 4 fail) | +41 unit test + 2 integration test (Qt offscreen thật) |
| Sau R07 | 328 | +45 test mới (task repo, schedule calculator, Qt clock, task/AI-planner services) |
| Sau R08 | **377** | +49 test mới (4 widget split, mỗi cái có unit + integration Qt offscreen) |
**4 fail cuối cùng — cùng 1 baseline có sẵn từ trước, xuyên suốt cả 4 EPIC, không phải do Team Hoa**:
* `tests/test_config_security.py` × 2 (EPIC R02/Team Nam — `config.py` hardcode `sandbox_pw`)
* `tests/unit/test_routing_wiring.py` × 2 (môi trường máy này có Ollama/llama3.1 thật, khác giả định "fresh install" của test)
### Đối chiếu Definition of Done
| # | Tiêu chí | Kết quả |
| :--- | :--- | :--- |
| 1 | Mọi file mới < 400 dòng | ✅ Lớn nhất: `presentation/graph/graph_renderer.py` 391 dòng |
| 2 | 0 import Qt trong `domain/`, `application/` | ✅ `check_imports.py` PASS |
| 3 | Comment tiếng Anh giải thích lý do ở mọi khối sửa/mới | ✅ |
| 4 | Có unit/contract/integration test, verify bằng chạy thật | ✅ +90 test mới sau R05/R06 lên tới 377; mọi widget Qt test bằng offscreen thật, không double |
| 5 | Không hồi quy | ✅ 377/381 pass — 4 fail cùng 1 baseline có sẵn, không đổi qua 4 EPIC |
| 6 | Ghi Start/End vào Checklist | ✅ 19 task đã tick kèm mốc thời gian thật |
| 7 | Cổng CASAN (`run_quality_gate.py`, R10-T02) | ⚠️ Chưa viết (thuộc R10, chưa tới lượt) — Check 3 đã PASS |
---
## 5. Lỗi thật phát hiện & quyết định kiến trúc
### 🔴 Lỗi 1 (R05-T04) — Tool MCP/Connector chạy hoàn toàn không qua permission gate
`core/chat_agent.py::run_cowork` có 2 nhánh dispatch tool call: built-in đi qua gate xác nhận khi Settings bật "confirm before running commands"; nhánh `extra_tools` (mọi tool từ MCP server hoặc Connector) gọi thẳng `extra_executor(name, args)` **không qua bước xác nhận nào**. Đây không phải khác biệt thiết kế — không có ghi chú, không có toggle riêng.
*Sửa*: mọi `extra_tools` gắn `ToolCapability` mặc định bảo toàn (`WRITE|EXECUTE|NETWORK`), đi qua CÙNG `ToolPolicyGateway` với built-in tools. **Thay đổi hành vi người dùng sẽ thấy**: tool MCP/connector giờ hỏi xác nhận khi "confirm before running commands" bật. Test: `tests/unit/test_cowork_extra_tool_policy.py`.
### 🟠 Lỗi 2 (R06-T02, R07-T01) — Ghi file không atomic ở 3 nơi
`core/projects.py::save_project`, `core/history.py::save_conversation/rename_conversation/set_pinned` (R06), và `core/tasks.py::save_task` (R07) đều từng dùng `path.write_text(json.dumps(...))` trần — crash giữa lúc ghi để lại file JSON hỏng, và hàm `load_*` tương ứng coi file hỏng như "không tồn tại" → **mất project/hội thoại/task âm thầm, không báo lỗi**. Cả 4 điểm ghi giờ qua `infrastructure/persistence/json/atomic_write.py::write_json` (temp file + `os.replace`). Có test giả lập crash giữa lúc ghi xác nhận file cũ không bị hỏng, cho cả 2 đợt sửa.
### 🟡 Lỗi 3 (R06-T04) — Turn chạy ngầm lưu nhầm lịch sử vào project khác
`ui/chat_panel.py::_persist_session` gọi `save_conversation(self.ctx.config.history_dir(), ...)`, đọc `config._project_history_dir` — field dùng chung bị `ui/workspace_tab.py::_load_current` ghi đè mỗi lần đổi project. Một turn chạy ngầm ở project A hoàn tất SAU khi user đã chuyển sang project B thì bị lưu nhầm vào lịch sử của B.
*Sửa*: thêm `"home_history_dir"` vào dict `ctx` mỗi turn, chụp giá trị tại lúc submit thay vì đọc sống lúc lưu. Test Qt offscreen thật: `tests/integration/test_history_dir_race.py`.
### 🔵 Quyết định kiến trúc (R07-T03) — `platform/qt/` đè lên module chuẩn `platform` của Python
Plan gốc đặt tên `platform/qt/qt_scheduler_clock.py`. Thực nghiệm trước khi viết:
```bash
cd <repo_root> && python -c "import cowork_local; import platform; print(platform.system())"
```
Sau khi tạo `platform/__init__.py`, lệnh trên báo lỗi `AttributeError: module 'platform' has no attribute 'system'` — bất cứ khi nào repo root nằm trực tiếp trên `sys.path` (không qua `__main__.py`'s parent-dir fixup), `import platform` phân giải nhầm vào package cục bộ. `core/windows_sandbox_vm.py`/`core/appcontainer_sandbox.py` đều `import platform`.
*Sửa*: chuyển sang `infrastructure/qt/qt_scheduler_clock.py` (không tạo package `platform/` mới) — đúng layer, cùng cấp `infrastructure/{filesystem,mcp,persistence,providers,telemetry}/`. Verify lại: PASS.
### Nối dây `FileWorkspaceService` (R06-T05 → R08-T12)
Báo cáo R06 để lại nợ: `FileWorkspaceService` (R06-T05) chưa có call site thật. Xác nhận lại bằng grep trước R08-T12: đúng 0 occurrence trong `ui/folder_tab.py`. Khi tách `document_preview_manager.py` (R08-T12), mọi điểm ghi text thuần (`save`, `create_new_file`, `write_content`) chuyển sang gọi `FileWorkspaceService.write_file` — dùng `WorkspaceSession.unscoped(...)` vì Folder Explorer duyệt bất kỳ thư mục nào, không giới hạn 1 project sandbox. Nhánh ghi `.pptx` (binary) vẫn giữ nguyên đường cũ.
**Tác dụng phụ có lợi**: `infrastructure/filesystem/file_tools.py::write_file` đã có sẵn cảnh báo cú pháp Python và tự build `.xlsx` thật từ text — 2 hành vi này **trước đây không tồn tại** trên đường ghi cũ của `folder_tab.py`, giờ được hưởng miễn phí.
---
## 6. Cải thiện phụ (không nằm trong yêu cầu task)
| Cải thiện | Ảnh hưởng |
| :--- | :--- |
| `McpServerConnection.is_alive()` (R05, `core/mcp_client.py`) | Cho `McpToolSourceManager` biết một connection cached đã chết để khởi động lại. |
| `domain/tasks/schedule_calculator.py` có bộ test riêng (R07) | `core/tasks.py` tự nhận "Qt-free, unit-testable" nhưng **0 test tồn tại** cho cron/interval/holiday-exclusion trước R07-T02. Giờ 14 test. |
| `AiEditModelResolver.routed_provider`/`.routed_model` (R08-T12, property public mới) | Cần thêm để giữ `tests/integration/test_routing_surfaces.py`'s 2 test AI-Edit routing sau khi lớp routing chuyển từ `FolderTab` sang `AiEditModelResolver` — tránh hồi quy 1 test có từ EPIC R03. |
---
## 7. Còn nợ & cần quyết định
| # | Nội dung | Người quyết |
| :--- | :--- | :--- |
| 1 | **Xung đột quy hoạch thư mục `infrastructure/persistence/json/atomic_write.py`** (R06) với `atomic_json_file.py` do Team Nam quy hoạch ở R02-T01 — chưa có xung đột file thật, cần xác nhận hợp nhất hay giữ 2 module song song. | Team Nam |
| 2 | **Xung đột quy hoạch thư mục `application/monitoring/`** (R08-T13, `dashboard_query_service.py`) — quy hoạch cho Team Nam ở R08-T07→T10, nhưng plan gốc lại đặt file Dashboard vào đúng thư mục này. Chưa có xung đột file thật (thư mục trống trước đó). | Team Nam |
| 3 | **`WorkspaceRepository`/`ConversationRepository` (R06) vẫn chưa có call site sản xuất thật** — R08-T12 chỉ nối `FileWorkspaceService`, chưa đụng 2 repository kia. | Chưa có EPIC nào nhận |
| 4 | **AI-Edit pipeline (`ai_edit_pipeline.py`) và Graph Q&A ask-flow (`graph_qa_widget.py::_ask`) chưa có test end-to-end thật** — cả 2 chạy trên `AgentWorker` (QThread) thật, và **vốn dĩ đã không có test nào trước khi refactor** (xác nhận bằng grep). | Có thể thuộc phạm vi R10 Testing Pyramid |
| 5 | **Dev tooling chưa cập nhật đường dẫn cũ**: `tools/check_controls_alive.py`, `tools/capture_screens.py`, `tools/build_audit_page.py`, `docs/screens/*.json`, `docs/ui-audit*.html` vẫn tham chiếu `ui/schedule_task_tab.py`/`ui/folder_tab.py`/`ui/dashboard_tab.py`/`ui/structure_graph_view.py` (không còn tồn tại). Không nằm trong `tests/`, không ảnh hưởng CI. | Chưa quyết định người phụ trách |
---
## 8. Phạm vi chưa kiểm thử
* **R05-T04 (gate cho MCP/connector) chưa test với MCP server thật** — dùng `ToolSpec` giả, chưa thử `core/mcp_client.py::McpServerConnection` chạy subprocess thật.
* **`McpToolSourceManager` (R05-T05) chưa test với subprocess MCP thật** — dùng `_FakeConnection`.
* **`presentation/folder/ai_edit_pipeline.py`** (toàn bộ luồng plan → edit → apply/discard qua `AgentWorker` streaming) — chỉ verify bằng import/construction, chưa gửi instruction qua worker thật.
* **`presentation/graph/graph_qa_widget.py::_ask`** — tương tự, chỉ test phần không cần AgentWorker thật.
* **`office_document_renderer.py`'s PDF/LibreOffice conversion path** — chưa xác nhận kịch bản fallback trên máy không có QtPdf/LibreOffice bằng test thật.
* **Đã mở app thật bằng `python -c "import cowork_local.app"` sau mỗi task** để xác nhận không lỗi import — **chưa** mở app GUI thật, thao tác tay qua toàn bộ 4 màn hình đã tách để xác nhận trải nghiệm người dùng cuối.
---
## 9. Việc kế tiếp của Team Hoa
Toàn bộ 4 EPIC thuộc phạm vi Team Hoa (R05, R06, R07, R08 phần T11→T14) đã **hoàn tất**. Các bước còn lại không thuộc EPIC riêng của Team Hoa nữa:
| Việc | Điều kiện |
| :--- | :--- |
| Checkpoint 2 (Services & Sub-widgets, 28/08) | Cần Team Duy (R08-T01→T06) và Team Nam (R08-T07→T10) xong phần UI split của họ |
| CASAN Check 2 (Modularity/LOC, Team Hoa chủ trì, 30/08) | `scripts/check_loc.py` chưa tồn tại (thuộc R10-T02, Team Duy) |
| Giải quyết mục 7 #1, #2 | Khi Team Nam bắt đầu R02-T01 và R08-T07→T10 |
| R10 (Testing, Packaging & Contributor Experience) | Team Duy chủ trì, chờ 3 team hoàn tất |
---
## 10. Lịch sử commit
| Commit | Nội dung |
| :--- | :--- |
| `ae4fe72` | feat(R05): tool capability registry, unified policy gateway, MCP lifecycle manager |
| `cf542b7` | feat(R06): workspace session snapshot, atomic persistence, history-dir race fix |
| `69ab8e1` | feat(R07): task repository, schedule calculator, Qt clock adapter, task/AI-planner services |
| `0e51356` | feat(R08): split ScheduleTaskTab, FolderTab, DashboardTab, StructureGraphView |
| *(gộp báo cáo)* | docs(refactor): merge Team Hoa reports R05→R08 into one; rename branch to `feature/teamhoa/r05-r08` |
+768
View File
@@ -0,0 +1,768 @@
<!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 &amp; 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 &amp; 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 &amp; 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) -&gt; 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 &amp; 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_&lt;tên&gt;.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 &amp; 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> &nbsp; 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 &gt;400 dòng · cập nhật checker</td>
<td class="c2">Nộp factory · dọn file &gt;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> &nbsp; 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> &nbsp; <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> &nbsp; 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>
+189
View File
@@ -0,0 +1,189 @@
# 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
+129 -170
View File
@@ -20,111 +20,6 @@
---
## 📊 TIẾN ĐỘ THỰC TẾ — TEAM DUY (cập nhật `2026-08-21 10:55`)
> [!NOTE]
> ### ✅ ĐÃ HOÀN TẤT: 16/16 task của **R01, R03, R04** — đã commit & push lên nhánh `feature/deltateam/refactor-plan`
>
> | EPIC | Task | Trạng thái |
> | :--- | :--- | :--- |
> | **R01** Architecture Foundation | T01 → T05 | ✅ 5/5 |
> | **R03** Providers & Routing | T01 → T06 | ✅ 6/6 |
> | **R04** Agent Runtime & Conversation | T01 → T05 | ✅ 5/5 |
>
> **Kiểm chứng (chạy thật, không phải ước lượng):**
> * `pytest tests/` ➔ **243 pass / 2 fail** trong 44s
> * Suite nhanh (`unit + contracts + characterization + routing`) ➔ **218 pass trong 1,16s** (đạt yêu cầu CASAN "A – Automated Tests < 1s cho unit")
> * `python scripts/check_imports.py` ➔ **PASS** (0 Qt import trong `domain/`, `application/`)
> * Mọi file production mới **< 400 dòng** (lớn nhất: `routing_application_service.py` 353 dòng)
> * 2 test fail là **lỗi có sẵn từ trước**, thuộc EPIC **R02**: `config.py` vẫn hardcode `sandbox_pw = "quandh14"` ➔ `tests/test_config_security.py` đỏ
>
> ### 📍 PHẠM VI TEAM DUY & PHẦN CÒN LẠI
> Theo `Feature_Architecture_Proposal.md` (dòng 7) và `DeltaTeam_prompt.md` (dòng 17), Team Duy chủ trì **R01, R03, R04, R08 (phân hệ Chat UI), R10**.
> * ✅ **R01, R03, R04** — xong 16/16 task, đã push.
> * ⬜ **R08 (R08-T01 ➔ R08-T06)** — chưa bắt đầu: tách `ui/chat_panel.py` (1.795 dòng) thành 6 widget < 400 dòng.
> * ⬜ **R10** — làm sau cùng, chờ 3 team hoàn tất.
> * **R02 thuộc 🟣 Team Nam** (xem mục EPIC R02 bên dưới) — đây là nguyên nhân 2 test đỏ ở trên, không phải việc của Team Duy.
>
> ### 📄 BÁO CÁO CHI TIẾT
> Xem `docs/refactor/BaoCao_TeamDuy_R01_R03_R04.md` — kết quả từng EPIC, bằng chứng kiểm thử, 3 lỗi thật đã phát hiện, và phạm vi **chưa** kiểm thử.
>
> ### 📌 CÒN NỢ / CẦN QUYẾT ĐỊNH
> 1. `ProviderRegistry` **chưa nối** vào `state.build_provider_for` (vẫn dùng `providers/factory.py`). Nối vào sẽ sửa luôn lỗi: usage của `ollama`/`github_copilot`/`codex` hiện bị ghi nhận nhầm thành `openai_compat` trên Dashboard — nhưng làm vậy sẽ **đổi cách gom dữ liệu lịch sử**.
> 2. Mode `fallback` đã hỗ trợ ở config + service nhưng **chưa có trên toggle UI** (thuộc R08).
> 3. Đã sửa 2 dòng trong `config.py` (`routing_mode_for` / `set_routing_mode_for`) để dùng chung một bộ từ vựng mode — **cần báo Team Nam** vì file này đang được refactor ở R02.
> 4. Circular import `core/model_pricing.py` ↔ `core/usage_tracker.py` **chưa xử lý** (task ngày 28/08).
> 5. Việc kế tiếp của Team Duy là **R08 phân hệ Chat UI** (6 widget con), rồi **R10** sau cùng.
---
## 📊 TIẾN ĐỘ THỰC TẾ — TEAM HOA (cập nhật `2026-08-21 22:57`)
> [!NOTE]
> ### ✅ ĐÃ HOÀN TẤT: 10/10 task của **R05 + R06** — branch `feature/teamhoa/r05-r06` (tạo từ `origin/feature/deltateam/refactor-plan`, có sẵn nền R01/R03/R04)
>
> | EPIC | Task | Trạng thái |
> | :--- | :--- | :--- |
> | **R05** Tool, MCP & Connector Policy | T01 → T05 | ✅ 5/5 |
> | **R06** Workspace, Filesystem & History Isolation | T01 → T05 | ✅ 5/5 |
>
> **Kiểm chứng (chạy thật):**
> * `pytest tests/` ➔ **283 pass / 4 fail** (+41 test mới cho R05+R06, gồm 2 test Qt offscreen thật trong `tests/integration/test_history_dir_race.py`)
> * 4 fail là **lỗi có sẵn từ trước**, không liên quan R05/R06: 2 trong `test_config_security.py` (EPIC R02, đã ghi nhận bởi Team Duy) + 2 trong `test_routing_wiring.py` (môi trường máy này có Ollama/llama3.1 thật + config routing cục bộ khác "fresh install").
> * `python scripts/check_imports.py` ➔ **PASS** (0 Qt import trong `domain/`, `application/`)
> * Mọi file mới **< 400 dòng** (lớn nhất: `domain/tools/tool_registry.py` 125 dòng). `core/tools.py` giảm từ 566 ➔ 291 dòng.
>
> ### 📄 BÁO CÁO CHI TIẾT
> Xem `docs/refactor/BaoCao_TeamHoa_R05_R08.md` (báo cáo gộp R05→R08) — kết quả từng EPIC, bằng chứng kiểm thử, 2 lỗi thật đã phát hiện (permission gate bị bỏ qua cho MCP tools, race condition lưu nhầm lịch sử), và phạm vi **chưa** kiểm thử.
>
> ### 🔧 TÓM TẮT R06
> * **R06-T01**: `domain/workspaces/workspace_session.py::WorkspaceSession` — snapshot bất biến (project_id, workspace_root, sandbox_dir, allowed_paths) + `is_allowed(path)`.
> * **R06-T02**: `infrastructure/persistence/json/{workspace_repository_impl,conversation_repository_impl}.py` bọc `core/projects.py`/`core/history.py`. **Đã sửa bug thật**: `save_project`/`save_conversation`/`rename_conversation`/`set_pinned` trước đây `path.write_text()` không atomic (crash giữa lúc ghi = file JSON hỏng, `load_project`/`load_conversation` coi file hỏng như "không tồn tại" — mất project/hội thoại âm thầm). Giờ cả 4 hàm ghi qua `infrastructure/persistence/json/atomic_write.py::write_json` (temp file + `os.replace`). Có test giả lập crash giữa lúc ghi xác nhận file cũ không bị hỏng.
> * **R06-T03**: `infrastructure/filesystem/execution_workspace.py::ExecutionWorkspace` — đặt tên cho quy ước `.scratch` đã có sẵn (không đổi vị trí file).
> * **R06-T04**: Sửa race trong `ui/chat_panel.py` (không phải trực tiếp `_load_current`, xem "còn nợ" #2). `ChatPanel._persist_session` (lưu hội thoại của turn CHẠY NGẦM, không phải conversation đang xem) trước đây gọi `self.ctx.config.history_dir()` SỐNG tại thời điểm turn xong — nếu user đổi project khi turn còn chạy (`_load_current` ghi `config._project_history_dir`), turn nền lưu nhầm vào thư mục lịch sử của project MỚI. Fix: thêm `"home_history_dir"` vào dict `ctx` per-turn đã có sẵn (cùng quy ước với `home_id`/`home_messages`/`home_title`), chụp tại lúc submit. Test thật bằng Qt offscreen: `tests/integration/test_history_dir_race.py`.
> * **R06-T05**: `application/workspaces/file_workspace_service.py::FileWorkspaceService` — cho File Explorer/AI Editor gọi `execute_tool` (list_dir/read_file/write_file/edit_file) giống agent, không tự viết lại logic.
>
> ### 🔧 TÓM TẮT R05
> * **R05-T01/T02**: `core/tools.py`'s if/elif dispatcher tách thành `infrastructure/filesystem/{file_tools,command_tools,fetch_tools,tool_context}.py` + `domain/tools/{tool_descriptor,tool_registry}.py`. `core/tools.py` còn lại là shim strangler-fig (re-export `ToolContext`/`ToolError`, dispatch qua dict).
> * **R05-T03**: `application/conversations/tool_policy_gateway.py::ToolPolicyGateway` — thay `if gate is not None and name in ("run_command","install_package")` (chat_agent.py) và `if name in (WRITE_TOOLS|MS365_WRITE_TOOLS)` (code_agent.py) bằng một lookup capability chung. Đã verify bằng test: đúng 2 tool cũ vẫn được gate, không tool nào khác bị ảnh hưởng.
> * **R05-T04 — ⚠️ THAY ĐỔI HÀNH VI CÓ CHỦ ĐÍCH**: trước đây MCP/connector/ext-connector tools (`core/mcp_client.py`, `core/ext_connectors.py`) chạy qua `extra_executor(name, args)` **không hề qua permission gate**. Giờ mọi `extra_tools` được gắn capability mặc định (`WRITE|EXECUTE|NETWORK`, vì MCP không có chuẩn khai báo rủi ro) và đi qua CÙNG `ToolPolicyGateway` như built-in tools. Khi Settings có "confirm before running commands" bật, tool MCP/connector giờ sẽ hỏi xác nhận — người dùng SẼ thấy thêm prompt so với trước. Test: `tests/unit/test_cowork_extra_tool_policy.py`.
> * **R05-T05**: `infrastructure/mcp/mcp_source_manager.py::McpToolSourceManager` — tách lifecycle connection (cache/lock/start-or-skip) ra khỏi `state.py::AppContext` (trước đây inline trong `_mcp_connections`/`_conn_lock`). `AppContext` giờ chỉ gọi `self._mcp_manager.ensure/stop/stop_all`. `_ext_connections` (Connectors CAD/CAE/MS365/Other) KHÔNG thuộc phạm vi T05, vẫn giữ `_conn_lock` riêng như cũ.
>
> ### 📌 CÒN NỢ / CẦN QUYẾT ĐỊNH
> 1. **Xung đột file với EPIC R02 (Team Nam)**: R02-T01 giao `infrastructure/persistence/json/atomic_json_file.py` cho Team Nam. R06-T02 cần atomic write NGAY (bug thật, không chờ được) nên đã tạo `infrastructure/persistence/json/atomic_write.py` — tên khác, cùng thư mục, không đụng file của Team Nam. `core/projects.py`/`core/history.py` đang dùng module này trực tiếp. **Cần Team Nam xác nhận khi bắt đầu R02-T01**: nên hợp nhất `atomic_write.py` vào `atomic_json_file.py` (Team Hoa đổi 4 import) hay giữ 2 module riêng (rủi ro trôi giữa 2 cách ghi atomic).
> 2. **`WorkspaceRepository`/`ConversationRepository`/`FileWorkspaceService` chưa có nơi gọi thật** — giống tình trạng `ProviderRegistry` của Team Duy ở R03. Mọi call site sản xuất (`ui/workspace_tab.py`, `ui/folder_tab.py`, `state.py`, task executors) vẫn dùng trực tiếp `core/projects.py`/`core/history.py`/`core/tools.py::execute_tool` — các class mới là seam cho tầng application ở EPIC sau (R07/R08), chưa nối dây.
> 3. **R06-T04 phạm vi thực tế khác một chút so với mô tả gốc**: bug không nằm ở `ui/workspace_tab.py::_load_current` (hàm đó chỉ *set* `config._project_history_dir`, không tự đọc lại nó) mà ở `ui/chat_panel.py::_persist_session` — nơi một turn chạy ngầm đọc SỐNG giá trị đó lúc turn xong. Đã sửa đúng điểm đọc, có test Qt offscreen thật (`tests/integration/test_history_dir_race.py`), nhưng chưa đổi kiến trúc `_load_current` như plan gốc gợi ý (dùng session id thay biến toàn cục) — việc đó cần tách `ChatPanel`/`WorkspaceTab` sâu hơn, thuộc phạm vi R08 (UI/Application Separation).
> 4. R05/R06 xong toàn bộ — Team Hoa chờ chỉ đạo cho **R07** (Scheduling & Workflow Runtime, phối hợp Team Nam) hoặc merge/review trước khi tiếp tục.
---
## 📊 TIẾN ĐỘ THỰC TẾ — TEAM HOA, R07 + R08 (cập nhật `2026-08-27 20:52`)
> [!NOTE]
> ### ✅ ĐÃ HOÀN TẤT: 9/9 task phạm vi Team Hoa của **R07 + R08** — cùng branch `feature/teamhoa/r05-r06`
>
> | EPIC | Task (phạm vi Team Hoa) | Trạng thái |
> | :--- | :--- | :--- |
> | **R07** Scheduling & Workflow Runtime | T01 → T05 | ✅ 5/5 (T06 Co4EWorkflowService là Team Nam) |
> | **R08** UI/Application Separation | T11 → T14 | ✅ 4/4 (T01-T10 là Team Duy/Team Nam) |
>
> **Kiểm chứng (chạy thật):**
> * `pytest tests/` ➔ **377 pass / 4 fail** (+94 test mới cho R07+R08 — 328 sau R07, 377 sau R08)
> * 4 fail là **lỗi có sẵn từ trước**, giống hệt baseline đã ghi nhận ở R05/R06 (2× `test_config_security.py` EPIC R02, 2× `test_routing_wiring.py` môi trường máy)
> * `python scripts/check_imports.py` ➔ **PASS** (0 Qt import trong `domain/`, `application/`)
> * Mọi file mới **< 400 dòng** (lớn nhất: `presentation/graph/graph_renderer.py` 391 dòng)
> * `python -c "import cowork_local.app"` ➔ OK sau mỗi task (app khởi động được với toàn bộ import mới)
>
> ### 📄 BÁO CÁO CHI TIẾT
> Xem `docs/refactor/BaoCao_TeamHoa_R05_R08.md` (báo cáo gộp R05→R08) — kết quả từng task, 1 quyết định kiến trúc đổi so với plan gốc (đã thực nghiệm xác nhận), việc "nối dây" `FileWorkspaceService` (nợ từ R06-T05), và phạm vi **chưa** kiểm thử.
>
> ### 📌 CÒN NỢ / CẦN QUYẾT ĐỊNH
> 1. **`application/monitoring/` mới tạo ở R08-T13** (`dashboard_query_service.py`) nhưng thư mục này được quy hoạch cho Team Nam (R08-T07→T10). Chưa có xung đột file thật (thư mục trống trước đó) nhưng **cần Team Nam xác nhận** khi bắt đầu phần Monitoring của họ — xem chi tiết trong báo cáo.
> 2. **R07-T03 đổi vị trí so với plan gốc**: `platform/qt/qt_scheduler_clock.py` ➔ `infrastructure/qt/qt_scheduler_clock.py`, sau khi xác nhận bằng thực nghiệm rằng một package `platform/` ở top-level đè lên module chuẩn `platform` của Python.
> 3. **AI-Edit pipeline (`presentation/folder/ai_edit_pipeline.py`) và Q&A ask-flow (`presentation/graph/graph_qa_widget.py::_ask`) chưa có test end-to-end** — cả hai chạy trên `AgentWorker` (QThread) thật và **vốn đã không có test nào từ trước khi refactor** (xác nhận bằng grep). Phạm vi test của R08-T12/T14 tập trung vào phần có thể test không cần thread thật (wiring, containment, rendering) — xem mục "Phạm vi chưa kiểm thử" trong báo cáo.
> 4. Chưa `git push` — nhánh cục bộ vẫn chưa lên được Gitea, giống tình trạng đã ghi nhận ở báo cáo R05/R06 mục 7-#1.
---
## 📌 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ệ)
@@ -132,15 +27,15 @@
* **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.
- [x] **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: `2026-08-21 09:56` | End: `2026-08-21 10:00`*
*Start: `2026-08-21 18:23` | End: `2026-08-21 18:24`*
- [x] **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: `2026-08-21 10:00` | End: `2026-08-21 10:02`*
*Start: `2026-08-21 18:24` | End: `2026-08-21 18:26`*
- [x] **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: `2026-08-21 09:58` | End: `2026-08-21 10:05`*
*Start: `2026-08-21 18:26` | End: `2026-08-21 18:28`*
- [x] **R01-T04 (Team Duy)**: Viết Characterization Tests cho `core/chat_agent.py::run_cowork` ➔ `tests/characterization/test_run_cowork.py`
*Start: `2026-08-21 10:02` | End: `2026-08-21 10:04`*
*Start: `2026-08-21 18:28` | End: `2026-08-21 18:32`*
- [x] **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: `2026-08-21 10:04` | End: `2026-08-21 10:05`*
*Start: `2026-08-21 18:32` | End: `2026-08-21 18:35`*
---
@@ -168,17 +63,71 @@
* **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.
- [x] **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: `2026-08-21 10:10` | End: `2026-08-21 10:12`*
*Start: `2026-08-22 18:59` | End: `2026-08-22 19:01`*
- [x] **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: `2026-08-21 10:06` | End: `2026-08-21 10:10`*
*Start: `2026-08-22 18:45` | End: `2026-08-22 18:50`*
- [x] **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: `2026-08-21 10:12` | End: `2026-08-21 10:15`*
*Start: `2026-08-22 18:53` | End: `2026-08-22 18:57`*
- [x] **R03-T04 (Team Duy)**: Di chuyển luồng gọi routing từ `ui/chat_panel.py#L638` sang `RoutingApplicationService`
*Start: `2026-08-21 10:17` | End: `2026-08-21 10:20`*
*Start: `2026-08-22 18:57` | End: `2026-08-22 18:58`*
- [x] **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: `2026-08-21 10:20` | End: `2026-08-21 10:22`*
*Start: `2026-08-22 18:58` | End: `2026-08-22 18:59`*
- [x] **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: `2026-08-21 10:15` | End: `2026-08-21 10:17`*
*Start: `2026-08-22 18:50` | End: `2026-08-22 18:53`*
#### 📦 KẾT QUẢ THỰC HIỆN EPIC R03 (Hoàn tất 2026-08-22 19:01 — nhánh `feature/delta-team/epic-R03`)
**File sản phẩm mới (tất cả < 400 dòng, 100% comment tiếng Anh):**
| Task | File | LOC | Nội dung chính |
| :--- | :--- | :---: | :--- |
| T02 | `domain/models/provider_descriptor.py` | 196 | `ProviderDescriptor` (frozen dataclass), `WireProtocol`, `AuthKind`; giá/context để `None` khi chưa biết thay vì đoán bừa |
| T02 | `infrastructure/providers/provider_registry.py` | 287 | `ProviderRegistry` thread-safe: tra cứu theo id/alias, **tra cứu động theo model ID** (`find_by_model`), dựng adapter theo wire protocol; `BUILTIN_DESCRIPTORS` cho 5 provider |
| T03 | `application/model_routing/routing_models.py` | 158 | DTO thuần Python: `RoutingMode` (Off/Auto/Manual/**Fallback**), `RoutingRequest` (immutable snapshot), `RouteEvaluation`, `RoutingOutcome` |
| T03 | `application/model_routing/routing_application_service.py` | 236 | `RoutingApplicationService` — 1 nơi duy nhất quyết định routing; 2 port hẹp (`RoutingDecisionPort`, `ModeResolver`) + callback confirm ⇒ 0 phụ thuộc Qt |
| T03 | `application/model_routing/core_routing_adapter.py` | 169 | `CoreRoutingEngine` (cầu nối sang `core/routing`), `AppContextModeResolver`, `build_routing_application_service(ctx)` (cache 1 instance/ctx) |
| T06 | `infrastructure/telemetry/usage_sink.py` | 288 | `UsageEvent` + `UsageEventSink` (Protocol) + `UsageTrackerSink` / `InMemoryUsageSink` / `CompositeUsageSink`; publish không bao giờ raise |
**File hiện hữu được sửa (đều có comment tiếng Anh tại mọi khối thay đổi):**
| File | Thay đổi |
| :--- | :--- |
| `providers/factory.py` | Bỏ bảng `_REGISTRY` nội bộ, ủy quyền cho `ProviderRegistry`; vẫn raise `ProviderError` để không vỡ call site cũ |
| `providers/openai_compat.py`, `providers/anthropic.py` | Không còn gọi thẳng `core/usage_tracker`; chỉ **publish** `UsageEvent` qua sink (T06) |
| `ui/chat_panel.py` (#L638), `ui/co4e_tab.py`, `ui/folder_tab.py` | Xóa 3 bản sao logic routing (~35 dòng/file) ➔ gọi chung `RoutingApplicationService` (T04, T05); widget chỉ còn dựng `RoutingRequest`, host modal confirm và render kết quả |
| `config.py`, `state.py`, `ui/routing_toggle.py`, `i18n.py` | Mở đường cho chế độ thứ 4 **Fallback**: hằng `AppConfig.ROUTING_MODES`, validate per-workspace, thêm mục trong combo + chuỗi EN/JA/VI |
| `core/usage_tracker.py` | Thêm `current_context()` để sink mượn/trả lại context của thread thay vì gán đè vĩnh viễn |
| `tests/conftest.py`, `tests/routing/conftest.py` | **Sửa lỗi hạ tầng test nghiêm trọng** (xem "Ghi chú" bên dưới) |
**Bộ test bổ sung (tất cả offline, không cần network/Qt):**
| File | Số test | Phạm vi |
| :--- | :---: | :--- |
| `tests/contracts/test_providers.py` (+ `provider_stubs.py`) | 50 | Contract chạy parametrize trên **mọi** provider trong registry: signature `chat()`, canonical assistant message, tool call chuẩn hóa, đóng response, dịch tool schema, `ProviderError`, `list_models`/`test_connection`, đúng 1 `UsageEvent`/turn |
| `tests/unit/test_routing_application_service.py` | 28 | Đủ 4 chế độ + mọi nhánh degrade (engine lỗi, resolver lỗi, dialog lỗi, thiếu callback) |
| `tests/unit/test_provider_registry.py` | 17 | Descriptor + registry + đối chiếu catalogue với `DEFAULT_CONFIG["providers"]` |
| `tests/unit/test_core_routing_adapter.py` | 12 | Dịch `RouteResult` ⇄ DTO, task type sai định dạng, thiếu ranking, cache service |
| `tests/unit/test_usage_sink.py` | 13 | Fan-out, subscriber lỗi, khôi phục thread context, publish không raise |
| `tests/integration/test_routing_unification.py` | 14 | Chạy `RoutingApplicationService` trên **engine `core/routing` thật**; 3 surface (cowork/co4e/ai_edit) cho ra cùng 1 quyết định |
**Kết quả cổng kiểm duyệt (DoD 7 tiêu chí):**
| # | Tiêu chí | Lệnh | Kết quả |
| :---: | :--- | :--- | :--- |
| 1 | LOC < 400 | `wc -l` các file mới | ✅ Lớn nhất 288 dòng (`usage_sink.py`); `openai_compat.py` 374, `anthropic.py` 332 |
| 2 | Clean Architecture | `python scripts/check_imports.py` | ✅ `[PASS] 0 forbidden imports detected` |
| 3 | Comment tiếng Anh | Review thủ công | ✅ 100% khối code mới/sửa có comment giải thích logic + lý do kiến trúc |
| 4 | Có test tự động | `pytest tests/unit tests/contracts tests/integration` | ✅ 134 test mới, pass 100% |
| 5 | No Regression | `pytest tests/` | ✅ **236 passed in ~2.0s** (nền trước R03: 102 passed) |
| 6 | Timestamps | Bảng trên | ✅ Đã ghi Start/End cho T01–T06 |
| 7 | CASAN Gate | `scripts/run_quality_gate.py` | ⚠️ Script **chưa tồn tại** — thuộc R10-T02 (chưa làm). Đã chạy thay bằng `check_imports.py` + `pytest tests/` |
**Ghi chú kỹ thuật cần biết khi review:**
1. **Đã sửa 1 lỗi hạ tầng test có thể gây kết quả sai lệch**: `tests/conftest.py` cũ đẩy thư mục **cha** của repo vào `sys.path`, nên `import cowork_local.*` (dùng bởi `tests/routing/*` và `tests/characterization/*`) trỏ sang **một checkout `cowork_local` khác** nằm cạnh thư mục làm việc — test vẫn báo xanh nhưng chạy trên mã nguồn khác. Nay conftest bind thẳng checkout hiện tại vào `sys.modules["cowork_local"]`.
2. **Chế độ Fallback** là chế độ *chống gãy*, không phải chế độ tối ưu: giữ nguyên model người dùng chọn kể cả khi có model điểm cao hơn, chỉ chuyển khi model đó **không phục vụ được** turn (không có trong ranking / unavailable / probe fail). Engine `core/routing` không cần biết chế độ này — service map Fallback ➔ Auto khi hỏi ranking rồi tự áp luật chấp nhận riêng.
3. **T06 hiện tại**: provider publish `UsageEvent`; khi R04 dựng xong `AgentEvent` bus thì `ConversationApplicationService` sẽ là nơi phát sự kiện, sink giữ nguyên không phải sửa.
4. **Cần cài `mcp>=1.0.0`** (đã có trong `requirements.txt`) để `tests/test_project_context_mcp_template.py` collect được — thiếu gói này toàn bộ suite bị interrupt.
---
@@ -187,15 +136,21 @@
* **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.
- [x] **R04-T01 (Team Duy)**: Định nghĩa immutable dataclass `ConversationExecutionRequest` ➔ `domain/agents/conversation_execution_request.py`
*Start: `2026-08-21 10:23` | End: `2026-08-21 10:25`*
- [x] **R04-T02 (Team Duy)**: Chuẩn hóa các sự kiện `AgentEvent` (TextChunk, ToolCallStarted, ToolCallResult, Error) ➔ `domain/agents/agent_event.py`
*Start: `2026-08-21 10:22` | End: `2026-08-21 10:23`*
- [x] **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: `2026-08-21 10:25` | End: `2026-08-21 10:27`*
*Start: `2026-08-23 00:56` | End: `2026-08-23 01:00`*
- [x] **R04-T02 (Team Duy)**: Chuẩn hóa các sự kiện `AgentEvent` (TextChunk, ToolCallStarted, ToolCallResult, Error) ➔ `domain/agents/agent_event.py` (+ `domain/agents/agent_event_codec.py` — shim dịch legacy dict, tách riêng để giữ LOC < 400 và để xoá gọn sau R08)
*Start: `2026-08-23 01:00` | End: `2026-08-23 01:08`*
- [x] **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` (+ `turn_runtime.py` định nghĩa 2 port/6 callable, `core_runtime_adapter.py` cầu nối sang `core/*`, `domain/agents/agent_result.py`)
*Start: `2026-08-23 01:08` | End: `2026-08-23 07:10`*
Chưa đổi call site nào — `run_cowork` giữ nguyên (Co4E vẫn dùng); việc chuyển call site là T04/T05. Bằng chứng tương đương: `tests/integration/test_conversation_service_parity.py` chạy cùng 1 script provider qua 2 đường và so khớp từng event/message/tool list trên 7 kịch bản.
- [x] **R04-T04 (Team Duy)**: Di chuyển `ui/cowork_tab.py::build_job` sang sử dụng `ConversationExecutionRequest`
*Start: `2026-08-21 10:27` | End: `2026-08-21 10:31`*
*Start: `2026-08-23 07:10` | End: `2026-08-23 07:23`*
`build_job` không còn gọi `run_cowork`: nó chụp state widget tại submit time ➔ `build_cowork_turn_request()` (mới, `application/conversations/cowork_turn_request.py`) ➔ `ConversationApplicationService`. Thêm `combine_instructions()` vào `turn_runtime.py` (project context + admin agent, T05 dùng lại) và tham số `messages=` cho `execute()` để service append vào **đúng list của widget** — `_reattach_running_turn` đọc list đó trong lúc turn đang chạy và `_finalize_turn` slice nó sau đó. Kiểm chứng: `tests/integration/test_cowork_tab_turn.py` gọi thẳng `CoworkTab.build_job` (widget stub, không cần Qt) và chạy turn thật với `FakeProvider`.
⚠️ `ui/cowork_tab.py` 416 ➔ 455 LOC — file này **vốn đã vượt 400 trước khi sửa**; phân rã thuộc EPIC R08.
- [x] **R04-T05 (Team Duy)**: Di chuyển `core/task_executors.py` sang dùng chung `ConversationApplicationService`
*Start: `2026-08-21 10:28` | End: `2026-08-21 10:30`*
*Start: `2026-08-23 07:23` | End: `2026-08-23 07:31`*
Nhánh `task_type == "cowork"` của `_run_agent` gọi service thay vì `run_cowork`; 5 hành vi riêng của unattended run giữ nguyên (plan reminder, `history_ready`, autosave History mỗi `assistant_done`, timeout notice, `plan_incomplete_reason`). Tách `_unattended_prompt()` dùng `combine_instructions` để thứ tự reminder → skill → persona → prompt nằm ở 1 chỗ đọc được. Lưới an toàn: `tests/integration/test_task_executor_turn.py` viết **trước** khi migrate và pass 8/8 trên code cũ, vẫn pass sau khi migrate.
⚠️ `core/task_executors.py` 476 ➔ 524 LOC — file này **vốn đã vượt 400 trước khi sửa**; phân rã thuộc EPIC R07 (`application/scheduling/`).
Còn lại gọi `run_cowork`: `core/co4e_runner.py` (×2) và `ui/co4e_tab.py` — phân hệ Co4E của 🟣 Team Nam, R04 không chạm theo luật 1 file 1 team.
---
@@ -203,16 +158,16 @@
* **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.
- [x] **R05-T01 (Team Hoa)**: Định nghĩa `ToolDescriptor`, `ToolCapability` (read/write/execute/network) ➔ `domain/tools/tool_descriptor.py` & `domain/tools/tool_registry.py`
*Start: `2026-08-21 21:40` | End: `2026-08-21 21:47`*
- [x] **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: `2026-08-21 21:47` | End: `2026-08-21 21:56`*
- [x] **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: `2026-08-21 21:56` | End: `2026-08-21 22:04`*
- [x] **R05-T04 (Team Hoa)**: Chuẩn hóa MCP tools từ `core/mcp_client.py` đi qua `ToolPolicyGateway`
*Start: `2026-08-21 22:04` | End: `2026-08-21 22:12`*
- [x] **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: `2026-08-21 22:12` | End: `2026-08-21 22:19`*
- [ ] **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: `____-__-__ __:__`*
---
@@ -220,16 +175,16 @@
* **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.
- [x] **R06-T01 (Team Hoa)**: Định nghĩa `WorkspaceSession` chứa snapshot project id, workspace root ➔ `domain/workspaces/workspace_session.py`
*Start: `2026-08-21 22:19` | End: `2026-08-21 22:24`*
- [x] **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: `2026-08-21 22:24` | End: `2026-08-21 22:35`*
- [x] **R06-T03 (Team Hoa)**: Xây dựng `ExecutionWorkspace` quản lý output/scratch files ➔ `infrastructure/filesystem/execution_workspace.py`
*Start: `2026-08-21 22:35` | End: `2026-08-21 22:40`*
- [x] **R06-T04 (Team Hoa)**: Khắc phục race condition trong `ui/workspace_tab.py::_load_current`
*Start: `2026-08-21 22:40` | End: `2026-08-21 22:50`*
- [x] **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: `2026-08-21 22:50` | End: `2026-08-21 22:57`*
- [ ] **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: `____-__-__ __:__`*
---
@@ -237,18 +192,18 @@
* **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`.
- [x] **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: `2026-08-27 16:05` | End: `2026-08-27 16:14`*
- [x] **R07-T02 (Team Hoa)**: Xây dựng `ScheduleCalculator` tính due-time / cron độc lập ➔ `domain/tasks/schedule_calculator.py`
*Start: `2026-08-27 16:14` | End: `2026-08-27 16:26`*
- [x] **R07-T03 (Team Hoa)**: Xây dựng `QtSchedulerClock` adapter (tách `TaskScheduler` khỏi `QTimer`) ➔ `infrastructure/qt/qt_scheduler_clock.py` (đổi so với plan gốc `platform/qt/...` — xem báo cáo)
*Start: `2026-08-27 16:26` | End: `2026-08-27 16:47`*
- [x] **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: `2026-08-27 16:47` | End: `2026-08-27 17:02`*
- [x] **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: `2026-08-27 17:02` | End: `2026-08-27 17:14`*
- [ ] **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: `____-__-__ __:__`* (ngoài phạm vi Team Hoa)
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
---
@@ -281,14 +236,14 @@
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
#### 🟢 Team Hoa (Workspace, Folder, Scheduling, Dashboard & Graph):
- [x] **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` (+ `run_history_dialog.py`, `schedule_task_tab.py` shell — xem báo cáo)
*Start: `2026-08-27 17:14` | End: `2026-08-27 17:39`*
- [x] **R08-T12**: Tách `ui/folder_tab.py#L350` ➔ `workspace_file_tree.py`, `document_preview_manager.py`, `ai_file_editor_dialog.py` (+ `code_editor.py`, `office_document_renderer.py`, `ai_edit_model_resolver.py`, `ai_edit_pipeline.py`, `folder_tab.py` shell — xem báo cáo). Đã nối `FileWorkspaceService` (nợ từ R06-T05).
*Start: `2026-08-27 17:39` | End: `2026-08-27 18:09`*
- [x] **R08-T13**: Tách `ui/dashboard_tab.py` ➔ `token_usage_card_widget.py`, `usage_chart_widget.py`, `habits_widget.py` (+ `dashboard_tab.py` shell, `application/monitoring/dashboard_query_service.py` — xem báo cáo về ghi chú xung đột thư mục với Team Nam)
*Start: `2026-08-27 18:09` | End: `2026-08-27 18:16`*
- [x] **R08-T14**: Tách `ui/structure_graph_view.py` ➔ `presentation/graph/structure_graph_view.py` (shell) & `graph_qa_widget.py` (+ `graph_renderer.py`, `graph_scene_items.py`, `graph_messages_view.py`, `application/workspaces/graph_index_service.py` — xem báo cáo)
*Start: `2026-08-27 18:16` | End: `2026-08-27 20:52`*
- [ ] **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: `____-__-__ __:__`*
---
@@ -334,16 +289,20 @@
| 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` | `2026-08-21 09:56` | `2026-08-21 10:25` | [x] |
| **22-23/08 (T7-CN)** | Chuẩn hóa `ProviderDescriptor`, `ProviderRegistry`; Wrap OpenAI, Anthropic, Ollama, FPT Gateway; Viết Contract Tests | `2026-08-21 10:06` | `2026-08-21 10:12` | [x] ⚠️ registry chưa nối vào `state.build_provider_for` |
| **24/08 (T2)** | Xây dựng `RoutingApplicationService` độc lập Qt; Tách `ComposerWidget` & `AttachmentPicker` | `2026-08-21 10:12` | `2026-08-21 10:15` | [~] RoutingApplicationService xong; tách widget thuộc R08 |
| **25/08 (T3)** | Xây dựng `ConversationApplicationService`; Tách `ChatHistoryWidget` và bubble renderer | `2026-08-21 10:25` | `2026-08-21 10:27` | [~] Service xong; tách widget thuộc R08 |
| **21/08 (T6)** | Khóa DTO `ConversationExecutionRequest`, `AgentEvent`; Xây dựng `FakeProvider`, `FakeToolExecutor` | `2026-08-21 18:23` | `2026-08-21 18:35` | [x] |
| **22-23/08 (T7-CN)** | Chuẩn hóa `ProviderDescriptor`, `ProviderRegistry`; Wrap OpenAI, Anthropic, Ollama, FPT Gateway; Viết Contract Tests | `2026-08-22 18:45` | `2026-08-22 19:01` | [x] |
| **24/08 (T2)** | Xây dựng `RoutingApplicationService` độc lập Qt; Tách `ComposerWidget` & `AttachmentPicker` | `2026-08-22 18:53` | `2026-08-22 18:57` | [~] |
| **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` | `2026-08-21 10:17` | `2026-08-21 10:22` | [~] 3 bản copy routing đã gỡ; circular import chưa xử lý |
| **29/08 (T7)** | Viết suite integration test cho toàn bộ luồng Chat (`tests/integration/test_chat_flow.py`) | `2026-08-21 10:35` | `2026-08-21 10:52` | [~] 25 integration test tại `tests/integration/{test_cowork_turn_flow,test_task_executor_flow,test_routing_surfaces}.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 | `2026-08-21 09:58` | `2026-08-21 10:05` | [x] PASS |
| **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 | `____-__-__ __:__` | `____-__-__ __:__` | [ ] chờ 3 team hoàn tất |
| **28/08 (T6)** | Xóa copy routing cũ trong `ui/chat_panel.py`; Fix circular import `model_pricing` ↔ `usage_tracker` | `2026-08-22 18:57` | `2026-08-22 18:59` | [~] |
| **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 | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
> **Chú thích trạng thái**: `[~]` = hoàn tất **phần thuộc EPIC R03**, phần còn lại của dòng đó thuộc EPIC khác nên chưa đóng.
> - Dòng **24/08**: đã xong `RoutingApplicationService` (R03-T03); phần `ComposerWidget`/`AttachmentPicker` thuộc R08-T01/T02 — chưa làm.
> - Dòng **28/08**: đã xóa copy routing trong `ui/chat_panel.py` (R03-T04) **và** cả `ui/co4e_tab.py`, `ui/folder_tab.py` (R03-T05); phần circular import `model_pricing` ↔ `usage_tracker` thuộc R09-T02 — chưa làm.
---
+155
View File
@@ -0,0 +1,155 @@
# NHẬT KÝ THEO DÕI VÀ PHÒNG NGỪA LỖI TÁI CẤU TRÚC (BUG & LESSONS LEARNED LOG)
## DỰ ÁN: COWORK LOCAL (COWORK-LOCAL BAMBOO)
Tài liệu này dùng để ghi nhận **toàn bộ các lỗi, xung đột kiến trúc và sự cố phát sinh** trong suốt quá trình refactoring của cả 3 team (Team Duy, Team Nam, Team Hoa).
> [!IMPORTANT]
> ### 🛡️ NGUYÊN TẮC VÀNG VỀ QUẢN TRỊ CHẤT LƯỢNG (ZERO RECURRENCE):
> 1. **Ghi nhận ngay lập tức**: Khi gặp bất kỳ lỗi nào (Syntax, Circular Import, Type Error, Test Failure, Thread Freeze, Data Corruption), kỹ sư/AI phải ghi ngay vào tài liệu này trước khi tiếp tục task.
> 2. **Phân tích nguyên nhân gốc rễ (Root Cause)**: Không chỉ sửa phần ngọn mà phải giải thích rõ bản chất vì sao lỗi xảy ra.
> 3. **Rút ra quy tắc phòng ngừa (Prevention Rule)**: Đặt ra nguyên tắc kỹ thuật để **TUYỆT ĐỐI KHÔNG TÁI PHẠM** ở các task tiếp theo.
> 4. **Checklist đầu vào**: Trước khi bắt đầu bất kỳ task mới nào, kỹ sư/AI **bắt buộc phải đọc lại toàn bộ file này**.
---
## 📌 BẢNG TỔNG HỢP CÁC LỖI ĐÃ PHÁT HIỆN & KHẮC PHỤC
| Bug ID | Ngày Phát Hiện | Phân Hệ / File Bị Ảnh Hưởng | Loại Lỗi | Trạng Thái | Team Phụ Trách |
| :--- | :---: | :--- | :--- | :---: | :---: |
| `BUG-001` | 2026-08-20 | `core/model_pricing.py` ↔ `core/usage_tracker.py` | Circular Dependency | 🟡 Đã có giải pháp (R09) | Team Duy & Team Nam |
| `BUG-002` | 2026-08-20 | `core/agent_security.py` ↔ `core/agent_security_alert.py` | Circular Dependency | 🟡 Đã có giải pháp (R09) | Team Nam |
| `BUG-003` | 2026-08-20 | `state.py::active_project_id` & `ui/workspace_tab.py` | Race Condition / Global State Leak | 🟡 Đã có giải pháp (R06) | Team Hoa |
| `BUG-004` | 2026-08-20 | `core/task_scheduler.py` ↔ `PySide6.QtCore.QTimer` | Architecture Violation (Qt in Domain/App) | 🟡 Đã có giải pháp (R07) | Team Hoa |
| `BUG-005` | 2026-08-20 | `ui/chat_panel.py#L638`, `ui/co4e_tab.py`, `ui/folder_tab.py` | Code Duplication (Copy Routing Logic) | 🟡 Đã có giải pháp (R03) | Team Duy |
| `BUG-006` | 2026-08-21 | `scripts/check_imports.py` | UnicodeEncodeError (Windows CP932 console emoji) | 🟢 Đã khắc phục (R01) | Team Duy |
| `BUG-007` | 2026-08-21 | `platform/` ➔ `infrastructure/platform/` | Standard Library Shadowing (`import platform`) | 🟢 Đã khắc phục (R01) | Team Duy |
---
## 🔍 CHI TIẾT TỪNG LỖI & QUY TẮC PHÒNG NGỪA
---
### 🔴 `BUG-001`: Circular Import giữa Module Định Giá (`model_pricing.py`) và Theo Dõi Token (`usage_tracker.py`)
* **Phân hệ**: `core/model_pricing.py` & `core/usage_tracker.py`
* **Triệu chứng (Symptom)**: Lỗi `ImportError: cannot import name 'ModelPricing' from partially initialized module` khi khởi động ứng dụng hoặc chạy test độc lập.
* **Nguyên nhân gốc rễ (Root Cause)**:
- `model_pricing.py` import `UsageTracker` để cập nhật dữ liệu tiêu thụ.
- Ngược lại, `usage_tracker.py` import `ModelPricing` để tính toán chi phí theo từng model ID.
* **Giải pháp khắc phục (Resolution)**:
- Tách Data Transfer Object (DTO) `ModelPricing` sang tầng Domain thuần túy `domain/models/model_pricing.py`.
- Cả `model_pricing.py` và `usage_tracker.py` đều import DTO từ `domain/models/`, chuyển quan hệ thành 1 chiều (Dependency Inversion).
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
> **Quy tắc**: Không bao giờ để 2 service hoặc 2 module nghiệp vụ import lẫn nhau. Mọi cấu trúc dữ liệu dùng chung (DTO/Value Object/Event) **phải được đặt tại tầng `domain/`**.
---
### 🔴 `BUG-002`: Circular Import giữa An Ninh Agent (`agent_security.py`) và Cảnh Báo (`agent_security_alert.py`)
* **Phân hệ**: `core/agent_security.py` & `core/agent_security_alert.py`
* **Triệu chứng (Symptom)**: Lỗi khởi tạo vòng tròn khi runtime bắn ra alert sự kiện bảo mật.
* **Nguyên nhân gốc rễ (Root Cause)**:
- Module security vừa kiểm tra policy vừa khởi tạo trực tiếp instance alert dialog, trong khi alert dialog lại import ngược lại rule security để hiển thị chi tiết mã lỗi.
* **Giải pháp khắc phục (Resolution)**:
- Tách sự kiện cảnh báo thành Event DTO `SecurityAlertEvent` tại `domain/security/security_event.py`.
- Tầng Security chỉ phát ra Event (`emit_event`), tầng Presentation/UI tự lắng nghe Event để render Dialog.
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
> **Quy tắc**: Logic an ninh và xử lý nghiệp vụ không bao giờ được gọi trực tiếp UI Dialog. Luôn giao tiếp thông qua cơ chế Event-Driven (`AgentEvent`, `SecurityEvent`).
---
### 🔴 `BUG-003`: Xung Đột Race Condition do Sử Dụng Biến Toàn Cục `active_project_id` trong `state.py`
* **Phân hệ**: `state.py`, `ui/workspace_tab.py`, Scheduled Task Runners
* **Triệu chứng (Symptom)**: Khi task scheduler chạy ngầm hoặc người dùng chuyển tab nhanh, file bị ghi nhầm vào thư mục dự án khác với dự án đang hiển thị trên màn hình.
* **Nguyên nhân gốc rễ (Root Cause)**:
- Ứng dụng đọc và ghi trực tiếp vào biến toàn cục `AppContext.active_project_id` từ nhiều luồng khác nhau mà không có cơ chế snapshot ngữ cảnh.
* **Giải pháp khắc phục (Resolution)**:
- Xóa bỏ việc đọc biến toàn cục. Mỗi lần khởi chạy turn hoặc task, tạo một snapshot bất biến `WorkspaceSession(project_id, root_path, allowed_paths)`.
- Luồng ngầm chỉ thao tác trên `WorkspaceSession` được truyền vào từ lúc khởi tạo.
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
> **Quy tắc**: Tuyệt đối không dùng biến toàn cục (Global State / Singletons có trạng thái thay đổi) để điều khiển luồng thực thi nền. Mọi ngữ cảnh phải được truyền tường minh qua DTO snapshot.
---
### 🔴 `BUG-004`: Vi Phạm Ranh Giới Kiến Trúc Khi Import `PySide6.QtCore.QTimer` trong Domain / Scheduling Engine
* **Phân hệ**: `core/task_scheduler.py#L20`
* **Triệu chứng (Symptom)**: Không thể viết Unit Test cho thuật toán tính toán lịch chạy (cron/interval) trên môi trường CI/CD (GitHub Actions / Linux Server headless) nếu thiếu driver màn hình X11/Wayland hoặc chưa cài `PySide6`.
* **Nguyên nhân gốc rễ (Root Cause)**:
- Động cơ lập lịch bị gắn chặt cứng với `QTimer` của framework Qt thay vì tách riêng logic tính toán thời gian.
* **Giải pháp khắc phục (Resolution)**:
- Tách thuật toán tính lịch sang `domain/tasks/schedule_calculator.py` (Pure Python 100%).
- Tạo `platform/qt/qt_scheduler_clock.py` làm adapter bọc `QTimer` cho app chạy thật, và `tests/fakes/fake_clock.py` cho unit test.
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
> **Quy tắc**: Tầng Domain và Application tuyệt đối không import thư viện GUI (`PySide6`, `PyQt`). Luôn bọc các thành phần phụ thuộc framework bên ngoài qua Adapter Interface.
---
### 🔴 `BUG-005`: Nhân Bản Mã Nguồn (Code Duplication) Logic Routing Mô Hình AI tại Nhiều Màn Hình
* **Phân hệ**: `ui/chat_panel.py#L638`, `ui/co4e_tab.py`, `ui/folder_tab.py`
* **Triệu chứng (Symptom)**: Khi cập nhật thêm model provider mới (như FPT Gateway hay Claude 3.7), phải sửa code thủ công ở 3 file UI khác nhau; phát sinh sai lệch quy tắc fallback giữa các màn hình.
* **Nguyên nhân gốc rễ (Root Cause)**:
- Thiếu một tầng Application Service tập trung, dẫn đến việc lập trình viên copy-paste hàm chọn model từ `ChatPanel` sang các tab khác.
* **Giải pháp khắc phục (Resolution)**:
- Xây dựng `application/model_routing/routing_application_service.py` duy nhất, cung cấp API `route_request(request) -> ModelRouteDecision`.
- Mọi màn hình UI chỉ gọi service này, không tự viết lại logic kiểm tra key hay fallback.
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
> **Quy tắc**: Nghiệp vụ dùng chung giữa các màn hình phải được đưa vào `application/` services. Không bao giờ viết logic nghiệp vụ trực tiếp trong các file Widget UI.
---
### 🟢 `BUG-006`: `UnicodeEncodeError` khi in Emojis trên Console Windows (CP932/CP1252)
* **Phân hệ / File**: `scripts/check_imports.py`
* **Triệu chứng (Symptom)**:
```text
Traceback (most recent call last):
File "scripts/check_imports.py", line 127, in main
print(f"\U0001f6e1\ufe0f Running Clean Architecture Import Guard...")
UnicodeEncodeError: 'cp932' codec can't encode character '\U0001f6e1' in position 0: illegal multibyte sequence
```
* **Nguyên nhân gốc rễ (Root Cause)**:
- Trên hệ điều hành Windows sử dụng locale tiếng Nhật (mã trang CP932) hoặc tiếng Anh (CP1252), `sys.stdout` mặc định không hỗ trợ các ký tự Unicode/Emoji ngoài bảng mã, dẫn đến crash khi in log dòng lệnh.
* **Giải pháp khắc phục (Resolution)**:
- Tự động bọc lại `sys.stdout` và `sys.stderr` bằng `io.TextIOWrapper` với `encoding="utf-8"` và `errors="replace"`.
- Thay thế các emoji phức tạp bằng các tag văn bản ASCII chuẩn hóa như `[Clean Arch Guard]`, `[PASS]`, `[FAIL]`.
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
> **Quy tắc**: Mọi script CLI (`scripts/*.py`) phải có cơ chế cấu hình `utf-8` stream wrapper và ưu tiên sử dụng text tags (`[INFO]`, `[WARN]`, `[ERROR]`) thay vì emoji Unicode trực tiếp để đảm bảo chạy mượt mà trên mọi môi trường Windows đa ngôn ngữ.
---
### 🟢 `BUG-007`: Xung Đột Tên Thư Mục Trùng Với Standard Library (`platform/` Shadowing `import platform`)
* **Phân hệ / File**: `platform/` ➔ Chuyển thành `infrastructure/platform/`
* **Triệu chứng (Symptom)**:
```text
INTERNALERROR> File "_pytest/terminal.py", line 853: verinfo = platform.python_version()
INTERNALERROR> AttributeError: module 'platform' has no attribute 'python_version'
```
* **Nguyên nhân gốc rễ (Root Cause)**:
- Khi tạo một package ở thư mục gốc có tên trùng với module thư viện chuẩn của Python (`platform`, `email`, `test`, `asyncio`, `logging`), Python trên `sys.path` sẽ ưu tiên import thư mục local thay vì thư viện chuẩn của Python runtime, dẫn đến crash toàn bộ pytest runner và các thư viện bên thứ ba.
* **Giải pháp khắc phục (Resolution)**:
- Xóa bỏ package `platform/` ở root.
- Đưa adapter Qt Scheduler Clock vào đúng vị trí hạ tầng: `infrastructure/platform/qt/`.
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
> **Quy tắc**: Tuyệt đối không đặt tên package/thư mục ở root trùng với tên các module built-in của Python (`platform`, `logging`, `types`, `time`, `io`, `os`, `sys`). Mọi platform adapter phải nằm trong `infrastructure/platform/` hoặc `platform_adapters/`.
---
## 📝 MẪU GHI NHẬN BUG MỚI (BUG REPORT TEMPLATE)
Khi gặp bất kỳ bug mới nào trong quá trình làm việc, hãy sao chép khối mẫu sau và điền vào cuối tài liệu:
```markdown
### 🔴 `BUG-XXX`: [Tóm tắt ngắn gọn tên lỗi]
* **Phân hệ / File**: `[Đường dẫn file bị lỗi]`
* **Triệu chứng (Symptom)**: `[Mô tả hiện tượng lỗi, paste thông báo traceback hoặc kết quả test fail]`
* **Nguyên nhân gốc rễ (Root Cause)**: `[Giải thích tại sao lỗi lại xảy ra]`
* **Giải pháp khắc phục (Resolution)**: `[Mô tả cách sửa, file DTO/Service tạo mới hoặc cách refactor]`
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
> **Quy tắc**: `[Nguyên tắc kỹ thuật cụ thể để không bao giờ tái phạm lỗi này]`
```
+1 -12
View File
@@ -1,12 +1 @@
"""Domain layer - pure Python entities, value objects and events.
The innermost layer of the 4-tier architecture (see
``docs/architecture/ADR-001-layered-architecture.md``). Modules here describe
WHAT the application is about - a turn of conversation, a model candidate, an
agent event - and depend on nothing but the standard library.
Hard rule (ADR-001 I1/I2, enforced by ``scripts/check_imports.py``): no imports
of PySide6/PyQt, and no imports from ``application/``, ``infrastructure/``,
``presentation/`` or the legacy ``core/``/``ui/`` packages. That is what keeps
this layer testable in milliseconds and reusable from a headless scheduler.
"""
"""domain/ — Quy tắc nghiệp vụ thuần. KHÔNG import PySide6, không chạm đĩa/mạng."""
+1 -48
View File
@@ -1,48 +1 @@
"""Domain entities for one agent turn: the request snapshot and the typed event
stream it produces (EPIC R04)."""
from .agent_event import (
AgentEvent,
AssistantDoneEvent,
ErrorEvent,
HistoryReadyEvent,
NoticeEvent,
OutputsAddedEvent,
OutputsRemovedEvent,
PlanUpdatedEvent,
ReasoningChunkEvent,
TextChunkEvent,
ToolCallFinishedEvent,
ToolCallStartedEvent,
ToolOutputEvent,
TurnCompletedEvent,
collect_text,
event_from_dict,
tool_calls,
)
from .conversation_execution_request import (
ConversationExecutionRequest,
new_turn_id,
)
__all__ = [
"ConversationExecutionRequest",
"new_turn_id",
"AgentEvent",
"TextChunkEvent",
"ReasoningChunkEvent",
"AssistantDoneEvent",
"PlanUpdatedEvent",
"ToolCallStartedEvent",
"ToolOutputEvent",
"ToolCallFinishedEvent",
"OutputsAddedEvent",
"OutputsRemovedEvent",
"NoticeEvent",
"HistoryReadyEvent",
"TurnCompletedEvent",
"ErrorEvent",
"event_from_dict",
"collect_text",
"tool_calls",
]
"""Domain agents package: turn requests, agent events, and role definitions."""
+262 -274
View File
@@ -1,370 +1,358 @@
"""AgentEvent - the typed event stream one agent turn produces (R04-T02).
"""Typed events a turn emits while it runs (R04-T02).
Today the turn engine talks to its caller through untyped dicts::
The runtime currently speaks in bare dicts: ``emit({"type": "tool_result", "id":
..., "ok": ...})``. Nothing declares which keys a given type carries, so the
only specification is the 130-line ``if/elif`` chain in
``ui/chat_panel.py::_on_event`` — and a typo in an emitter surfaces as a widget
that silently renders nothing.
emit({"type": "tool_result", "id": tc_id, "name": name,
"ok": result.get("ok", False), "output": result.get("output", "")})
This module makes the vocabulary explicit. Each event is a frozen dataclass with
real fields, and each one knows how to serialise itself back to the exact legacy
dict the widget already reads (:meth:`AgentEvent.to_legacy_dict`), with
:func:`from_legacy_dict` parsing the other way. That two-way bridge is what lets
R04 introduce typed events WITHOUT touching the presentation layer — decomposing
``_on_event`` into a renderer is R08-T01's job, and forcing both changes into one
PR is exactly the "rewrite everything at once" the refactor plan forbids.
and every consumer re-discovers the vocabulary by reading the producer. There
are eleven such shapes across ``core/chat_agent.py``, ``core/code_agent.py`` and
``core/task_executors.py``; a consumer that misspells ``"tool_result"`` or reads
``"result"`` instead of ``"output"`` fails silently, at runtime, only for the
tool path that triggers it.
Scope note: this covers the interactive/scheduled **Cowork turn** vocabulary
(the ``run_cowork`` path R04 unifies). Co4E's own node events (``node_status``,
``stage_text``, ``run_done``) belong to ``Co4EWorkflowService`` in R07-T06 and
are deliberately left as dicts here — :func:`from_legacy_dict` returns ``None``
for them so a bridge can pass them straight through.
This module makes the vocabulary explicit. Each event is a frozen dataclass, so:
* the set of possible events is enumerable (see :data:`EVENT_TYPES`);
* a field name typo is an ``AttributeError`` at the point of use, not a silently
missing chat bubble;
* an event can cross a thread boundary safely - it cannot be mutated after the
producer hands it over, which is exactly what the Qt-signal seam needs.
Bridging with the legacy dicts is deliberate and two-way: :func:`event_from_dict`
adapts what ``run_cowork`` emits today, and :meth:`AgentEvent.to_dict` renders an
event back into the legacy shape so existing widgets keep working untouched
while the presentation layer migrates screen by screen (EPIC R08).
Pure domain code: stdlib only, no Qt, no I/O.
Layer rules (``docs/architecture/ADR-001-layered-architecture.md``): domain
layer, standard library only. No PySide6, no ``core/*`` imports.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
from typing import Any, ClassVar, Dict, Iterable, Optional, Tuple
@dataclass(frozen=True)
class AgentEvent:
"""Base class for everything a turn can report.
``type`` is the legacy string tag, kept as a class attribute so the bridge
functions can round-trip an event without a separate mapping table.
"""
type: str = field(init=False, default="event")
def to_dict(self) -> Dict[str, Any]:
"""Render into the legacy ``emit()`` dict shape."""
return {"type": self.type}
# Notice levels. "progress" is special-cased by the UI (it retargets the live
# thinking indicator instead of adding a bubble), so the vocabulary is pinned
# here rather than left to each emitter's string literal.
NOTICE_INFO = "info"
NOTICE_WARNING = "warning"
NOTICE_PROGRESS = "progress"
# --------------------------------------------------------------------------- #
# Assistant output
# Value objects shared by several events.
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class ToolPreview:
"""The human-readable preview of a proposed tool call.
Mirrors ``core/tools.py::describe_action``'s return shape exactly (three
string keys, nothing else), so wrapping it in a type is lossless. ``kind``
drives which bubble the UI renders: "diff" -> coloured before/after,
"command" -> terminal block, "info" -> plain text.
"""
kind: str = "info"
title: str = ""
text: str = ""
def to_dict(self) -> Dict[str, str]:
return {"kind": self.kind, "title": self.title, "text": self.text}
@classmethod
def from_dict(cls, raw: Any) -> Optional["ToolPreview"]:
"""Parse a legacy preview dict; ``None`` when there was none.
A non-dict value degrades to ``None`` rather than raising: a malformed
preview must cost the user a nicer bubble, never the whole turn.
"""
if not isinstance(raw, dict) or not raw:
return None
return cls(kind=str(raw.get("kind", "info")), title=str(raw.get("title", "")),
text=str(raw.get("text", "")))
@dataclass(frozen=True)
class PlanStep:
"""One entry of the agent's ``update_plan`` checklist.
``status`` is kept a plain string on purpose: ``core/plan.py`` already owns
validation (clamping anything unknown to "pending" against
pending/running/done/error), and duplicating that vocabulary here would give
the app two sources of truth to drift apart.
"""
title: str
status: str = "pending"
def to_dict(self) -> Dict[str, str]:
return {"title": self.title, "status": self.status}
def _as_str_tuple(values: Iterable[Any]) -> Tuple[str, ...]:
"""Freeze an iterable of paths into a tuple of strings.
Emitters hand us live lists (``record["outputs"]``, ``_cleanup``'s result);
copying decouples the event from later mutation of that list.
"""
return tuple(str(v) for v in (values or ()))
# --------------------------------------------------------------------------- #
# Base class.
# --------------------------------------------------------------------------- #
class AgentEvent:
"""Base for every turn event.
Not a dataclass itself (it holds no data) — subclasses are the frozen
dataclasses. ``EVENT_TYPE`` is the legacy wire name, which stays the single
identifier shared between the typed world and the dict world.
"""
EVENT_TYPE: ClassVar[str] = ""
def _payload(self) -> Dict[str, Any]:
"""Type-specific keys of the legacy dict (without ``type``)."""
return {}
def to_legacy_dict(self) -> Dict[str, Any]:
"""The exact dict shape ``ui/chat_panel.py::_on_event`` dispatches on."""
return {"type": self.EVENT_TYPE, **self._payload()}
# --------------------------------------------------------------------------- #
# Streaming events.
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class TextChunkEvent(AgentEvent):
"""One fragment of the visible answer, as it streams in."""
"""A fragment of the assistant's visible answer."""
delta: str
type: str = field(init=False, default="text")
EVENT_TYPE: ClassVar[str] = "text"
delta: str = ""
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "delta": self.delta}
def _payload(self) -> Dict[str, Any]:
return {"delta": self.delta}
@dataclass(frozen=True)
class ReasoningChunkEvent(AgentEvent):
"""One fragment of the model's PRIVATE reasoning.
"""A fragment of a reasoning model's thinking, shown in a collapsed box."""
Drives the "Thinking" indicator only. Consumers must never append this to
the answer or persist it into conversation history - keeping it a distinct
type is what makes that mistake hard to make by accident.
EVENT_TYPE: ClassVar[str] = "reasoning"
delta: str = ""
def _payload(self) -> Dict[str, Any]:
return {"delta": self.delta}
@dataclass(frozen=True)
class AssistantMessageCompletedEvent(AgentEvent):
"""One assistant message finished streaming.
Emitted once per provider call, so a tool-using turn produces SEVERAL of
these — it marks an autosave point, not the end of the turn. The end of the
turn is :class:`TurnCompletedEvent`.
"""
delta: str
type: str = field(init=False, default="reasoning")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "delta": self.delta}
@dataclass(frozen=True)
class AssistantDoneEvent(AgentEvent):
"""One assistant message finished. A turn with tool calls emits this once
per step, not once per turn - see :class:`TurnCompletedEvent`."""
EVENT_TYPE: ClassVar[str] = "assistant_done"
content: str = ""
type: str = field(init=False, default="assistant_done")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "content": self.content}
def _payload(self) -> Dict[str, Any]:
return {"content": self.content}
# --------------------------------------------------------------------------- #
# Planning
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class PlanUpdatedEvent(AgentEvent):
"""The agent rewrote its plan (the ``update_plan`` tool)."""
steps: Tuple[Dict[str, Any], ...] = ()
type: str = field(init=False, default="plan_set")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "steps": [dict(s) for s in self.steps]}
# --------------------------------------------------------------------------- #
# Tool lifecycle
# Tool-call lifecycle.
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class ToolCallStartedEvent(AgentEvent):
"""A tool call is about to run, with the preview shown to the user.
"""A tool call is about to run (after any security/permission gate).
Maps the legacy ``tool_proposed`` event. "Proposed" was a misnomer: by the
time it is emitted the call is already going to run unless a permission gate
rejects it, and the gate reports that as a finished call with ``ok=False``.
Field names are the typed ones (``call_id``, ``arguments``); the legacy keys
``id``/``args`` are produced only at the serialisation boundary, so new code
never has to shadow the ``id`` builtin.
"""
call_id: str
name: str
args: Dict[str, Any] = field(default_factory=dict)
preview: Optional[Dict[str, Any]] = None
type: str = field(init=False, default="tool_proposed")
EVENT_TYPE: ClassVar[str] = "tool_proposed"
call_id: str = ""
name: str = ""
arguments: Dict[str, Any] = field(default_factory=dict)
preview: Optional[ToolPreview] = None
def to_dict(self) -> Dict[str, Any]:
out: Dict[str, Any] = {"type": self.type, "id": self.call_id,
"name": self.name, "args": dict(self.args)}
def _payload(self) -> Dict[str, Any]:
payload: Dict[str, Any] = {"id": self.call_id, "name": self.name,
"args": dict(self.arguments)}
# Omitted rather than sent as None: the widget does
# ``preview = ev.get("preview") or {}`` and an absent key is the shape it
# already handles for tools without a preview.
if self.preview is not None:
out["preview"] = dict(self.preview)
return out
payload["preview"] = self.preview.to_dict()
return payload
@dataclass(frozen=True)
class ToolOutputEvent(AgentEvent):
"""A line of live output from a running tool (command stdout, for example)."""
class ToolOutputChunkEvent(AgentEvent):
"""Live stdout/stderr from a running command, appended to its step bubble."""
call_id: str
name: str
delta: str
type: str = field(init=False, default="tool_output")
EVENT_TYPE: ClassVar[str] = "tool_output"
call_id: str = ""
name: str = ""
delta: str = ""
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "id": self.call_id, "name": self.name,
"delta": self.delta}
def _payload(self) -> Dict[str, Any]:
return {"id": self.call_id, "name": self.name, "delta": self.delta}
@dataclass(frozen=True)
class ToolCallFinishedEvent(AgentEvent):
"""A tool call ended, successfully or not.
"""A tool call returned. ``path``/``produced`` name files it created."""
``ok=False`` covers every failure mode alike - the tool raised, the sandbox
blocked it, or the user rejected it at the permission gate - because the
consumer's job is the same in all three: show the failure and let the model
react to it.
"""
call_id: str
name: str
EVENT_TYPE: ClassVar[str] = "tool_result"
call_id: str = ""
name: str = ""
ok: bool = False
output: str = ""
path: str = "" # file the tool wrote, when it wrote one
produced: Tuple[str, ...] = () # extra artefacts (e.g. a generator's outputs)
type: str = field(init=False, default="tool_result")
path: str = "" # the single file this call wrote, if any
produced: Tuple[str, ...] = () # extra deliverables a command produced
def to_dict(self) -> Dict[str, Any]:
out: Dict[str, Any] = {"type": self.type, "id": self.call_id, "name": self.name,
"ok": self.ok, "output": self.output}
def __post_init__(self) -> None:
# Callers pass a live list; freeze it so the event cannot change later.
object.__setattr__(self, "produced", _as_str_tuple(self.produced))
def _payload(self) -> Dict[str, Any]:
payload: Dict[str, Any] = {"id": self.call_id, "name": self.name,
"ok": self.ok, "output": self.output}
# Both keys stay ABSENT when empty, matching what chat_agent emits today:
# downstream code tests them with ``ev.get(...)`` truthiness and iterates
# ``ev.get("produced", [])``, so adding empty values would be a change.
if self.path:
out["path"] = self.path
payload["path"] = self.path
if self.produced:
out["produced"] = list(self.produced)
return out
payload["produced"] = list(self.produced)
return payload
# --------------------------------------------------------------------------- #
# Output folder
# Side-channel events (plan, notices, output folder).
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class OutputsAddedEvent(AgentEvent):
"""Files appeared in the turn's output folder."""
class PlanUpdatedEvent(AgentEvent):
"""The agent published a new version of its step checklist (full list)."""
paths: Tuple[str, ...] = ()
type: str = field(init=False, default="outputs_added")
EVENT_TYPE: ClassVar[str] = "plan_set"
steps: Tuple[PlanStep, ...] = ()
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "paths": list(self.paths)}
def __post_init__(self) -> None:
object.__setattr__(self, "steps", tuple(self.steps or ()))
@dataclass(frozen=True)
class OutputsRemovedEvent(AgentEvent):
"""Files were cleaned up from the turn's output folder (intermediates)."""
paths: Tuple[str, ...] = ()
type: str = field(init=False, default="outputs_removed")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "paths": list(self.paths)}
def _payload(self) -> Dict[str, Any]:
return {"steps": [s.to_dict() for s in self.steps]}
@dataclass(frozen=True)
class NoticeEvent(AgentEvent):
"""A UI-visible aside that is not part of the model's answer.
"""An aside outside the model's own answer.
Three producers today, all reachable from a normal turn:
``core/agent_security.py`` (a request or command blocked by the security
layer), ``core/context_budget.py`` (the conversation was auto-compressed)
and the attachment readers (a file that could not be processed, plus live
"reading page X/Y" progress).
``level`` selects how the UI renders it: ``"progress"`` updates the thinking
indicator in place, anything else becomes a warning bubble. Dropping these
would silently hide security warnings from the user, which is why the type
exists rather than being folded into TextChunkEvent.
Three sources today: context auto-compaction (info), a blocked
security check (warning), and attachment reading progress (progress).
"""
text: str
level: str = "info"
type: str = field(init=False, default="notice")
EVENT_TYPE: ClassVar[str] = "notice"
text: str = ""
level: str = NOTICE_INFO
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "level": self.level, "text": self.text}
def _payload(self) -> Dict[str, Any]:
return {"level": self.level, "text": self.text}
@dataclass(frozen=True)
class OutputsAddedEvent(AgentEvent):
"""Deliverables appeared in the turn's output folder."""
EVENT_TYPE: ClassVar[str] = "outputs_added"
paths: Tuple[str, ...] = ()
def __post_init__(self) -> None:
object.__setattr__(self, "paths", _as_str_tuple(self.paths))
def _payload(self) -> Dict[str, Any]:
return {"paths": list(self.paths)}
@dataclass(frozen=True)
class OutputsRemovedEvent(AgentEvent):
"""Intermediate/generator files were cleaned up — drop them from Output."""
EVENT_TYPE: ClassVar[str] = "outputs_removed"
paths: Tuple[str, ...] = ()
def __post_init__(self) -> None:
object.__setattr__(self, "paths", _as_str_tuple(self.paths))
def _payload(self) -> Dict[str, Any]:
return {"paths": list(self.paths)}
@dataclass(frozen=True)
class HistoryReadyEvent(AgentEvent):
"""A history session exists for this run and can be opened."""
"""The turn's conversation now exists on disk and can be opened.
session_id: str
type: str = field(init=False, default="history_ready")
Emitted by the unattended (Schedule Task) path so the scheduler refreshes
History only once the session is really there.
"""
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "session_id": self.session_id}
EVENT_TYPE: ClassVar[str] = "history_ready"
session_id: str = ""
def _payload(self) -> Dict[str, Any]:
return {"session_id": self.session_id}
# --------------------------------------------------------------------------- #
# Turn lifecycle - emitted by the application service, not by the legacy engine
# Turn-level events introduced by R04 (no legacy consumer yet).
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class TurnCompletedEvent(AgentEvent):
"""The whole turn finished: no more events will follow.
"""The whole turn ended — exactly once per turn.
New in R04. The legacy engine has no end-of-turn signal at all, so every
consumer infers "done" from the worker thread finishing - which is why a
cancelled turn and a failed turn look identical to the UI today.
Nothing consumes ``"turn_completed"`` yet: the widget's ``if/elif`` chain
simply has no branch for it, so emitting it is inert until R08 wires a
renderer. It exists now because the state it carries (was the turn
cancelled? did it hit the step ceiling?) is currently reconstructed by the
UI from side effects rather than being told to it.
"""
content: str = ""
EVENT_TYPE: ClassVar[str] = "turn_completed"
final_text: str = ""
steps_used: int = 0
cancelled: bool = False
type: str = field(init=False, default="turn_completed")
budget_exhausted: bool = False # stopped at effective_max_steps
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "content": self.content, "cancelled": self.cancelled}
def _payload(self) -> Dict[str, Any]:
return {"final_text": self.final_text, "steps_used": self.steps_used,
"cancelled": self.cancelled, "budget_exhausted": self.budget_exhausted}
@dataclass(frozen=True)
class ErrorEvent(AgentEvent):
"""The turn failed. ``recoverable`` marks errors the user can act on
(pick another model, shorten the prompt) rather than a hard outage."""
"""The turn hit an error.
message: str
``recoverable`` separates "this turn is over" from "something failed but the
loop carried on" — a distinction the current code loses, because both end up
as a bare ``except Exception`` plus a text bubble.
"""
EVENT_TYPE: ClassVar[str] = "error"
message: str = ""
recoverable: bool = False
type: str = field(init=False, default="error")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "message": self.message,
"recoverable": self.recoverable}
# The legacy tag -> event class map. Also the authoritative list of what a turn
# can emit, which is what makes an exhaustive consumer possible for the first time.
EVENT_TYPES: Dict[str, type] = {
"text": TextChunkEvent,
"reasoning": ReasoningChunkEvent,
"assistant_done": AssistantDoneEvent,
"plan_set": PlanUpdatedEvent,
"tool_proposed": ToolCallStartedEvent,
"tool_start": ToolCallStartedEvent,
"tool_output": ToolOutputEvent,
"tool_result": ToolCallFinishedEvent,
"outputs_added": OutputsAddedEvent,
"outputs_removed": OutputsRemovedEvent,
"notice": NoticeEvent,
"history_ready": HistoryReadyEvent,
"turn_completed": TurnCompletedEvent,
"error": ErrorEvent,
}
def event_from_dict(payload: Mapping[str, Any]) -> Optional[AgentEvent]:
"""Adapt one legacy ``emit()`` dict into a typed event.
Returns ``None`` for an unknown tag instead of raising: the legacy engine is
still being refactored and may grow an event before this module knows about
it. Dropping an unrecognised event degrades the UI by one missing bubble;
raising here would abort a turn that had otherwise succeeded.
"""
kind = str(payload.get("type", ""))
cls = EVENT_TYPES.get(kind)
if cls is None:
return None
if cls is TextChunkEvent or cls is ReasoningChunkEvent:
return cls(delta=str(payload.get("delta", "")))
if cls is AssistantDoneEvent:
return AssistantDoneEvent(content=str(payload.get("content", "")))
if cls is PlanUpdatedEvent:
return PlanUpdatedEvent(steps=tuple(payload.get("steps") or ()))
if cls is ToolCallStartedEvent:
return ToolCallStartedEvent(
call_id=str(payload.get("id", "")), name=str(payload.get("name", "")),
args=dict(payload.get("args") or {}), preview=payload.get("preview"),
)
if cls is ToolOutputEvent:
return ToolOutputEvent(call_id=str(payload.get("id", "")),
name=str(payload.get("name", "")),
delta=str(payload.get("delta", "")))
if cls is ToolCallFinishedEvent:
return ToolCallFinishedEvent(
call_id=str(payload.get("id", "")), name=str(payload.get("name", "")),
ok=bool(payload.get("ok", False)), output=str(payload.get("output", "")),
path=str(payload.get("path", "") or ""),
produced=tuple(payload.get("produced") or ()),
)
if cls is OutputsAddedEvent or cls is OutputsRemovedEvent:
return cls(paths=tuple(str(p) for p in (payload.get("paths") or ())))
if cls is NoticeEvent:
return NoticeEvent(text=str(payload.get("text", "")),
level=str(payload.get("level", "info")))
if cls is HistoryReadyEvent:
return HistoryReadyEvent(session_id=str(payload.get("session_id", "")))
if cls is TurnCompletedEvent:
return TurnCompletedEvent(content=str(payload.get("content", "")),
cancelled=bool(payload.get("cancelled", False)))
return ErrorEvent(message=str(payload.get("message", "")),
recoverable=bool(payload.get("recoverable", False)))
def collect_text(events: Sequence[AgentEvent]) -> str:
"""Join every :class:`TextChunkEvent` - the visible answer, reasoning excluded.
Provided here so no consumer has to re-derive "which events are the answer",
the question the untyped dicts made easy to get wrong.
"""
return "".join(e.delta for e in events if isinstance(e, TextChunkEvent))
def tool_calls(events: Sequence[AgentEvent]) -> List[ToolCallFinishedEvent]:
"""Every finished tool call, in order - for audit views and assertions."""
return [e for e in events if isinstance(e, ToolCallFinishedEvent)]
def _payload(self) -> Dict[str, Any]:
return {"message": self.message, "recoverable": self.recoverable}
__all__ = [
"AgentEvent",
"TextChunkEvent",
"ReasoningChunkEvent",
"AssistantDoneEvent",
"PlanUpdatedEvent",
"ToolCallStartedEvent",
"ToolOutputEvent",
"ToolCallFinishedEvent",
"OutputsAddedEvent",
"OutputsRemovedEvent",
"NoticeEvent",
"HistoryReadyEvent",
"TurnCompletedEvent",
"ErrorEvent",
"EVENT_TYPES",
"event_from_dict",
"collect_text",
"tool_calls",
"NOTICE_INFO", "NOTICE_WARNING", "NOTICE_PROGRESS",
"AgentEvent", "ToolPreview", "PlanStep",
"TextChunkEvent", "ReasoningChunkEvent", "AssistantMessageCompletedEvent",
"ToolCallStartedEvent", "ToolOutputChunkEvent", "ToolCallFinishedEvent",
"PlanUpdatedEvent", "NoticeEvent", "OutputsAddedEvent", "OutputsRemovedEvent",
"HistoryReadyEvent", "TurnCompletedEvent", "ErrorEvent",
]
+123
View File
@@ -0,0 +1,123 @@
"""Legacy dict -> typed :mod:`agent_event` translation (R04-T02).
Kept in its own module for two reasons. It is a **temporary compatibility
shim**: once R08-T01 turns ``ui/chat_panel.py::_on_event`` into an event
renderer that consumes typed events directly, nothing needs to parse dicts any
more and this whole file gets deleted — a deletion that stays trivial only while
it is isolated. And it keeps ``agent_event.py`` inside the 400-LOC limit the
architecture rules impose, without diluting either file's single job: one
declares the vocabulary, the other bridges it to the old wire format.
Serialisation the other way lives on the events themselves
(``AgentEvent.to_legacy_dict``), because an event has to be emittable without
anyone importing a codec.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
from .agent_event import (
AgentEvent,
AssistantMessageCompletedEvent,
ErrorEvent,
HistoryReadyEvent,
NOTICE_INFO,
NoticeEvent,
OutputsAddedEvent,
OutputsRemovedEvent,
PlanStep,
PlanUpdatedEvent,
ReasoningChunkEvent,
TextChunkEvent,
ToolCallFinishedEvent,
ToolCallStartedEvent,
ToolOutputChunkEvent,
ToolPreview,
TurnCompletedEvent,
)
def _plan_steps_from_legacy(raw: Any) -> Tuple[PlanStep, ...]:
"""Parse the legacy ``steps`` list, dropping anything unusable.
A step with no title cannot be rendered or ticked off, so it is discarded
instead of becoming a blank row in the Plan panel.
"""
if not isinstance(raw, list):
return ()
steps: List[PlanStep] = []
for item in raw:
if not isinstance(item, dict):
continue
title = str(item.get("title", "")).strip()
if not title:
continue
steps.append(PlanStep(title=title, status=str(item.get("status", "pending"))))
return tuple(steps)
def _parse_tool_started(raw: Dict[str, Any]) -> ToolCallStartedEvent:
"""Rebuild a ``tool_proposed`` event, mapping ``id``/``args`` to typed names."""
args = raw.get("args")
return ToolCallStartedEvent(
call_id=str(raw.get("id", "")), name=str(raw.get("name", "")),
arguments=dict(args) if isinstance(args, dict) else {},
preview=ToolPreview.from_dict(raw.get("preview")),
)
def _parse_tool_finished(raw: Dict[str, Any]) -> ToolCallFinishedEvent:
"""Rebuild a ``tool_result`` event; the optional file keys may be absent."""
return ToolCallFinishedEvent(
call_id=str(raw.get("id", "")), name=str(raw.get("name", "")),
ok=bool(raw.get("ok", False)), output=str(raw.get("output", "")),
path=str(raw.get("path", "") or ""), produced=raw.get("produced") or (),
)
# One parser per wire name. A table (rather than an if/elif chain) keeps adding
# an event a single-line change and makes the supported set introspectable.
_PARSERS = {
TextChunkEvent.EVENT_TYPE: lambda raw: TextChunkEvent(delta=str(raw.get("delta", ""))),
ReasoningChunkEvent.EVENT_TYPE: lambda raw: ReasoningChunkEvent(
delta=str(raw.get("delta", ""))),
AssistantMessageCompletedEvent.EVENT_TYPE: lambda raw: AssistantMessageCompletedEvent(
content=str(raw.get("content", ""))),
ToolCallStartedEvent.EVENT_TYPE: _parse_tool_started,
ToolOutputChunkEvent.EVENT_TYPE: lambda raw: ToolOutputChunkEvent(
call_id=str(raw.get("id", "")), name=str(raw.get("name", "")),
delta=str(raw.get("delta", ""))),
ToolCallFinishedEvent.EVENT_TYPE: _parse_tool_finished,
PlanUpdatedEvent.EVENT_TYPE: lambda raw: PlanUpdatedEvent(
steps=_plan_steps_from_legacy(raw.get("steps"))),
NoticeEvent.EVENT_TYPE: lambda raw: NoticeEvent(
text=str(raw.get("text", "")), level=str(raw.get("level", NOTICE_INFO))),
OutputsAddedEvent.EVENT_TYPE: lambda raw: OutputsAddedEvent(paths=raw.get("paths") or ()),
OutputsRemovedEvent.EVENT_TYPE: lambda raw: OutputsRemovedEvent(paths=raw.get("paths") or ()),
HistoryReadyEvent.EVENT_TYPE: lambda raw: HistoryReadyEvent(
session_id=str(raw.get("session_id", ""))),
TurnCompletedEvent.EVENT_TYPE: lambda raw: TurnCompletedEvent(
final_text=str(raw.get("final_text", "")), steps_used=int(raw.get("steps_used", 0) or 0),
cancelled=bool(raw.get("cancelled", False)),
budget_exhausted=bool(raw.get("budget_exhausted", False))),
ErrorEvent.EVENT_TYPE: lambda raw: ErrorEvent(
message=str(raw.get("message", "")), recoverable=bool(raw.get("recoverable", False))),
}
def from_legacy_dict(payload: Any) -> Optional[AgentEvent]:
"""Parse an emitted dict into a typed event, or ``None`` if it isn't ours.
``None`` (rather than an exception) is the contract that makes incremental
adoption possible: a bridge sitting between the runtime and the widget can
type the events it recognises and forward everything else — Co4E's node
events, or anything a future emitter adds — completely untouched.
"""
if not isinstance(payload, dict):
return None
parser = _PARSERS.get(str(payload.get("type", "")))
return parser(payload) if parser is not None else None
__all__ = ["from_legacy_dict"]
+86
View File
@@ -0,0 +1,86 @@
"""What one finished turn produced (R04-T03).
The outcome of a turn is currently spread over three shapes: ``run_cowork``
returns the mutated message list, ``task_executors._run_agent`` returns a
``(answer_text, timed_out, incomplete_reason)`` tuple, and the UI reconstructs
the rest (did it get cancelled? did it hit the ceiling?) from side effects. Each
caller therefore knows a slightly different amount about the same turn.
:class:`AgentResult` is the single answer. Frozen, like the request that started
the turn, so a result cannot be edited into disagreeing with what actually
happened.
Layer rules (``docs/architecture/ADR-001-layered-architecture.md``): domain
layer — standard library plus sibling domain types only.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, Tuple
from .agent_event import PlanStep, TurnCompletedEvent
@dataclass(frozen=True)
class AgentResult:
"""The outcome of one conversation turn."""
# The conversation AFTER the turn (system prompt, history, the new user
# message, every assistant reply and tool result).
messages: Tuple[Dict[str, Any], ...] = ()
steps_used: int = 0 # provider calls this turn consumed
cancelled: bool = False # the user pressed Stop
budget_exhausted: bool = False # stopped at effective_max_steps
# The agent's final checklist, so a caller can ask "did it really finish?"
# (``core/plan.py::plan_incomplete_reason``) without replaying the events.
plan_steps: Tuple[PlanStep, ...] = ()
# Non-empty when the turn ended on a failure. A string rather than the
# exception: the domain layer must not depend on where the error came from,
# and the message is what every consumer (bubble, error.txt, audit) shows.
error: str = ""
def __post_init__(self) -> None:
"""Freeze the collections the runtime hands over.
Both arrive as live lists that the caller keeps appending to after the
turn (the UI merges messages back into its own history), so copying here
is what keeps a result a record rather than a moving target.
"""
object.__setattr__(self, "messages", tuple(self.messages or ()))
object.__setattr__(self, "plan_steps", tuple(self.plan_steps or ()))
@property
def final_text(self) -> str:
"""The answer to show the user.
Scans backwards for the last assistant message with real content, which
is not the same as ``messages[-1]``: a turn that was cancelled or that
ran out of steps mid-loop ends on a tool message, and a reasoning-only
reply leaves a blank assistant message behind. Same rule as
``core/task_executors.py::_last_assistant_text``, which this replaces.
"""
for message in reversed(self.messages):
if message.get("role") == "assistant" and (message.get("content") or "").strip():
return str(message["content"])
return ""
@property
def ok(self) -> bool:
"""Whether the turn ran to a normal end.
Hitting the step ceiling still counts as ok: the agent did work and
produced an answer, it just was not allowed to keep going — which the
transcript says in its own note rather than by failing the turn.
"""
return not self.error and not self.cancelled
def to_turn_completed_event(self) -> TurnCompletedEvent:
"""The end-of-turn event carrying this outcome to subscribers."""
return TurnCompletedEvent(
final_text=self.final_text, steps_used=self.steps_used,
cancelled=self.cancelled, budget_exhausted=self.budget_exhausted,
)
__all__ = ["AgentResult"]
+188 -158
View File
@@ -1,192 +1,222 @@
"""ConversationExecutionRequest - an immutable snapshot of one turn (R04-T01).
"""The immutable snapshot of ONE chat turn (R04-T01).
``ui/cowork_tab.py::build_job`` currently builds a closure that reads widget
state from inside the worker thread::
Today a turn's inputs live in a closure plus a 15-key ``ctx`` dict built inside
``ui/chat_panel.py::_start_turn``, and the worker thread reads the widget back
(``self._model``, ``self.title``, ``self.project_id``) while it runs. That is
the mechanism behind the whole class of "I changed the model mid-answer and the
running turn behaved oddly" reports: the turn has no snapshot of its own, so
every later click on the UI is visible to work already in flight.
def job(worker):
provider = self.build_provider() # reads combo boxes
extra_tools, extra_exec = self.ctx.build_mcp_tools()
proj_ctx = project_context_text(load_project(project_id))
...
:class:`ConversationExecutionRequest` is that missing snapshot. Everything the
runtime needs for one turn is captured once, on the UI thread, at submit time,
and then handed to code that runs on a worker thread. Frozen, so no caller —
widget or service — can retroactively change a decision the turn already acted
on.
Everything that closure touches can change while the turn is running: the user
can pick another model, switch workspace, or edit the project instructions. The
turn then runs on a mixture of old and new state, and which mixture depends on
thread timing - the class of bug that reproduces once a week and never in a test.
This value object is the fix: the presentation layer captures everything a turn
needs ON THE UI THREAD, at submit time, into one frozen object. Whatever happens
to the widgets afterwards, the turn keeps running on the state the user actually
submitted.
Pure domain code: stdlib only, no Qt, no filesystem access. Paths are held as
strings, not ``Path`` objects, so the snapshot stays trivially serialisable -
which is what will let a turn be queued, replayed or logged later.
Layer rules (see ``docs/architecture/ADR-001-layered-architecture.md``): this is
the domain layer, so standard library only. No PySide6, no ``requests``, no
filesystem access, and deliberately no import of ``core/*`` — a request only
*describes* a turn; running it is the application layer's job
(``application/conversations/``).
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field, replace
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
# Default tool-use budget for an interactive turn, and the higher ceiling a
# run-to-completion step (a Co4E flow step) is allowed. Same numbers
# ``core.chat_agent.run_cowork`` defaults to - kept here so the policy is
# visible in the request rather than buried in a function signature.
DEFAULT_MAX_STEPS = 30
DEFAULT_COMPLETION_MAX_STEPS = 200
def new_turn_id() -> str:
"""A fresh turn id. Short and random: it only has to be unique within a
session's lifetime, and it shows up in log lines humans read."""
return uuid.uuid4().hex[:12]
# Separator between an instruction prefix (a ``/skill`` block, an ``/agent``
# persona) and the user's own request. Kept as a constant because the prefix is
# assembled in the presentation layer while the body is only known later on the
# worker thread — both halves must agree on the exact separator or the model
# sees a different prompt shape than it did before this refactor.
PREFIX_SEPARATOR = "\n\n---\n\n"
@dataclass(frozen=True)
class ConversationExecutionRequest:
"""Everything one agent turn needs, captured at submit time.
"""Everything needed to execute one conversation turn.
Attributes:
prompt: the user's message for this turn (already assembled, including
any attachment text the UI inlined).
messages: the full conversation to send, oldest first. Held as a tuple
so the snapshot cannot be mutated after capture; use
:meth:`message_list` to get the mutable copy the engine expects.
output_dir: this turn's OWN folder. Each turn writes into an isolated
directory so parallel turns cannot clobber each other's files.
session_id: the conversation this turn belongs to.
turn_id: unique per turn, for logs and for matching events to a turn.
surface: which screen submitted it ("cowork", "co4e", "ai_edit", "task").
provider / model: what to run on, already resolved (routing included).
Empty ``model`` means "the provider's configured default".
title: conversation title, used to name generated files.
project_id / project_context: the workspace and its shared instructions,
snapshotted so a mid-turn workspace switch cannot change them.
agent_role: audit-log attribution for every tool call this turn makes.
allowed_tools: permission scope. ``None`` means "all enabled tools";
a list restricts the ADVERTISED catalogue, so a read-only step
literally cannot be offered a writing tool.
max_steps / run_to_completion / completion_max_steps: tool-use budget.
enforce_rules: run the security rulebase. Co4E sandboxed runs disable it.
confirm_commands: ask before run_command/install_package (permission gate).
metadata: free-form extras a caller wants carried along (never
interpreted here) - e.g. a scheduled task's id.
Frozen for the reason above; use :meth:`with_model` / :meth:`with_output_dir`
to derive an adjusted copy rather than mutating one another thread may be
reading.
Note on depth: ``messages`` is a *shallow* snapshot (a tuple holding the
same message dicts the caller passed). That matches the existing
``snapshot = list(self.messages)`` semantics in ``_start_turn`` exactly —
the turn is protected from the history list being appended to or replaced,
which is what actually happens between turns. Making it deep would silently
change how ``_finalize_turn`` merges the turn's messages back, so the
stronger guarantee is left to R04-T03 where that merge moves.
"""
prompt: str
messages: Tuple[Mapping[str, Any], ...] = ()
output_dir: str = ""
session_id: str = ""
turn_id: str = field(default_factory=new_turn_id)
surface: str = "cowork"
provider: str = ""
model: str = ""
title: str = ""
project_id: str = ""
project_context: str = ""
agent_role: str = ""
# -- identity ------------------------------------------------------- #
turn_id: str # unique within a session ("t1", "t2", ...)
session_id: str # the conversation this turn belongs to
surface: str = "cowork" # routing/mode key: "cowork" | "co4e" | "ai_edit"
project_id: str = "" # workspace the turn is confined to
title: str = "" # conversation title; also names saved files
# -- what the user asked -------------------------------------------- #
# The typed request, already stripped of any ``/skill`` or ``/agent``
# directive (those become ``instruction_prefix``).
prompt: str = ""
instruction_prefix: str = "" # skill rules + agent persona for this turn
# Prepended when the model/agent was switched mid-conversation, asking the
# model to re-check the previous step before continuing. Invisible in the
# chat bubble — it only travels in the payload sent to the provider.
review_note: str = ""
# Attachment PATHS, not their text: extracting a .docx can pip-install a
# parser or shell out to LibreOffice, which must not run on the UI thread.
# The runtime reads them later and passes the result to :meth:`user_content`.
attachments: Tuple[str, ...] = ()
# Conversation history as of submit time; the new user message is NOT part
# of it (the runtime appends it once the body is composed).
messages: Tuple[Dict[str, Any], ...] = ()
# -- which model answers -------------------------------------------- #
# Already resolved upstream: an Admin-agent pin, the tab's own picker, or a
# routing override published by ``RoutingApplicationService`` (R03). The
# runtime does not re-decide, so a switch cannot land mid-turn.
provider_id: str = ""
model: str = "" # "" = the provider's configured default
# -- standing instructions ------------------------------------------ #
project_context: str = "" # Claude-Projects-style shared instructions
session_notes: str = "" # e.g. files this conversation already produced
# -- tool scope and turn limits -------------------------------------- #
# None = every enabled built-in tool. An explicit (possibly empty) tuple
# restricts the ADVERTISED tools, which is how a "read-only" step is made
# literally unable to write.
allowed_tools: Optional[Tuple[str, ...]] = None
max_steps: int = DEFAULT_MAX_STEPS
run_to_completion: bool = False
completion_max_steps: int = DEFAULT_COMPLETION_MAX_STEPS
enforce_rules: bool = True
confirm_commands: bool = False
metadata: Mapping[str, Any] = field(default_factory=dict)
max_steps: int = 30 # interactive cap
completion_max_steps: int = 200 # runaway ceiling for run-to-completion work
run_to_completion: bool = False # Co4E flow steps need the higher ceiling
enforce_rules: bool = True # False for sandboxed Co4E runs
gate_mode: str = "auto" # "confirm" -> ask before run_command/install
agent_role: str = "cowork" # audit-log attribution ("cowork" | "task" | ...)
# -- construction helpers ------------------------------------------- #
@classmethod
def create(cls, prompt: str, messages: Optional[Sequence[Mapping[str, Any]]] = None,
**kwargs: Any) -> "ConversationExecutionRequest":
"""Build a request from ordinary mutable inputs.
# -- where its files go ---------------------------------------------- #
output_dir: Optional[Path] = None # this turn's isolated sandbox
home_output_root: Optional[Path] = None # conversation Output root to promote into
The messages list is copied element by element, so a later append by the
caller (the chat panel keeps appending to its own list) cannot reach
inside a request that is already running.
# -- unattended execution (Schedule Task) ----------------------------- #
unattended: bool = False # no human watching; plan tracking is enforced
timeout_sec: Optional[int] = None # None = no wall-clock limit
# Escape hatch for surface-specific data a future task needs to thread
# through without another schema change (same role as
# ``ProviderDescriptor.extras``).
extras: Dict[str, Any] = field(default_factory=dict)
# -- validation / normalisation --------------------------------------- #
def __post_init__(self) -> None:
"""Reject unusable requests and freeze the mutable inputs.
Validation lives here (not at the call site) so a request that exists is
always safe to key by: the audit log, the History autosave and the
per-turn output folder are all named from ``session_id``/``turn_id``.
Normalisation matters just as much: the caller hands us the composer's
own attachment LIST and the live history LIST, and both get cleared or
appended to for the next turn. Copying them into tuples here is what
actually makes the snapshot a snapshot. ``object.__setattr__`` is the
standard way to do this in a frozen dataclass.
"""
snapshot = tuple(dict(m) for m in (messages or ()))
allowed = kwargs.pop("allowed_tools", None)
return cls(prompt=prompt, messages=snapshot,
allowed_tools=tuple(allowed) if allowed is not None else None,
**kwargs)
if not (self.turn_id or "").strip():
raise ValueError("ConversationExecutionRequest.turn_id must not be empty")
if not (self.session_id or "").strip():
raise ValueError("ConversationExecutionRequest.session_id must not be empty")
def with_messages(self, messages: Sequence[Mapping[str, Any]]
) -> "ConversationExecutionRequest":
"""A copy carrying a different message list, everything else unchanged.
object.__setattr__(self, "attachments", tuple(self.attachments or ()))
object.__setattr__(self, "messages", tuple(self.messages or ()))
# None must survive: it means "no restriction", while an empty tuple
# means "deny every built-in tool" — two very different turns.
if self.allowed_tools is not None:
object.__setattr__(self, "allowed_tools", tuple(self.allowed_tools))
# Accept str paths so a call site holding a config value does not have to
# wrap it; everything downstream can then assume Path.
for name in ("output_dir", "home_output_root"):
value = getattr(self, name)
if value is not None and not isinstance(value, Path):
object.__setattr__(self, name, Path(value))
Used when a caller assembles the system prompt or trims history after
building the request - it must produce a NEW snapshot rather than mutate
the one a turn may already be running on.
# -- derived turn policy ---------------------------------------------- #
@property
def has_prompt(self) -> bool:
"""Whether the user actually typed something (an attachment-only turn
legitimately has none). Mirrors ``RoutingRequest.has_prompt`` so both
DTOs answer the "is there anything to work with?" question the same way.
"""
return replace(self, messages=tuple(dict(m) for m in messages))
def with_model(self, provider: str, model: str) -> "ConversationExecutionRequest":
"""A copy pinned to another provider/model - how a routing switch is
applied without touching the user's saved settings."""
return replace(self, provider=provider, model=model)
# -- accessors ------------------------------------------------------ #
def message_list(self) -> List[Dict[str, Any]]:
"""A fresh mutable copy of the messages, for the engine to append to.
The legacy engine mutates the list it is given (it inserts the system
prompt and appends assistant/tool messages). Handing it a copy is what
keeps this snapshot immutable in practice and not just by declaration.
"""
return [dict(m) for m in self.messages]
return bool((self.prompt or "").strip())
@property
def effective_max_steps(self) -> int:
"""The tool-use ceiling actually in force for this turn."""
"""The tool-use budget for this turn.
Run-to-completion work (a Co4E flow step whose single instruction may
need many tool calls) gets the higher ceiling; interactive chat keeps the
tight cap. Either way the turn still ends the moment the model stops
calling tools — this is only the runaway limit.
"""
return self.completion_max_steps if self.run_to_completion else self.max_steps
@property
def has_output_dir(self) -> bool:
"""True when this turn may write files."""
return bool(self.output_dir)
def requires_permission_gate(self) -> bool:
"""Whether ``run_command``/``install_package`` must be approved first.
def allows_tool(self, name: str) -> bool:
"""Whether ``name`` is inside this turn's permission scope.
``update_plan`` is always allowed: it has no side effects and drives the
Plan panel, so scoping it out would silently break the UI rather than
restrict a capability.
Resolved by the caller (per-workspace Auto-run override, else the global
"confirm before running commands" setting) and frozen here, so toggling
the setting mid-turn cannot change the rules the turn started under.
"""
if self.allowed_tools is None:
return True
return name == "update_plan" or name in self.allowed_tools
return self.gate_mode == "confirm"
def describe(self) -> str:
"""Compact one-line identity for log lines."""
target = f"{self.provider}/{self.model}" if self.model else self.provider or "default"
return f"turn={self.turn_id} surface={self.surface} model={target}"
# -- prompt composition ------------------------------------------------ #
def user_content(self, body: str = "") -> str:
"""The exact ``content`` to send as this turn's user message.
def to_dict(self) -> Dict[str, Any]:
"""JSON-safe projection, for logging a turn or persisting it for replay."""
return {
"turn_id": self.turn_id,
"session_id": self.session_id,
"surface": self.surface,
"prompt": self.prompt,
"message_count": len(self.messages),
"output_dir": self.output_dir,
"provider": self.provider,
"model": self.model,
"title": self.title,
"project_id": self.project_id,
"agent_role": self.agent_role,
"allowed_tools": list(self.allowed_tools) if self.allowed_tools is not None else None,
"max_steps": self.effective_max_steps,
"run_to_completion": self.run_to_completion,
"enforce_rules": self.enforce_rules,
"confirm_commands": self.confirm_commands,
"metadata": dict(self.metadata),
}
``body`` is the request text AFTER attachment extraction, which happens
on the worker thread — hence a method taking it as an argument rather
than a stored field. The assembly order reproduces the closure in
``_start_turn`` byte for byte, because changing what a model receives is
a behaviour change, not a refactor:
1. session notes are appended after the body;
2. the instruction prefix goes in front, behind a fixed separator;
3. the model-switch review note goes ahead of everything.
"""
content = body or ""
notes = self.session_notes or ""
if notes:
# Guard the empty-body case (attachment-only turn) so the payload
# never opens with a stray blank line.
content = f"{content}\n\n{notes}" if content else notes
prefix = self.instruction_prefix or ""
if prefix:
content = f"{prefix}{PREFIX_SEPARATOR}{content}"
review = self.review_note or ""
if review:
content = f"{review}\n\n{content}"
return content
# -- derivation --------------------------------------------------------- #
def with_model(self, provider_id: str = "", model: str = "") -> "ConversationExecutionRequest":
"""A copy pinned to another provider/model.
Needed when a decision lands between building the request and running it
(a routing override, an Admin-agent pin). Deriving a new request keeps
the "one turn, one immutable snapshot" rule intact instead of patching a
request another thread may already hold.
"""
return replace(self, provider_id=provider_id or self.provider_id,
model=model or self.model)
def with_output_dir(self, output_dir) -> "ConversationExecutionRequest":
"""A copy writing into a different sandbox — used when the caller only
learns the per-turn folder after the request is assembled."""
return replace(self, output_dir=output_dir)
__all__ = [
"ConversationExecutionRequest",
"new_turn_id",
"DEFAULT_MAX_STEPS",
"DEFAULT_COMPLETION_MAX_STEPS",
]
__all__ = ["PREFIX_SEPARATOR", "ConversationExecutionRequest"]
+1 -5
View File
@@ -1,5 +1 @@
"""Domain models: provider/model catalogue value objects (EPIC R03)."""
from .provider_descriptor import ProviderCapability, ProviderDescriptor
__all__ = ["ProviderDescriptor", "ProviderCapability"]
"""Domain models package: provider descriptors, model pricing, and routing metadata."""
+158 -133
View File
@@ -1,171 +1,196 @@
"""ProviderDescriptor - the declarative catalogue entry for one model provider (R03-T02).
"""Provider catalog metadata — the domain-layer description of ONE LLM provider.
Today the knowledge of "what a provider is" is scattered across three places
that must be edited together and can silently drift apart:
Before R03 the answer to "which providers exist, what do they cost, what can
they do?" was spread over three places: the class table in
``providers/factory.py``, the hand-maintained pricing table in
``core/routing/metadata.py`` and a handful of ``if provider == "anthropic"``
branches in the UI. :class:`ProviderDescriptor` is the single declarative
record those call sites now read from.
* ``providers/factory.py::_REGISTRY`` - name -> implementation class
* ``config.py::DEFAULT_CONFIG["providers"]`` - default base_url / model / api_key
* ``config.py::PROVIDER_LABELS`` - the human label shown in Settings
Adding a provider means remembering all three; forgetting one produces a
provider that exists but has no label, or a label with no implementation. This
value object folds those facts into a single immutable description that the
registry (``infrastructure/providers/provider_registry.py``) and the UI can both
read, so a new provider is declared once.
Pure domain code: stdlib only, no Qt, no network, no config access. It describes
a provider; building one is infrastructure's job.
Layer rules (see ``docs/architecture/ADR-001-layered-architecture.md``): this
module is 100% pure Python — no PySide6, no ``requests``, no filesystem, and no
import of the concrete ``providers/*`` adapters. It only *describes* a provider;
constructing one is the infrastructure layer's job
(``infrastructure/providers/provider_registry.py``).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
from enum import Enum
from typing import Any, Dict, FrozenSet, List, Mapping, Optional, Tuple
from typing import Any, Dict, Optional, Tuple
class ProviderCapability(str, Enum):
"""What a provider can do, as advertised by its descriptor.
class AuthKind(str, Enum):
"""How a provider authenticates, so Settings/onboarding can ask for the
right thing instead of hard-coding per-provider form fields.
Kept as a closed enum rather than free-form strings so a typo
(``"vison"``) fails at import time instead of silently disabling a feature
at runtime. Inherits ``str`` so existing dict/JSON code that compares against
plain strings keeps working during the migration.
Inherits ``str`` so a descriptor round-trips through JSON unchanged (the
value is written as a plain string), matching how the routing models in
``core/routing/models.py`` already serialize their enums.
"""
STREAMING = "streaming" # can stream answer fragments through on_text
TOOLS = "tools" # can be given a ToolSpec catalogue and call tools
VISION = "vision" # accepts image content blocks (see providers/base.py)
REASONING = "reasoning" # emits a separate private "thinking" stream
MODEL_LISTING = "model_listing" # list_models() returns a real catalogue
NONE = "none" # local runtimes (Ollama) — nothing to supply
API_KEY = "api_key" # bearer/x-api-key style secret
OAUTH_TOKEN = "oauth" # token minted by an external login flow (Copilot)
class WireProtocol(str, Enum):
"""The on-the-wire dialect a provider speaks.
Several *distinct* providers share one protocol (Ollama, Codex, GitHub
Copilot and generic gateways are all OpenAI Chat Completions), which is
exactly why protocol is a separate field from the provider id: the registry
picks the adapter class from the protocol, while everything user-facing
keys off the id.
"""
OPENAI_COMPAT = "openai_compat"
ANTHROPIC = "anthropic"
@dataclass(frozen=True)
class ProviderDescriptor:
"""An immutable description of one provider the app can talk to.
"""Immutable metadata for one provider the app can route work to.
Attributes:
id: the config key, e.g. ``"openai_compat"``. Also the ``provider`` half
of a routing candidate key (``provider/model_id``).
label: human-readable name for Settings and the model picker.
protocol: which wire format this provider speaks. Several ids share one
protocol - ``ollama``, ``github_copilot`` and ``codex`` are all
OpenAI-compatible endpoints - which is exactly why protocol and id
must be separate fields.
default_model: the model used when the user has not chosen one.
capabilities: what the provider supports (see :class:`ProviderCapability`).
requires_api_key: whether an empty ``api_key`` makes it unusable.
requires_base_url: whether an empty ``base_url`` makes it unusable.
local: True when the endpoint runs on the user's own machine. Routing
treats local models as zero-cost, and the security layer treats them
as not leaving the machine, so this is a real behavioural flag and
not just documentation.
notes: free-form remark shown in Settings (e.g. "paste a Copilot token").
Frozen because descriptors are shared process-wide by the registry, the
routing service and (eventually) the Settings screen; making them read-only
removes any chance one caller mutates the catalog another caller is
iterating. Use :meth:`with_models` to derive an updated copy instead.
Unknown pricing/context values stay ``None`` rather than being guessed —
the routing scorer needs to distinguish "free" from "we don't know", the
same contract ``core/routing/models.py::ModelMetadata`` already follows.
"""
id: str
label: str
protocol: str
default_model: str = ""
capabilities: FrozenSet[ProviderCapability] = field(default_factory=frozenset)
requires_api_key: bool = True
requires_base_url: bool = True
local: bool = False
notes: str = ""
provider_id: str # config key, e.g. "anthropic"
display_name: str # human label for Settings/UI
wire_protocol: WireProtocol # which adapter class implements it
auth_kind: AuthKind = AuthKind.API_KEY
default_model: str = "" # used when no model is selected
models: Tuple[str, ...] = () # known model ids (may be empty)
max_context: Optional[int] = None # tokens; None = unknown
cost_per_1k_input: Optional[float] = None # USD per 1K input tokens
cost_per_1k_output: Optional[float] = None # USD per 1K output tokens
supports_vision: bool = False
supports_tools: bool = True
supports_streaming: bool = True
requires_base_url: bool = False # gateway endpoints must be configured
# Extra ids that should resolve to this descriptor (renames/aliases kept for
# backwards compatibility with configs written by older app versions).
aliases: Tuple[str, ...] = ()
# Free-form extension point so a team can attach provider-specific hints
# without another schema migration.
extras: Dict[str, Any] = field(default_factory=dict)
# -- capability queries ---------------------------------------------- #
def supports(self, capability: ProviderCapability) -> bool:
"""True when this provider advertises ``capability``."""
def __post_init__(self) -> None:
"""Reject descriptors that could never be looked up.
Raising here (rather than at registration time) means a malformed
descriptor cannot exist at all, so every consumer downstream may assume
``provider_id`` is a usable dict key.
"""
if not self.provider_id:
raise ValueError("ProviderDescriptor.provider_id must not be empty")
if not isinstance(self.wire_protocol, WireProtocol):
raise TypeError("ProviderDescriptor.wire_protocol must be a WireProtocol")
# -- identity ------------------------------------------------------- #
@property
def identifiers(self) -> Tuple[str, ...]:
"""Every id this descriptor answers to (canonical id first)."""
return (self.provider_id, *self.aliases)
def matches(self, provider_id: str) -> bool:
"""Case-insensitive id/alias match — config files and CLI flags are
typed by humans, so lookup must not be case sensitive."""
needle = (provider_id or "").strip().lower()
return any(needle == known.lower() for known in self.identifiers)
# -- capability queries --------------------------------------------- #
def knows_model(self, model_id: str) -> bool:
"""Whether ``model_id`` is in this provider's declared catalog.
A miss is NOT proof the model is unusable: gateways expose models we
cannot enumerate offline, so callers treat this as a hint (used to
resolve a bare model id back to its provider) and never as a gate that
blocks a request.
"""
needle = (model_id or "").strip().lower()
return any(needle == known.strip().lower() for known in self.models)
def has_capability(self, capability: str) -> bool:
"""Capability check by name, mirroring the vocabulary the routing
selector already filters on (``"vision"``, ``"tools"``, ``"streaming"``)
so a descriptor can be fed straight into ``rank_models``."""
return capability in self.capabilities
@property
def supports_vision(self) -> bool:
"""Mirrors ``providers.base.Provider.supports_vision`` so callers can ask
the descriptor (no instance, no network) before building a provider."""
return self.supports(ProviderCapability.VISION)
def capabilities(self) -> frozenset:
"""Capability set in the same vocabulary as
``core/routing/models.py::ModelMetadata.capabilities``."""
caps = set()
if self.supports_vision:
caps.add("vision")
if self.supports_tools:
caps.add("tools")
if self.supports_streaming:
caps.add("streaming")
return frozenset(caps)
@property
def supports_tools(self) -> bool:
"""True when this provider can run an agent turn with tools. A provider
without it can still chat, but must never be routed a tool-using task."""
return self.supports(ProviderCapability.TOOLS)
def avg_cost_per_1k(self) -> Optional[float]:
"""Blended input/output price, or ``None`` when either side is unknown.
def capability_names(self) -> List[str]:
"""Capabilities as sorted plain strings - the shape the routing layer's
``required_capabilities`` filter and the assessment store both use."""
return sorted(c.value for c in self.capabilities)
# -- configuration validation ---------------------------------------- #
def missing_settings(self, conf: Mapping[str, Any]) -> List[str]:
"""Which required config keys are absent or blank in ``conf``.
Returned as a list (not a bool) so Settings can tell the user exactly
what to fill in, instead of a generic "not configured". A provider that
needs nothing returns an empty list.
Uses the same 1:3 input:output weighting as
``ModelMetadata.avg_cost_per_1k`` so a descriptor and an assessment
never disagree about what a model costs.
"""
missing: List[str] = []
if self.requires_api_key and not str(conf.get("api_key", "") or "").strip():
missing.append("api_key")
if self.requires_base_url and not str(conf.get("base_url", "") or "").strip():
missing.append("base_url")
return missing
ci, co = self.cost_per_1k_input, self.cost_per_1k_output
if ci is None or co is None:
return None
return (ci + 3.0 * co) / 4.0
def is_configured(self, conf: Mapping[str, Any]) -> bool:
"""True when ``conf`` carries everything this provider needs to run."""
return not self.missing_settings(conf)
def resolve_model(self, requested: str = "") -> str:
"""The model id to actually call: the caller's choice when they made
one, otherwise this provider's default. Centralised here because every
surface (chat, Co4E, AI-Edit) previously re-implemented the same
``model or config_default`` fallback inline."""
return (requested or "").strip() or self.default_model
def resolve_model(self, conf: Optional[Mapping[str, Any]] = None,
requested: str = "") -> str:
"""Pick the model id for a call: explicit request, else configured, else
this descriptor's default.
# -- derivation / serialization ------------------------------------- #
def with_models(self, models, *, default_model: str = "") -> "ProviderDescriptor":
"""A copy carrying a freshly discovered model list.
Centralised here because the same three-step fallback is currently
re-implemented at every call site (chat panel, Co4E, AI-edit, scheduler),
and each of them gets the precedence subtly different.
Providers can enumerate their models at runtime (``list_models()``);
because the descriptor is frozen, discovery produces a NEW descriptor
that the registry swaps in atomically instead of mutating one that other
threads may be reading.
"""
if requested:
return requested
configured = str((conf or {}).get("model", "") or "").strip()
return configured or self.default_model
def describe(self, conf: Optional[Mapping[str, Any]] = None) -> str:
"""One-line summary for logs and the Settings row, e.g.
``"anthropic:claude-sonnet-4-6 (Anthropic Claude)"``."""
return f"{self.id}:{self.resolve_model(conf)} ({self.label})"
def candidate_key(self, model_id: str) -> str:
"""The ``provider/model_id`` identity the routing layer keys on.
Defined here so the domain owns the format; ``core.routing.models`` has
its own ``candidate_key()`` helper producing the identical string, and
keeping them equal is what lets the new registry and the existing
assessment store share one keyspace during the migration.
"""
return f"{self.id}/{model_id}"
ordered = tuple(dict.fromkeys(m for m in models if m)) # de-dup, keep order
chosen = default_model or self.default_model
# Keep the default pointing at something real: fall back to the first
# discovered model when the configured default vanished from the catalog.
if ordered and chosen not in ordered:
chosen = ordered[0]
return replace(self, models=ordered, default_model=chosen)
def to_dict(self) -> Dict[str, Any]:
"""JSON-safe projection, for persisting a catalogue snapshot or sending
the descriptor to a UI layer that must not import domain types."""
"""JSON-friendly view for config persistence and the Settings UI."""
return {
"id": self.id,
"label": self.label,
"protocol": self.protocol,
"provider_id": self.provider_id,
"display_name": self.display_name,
"wire_protocol": self.wire_protocol.value,
"auth_kind": self.auth_kind.value,
"default_model": self.default_model,
"capabilities": self.capability_names(),
"requires_api_key": self.requires_api_key,
"models": list(self.models),
"max_context": self.max_context,
"cost_per_1k_input": self.cost_per_1k_input,
"cost_per_1k_output": self.cost_per_1k_output,
"capabilities": sorted(self.capabilities),
"requires_base_url": self.requires_base_url,
"local": self.local,
"notes": self.notes,
"aliases": list(self.aliases),
}
def split_candidate_key(key: str) -> Tuple[str, str]:
"""Inverse of :meth:`ProviderDescriptor.candidate_key`.
Splits on the FIRST ``/`` only: some gateways expose model ids that contain
a slash (``org/model``), and splitting on the last one would corrupt them.
"""
provider, _, model_id = key.partition("/")
return provider, model_id
__all__ = ["ProviderCapability", "ProviderDescriptor", "split_candidate_key"]
__all__ = ["AuthKind", "WireProtocol", "ProviderDescriptor"]
+1
View File
@@ -0,0 +1 @@
"""Domain security package: security policies, alert events, and permission types."""
+110
View File
@@ -0,0 +1,110 @@
"""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 -5
View File
@@ -1,5 +1 @@
"""Domain entities for schedule/due-time computation (EPIC R07)."""
from .schedule_calculator import ScheduleCalculator
__all__ = ["ScheduleCalculator"]
"""Domain tasks package: task definitions and deterministic schedule calculators."""
-165
View File
@@ -1,165 +0,0 @@
"""ScheduleCalculator - due-time / cron / interval math for Schedule Task,
extracted from ``core/tasks.py``'s "schedule math" section (R07-T02).
``core/tasks.py`` is already Qt-free (its own docstring says so), but it
still lives under ``core/`` where nothing enforces that "pure" claim - and it
is the ONE piece of scheduling logic ``docs/refactor/plan.md`` calls out as
needing its own unit tests (none existed before this task; see
``tests/unit/test_schedule_calculator.py``). Moving it to ``domain/tasks/``
makes the purity a build-time guarantee (``scripts/check_imports.py`` fails
the build if this file ever imports Qt, ``core``, or anything with I/O) and
gives the date math a home that is trivially unit-testable without going
through ``core/tasks.py``'s file-repository concerns at all.
Two pieces of this math are themselves implemented elsewhere in ``core/`` -
``core/cron.py::Cron`` (5-field cron parsing) and
``core/holiday_calendar.py::is_holiday`` (VN public holidays). Importing
``core`` from ``domain`` is exactly what ADR-001 rule I2 forbids (domain must
not know infrastructure/core exists), so this class takes them as
constructor-injected callables instead of importing them - the same
dependency-inversion shape ``application/conversations/conversation_
application_service.py`` (R04-T03) already uses for its provider factory.
``core/tasks.py`` wires the real ``Cron``/``is_holiday`` in; tests can inject
plain stub functions with zero I/O.
"""
from __future__ import annotations
import calendar
from datetime import datetime, timedelta
from typing import Any, Callable, Dict, List, Optional, Protocol
# Same on-disk format core/tasks.py::_TIME_FMT uses for schedule.run_at.
# Duplicated here (not imported - that would be a domain -> core edge) since
# it's a 1-line format string, not business logic.
_TIME_FMT = "%Y-%m-%d %H:%M"
_CRON_SEARCH_GUARD = 400 # matches the guard core/tasks.py used before extraction
class _CronLike(Protocol):
"""Structural shape this class needs from a cron object - satisfied by
``core/cron.py::Cron`` without this module importing it."""
def next_after(self, after: datetime) -> Optional[datetime]:
...
def _parse_run_at(value: Optional[str]) -> Optional[datetime]:
if not value:
return None
try:
return datetime.strptime(value, _TIME_FMT)
except ValueError:
return None
class ScheduleCalculator:
"""Pure due-time computation for one task's ``schedule`` dict.
``is_holiday``: ``Callable[[date, country_code], bool]`` or ``None`` -
when ``None``, a schedule with ``skip_holidays`` set simply never treats
any day as a holiday (degrades gracefully instead of raising, mirroring
how a caller who doesn't care about holidays can just not wire it up).
``make_cron``: ``Callable[[str], _CronLike]`` (raises on a malformed
expression) or ``None`` - when ``None``, ``repeat_type == "cron"``
schedules never produce a next run (same as an invalid expression today).
"""
def __init__(self,
is_holiday: Optional[Callable[[Any, str], bool]] = None,
make_cron: Optional[Callable[[str], _CronLike]] = None) -> None:
self._is_holiday = is_holiday
self._make_cron = make_cron
def is_excluded_day(self, dt: datetime, sched: Dict[str, Any]) -> bool:
"""True when ``dt`` falls on a day this schedule must skip: a
weekend (working_days_only) or a public holiday of the configured
country."""
if sched.get("working_days_only") and dt.weekday() >= 5: # 5=Sat, 6=Sun
return True
if sched.get("skip_holidays") and self._is_holiday is not None:
if self._is_holiday(dt.date(), sched.get("holiday_country", "")):
return True
return False
def add_month(self, dt: datetime) -> datetime:
"""Calendar-aware +1 month, clamping the day to the target month's
length (e.g. Jan 31 + 1 month -> Feb 28/29, not an overflow error)."""
year = dt.year + (1 if dt.month == 12 else 0)
month = 1 if dt.month == 12 else dt.month + 1
day = min(dt.day, calendar.monthrange(year, month)[1])
return dt.replace(year=year, month=month, day=day)
def shift_off_excluded_days(self, dt: datetime, sched: Dict[str, Any]) -> datetime:
"""Push ``dt`` forward one day at a time until it lands on an
allowed day (same time of day) - used for one-time schedules set on
a weekend/holiday."""
guard = 0
while self.is_excluded_day(dt, sched) and guard < _CRON_SEARCH_GUARD:
dt += timedelta(days=1)
guard += 1
return dt
def compute_next_run(self, task: Dict[str, Any], after: datetime) -> Optional[datetime]:
"""The next run time strictly after ``after`` for a repeating task
(daily / weekly / monthly / cron), or ``None`` for one-shot
schedules. Occurrences on excluded days are skipped forward."""
sched = task.get("schedule", {})
repeat = sched.get("repeat_type", "none")
if repeat == "cron":
if self._make_cron is None:
return None
try:
cron = self._make_cron(sched.get("cron_expression") or "")
except Exception:
# Any malformed-expression error the injected factory raises
# (core/cron.py::CronError, or a fake's own error type in
# tests) means "this schedule can't compute a next run" - not
# a domain-layer crash.
return None
nxt = cron.next_after(after)
guard = 0
while nxt is not None and self.is_excluded_day(nxt, sched) and guard < _CRON_SEARCH_GUARD:
nxt = cron.next_after(nxt)
guard += 1
return nxt
base = _parse_run_at(sched.get("run_at"))
if base is None:
return None
if repeat == "daily":
advance = lambda d: d + timedelta(days=1) # noqa: E731
elif repeat == "weekly":
advance = lambda d: d + timedelta(weeks=1) # noqa: E731
elif repeat == "monthly":
advance = self.add_month
else:
return None
nxt = base
while nxt <= after:
nxt = advance(nxt)
guard = 0
while self.is_excluded_day(nxt, sched) and guard < _CRON_SEARCH_GUARD:
nxt = advance(nxt)
guard += 1
return nxt
def due_tasks(self, tasks: List[Dict[str, Any]], now: datetime) -> List[Dict[str, Any]]:
"""Tasks that should start now: Scheduled + schedule enabled +
run_at due."""
due = []
for t in tasks:
if t.get("status") != "scheduled":
continue
sched = t.get("schedule", {})
if not sched.get("enabled"):
continue
run_at = _parse_run_at(sched.get("run_at"))
if run_at is not None and run_at <= now:
due.append(t)
return due
__all__ = ["ScheduleCalculator"]
+1 -18
View File
@@ -1,18 +1 @@
"""Domain entities for tool risk classification and lookup (EPIC R05)."""
from .tool_descriptor import ToolCapability, ToolDescriptor
from .tool_registry import (
BUILT_IN_CAPABILITIES,
UNKNOWN_SOURCE_CAPABILITIES,
ToolRegistry,
default_registry,
)
__all__ = [
"ToolCapability",
"ToolDescriptor",
"ToolRegistry",
"BUILT_IN_CAPABILITIES",
"UNKNOWN_SOURCE_CAPABILITIES",
"default_registry",
]
"""Domain tools package: tool descriptors, capability scopes, and registry interfaces."""
-86
View File
@@ -1,86 +0,0 @@
"""ToolCapability / ToolDescriptor - the risk-tagged catalogue entry for one
tool the agent loop can call (R05-T01).
Today a tool is just a name inside ``core/tools.py::TOOL_SPECS`` (a
``providers.base.ToolSpec`` — name/description/JSON-schema parameters, with
no notion of risk) plus a hand-written membership test wherever gating is
needed: ``core/tools.py::WRITE_TOOLS``, ``core/code_agent.py``'s
``WRITE_TOOLS | MS365_WRITE_TOOLS``, and ``core/chat_agent.py``'s literal
``name in ("run_command", "install_package")``. Three call sites, three
independently-maintained lists, and a new tool (or an MCP/connector tool,
which has no list membership at all - see ``core/mcp_client.py``) is gated
only if someone remembers to add it everywhere.
``ToolDescriptor`` makes the risk an attribute of the tool itself, declared
once, so ``application/conversations/tool_policy_gateway.py`` (R05-T03) can
decide ALLOW/CONFIRM/DENY from data instead of a growing set of literal
tuples.
Pure domain code: stdlib only, no Qt, no I/O. ``to_spec``/``from_spec`` are
the only place this module touches something outside domain/, and that
something (``providers.base.ToolSpec``) is itself a plain dataclass with no
further dependencies.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Flag, auto
from typing import Any, Dict
from cowork_local.providers.base import ToolSpec
class ToolCapability(Flag):
"""What calling a tool can do to the machine or the network.
A ``Flag`` (not a plain ``Enum``) because a single tool can combine risks
- ``install_package`` writes to the environment, runs pip as a
subprocess, AND needs network access. Composing three separate booleans
per call site is exactly the duplication this type replaces.
"""
NONE = 0
READ = auto()
WRITE = auto()
EXECUTE = auto()
NETWORK = auto()
@dataclass(frozen=True)
class ToolDescriptor:
"""An immutable description of one callable tool.
Attributes:
name: the identifier the model calls (``ToolSpec.name``).
description: shown to the model, unchanged from ``ToolSpec``.
parameters: JSON-Schema object for the call's arguments.
capabilities: the risk this tool carries - see :class:`ToolCapability`.
"""
name: str
description: str
parameters: Dict[str, Any] = field(default_factory=dict)
capabilities: ToolCapability = ToolCapability.NONE
def has(self, capability: ToolCapability) -> bool:
"""True when this tool carries (any bit of) ``capability``."""
return bool(self.capabilities & capability)
def to_spec(self) -> ToolSpec:
"""Project back to the ``ToolSpec`` shape the model-facing catalogue
and the provider call actually use - risk tagging is metadata the
wire format has no room for."""
return ToolSpec(name=self.name, description=self.description,
parameters=self.parameters)
@classmethod
def from_spec(cls, spec: ToolSpec,
capabilities: ToolCapability = ToolCapability.NONE) -> "ToolDescriptor":
"""Wrap an existing ``ToolSpec`` (built-in, MCP, or connector) with a
capability tag. The one place callers attach risk to a spec they did
not author themselves."""
return cls(name=spec.name, description=spec.description,
parameters=spec.parameters, capabilities=capabilities)
__all__ = ["ToolCapability", "ToolDescriptor"]
-125
View File
@@ -1,125 +0,0 @@
"""ToolRegistry - the centralised catalogue every tool source registers into
(R05-T01).
Built-in file/command/fetch tools (``core/tools.py``), MCP server tools
(``core/mcp_client.py``) and unified connectors (``core/ext_connectors.py``)
each produce their own ``List[ToolSpec]`` today, concatenated ad-hoc by
``core/tools.py::combine_tool_sources``. None of that concatenation carries
risk information, which is exactly why an MCP tool call reaches
``core/chat_agent.py`` with no ``ToolDescriptor`` to consult and skips the
permission gate entirely (the gap R05-T04 closes).
``ToolRegistry`` is the one place a :class:`~domain.tools.tool_descriptor.ToolDescriptor`
is looked up by name, so a policy gateway - or anything else that needs to ask
"what can this tool do" - has a single source of truth instead of re-deriving
it from a spec list.
Pure domain code: stdlib only, no Qt, no I/O.
"""
from __future__ import annotations
from typing import Dict, Iterable, List, Optional
from cowork_local.providers.base import ToolSpec
from .tool_descriptor import ToolCapability, ToolDescriptor
class ToolRegistry:
"""An in-memory, name-keyed catalogue of :class:`ToolDescriptor`.
Deliberately mutable and unordered-by-name-only: a turn builds one
registry from whichever tool sources it has (built-ins + whatever MCP
servers/connectors are enabled), so re-registering the same name simply
replaces the previous descriptor rather than raising - the same
"last one wins" behaviour ``combine_tool_sources`` already has for
duplicate tool names across sources.
"""
def __init__(self, descriptors: Optional[Iterable[ToolDescriptor]] = None) -> None:
self._by_name: Dict[str, ToolDescriptor] = {}
for descriptor in descriptors or ():
self.register(descriptor)
def register(self, descriptor: ToolDescriptor) -> None:
self._by_name[descriptor.name] = descriptor
def get(self, name: str) -> Optional[ToolDescriptor]:
return self._by_name.get(name)
def all(self) -> List[ToolDescriptor]:
return list(self._by_name.values())
def specs(self) -> List[ToolSpec]:
"""Every registered descriptor, projected back to ``ToolSpec`` - the
shape the provider call and the model-facing catalogue need."""
return [d.to_spec() for d in self._by_name.values()]
def capabilities_for(self, name: str) -> ToolCapability:
"""The capability set for ``name``, or ``NONE`` for an unknown tool.
Returning ``NONE`` rather than raising lets a policy gateway treat an
unregistered tool the same way as one with no declared risk - the
gateway's DENY-on-unknown-name rule is a deliberate, separate check,
not something this lookup should pre-empt.
"""
descriptor = self._by_name.get(name)
return descriptor.capabilities if descriptor is not None else ToolCapability.NONE
def __contains__(self, name: str) -> bool:
return name in self._by_name
def __len__(self) -> int:
return len(self._by_name)
# --------------------------------------------------------------------------- #
# Default capability map for this app's built-in tools (core/tools.py).
# Kept here, next to the registry, rather than inside core/tools.py itself -
# core/ is the legacy engine layer being strangled, not where new domain facts
# should accumulate.
# --------------------------------------------------------------------------- #
_CAP = ToolCapability
BUILT_IN_CAPABILITIES: Dict[str, ToolCapability] = {
"read_file": _CAP.READ,
"list_dir": _CAP.READ,
"write_file": _CAP.WRITE,
"edit_file": _CAP.WRITE,
"run_command": _CAP.EXECUTE,
"install_package": _CAP.WRITE | _CAP.EXECUTE | _CAP.NETWORK,
"fetch_url": _CAP.NETWORK,
"jira_search": _CAP.NETWORK,
"jira_get_issue": _CAP.NETWORK,
# Advertised by every engine but has no filesystem/process/network effect
# of its own - it only drives the Plan panel (see core/chat_agent.py).
"update_plan": _CAP.NONE,
"save_file": _CAP.WRITE,
}
# Tools with no standard, self-declared risk metadata (every MCP server tool,
# every unified connector) are tagged with this conservative default - see
# R05-T04. Better to over-gate an unknown remote tool than to silently let it
# through as READ-only.
UNKNOWN_SOURCE_CAPABILITIES: ToolCapability = _CAP.WRITE | _CAP.EXECUTE | _CAP.NETWORK
def default_registry(specs: Iterable[ToolSpec]) -> ToolRegistry:
"""Build a registry from ``core/tools.py``'s own ``TOOL_SPECS`` (plus
``save_file``/``update_plan``, which the engines add separately), using
:data:`BUILT_IN_CAPABILITIES`. A spec with no entry in that map falls back
to :data:`UNKNOWN_SOURCE_CAPABILITIES` - the same conservative default
applied to MCP/connector tools, so a built-in nobody has classified yet
fails safe instead of silently ungated."""
registry = ToolRegistry()
for spec in specs:
capability = BUILT_IN_CAPABILITIES.get(spec.name, UNKNOWN_SOURCE_CAPABILITIES)
registry.register(ToolDescriptor.from_spec(spec, capability))
return registry
__all__ = [
"ToolRegistry",
"BUILT_IN_CAPABILITIES",
"UNKNOWN_SOURCE_CAPABILITIES",
"default_registry",
]
View File
+1 -5
View File
@@ -1,5 +1 @@
"""Domain entities for workspace/project isolation (EPIC R06)."""
from .workspace_session import WorkspaceSession
__all__ = ["WorkspaceSession"]
"""Domain workspaces package: immutable WorkspaceSession definitions."""
-96
View File
@@ -1,96 +0,0 @@
"""WorkspaceSession - an immutable snapshot of which project a turn belongs
to and where it may touch the filesystem (R06-T01).
``state.py::AppContext.active_project_id`` is a single mutable field read by
every background worker thread. ``ui/workspace_tab.py::_load_current`` writes
it (and the related ``config._project_history_dir``) on the UI thread the
moment the user switches projects - while a turn already running on a
worker thread may read either field mid-switch and end up acting on the
OTHER project's workspace/history for the rest of its run (the race
R06-T04 fixes).
The fix, same shape as R04's ``ConversationExecutionRequest``: capture the
workspace facts a turn needs ONCE, on the thread that knows which project is
selected, into one frozen object. Whatever the user does to the UI afterwards,
the turn keeps using the workspace it was handed at submit time.
Pure domain code: stdlib only, no Qt, no network. It does touch ``Path`` (not
plain strings, unlike ``ConversationExecutionRequest``) because its whole job
is path-containment checking - a snapshot with no room to answer "is this
path mine" would not replace what ``ToolContext.resolve`` currently does
inline.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Tuple
@dataclass(frozen=True)
class WorkspaceSession:
"""Everything a turn needs to know about ITS workspace, fixed at the
moment it was submitted.
Attributes:
project_id: the project this turn belongs to (``""`` when no project
is selected - e.g. the Code tab, which has no project concept).
workspace_root: the project's sandbox root (``Project.workspace_dir()``).
sandbox_dir: the ``.scratch`` subtree inside ``workspace_root`` used for
generator/helper scripts, never a final deliverable (see
``infrastructure/filesystem/file_tools.py::_flatten_rel``).
allowed_paths: every root a tool call may read/write under. Almost
always just ``(workspace_root,)``; a project with a custom
``output_dir`` outside the managed workspace tree still resolves
to exactly one root - the tuple exists so a future caller (e.g. a
step scoped to a shared input folder) can widen it without a
shape change.
"""
project_id: str
workspace_root: Path
sandbox_dir: Path
allowed_paths: Tuple[Path, ...] = field(default_factory=tuple)
def __post_init__(self) -> None:
if not self.allowed_paths:
object.__setattr__(self, "allowed_paths", (self.workspace_root,))
@classmethod
def from_project(cls, project) -> "WorkspaceSession":
"""Build a session from a ``core.projects.Project``. ``project`` is
typed loosely (not imported) so this module has no dependency on
``core/`` - the caller (``core/projects.py`` itself, or
``application/conversations``) already has the Project in hand."""
root = Path(project.workspace_dir())
return cls(project_id=project.project_id, workspace_root=root,
sandbox_dir=root / ".scratch", allowed_paths=(root,))
@classmethod
def unscoped(cls, workspace_root: Path) -> "WorkspaceSession":
"""A session for callers with no project concept (e.g. the Code tab,
which sandboxes to a plain folder rather than a ``Project``)."""
root = Path(workspace_root)
return cls(project_id="", workspace_root=root, sandbox_dir=root / ".scratch")
def is_allowed(self, path: Path) -> bool:
"""True when ``path`` resolves inside one of :attr:`allowed_paths`.
Same containment rule as ``ToolContext.resolve`` (an exact root match
or a real descendant), but side-effect-free: it reports the answer
instead of raising, so a caller (``FileWorkspaceService``, R06-T05)
can decide what "not allowed" means for its own UI instead of
catching a ``ToolError``.
"""
try:
resolved = Path(path).expanduser().resolve()
except OSError:
return False
for allowed in self.allowed_paths:
root = Path(allowed).resolve()
if resolved == root or root in resolved.parents:
return True
return False
__all__ = ["WorkspaceSession"]
+6 -3
View File
@@ -583,10 +583,13 @@ STRINGS: Dict[str, Dict[str, str]] = {
"routing.mode_off": {"en": "Off", "ja": "オフ", "vi": "Tắt"},
"routing.mode_auto": {"en": "Auto", "ja": "自動", "vi": "Tự động"},
"routing.mode_manual": {"en": "Manual", "ja": "手動", "vi": "Thủ công"},
# Fallback (R03-T03): resilience mode -- never switches for a better
# score, only to rescue a selected model that cannot serve the turn.
"routing.mode_fallback": {"en": "Fallback", "ja": "フォールバック", "vi": "Dự phòng"},
"routing.toggle_tooltip": {
"en": "Auto model routing for this chat.\nOff: always use the selected model.\nAuto: silently switch to the best-fit model.\nManual: ask before switching.",
"ja": "このチャットの自動モデルルーティング。\nオフ: 選択したモデルを常に使用。\n自動: 最適なモデルへ自動切替。\n手動: 切替前に確認。",
"vi": "Tự động định tuyến model cho khung chat này.\nTắt: luôn dùng model đã chọn.\nTự động: tự chuyển sang model phù hợp nhất.\nThủ công: hỏi xác nhận trước khi chuyển.",
"en": "Auto model routing for this chat.\nOff: always use the selected model.\nAuto: silently switch to the best-fit model.\nManual: ask before switching.\nFallback: keep the selected model, switch only if it is unavailable.",
"ja": "このチャットの自動モデルルーティング。\nオフ: 選択したモデルを常に使用。\n自動: 最適なモデルへ自動切替。\n手動: 切替前に確認。\nフォールバック: 選択モデルを維持し、利用できない場合のみ切替。",
"vi": "Tự động định tuyến model cho khung chat này.\nTắt: luôn dùng model đã chọn.\nTự động: tự chuyển sang model phù hợp nhất.\nThủ công: hỏi xác nhận trước khi chuyển.\nDự phòng: giữ model đã chọn, chỉ chuyển khi model đó không dùng được.",
},
"routing.confirm_title": {
"en": "Switch model?", "ja": "モデルを切り替えますか?", "vi": "Chuyển model?",
+1 -7
View File
@@ -1,7 +1 @@
"""Infrastructure layer - adapters to the outside world.
Concrete implementations of what the inner layers only describe: HTTP calls to
model gateways, the OS keyring, the filesystem, subprocesses, telemetry sinks.
May import ``domain/`` (to speak its types) and third-party libraries, but never
``presentation/``/``ui/``.
"""
"""infrastructure/ — Chạm thế giới thật: file, keyring, HTTP, tiến trình. Cài đặt interface."""
+1
View File
@@ -0,0 +1 @@
"""Infrastructure config package: ConfigRepository and typed settings facades."""
+104
View File
@@ -0,0 +1,104 @@
"""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).
"""
...
@@ -0,0 +1,197 @@
"""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
+134
View File
@@ -0,0 +1,134 @@
"""Đá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
+178
View File
@@ -0,0 +1,178 @@
"""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 -6
View File
@@ -1,6 +1 @@
"""Filesystem/process/network tool adapters split out of ``core/tools.py``
(EPIC R05) and the sandbox execution context they share."""
from .tool_context import CancelFn, ToolContext, ToolError
__all__ = ["CancelFn", "ToolContext", "ToolError"]
"""Infrastructure filesystem package: Tool handlers (file, command, fetch tools) and execution workspace."""
-110
View File
@@ -1,110 +0,0 @@
"""Command tools - run_command, install_package (R05-T02).
Moved verbatim out of ``core/tools.py`` (see ``file_tools.py`` for why). These
two are the ones today's hand-written permission gate in
``core/chat_agent.py`` singles out by literal name
(``name in ("run_command", "install_package")``) — R05-T03 replaces that
tuple with a capability lookup, but the tools themselves are unchanged here.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any, Dict, Optional
from .tool_context import CancelFn, ToolContext
COMMAND_TIMEOUT = 120 # seconds
_SNAPSHOT_SKIP = {".git", "__pycache__", "node_modules", ".scratch", ".venv",
".idea", ".mypy_cache", ".pytest_cache"}
def _snapshot(workdir: Path) -> Dict[str, Any]:
"""Map of file path -> (mtime, size) under the workdir (noise dirs skipped)."""
snap: Dict[str, Any] = {}
try:
for dirpath, dirnames, filenames in os.walk(str(workdir)):
dirnames[:] = [d for d in dirnames if d not in _SNAPSHOT_SKIP]
for fn in filenames:
full = os.path.join(dirpath, fn)
try:
st = os.stat(full)
snap[full] = (st.st_mtime_ns, st.st_size)
except OSError:
pass
if len(snap) > 5000:
return snap
except OSError:
pass
return snap
def _sandbox_python(ctx: ToolContext, cancel: Optional[CancelFn] = None,
on_output=None) -> Optional[str]:
"""Lazily create/reuse this ctx's project sandbox venv (Code tab only —
``ctx.sandbox``); returns its python path, or None to use the app's own."""
if not ctx.sandbox:
return None
from cowork_local.core.deps import ensure_project_venv
py = ensure_project_venv(ctx.workdir, cancel=cancel, on_output=on_output)
return str(py) if py else None
def run_command(ctx: ToolContext, args: Dict[str, Any],
cancel: Optional[CancelFn] = None,
on_output=None) -> Dict[str, Any]:
from cowork_local.core.deps import network_blocked_env, run_cancellable, sandbox_env
from cowork_local.core.sandbox_manager import ExecutionConfig, SandboxManager
from cowork_local.security.command_risk_classifier import classify_command
command = str(args.get("command", "")).strip()
if not command:
return {"ok": False, "output": "Empty command."}
# --- Security validation pipeline ---
risk = classify_command(command, is_cowork_mode=ctx.flatten_writes)
if risk.blocked:
denial = "Command blocked by security policy: " + "; ".join(risk.reasons)
return {"ok": False, "output": denial}
# Route through SandboxManager for risk-based isolation
mgr = SandboxManager(ExecutionConfig(
enabled=True,
block_network_by_default=ctx.block_network,
is_cowork_mode=ctx.flatten_writes,
))
sandbox_result = mgr.run(
command=command,
workdir=str(ctx.workdir),
block_network=ctx.block_network,
timeout_sec=COMMAND_TIMEOUT,
cancel=cancel,
)
# Sandbox ALWAYS executes (never double-run). Return its result directly.
if sandbox_result.get("sandbox") == "blocked":
return {"ok": False, "output": sandbox_result.get("stderr", "Command blocked")}
out = sandbox_result.get("stdout", "").strip() or "(no output)"
err = sandbox_result.get("stderr", "")
rc = sandbox_result.get("returncode", -1)
if err:
out = f"{out}\n{err}" if out else err
return {"ok": sandbox_result.get("ok", False), "output": f"[exit {rc}]\n{out}"}
def install_package(ctx: ToolContext, args: Dict[str, Any],
cancel: Optional[CancelFn] = None,
on_output=None) -> Dict[str, Any]:
from cowork_local.core.deps import pip_install
package = str(args.get("package", "")).strip()
if not package:
return {"ok": False, "output": "No package specified."}
python = _sandbox_python(ctx, cancel, on_output)
ok, detail = pip_install(package, cancel=cancel, on_output=on_output, python=python)
head = f"Installed {package}." if ok else f"Could not install {package}."
return {"ok": ok, "output": f"{head}\n{detail}"}
__all__ = ["COMMAND_TIMEOUT", "run_command", "install_package", "_snapshot"]
@@ -1,80 +0,0 @@
"""ExecutionWorkspace - the output folder vs. the scratch folder for one
turn, as two distinct properties instead of a name convention (R06-T03).
Today the ``.scratch`` subtree is a special case buried inside
``_flatten_rel`` (``infrastructure/filesystem/file_tools.py``): a generator
script writes there, the deliverable lands in the output root, and
``core/chat_agent.py`` cleans ``.scratch`` up after the turn — but nothing
NAMES "the scratch folder" as a thing; every call site re-derives
``workdir / ".scratch"`` (or checks ``Path(rel).parts[0] == ".scratch"``) by
hand. This class gives that convention one home.
It does not change WHERE files land - ``workspace_root/.scratch`` stays
exactly what it always was. It exists so a caller (an application service,
R06-T05's ``FileWorkspaceService``, or a future turn-cleanup step) can ask
for "the output dir" / "the scratch dir" instead of hand-building the path
and hoping the convention hasn't drifted.
"""
from __future__ import annotations
import shutil
from dataclasses import dataclass
from pathlib import Path
from cowork_local.domain.workspaces import WorkspaceSession
SCRATCH_DIRNAME = ".scratch"
@dataclass(frozen=True)
class ExecutionWorkspace:
"""The two folders a turn actually writes to, derived from a
:class:`WorkspaceSession`.
``output_dir`` is always the session's ``workspace_root`` itself, not a
per-turn subfolder - Cowork's whole design is that every deliverable lands
directly in the one configured Output folder (see
``infrastructure/filesystem/file_tools.py::_flatten_rel``'s docstring).
``scratch_dir`` is the SAME flat ``workspace_root/.scratch`` every turn on
that workspace already shares today (``core/chat_agent.py``'s
``_cleanup_cowork_intermediates`` operates on that exact path) - this
class does not introduce per-turn namespacing that doesn't exist in the
engine yet, only names the existing convention.
``turn_id`` is kept as metadata for callers that want to attribute a
workspace to the turn that used it (logging, future per-turn scratch
namespacing); it does not affect either path today.
"""
session: WorkspaceSession
turn_id: str
@property
def output_dir(self) -> Path:
return self.session.workspace_root
@property
def scratch_dir(self) -> Path:
return self.session.workspace_root / SCRATCH_DIRNAME
def ensure_dirs(self) -> None:
"""Create both folders if they don't exist yet. Callers that only
need one (most do) can skip this and let ``write_file`` create parents
on demand, same as today."""
self.output_dir.mkdir(parents=True, exist_ok=True)
self.scratch_dir.mkdir(parents=True, exist_ok=True)
def cleanup_scratch(self) -> None:
"""Unconditionally remove the scratch subtree.
Coarser than ``core/chat_agent.py::_cleanup_cowork_intermediates``,
which rescues any real deliverable a generator script wrote INSIDE
``.scratch`` before wiping it - that rescue logic stays there. This
is for callers that only need "make the scratch folder go away"
(e.g. before starting a fresh run) and know it holds nothing worth
saving.
"""
shutil.rmtree(self.scratch_dir, ignore_errors=True)
__all__ = ["ExecutionWorkspace", "SCRATCH_DIRNAME"]
-55
View File
@@ -1,55 +0,0 @@
"""Fetch tools - fetch_url, jira_search, jira_get_issue (R05-T02).
Moved verbatim out of ``core/tools.py`` (see ``file_tools.py`` for why). The
network access these three carry is exactly what the ``ToolCapability.NETWORK``
tag added in R05-T01/domain/tools/tool_registry.py describes.
"""
from __future__ import annotations
from typing import Any, Dict
from .tool_context import ToolContext
def fetch_url(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
"""Fetch a URL's text content (web page / online document / SharePoint-
OneDrive share link) via link_fetch — the same parser task-link attachments
use. Honors the Sandbox Security Layer's "Block network" policy."""
url = str(args.get("url", "")).strip()
if not url:
return {"ok": False, "output": "fetch_url: 'url' is required."}
if not url.lower().startswith(("http://", "https://")):
return {"ok": False, "output": f"fetch_url: not an http(s) URL: {url}"}
if not ctx.allow_url_fetch:
return {"ok": False,
"output": ("fetch_url: URL fetching is turned off in Settings → Security "
"(\"Allow the agent to fetch URLs\").")}
# A pasted Jira issue link on the CONNECTED Jira host is read via the
# authenticated API (so private issues resolve, not a login page). Public
# links / any other URL fall through to the normal fetcher below.
from cowork_local.core import jira_tool
if jira_tool.is_jira_issue_url(ctx.jira, url):
return {"ok": True, "output": jira_tool.get_issue_by_url(ctx.jira, url)}
from cowork_local.core.link_fetch import fetch_link_preview
return {"ok": True, "output": fetch_link_preview(url)}
def jira_search(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
from cowork_local.core import jira_tool
out = jira_tool.search(ctx.jira, str(args.get("jql", "")),
int(args.get("max_results", 25) or 25))
return {"ok": not out.lower().startswith(("jira is not configured", "jira search failed")),
"output": out}
def jira_get_issue(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
from cowork_local.core import jira_tool
out = jira_tool.get_issue(ctx.jira, str(args.get("key", "")))
return {"ok": not out.lower().startswith(("jira is not configured", "could not fetch")),
"output": out}
__all__ = ["fetch_url", "jira_search", "jira_get_issue"]
-136
View File
@@ -1,136 +0,0 @@
"""File tools - read_file, list_dir, write_file, edit_file (R05-T02).
Moved verbatim out of ``core/tools.py``, whose ``execute_tool`` used to
dispatch to these via a hand-written if/elif chain over every tool name it
knew about. Splitting the built-in handlers into per-concern modules
(this one, ``command_tools.py``, ``fetch_tools.py``) means adding a tool no
longer means growing that one function; ``core/tools.py::execute_tool`` now
looks the name up in a dict built from these modules instead.
Behavior is unchanged from before the split - this is a pure move, not a
rewrite. Every existing characterization/contract test that exercises
read_file/write_file/edit_file/list_dir through ``core.tools.execute_tool``
still exercises the exact same code, just imported from here.
"""
from __future__ import annotations
import ast
from pathlib import Path
from typing import Any, Dict
from .tool_context import ToolContext
MAX_READ_BYTES = 200_000
def _flatten_rel(rel: str) -> str:
"""Collapse a sub-folder path down to a bare filename so the file lands in the
workdir root — EXCEPT the ``.scratch`` sandbox subtree, which is preserved.
Used by the Cowork agent (flatten_writes=True) so it can never create a
per-session / per-chat / per-task output sub-folder: every deliverable stays
directly in the single configured Output folder."""
parts = Path(rel).parts
if parts and parts[0] == ".scratch":
return rel # temporary sandbox is allowed (and cleaned up afterwards)
return Path(rel).name or rel
def _check_python_syntax(target: Path, content: str) -> str:
"""Return a short warning if ``content`` is invalid Python, else ''.
Catches syntax errors the instant a .py file is written/edited — before the
agent wastes a whole run_command round-trip just to get the same error back
from a traceback."""
if target.suffix.lower() not in (".py", ".pyw"):
return ""
try:
ast.parse(content, filename=str(target))
return ""
except SyntaxError as exc:
return f"\n⚠ Syntax error at line {exc.lineno}: {exc.msg} — fix this before running the file."
def read_file(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
target = ctx.resolve(str(args.get("path", "")))
if not target.exists():
return {"ok": False, "output": f"File not found: {args.get('path')}"}
data = target.read_bytes()[:MAX_READ_BYTES]
text = data.decode("utf-8", errors="replace")
return {"ok": True, "output": text}
def list_dir(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
rel = str(args.get("path", ".") or ".")
target = ctx.resolve(rel)
# A missing/not-yet-created path is NOT a tool failure — report it as an
# ordinary result so the agent can create it or pick another path and keep
# going. Returning ok=False here surfaced a false "tool failed: list_dir" in
# Co4E flows and could stall a step on a recoverable situation.
if not target.exists():
return {"ok": True, "output": f"(path '{rel}' does not exist yet — create it or use another path)"}
if target.is_file():
return {"ok": True, "output": f"('{rel}' is a file, not a directory)"}
entries = []
for child in sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):
marker = "/" if child.is_dir() else ""
entries.append(f"{child.name}{marker}")
return {"ok": True, "output": "\n".join(entries) or "(empty folder)"}
def write_file(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
rel = str(args.get("path", ""))
if ctx.flatten_writes:
rel = _flatten_rel(rel)
target = ctx.resolve(rel)
content = str(args.get("content", ""))
target.parent.mkdir(parents=True, exist_ok=True)
# A .xlsx is a binary package — build a REAL workbook from the content
# (CSV/TSV/Markdown-table/JSON) rather than writing raw text (which corrupts it).
if target.suffix.lower() in (".xlsx", ".xlsm"):
from cowork_local.core import xlsx_write
if xlsx_write.build_xlsx_from_text(target, content):
return {"ok": True, "path": str(target),
"output": f"Wrote spreadsheet {rel} ({target.name})."}
return {"ok": False, "output": "Could not build the .xlsx (openpyxl unavailable) — "
"write a .csv instead, or use a generator script."}
target.write_text(content, encoding="utf-8")
warning = _check_python_syntax(target, content)
return {"ok": True, "path": str(target),
"output": f"Wrote {len(content)} chars to {rel}.{warning}"}
def edit_file(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
"""Replace an exact snippet inside an existing file (precise patch edit)."""
rel = str(args.get("path", ""))
if ctx.flatten_writes:
rel = _flatten_rel(rel)
target = ctx.resolve(rel)
if not target.exists():
return {"ok": False,
"output": f"File not found: {rel} — use write_file to create it."}
old = str(args.get("old_string", ""))
new = str(args.get("new_string", ""))
replace_all = bool(args.get("replace_all", False))
if not old:
return {"ok": False, "output": "old_string is empty — provide the exact text to replace."}
try:
text = target.read_text(encoding="utf-8", errors="replace")
except OSError as exc:
return {"ok": False, "output": f"Could not read file: {exc}"}
count = text.count(old)
if count == 0:
return {"ok": False, "output": ("old_string not found. Read the file and copy the exact "
"text to replace, including indentation/whitespace.")}
if count > 1 and not replace_all:
return {"ok": False, "output": (f"old_string appears {count} times — add surrounding "
"context to make it unique, or set replace_all=true.")}
updated = text.replace(old, new) if replace_all else text.replace(old, new, 1)
target.write_text(updated, encoding="utf-8")
n = count if replace_all else 1
warning = _check_python_syntax(target, updated)
return {"ok": True,
"output": f"Edited {args.get('path')} ({n} replacement{'' if n == 1 else 's'}).{warning}"}
__all__ = ["MAX_READ_BYTES", "read_file", "list_dir", "write_file", "edit_file"]
-62
View File
@@ -1,62 +0,0 @@
"""ToolContext / ToolError / CancelFn - the sandboxed execution context every
built-in tool runs against (moved out of ``core/tools.py`` in R05-T02).
Kept as its own leaf module (no dependency on any sibling in this package) so
``file_tools.py``, ``command_tools.py`` and ``fetch_tools.py`` can each import
it without creating an import cycle back through ``core/tools.py``, which
itself re-exports ``ToolContext``/``ToolError`` from here for the existing
callers (``core/chat_agent.py``, ``core/code_agent.py``,
``core/task_executors.py``) that do ``from .tools import ToolContext``.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Dict, Optional
CancelFn = Callable[[], bool]
class ToolError(Exception):
pass
@dataclass
class ToolContext:
workdir: Path
flatten_writes: bool = False # Cowork: force every write into the workdir root
sandbox: bool = False # Code tab: isolate run_command/install_package into <workdir>/.venv
# Sandbox Security Layer — Settings' "Resource Limits" (cpu_percent/memory_mb/
# disk_mb), applied to every run_command/install_package this context runs.
# None (default) = no limits, matching pre-existing behavior.
resource_limits: Optional[Dict[str, float]] = None
# Sandbox Security Layer — Settings' "Block network for agent commands"
# (policy-level, see deps.py::network_blocked_env). False (default) =
# unrestricted, matching pre-existing behavior.
block_network: bool = False
# Whether the fetch_url tool may read URLs — SEPARATE from block_network
# (reading a web page/share link for info is safe; running networked shell
# commands is the risk). Defaults True; set from agent_security.allow_url_fetch.
allow_url_fetch: bool = True
# Jira read connector config (base_url/email/api_token) — None disables the
# jira_* tools' ability to connect. Populated from config.data["jira"].
jira: Optional[Dict[str, Any]] = None
def resolve(self, rel: str) -> Path:
"""Resolve ``rel`` inside the workdir, rejecting escapes."""
if rel in ("", "."):
return self.workdir
candidate = (self.workdir / rel).expanduser()
try:
resolved = candidate.resolve()
except OSError as exc:
raise ToolError(f"Invalid path: {rel} ({exc})")
root = self.workdir.resolve()
if resolved != root and root not in resolved.parents:
raise ToolError(
f"Refused: '{rel}' is outside the working folder ({root})."
)
return resolved
__all__ = ["CancelFn", "ToolError", "ToolContext"]
+1 -5
View File
@@ -1,5 +1 @@
"""MCP server connection lifecycle management (EPIC R05)."""
from .mcp_source_manager import McpToolSourceManager
__all__ = ["McpToolSourceManager"]
"""Infrastructure MCP package: McpToolSourceManager and child process lifecycle."""
-113
View File
@@ -1,113 +0,0 @@
"""McpToolSourceManager - the MCP server connection lifecycle, extracted out
of ``state.py::AppContext`` (R05-T05).
Today ``AppContext.build_mcp_tools`` inlines all of this: a ``_mcp_connections``
dict, a ``_conn_lock`` guarding check-then-create against concurrent turns (a
Cowork tab, a Co4E flow and a Scheduled Task can all call it at once), and a
"start it, cache it, skip it on failure" loop repeated for both the
admin-configured servers AND the built-in MS365 server
(``_ms365_builtin_connection``). None of that logic touches Qt; it was only
ever inline because ``AppContext`` is where the config lived.
This class owns the SAME cache/lock/start-or-skip behavior as a standalone,
directly testable object — ``AppContext`` becomes a thin caller (one instance
per app, same as it holds one ``RoutingApplicationService``).
Pure Python: no Qt. It DOES touch the network/filesystem via
``core.mcp_client.McpServerConnection`` (a subprocess + asyncio loop), which is
exactly what makes it infrastructure rather than domain.
"""
from __future__ import annotations
import threading
from typing import Dict, List, Optional
from cowork_local.core.mcp_client import McpServerConnection
class McpToolSourceManager:
"""Caches and supervises one :class:`McpServerConnection` per server name.
``connection_factory`` defaults to ``McpServerConnection`` itself; tests
substitute a fake so no real subprocess is spawned (see
``tests/unit/test_mcp_source_manager.py``).
"""
def __init__(self, connection_factory=McpServerConnection) -> None:
self._connections: Dict[str, McpServerConnection] = {}
self._lock = threading.Lock()
self._connection_factory = connection_factory
def ensure(self, name: str, command: str, args: Optional[List[str]] = None,
env: Optional[Dict[str, str]] = None) -> Optional[McpServerConnection]:
"""Return a live connection for ``name``, starting one if there is
none cached or the cached one's subprocess has died.
Serialized under one lock so two turns racing to build their tool
list at the same moment share one subprocess per server instead of
each spawning their own (the bug this replaces:
``AppContext._conn_lock``'s original docstring). Returns ``None`` -
never raises - when the server fails to start, matching the existing
"one broken server must not block the turn" behavior.
"""
with self._lock:
existing = self._connections.get(name)
if existing is not None and existing.is_alive():
return existing
if existing is not None:
self._connections.pop(name, None)
connection = self._connection_factory(name, command, args or [], env)
try:
connection.start()
except Exception: # noqa: BLE001 - one broken server must not block the turn
return None
self._connections[name] = connection
return connection
def get(self, name: str) -> Optional[McpServerConnection]:
"""The cached connection for ``name``, without starting one."""
return self._connections.get(name)
def is_alive(self, name: str) -> bool:
connection = self._connections.get(name)
return connection is not None and connection.is_alive()
def restart(self, name: str, command: str, args: Optional[List[str]] = None,
env: Optional[Dict[str, str]] = None) -> Optional[McpServerConnection]:
"""Force a fresh connection for ``name`` even if the cached one still
looks alive - for a server the caller knows is misbehaving."""
with self._lock:
self._connections.pop(name, None)
return self.ensure(name, command, args, env)
def stop(self, name: str) -> None:
"""Stop and forget one connection - used when a server becomes
unavailable by configuration (e.g. MS365 signed out) rather than by
crashing."""
with self._lock:
connection = self._connections.pop(name, None)
if connection is not None:
try:
connection.stop()
except Exception: # noqa: BLE001 - shutdown must never raise into the caller
pass
def active(self) -> List[McpServerConnection]:
"""Every currently cached connection - what
``core/mcp_client.py::build_mcp_tools`` merges tool specs from."""
return list(self._connections.values())
def stop_all(self) -> None:
"""Terminate every connection's subprocess - called on app shutdown
so none of them linger as orphan processes."""
with self._lock:
connections = list(self._connections.values())
self._connections.clear()
for connection in connections:
try:
connection.stop()
except Exception: # noqa: BLE001
pass
__all__ = ["McpToolSourceManager"]
+1 -1
View File
@@ -1 +1 @@
"""Persistence adapters (EPIC R02/R06)."""
"""Infrastructure persistence package."""
+1 -9
View File
@@ -1,9 +1 @@
"""JSON-file persistence adapters: crash-safe writes and the workspace/
conversation/task repositories built on them (EPIC R06, R07)."""
from .atomic_write import write_json
from .conversation_repository_impl import ConversationRepository
from .task_repository_impl import TaskRepository
from .workspace_repository_impl import WorkspaceRepository
__all__ = ["write_json", "WorkspaceRepository", "ConversationRepository", "TaskRepository"]
"""Infrastructure JSON persistence package: AtomicJsonFile and repositories."""
@@ -0,0 +1,131 @@
"""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
import time
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 ------------------------------------------------------------
#: Số lần thử lại ``os.replace`` và khoảng nghỉ giữa các lần (giây).
_REPLACE_TRIES = 6
_REPLACE_BACKOFF = 0.02
@classmethod
def _replace_ben_bi(cls, src: Path, dst: Path) -> None:
"""``os.replace`` có thử lại — bắt buộc trên Windows.
MoveFileEx trả ERROR_ACCESS_DENIED khi có tiến trình khác đang giữ
handle lên nguồn hoặc đích. Trên Windows thật thì gần như luôn là
Defender hoặc Search Indexer quét file vừa tạo, giữ handle vài chục
mili-giây rồi nhả. Không phải lỗi quyền thật, thử lại là hết.
Đo trên máy dev 25/08: hỏng 1 trong 7 lượt chạy 20 lần ghi, tức
khoảng 1 trên 140 lần lưu. Không có vòng này thì người dùng thỉnh
thoảng bấm Lưu là văng lỗi mà không tài nào tái hiện.
POSIX không có kiểu hỏng này nên vòng lặp chạy đúng một lượt.
"""
for lan in range(cls._REPLACE_TRIES):
try:
os.replace(src, dst)
return
except PermissionError:
if lan == cls._REPLACE_TRIES - 1:
raise
time.sleep(cls._REPLACE_BACKOFF * (2 ** lan))
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
self._replace_ben_bi(tmp, self.path) # nguyên tử, có thử lại
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,56 +0,0 @@
"""write_json - crash-safe JSON writes (R06-T02).
``core/projects.py::save_project`` and ``core/history.py``'s
``save_conversation``/``rename_conversation``/``set_pinned`` all do a plain
``path.write_text(json.dumps(...))`` today. That is two syscalls with a gap in
between: a crash, a killed process, or a full disk between the truncate and
the write leaves a half-written, unparseable JSON file - the NEXT read of
that project/conversation then fails outright (``load_project`` /
``load_conversation`` already treat a parse error as "missing", so this isn't
even a loud failure - a project can silently vanish).
``write_json`` fixes this the standard way: write the full content to a
temporary file in the SAME directory (so the following replace is on one
filesystem, not crossing a mount point), then atomically rename it over the
target. Either the old file is still there, or the new one is fully there -
never a partial one.
Transitional note: EPIC R02 (Team Nam, ``docs/refactor/Refactoring_Checklist.md``
R02-T01) plans a shared ``infrastructure/persistence/json/atomic_json_file.py``
for the SAME purpose across the whole app (config, secrets, ...). This module
is deliberately named differently and scoped to R06's two repositories only,
so the two EPICs don't edit the same file in parallel; once R02-T01 lands,
``WorkspaceRepository``/``ConversationRepository`` should switch to it and
this module can go away.
"""
from __future__ import annotations
import json
import os
import tempfile
from pathlib import Path
from typing import Any
def write_json(path: Path, data: Any) -> None:
"""Serialize ``data`` as indented UTF-8 JSON and write it to ``path``
atomically. Creates parent directories if needed."""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
text = json.dumps(data, ensure_ascii=False, indent=2)
fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(text)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp_name, path)
except BaseException:
try:
os.unlink(tmp_name)
except OSError:
pass
raise
__all__ = ["write_json"]
@@ -1,54 +0,0 @@
"""ConversationRepository - an object-shaped, atomic-write-backed facade over
``core/history.py`` (R06-T02). Same rationale as
``workspace_repository_impl.py``: the module-level functions in
``core/history.py`` are still what production code calls (they now write
atomically themselves), this class is the seam for application-layer code
that wants an object instead of a directory-parameterised function.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, List, Optional
from cowork_local.config import HISTORY_DIR
from cowork_local.core.history import (
delete_conversation,
list_conversations,
load_conversation,
new_session_id,
rename_conversation,
save_conversation,
set_pinned,
)
class ConversationRepository:
"""CRUD + search over conversation JSON files, scoped to one
``directory`` (defaults to the app's real ``HISTORY_DIR``)."""
def __init__(self, directory: Optional[Path] = None) -> None:
self._directory = Path(directory) if directory is not None else HISTORY_DIR
def new_session_id(self) -> str:
return new_session_id()
def save(self, kind: str, session_id: str, messages: List[Dict[str, Any]], **kwargs) -> Path:
return save_conversation(self._directory, kind, session_id, messages, **kwargs)
def load(self, path: Path) -> Dict[str, Any]:
return load_conversation(path)
def list(self, query: str = "") -> List[Dict[str, Any]]:
return list_conversations(self._directory, query)
def delete(self, path: Path) -> None:
delete_conversation(path)
def rename(self, path: Path, new_title: str) -> None:
rename_conversation(path, new_title)
def set_pinned(self, path: Path, pinned: bool) -> None:
set_pinned(path, pinned)
__all__ = ["ConversationRepository"]
@@ -1,63 +0,0 @@
"""TaskRepository - an object-shaped, atomic-write-backed facade over
``core/tasks.py`` (R07-T01).
``core/tasks.py``'s module-level functions (``list_tasks``, ``load_task``,
``save_task``, ``delete_task``, ``new_task``, ``duplicate_task``) are still
what every existing call site (``core/task_scheduler.py``,
``core/task_executors.py``, ``ui/schedule_task_tab.py``) uses, and stay that
way - ``save_task`` now writes through :func:`atomic_write.write_json`
itself (R07-T01, same class of durability fix already applied to
``core/projects.py``/``core/history.py`` at R06-T02), so the fix applies
whether or not a caller ever touches this class.
This repository exists for the application layer
(``application/scheduling``, R07-T04) to depend on an interface instead of
reaching into ``core/`` directly. It is a thin pass-through today, not a
re-implementation: same on-disk format, same directory, same functions
underneath.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, List, Optional
from cowork_local.core.tasks import (
TASKS_DIR,
delete_task,
duplicate_task,
list_tasks,
load_task,
new_task,
save_task,
)
class TaskRepository:
"""CRUD over task dicts (see ``core/tasks.py::DEFAULT_TASK`` for shape),
scoped to one ``directory`` (defaults to the app's real ``TASKS_DIR``;
tests pass a ``tmp_path`` so nothing touches the user's real config
folder)."""
def __init__(self, directory: Optional[Path] = None) -> None:
self._directory = directory or TASKS_DIR
def list(self) -> List[Dict[str, Any]]:
return list_tasks(self._directory)
def get(self, task_id: str) -> Optional[Dict[str, Any]]:
return load_task(task_id, self._directory)
def save(self, task: Dict[str, Any]) -> Path:
return save_task(task, self._directory)
def create(self, title: str = "", **overrides: Any) -> Dict[str, Any]:
return new_task(title, **overrides)
def duplicate(self, task: Dict[str, Any]) -> Dict[str, Any]:
return duplicate_task(task)
def delete(self, task_id: str) -> None:
delete_task(task_id, self._directory)
__all__ = ["TaskRepository"]
@@ -1,59 +0,0 @@
"""WorkspaceRepository - an object-shaped, atomic-write-backed facade over
``core/projects.py`` (R06-T02).
``core/projects.py``'s module-level functions (``list_projects``,
``load_project``, ``save_project``, ``new_project``, ``delete_project``) are
still what every existing call site (``ui/workspace_tab.py``, ``state.py``,
task executors) uses, and stay that way - they now write through
:func:`atomic_write.write_json` themselves, so the durability fix applies
whether or not a caller ever touches this class.
This repository exists for the application layer (``application/workspaces``,
R06-T05) to depend on an interface instead of reaching into ``core/`` -
useful once code above ``core/`` starts being written against
``domain``/``application`` seams instead of the legacy module functions. It
is a thin pass-through today, not a re-implementation: same on-disk format,
same directory, same functions underneath.
"""
from __future__ import annotations
from pathlib import Path
from typing import List, Optional
from cowork_local.core.projects import (
PROJECTS_DIR,
Project,
delete_project,
list_projects,
load_project,
new_project,
save_project,
)
class WorkspaceRepository:
"""CRUD over :class:`~cowork_local.core.projects.Project`, scoped to one
``directory`` (defaults to the app's real ``PROJECTS_DIR``; tests pass a
``tmp_path`` so nothing touches the user's real config folder)."""
def __init__(self, directory: Optional[Path] = None) -> None:
self._directory = directory or PROJECTS_DIR
def list(self) -> List[Project]:
return list_projects(self._directory)
def get(self, project_id: str) -> Optional[Project]:
return load_project(project_id, self._directory)
def save(self, project: Project) -> Path:
return save_project(project, self._directory)
def create(self, name: str, description: str = "", instructions: str = "",
output_dir: str = "") -> Project:
return new_project(name, description, instructions, output_dir, self._directory)
def delete(self, project_id: str) -> bool:
return delete_project(project_id, self._directory)
__all__ = ["WorkspaceRepository"]
+1
View File
@@ -0,0 +1 @@
"""Infrastructure platform adapters package."""
+1
View File
@@ -0,0 +1 @@
"""Infrastructure Qt platform adapters: QtSchedulerClock."""
+1 -5
View File
@@ -1,5 +1 @@
"""Provider adapters and the central provider catalogue (EPIC R03)."""
from .provider_registry import ProviderRegistry, default_registry
__all__ = ["ProviderRegistry", "default_registry"]
"""Infrastructure providers package: LLM provider adapters and ProviderRegistry."""
+236 -156
View File
@@ -1,207 +1,287 @@
"""ProviderRegistry - the one place a provider is declared (R03-T02).
"""Central registry of every LLM provider the app can talk to.
Replaces the three-way split between ``providers/factory.py::_REGISTRY``,
``config.py::DEFAULT_CONFIG["providers"]`` and ``config.py::PROVIDER_LABELS``
with a single catalogue of :class:`ProviderDescriptor` objects plus the
implementation class each one maps to.
Replaces the bare ``{name: class}`` dict in ``providers/factory.py`` as the
single catalogue of providers. Two responsibilities, kept deliberately narrow:
Adding a provider is now one entry in :data:`BUILT_IN_PROVIDERS` (declarative
facts) and one line in :data:`_IMPLEMENTATIONS` (which class speaks that
protocol) - see ``docs/governance/contributor-recipes.md`` (R10-T04).
1. **Lookup** — resolve a provider id (or one of its aliases, or a bare model
id) to its :class:`~domain.models.provider_descriptor.ProviderDescriptor`.
2. **Construction** — instantiate the concrete adapter class that speaks the
descriptor's wire protocol.
Migration note (strangler fig, ADR-001 section 4): this registry does not
re-implement any provider. It builds the SAME classes ``providers/factory.py``
builds, so both entry points stay behaviourally identical while call sites move
over one at a time.
This is infrastructure, not domain: it is allowed to import the concrete
``providers/*`` adapters (which pull in ``requests``). The adapters are imported
lazily inside :meth:`build` so that merely *reading the catalogue* — which the
pure routing service does on every turn — never drags the HTTP stack into the
process.
"""
from __future__ import annotations
from typing import Any, Dict, Iterable, List, Mapping, Optional
import threading
from typing import Any, Dict, Iterable, List, Optional
from cowork_local.domain.models.provider_descriptor import (
ProviderCapability,
from ...domain.models.provider_descriptor import (
AuthKind,
ProviderDescriptor,
WireProtocol,
)
from cowork_local.providers.base import Provider, ProviderError
_CAP = ProviderCapability
# Every provider the app ships with, described once.
# --------------------------------------------------------------------------- #
# Built-in catalogue.
#
# The capability sets are deliberately conservative: a capability listed here is
# one the adapter genuinely implements today. Claiming VISION for a provider
# whose chat() cannot translate an image block would route an image turn into a
# guaranteed failure, so an unimplemented capability must stay off the list.
BUILT_IN_PROVIDERS: tuple = (
# Mirrors DEFAULT_CONFIG["providers"] in config.py (ids + default models) and
# providers/factory.py (id -> wire protocol). Prices are intentionally absent:
# core/routing/metadata.py owns cost, and a guessed price is worse than a
# known-unknown (see that module's docstring).
# --------------------------------------------------------------------------- #
BUILTIN_DESCRIPTORS: tuple = (
ProviderDescriptor(
id="openai_compat",
label="OpenAI-compatible (Internal Gateway)",
protocol="openai_compat",
provider_id="openai_compat",
display_name="OpenAI-compatible gateway",
wire_protocol=WireProtocol.OPENAI_COMPAT,
auth_kind=AuthKind.API_KEY,
default_model="gpt-4o-mini",
capabilities=frozenset({_CAP.STREAMING, _CAP.TOOLS, _CAP.VISION,
_CAP.REASONING, _CAP.MODEL_LISTING}),
notes="Any endpoint speaking the OpenAI Chat Completions protocol.",
supports_vision=True,
# A generic gateway has no fixed host, so the endpoint MUST be
# configured before the provider can be used at all.
requires_base_url=True,
),
ProviderDescriptor(
id="anthropic",
label="Anthropic Claude",
protocol="anthropic",
provider_id="anthropic",
display_name="Anthropic Claude",
wire_protocol=WireProtocol.ANTHROPIC,
auth_kind=AuthKind.API_KEY,
default_model="claude-sonnet-4-6",
capabilities=frozenset({_CAP.STREAMING, _CAP.TOOLS, _CAP.VISION,
_CAP.MODEL_LISTING}),
# Kept in sync with AnthropicProvider._FALLBACK_MODELS — the list the
# provider itself falls back to when /v1/models cannot be reached.
models=("claude-opus-4-8", "claude-sonnet-4-6", "claude-haiku-4-5-20251001"),
max_context=200000,
supports_vision=True,
),
ProviderDescriptor(
id="ollama",
label="Ollama (local models)",
protocol="openai_compat",
provider_id="ollama",
display_name="Ollama (local)",
wire_protocol=WireProtocol.OPENAI_COMPAT,
# A local runtime needs no credential; Settings must not demand one.
auth_kind=AuthKind.NONE,
default_model="llama3.1",
capabilities=frozenset({_CAP.STREAMING, _CAP.TOOLS, _CAP.REASONING,
_CAP.MODEL_LISTING}),
# Ollama ignores the key, but the OpenAI client layer requires a value,
# so the default config ships a placeholder rather than an empty string.
requires_api_key=False,
local=True,
notes="Runs on this machine - no data leaves the device, no token cost.",
supports_vision=False,
requires_base_url=True,
),
ProviderDescriptor(
id="github_copilot",
label="GitHub Copilot",
protocol="openai_compat",
provider_id="github_copilot",
display_name="GitHub Copilot",
wire_protocol=WireProtocol.OPENAI_COMPAT,
# The credential is a Copilot token minted by an external login flow,
# not a self-service API key.
auth_kind=AuthKind.OAUTH_TOKEN,
default_model="gpt-4o",
capabilities=frozenset({_CAP.STREAMING, _CAP.TOOLS, _CAP.MODEL_LISTING}),
notes="Paste a Copilot token as the API key.",
models=("gpt-4o", "gpt-4o-mini"),
max_context=128000,
supports_vision=True,
),
ProviderDescriptor(
id="codex",
label="OpenAI (Codex / GPT)",
protocol="openai_compat",
provider_id="codex",
display_name="OpenAI",
wire_protocol=WireProtocol.OPENAI_COMPAT,
auth_kind=AuthKind.API_KEY,
default_model="gpt-4o-mini",
capabilities=frozenset({_CAP.STREAMING, _CAP.TOOLS, _CAP.VISION,
_CAP.REASONING, _CAP.MODEL_LISTING}),
models=("gpt-4o", "gpt-4o-mini", "o1", "o3"),
max_context=128000,
supports_vision=True,
# Historic config key: early builds stored this provider as "openai".
aliases=("openai",),
),
)
def _implementations() -> Dict[str, type]:
"""Protocol -> adapter class.
class ProviderNotFoundError(LookupError):
"""Raised when no descriptor answers to the requested provider id.
Imported lazily inside the function because ``providers/anthropic.py`` and
``providers/openai_compat.py`` pull in ``requests`` at import time; keeping
that out of module import means a test that only inspects descriptors pays
no import cost at all.
A dedicated type (rather than bare ``KeyError``) lets callers distinguish
"this provider is not in the catalogue" from an unrelated dict miss, and
keeps the message actionable by listing what IS registered.
"""
from cowork_local.providers.anthropic import AnthropicProvider
from cowork_local.providers.openai_compat import OpenAICompatProvider
return {
"openai_compat": OpenAICompatProvider,
"anthropic": AnthropicProvider,
}
class ProviderRegistry:
"""Catalogue of known providers + the factory that instantiates them.
"""Thread-safe catalogue of :class:`ProviderDescriptor` records.
Intentionally holds no config and no app context: it is a pure lookup table
plus a build step, so it can be constructed in a test with a custom
descriptor list and no application running.
Thread-safety matters because model discovery runs on background worker
threads (the routing prober, Settings' "Load models") and republishes an
updated descriptor via :meth:`replace`, while chat turns on other threads
are reading the catalogue concurrently.
"""
def __init__(self, descriptors: Optional[Iterable[ProviderDescriptor]] = None) -> None:
# Dict preserves declaration order (Python 3.7+), which is the order
# Settings lists providers in - so the catalogue order is data, not luck.
self._by_id: Dict[str, ProviderDescriptor] = {
d.id: d for d in (descriptors if descriptors is not None else BUILT_IN_PROVIDERS)
}
# Keyed by canonical id; alias resolution walks the values so an alias
# can never shadow a real provider id.
self._by_id: Dict[str, ProviderDescriptor] = {}
self._lock = threading.RLock()
for descriptor in descriptors or ():
self.register(descriptor)
# -- catalogue queries ------------------------------------------------ #
def ids(self) -> List[str]:
"""Known provider ids, in declaration order."""
return list(self._by_id)
# -- registration --------------------------------------------------- #
def register(self, descriptor: ProviderDescriptor) -> ProviderDescriptor:
"""Add a descriptor. Refuses to silently overwrite an existing id so a
typo in a plugin cannot hijack a built-in provider; use :meth:`replace`
when an update is the actual intent."""
with self._lock:
existing = self._by_id.get(descriptor.provider_id)
if existing is not None and existing != descriptor:
raise ValueError(
f"Provider '{descriptor.provider_id}' is already registered; "
"call replace() to update it."
)
self._by_id[descriptor.provider_id] = descriptor
return descriptor
def replace(self, descriptor: ProviderDescriptor) -> ProviderDescriptor:
"""Register or update a descriptor unconditionally — the path model
discovery uses to publish a freshly enumerated model list."""
with self._lock:
self._by_id[descriptor.provider_id] = descriptor
return descriptor
# -- lookup ---------------------------------------------------------- #
def get(self, provider_id: str) -> ProviderDescriptor:
"""Descriptor for ``provider_id`` (canonical id or alias).
Raises :class:`ProviderNotFoundError` rather than returning ``None`` so
a misconfigured provider fails loudly at the call site instead of
surfacing later as an ``AttributeError`` on ``None``.
"""
found = self.find(provider_id)
if found is None:
known = ", ".join(sorted(self._by_id)) or "<empty registry>"
raise ProviderNotFoundError(
f"Unsupported provider: {provider_id!r}. Registered: {known}"
)
return found
def find(self, provider_id: str) -> Optional[ProviderDescriptor]:
"""Non-raising :meth:`get` — ``None`` when nothing matches."""
needle = (provider_id or "").strip()
if not needle:
return None
with self._lock:
direct = self._by_id.get(needle)
if direct is not None:
return direct
# Fall back to a case-insensitive id/alias scan; order is stable
# because dicts preserve insertion order, so the earliest-registered
# provider wins a tie.
for descriptor in self._by_id.values():
if descriptor.matches(needle):
return descriptor
return None
def find_by_model(self, model_id: str) -> Optional[ProviderDescriptor]:
"""Resolve a bare model id back to the provider that serves it.
This is the "dynamic lookup by model ID" R03-T02 calls for: routing
decisions and saved conversations sometimes carry only a model name, and
the caller still needs to know which provider to build. Returns ``None``
when the model belongs to a gateway whose catalogue we cannot enumerate
offline — callers then fall back to the configured active provider.
"""
needle = (model_id or "").strip()
if not needle:
return None
with self._lock:
for descriptor in self._by_id.values():
if descriptor.knows_model(needle):
return descriptor
return None
def all(self) -> List[ProviderDescriptor]:
"""Every descriptor, in declaration order."""
return list(self._by_id.values())
"""Every registered descriptor, in registration order (snapshot copy —
safe to iterate while another thread registers)."""
with self._lock:
return list(self._by_id.values())
def get(self, provider_id: str) -> Optional[ProviderDescriptor]:
"""The descriptor for ``provider_id``, or None when unknown.
def ids(self) -> List[str]:
"""Canonical provider ids, sorted for stable UI/reporting output."""
with self._lock:
return sorted(self._by_id)
Returns None rather than raising because the caller is often reacting to
a config file that may name a provider from a newer version; the UI
should be able to skip it, not crash.
def __contains__(self, provider_id: object) -> bool:
return isinstance(provider_id, str) and self.find(provider_id) is not None
def __len__(self) -> int:
with self._lock:
return len(self._by_id)
# -- construction ---------------------------------------------------- #
def adapter_class(self, provider_id: str):
"""Concrete ``Provider`` subclass implementing this provider's protocol.
The adapters are imported here (not at module import) so the pure
routing/domain code can consult the catalogue without loading
``requests`` and the whole HTTP stack.
"""
return self._by_id.get(provider_id)
descriptor = self.get(provider_id)
from ...providers.anthropic import AnthropicProvider
from ...providers.openai_compat import OpenAICompatProvider
def require(self, provider_id: str) -> ProviderDescriptor:
"""Like :meth:`get` but raises :class:`ProviderError` when unknown.
Same error type ``providers/factory.py::build_provider`` already raises,
so callers that migrate to the registry keep their existing except clause.
"""
descriptor = self._by_id.get(provider_id)
if descriptor is None:
known = ", ".join(self._by_id) or "(none)"
raise ProviderError(f"Unsupported provider: {provider_id} (known: {known})")
return descriptor
def labels(self) -> Dict[str, str]:
"""``{id: label}`` - the drop-in replacement for ``config.PROVIDER_LABELS``."""
return {d.id: d.label for d in self._by_id.values()}
def supporting(self, capability: ProviderCapability) -> List[ProviderDescriptor]:
"""Every descriptor advertising ``capability`` - used to answer "which
providers could serve this turn?" before any of them is built."""
return [d for d in self._by_id.values() if d.supports(capability)]
def configured(self, providers_conf: Mapping[str, Mapping[str, Any]]
) -> List[ProviderDescriptor]:
"""Descriptors whose config section is complete enough to actually call.
``providers_conf`` is ``AppConfig.data["providers"]``. Passing the raw
mapping (not the AppConfig object) keeps this layer independent of the
config implementation, which EPIC R02 is rewriting in parallel.
"""
return [d for d in self._by_id.values()
if d.is_configured(providers_conf.get(d.id, {}) or {})]
# -- construction ----------------------------------------------------- #
def build(self, provider_id: str, conf: Mapping[str, Any],
model: str = "") -> Provider:
"""Instantiate the adapter for ``provider_id``.
``model`` overrides the configured model for this instance only - that is
how the routing layer runs one turn on a different model without mutating
the user's saved settings.
"""
descriptor = self.require(provider_id)
impl = _implementations().get(descriptor.protocol)
if impl is None: # pragma: no cover - only reachable via a bad descriptor
raise ProviderError(
f"Provider '{provider_id}' declares unknown protocol "
f"'{descriptor.protocol}'."
protocol_to_class = {
WireProtocol.OPENAI_COMPAT: OpenAICompatProvider,
WireProtocol.ANTHROPIC: AnthropicProvider,
}
adapter = protocol_to_class.get(descriptor.wire_protocol)
if adapter is None: # pragma: no cover — unreachable while the map is total
raise ProviderNotFoundError(
f"No adapter implements wire protocol {descriptor.wire_protocol!r}"
)
# Copy before mutating: conf is the caller's live config dict, and
# writing the routed model into it would silently change the user's
# saved default for every later turn.
resolved = dict(conf or {})
resolved["model"] = descriptor.resolve_model(conf, model)
instance = impl(resolved)
# The adapter class is shared by several ids (three of them are
# OpenAI-compatible), so its class-level `name` cannot identify which
# provider this is. Stamping the instance keeps usage records, audit
# entries and routing candidate keys attributed to the right provider.
instance.name = descriptor.id
return instance
return adapter
def describe(self, provider_id: str, conf: Optional[Mapping[str, Any]] = None) -> str:
"""One-line description used in logs and error messages."""
return self.require(provider_id).describe(conf)
def build(self, provider_id: str, conf: Dict[str, Any]):
"""Instantiate a ready-to-use provider adapter.
The descriptor's ``default_model`` fills in a missing/blank ``model`` so
a half-written config still produces a working provider instead of an
empty model id that only fails once the request hits the gateway.
"""
descriptor = self.get(provider_id)
adapter = self.adapter_class(descriptor.provider_id)
merged = dict(conf or {})
merged["model"] = descriptor.resolve_model(merged.get("model", ""))
return adapter(merged)
# Shared default instance. Callers that need the built-in catalogue use this
# instead of constructing a registry each time; tests build their own with an
# explicit descriptor list.
default_registry = ProviderRegistry()
# --------------------------------------------------------------------------- #
# Process-wide default registry.
#
# Built lazily under a lock: several UI screens can ask for it during startup
# from different threads, and double-construction would hand out two catalogues
# whose discovered model lists then drift apart.
# --------------------------------------------------------------------------- #
_default_registry: Optional[ProviderRegistry] = None
_default_lock = threading.Lock()
__all__ = ["ProviderRegistry", "BUILT_IN_PROVIDERS", "default_registry"]
def default_registry() -> ProviderRegistry:
"""The shared registry seeded with :data:`BUILTIN_DESCRIPTORS`."""
global _default_registry
if _default_registry is None:
with _default_lock:
if _default_registry is None:
_default_registry = ProviderRegistry(BUILTIN_DESCRIPTORS)
return _default_registry
def reset_default_registry() -> None:
"""Drop the cached registry — test-support hook so one test's registrations
cannot leak into the next."""
global _default_registry
with _default_lock:
_default_registry = None
__all__ = [
"BUILTIN_DESCRIPTORS",
"ProviderNotFoundError",
"ProviderRegistry",
"default_registry",
"reset_default_registry",
]
-18
View File
@@ -1,18 +0,0 @@
"""Qt-backed adapters for pure interfaces used elsewhere in the app (EPIC R07).
Note: the original plan (``docs/refactor/Feature_Architecture_Proposal.md``)
placed this adapter at a new top-level ``platform/qt/`` package. That name
was dropped after it was shown to actually shadow the stdlib ``platform``
module (used by ``core/windows_sandbox_vm.py``/``core/appcontainer_sandbox.
py``) whenever the repo root ends up on ``sys.path`` directly - e.g. running
``python -c "..."`` (or any script) with the repo root as the working
directory, which resolves a bare ``import platform`` to this package instead
of the standard library one. ``infrastructure/`` already exists as a layer
for exactly this kind of toolkit-specific implementation
(``infrastructure/filesystem/``, ``infrastructure/mcp/``, ...), so the
adapter lives here instead - same content, safer location.
"""
from .qt_scheduler_clock import QtSchedulerClock
__all__ = ["QtSchedulerClock"]
-70
View File
@@ -1,70 +0,0 @@
"""QtSchedulerClock - the ``QTimer``-backed periodic ticker `TaskScheduler`
needs, pulled out from ``core/task_scheduler.py`` into its own adapter
(R07-T03).
``core/task_scheduler.py::TaskScheduler`` is the only file in the scheduling
stack that imports Qt at all (confirmed by grep — ``core/tasks.py`` and
``core/task_executors.py`` are Qt-free). Everything it needs Qt FOR is small
and mechanical: an interval timer that calls back into ``tick()`` every
``TICK_MS``, plus, during ``stop()``, a way to pump the event loop so a
worker thread's queued ``finished_ok``/``failed`` signal still gets delivered
while draining running tasks (see the long comment on ``TaskScheduler.stop()``
for why that pump matters).
Wrapping exactly that surface — ``start(interval_ms, callback)``, ``stop()``,
``pump()`` — behind :class:`QtSchedulerClock` lets ``TaskScheduler`` take a
clock as a constructor parameter instead of constructing a ``QTimer``
itself. Production wiring is unchanged (``TaskScheduler`` defaults to a real
``QtSchedulerClock`` when no clock is passed); tests can inject
``tests/fakes/fake_clock.py::FakeClock`` to control ticks by hand with no Qt
event loop running at all.
See ``infrastructure/qt/__init__.py`` for why this lives under
``infrastructure/qt/`` and not the ``platform/qt/`` path the original plan
named.
"""
from __future__ import annotations
from typing import Callable, Optional
from PySide6.QtCore import QCoreApplication, QObject, QTimer
class QtSchedulerClock:
"""Owns one ``QTimer``. Not itself a ``QObject`` subclass — it OWNS a
``QObject``-parented timer instead of inheriting from one, so callers
(like ``FakeClock`` in tests) can satisfy the same duck-typed interface
without any Qt base class at all."""
def __init__(self, parent: Optional[QObject] = None) -> None:
# Parented so the timer is torn down with its owner instead of
# outliving it — the same lifetime QTimer(self) gave it inside
# TaskScheduler before this extraction.
self._timer = QTimer(parent)
self._timer.timeout.connect(self._on_timeout)
self._callback: Optional[Callable[[], None]] = None
def _on_timeout(self) -> None:
if self._callback is not None:
self._callback()
def start(self, interval_ms: int, callback: Callable[[], None]) -> None:
"""Arm and start the timer. Calling this again while already
running re-arms it with the new interval/callback (matches
``QTimer.start()``'s own restart-on-repeat-call behaviour)."""
self._callback = callback
self._timer.setInterval(interval_ms)
self._timer.start()
def stop(self) -> None:
self._timer.stop()
def pump(self) -> None:
"""Process one batch of pending Qt events — used by
``TaskScheduler.stop()``'s bounded drain loop so a worker thread's
queued completion signal can still be delivered while we wait for it
to exit."""
QCoreApplication.processEvents()
__all__ = ["QtSchedulerClock"]
+1
View File
@@ -0,0 +1 @@
"""Infrastructure sandbox package: OS-specific sandbox capability adapters."""
View File
+86
View File
@@ -0,0 +1,86 @@
"""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
+46
View File
@@ -0,0 +1,46 @@
"""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 -21
View File
@@ -1,21 +1 @@
"""Telemetry sinks: where token usage and turn metrics are recorded (EPIC R03)."""
from .usage_sink import (
NullUsageSink,
RecordingUsageSink,
UsageEvent,
UsageEventSink,
UsageTrackerSink,
default_sink,
set_default_sink,
)
__all__ = [
"UsageEvent",
"UsageEventSink",
"UsageTrackerSink",
"NullUsageSink",
"RecordingUsageSink",
"default_sink",
"set_default_sink",
]
"""Infrastructure telemetry package: CanonicalAuditLogger and token usage sinks."""

Some files were not shown because too many files have changed in this diff Show More