Compare commits

...
Author SHA1 Message Date
anhtnm1andClaude Opus 5 e29a0ccdbd refactor: vá 4 hồi quy, tách 4 file chạm trần LOC, docstring lên 100%
Hồi quy đã vá
-------------
F-12  Kéo–thả hoặc dán tệp vào ô chat ném NameError. R08 tách `_Input` sang
      `chat_input_box.py` nhưng để `_paths_from_mime()` ở lại
      `composer_widget.py`, nên hai hàm sự kiện Qt gọi một cái tên không tồn
      tại. Bốn hàm dùng chung chuyển sang `composer_mime.py` — module thứ ba
      là chỗ duy nhất không lặp lại được lỗi này. Đo lại: cả thả lẫn dán đều
      gắn 1 tệp, khớp bản trước refactor.

F-01  Đổi provider thì bộ chọn model AI-Edit không làm gì. Hook cũ kiểm
      `folder.ai_model_combo`, thuộc tính R08-T12 đã dời sang
      `ai_panel.resolver`. Làm mới vô điều kiện, đúng như tab cũ: lần lấy đầu
      tiên hỏng thì đổi provider chính là lúc phải thử lại.

F-07  Hàng chọn kỳ của Dashboard bị đẩy xuống dưới các thẻ số liệu. Hàng này
      lọc CẢ BA thẻ con chứ không riêng biểu đồ, nên để nó nằm dưới là bắt
      người dùng đọc con số trước khi thấy con số đó tính cho kỳ nào. Kèm
      theo: `TokenUsageCardWidget` bị bỏ sót `setContentsMargins(0,0,0,0)`
      mà hai thẻ con còn lại đã có, đẩy cả hàng thẻ lệch 9px.
      `check_layout_geometry` nay khớp TỪNG BYTE với bản trước refactor.

F-11  Hai lớp khai trùng tên phương thức; Python giữ bản sau nên bản đầu là
      mã chết. `co4e_tab.py::showEvent` bản đầu gọi `_narrow_guard.attach()`
      và không bao giờ chạy.

Tách file (F-09)
----------------
Bốn file chạm trần 400 dòng, mỗi lần cắt ra một trách nhiệm thật:

    graph_renderer.py         -> graph_scene_builder.py + graph_export.py
    co4e_workflow_service.py  -> co4e_run_history.py
    json_config_repository.py -> config_sections.py
    agents_admin_tab.py       -> shared/agent_kind_visuals.py

File cuối còn xoá 3 bản sao của hàm đã có trong `shared/formatters.py`,
giống hệt đến từng dòng — nay định dạng thời gian và avatar không lệch nhau
giữa các bảng Giám sát nữa.

Docstring
---------
41,6% -> 100% (3.478/3.478 định nghĩa production), kể cả module dormant và
phương thức dunder. Toàn bộ phần bổ sung viết bằng tiếng Việt; comment tiếng
Anh có sẵn giữ nguyên — dịch ngược là một đợt riêng.

Seam chưa nối dây (F-05)
------------------------
9 seam mang nhãn `SEAM · dựng <ngày>` kèm hai câu: được nối khi nào, và để
dormant thì hỏng gì. Ngày lấy từ lịch sử git, không phải hạn tự đặt. Gate O
đọc nhãn đó và nhắc khi quá 30 ngày.

859 test xanh · 4/4 cổng CASAN · 19/24 checker khớp từng byte bản cũ.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:41:45 +09:00
anhtnm1andClaude Opus 5 d20306be08 fix(ui): dải chọn ngôn ngữ cắt mất chữ khi mục đang được chọn
`theme_qss.py` đặt `font-weight: 600` cho nút đang chọn, nhưng `QPushButton`
tính `sizeHint()` theo phông thường. Chữ đậm rộng hơn — nên đúng lúc một mục
được chọn thì nó không còn đủ chỗ và Qt cắt bớt chữ.

Đo được trước khi vá:

    Tiếng Việt                85px  cần 87px   thiếu 2px
    English                   67px  cần 69px   thiếu 2px
    Tự động (theo hệ thống)  170px  cần 177px  thiếu 7px
    日本語                     50px  cần 50px   —

Tiếng Việt lộ rõ nhất vì nó vừa là nhãn dài nhất trong dải ngôn ngữ, vừa có
dấu, và với người dùng tiếng Việt thì nó LUÔN là mục đang được chọn, tức luôn
là mục bị in đậm. Chữ Nhật không dính vì bề rộng glyph CJK không đổi theo độ
đậm.

Cách vá: chừa sẵn bề rộng cho chữ đậm ngay khi tạo nút. Không viết cứng con
số padding nào — lấy phần khung bằng cách trừ bề rộng chữ khỏi `sizeHint()`,
rồi cộng lại bề rộng chính chữ ấy ở độ đậm 600, nên QSS đổi padding thì phép
đo tự theo. Vá cả đường đổi nhãn khi chuyển ngôn ngữ, nếu không đổi sang
tiếng Anh xong bề rộng vẫn giữ theo nhãn tiếng Việt cũ.

`SegmentedControl` phải tách ra file riêng vì `ui/widgets.py` đang ở đúng 505
dòng mã = đúng trần bánh cóc của cổng LOC, thêm một dòng là cổng đỏ. File cũ
giảm còn 466 dòng và vẫn nối lại tên cũ nên hai chỗ đang import không phải
sửa gì.

Kiểm cả 3 ngôn ngữ: 18/18 nút đều đủ chỗ.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:41:45 +09:00
anhtnm1andClaude Opus 5 fa94a0b287 feat(launcher): install.bat + run.bat, và 8 thư viện thiếu trong requirements
Trình chạy
----------
Cả hai lệnh trong README đều không chạy được từ một thư mục checkout tên
khác `cowork_local`:

    python -m cowork_local   -> No module named cowork_local
    python __main__.py       -> ModuleNotFoundError: No module named 'cowork_local'

Không sửa được bằng mẹo sys.path, vì `state.py` khởi động máy chủ MCP MS365
bằng tiến trình con `python -m cowork_local.mcp_servers.ms365_server` — tiến
trình con cũng phải import được. Hai script tạo một junction ở
`%LOCALAPPDATA%\CoworkLocal\launcher` thay vì bắt người dùng đổi tên thư mục
làm việc.

Môi trường ảo đặt ở `%LOCALAPPDATA%\CoworkLocal\venv`, cố ý KHÔNG đặt trong
repo: các cổng chất lượng quét toàn bộ cây thư mục chứ không đọc
`.gitignore`, nên một `.venv` ở đây sẽ biến vài nghìn module thư viện thành
"mã production không ai import" và làm Gate O đỏ.

requirements.txt
----------------
Chạy thử `run.bat` trên một profile trắng thì app chết ngay lúc mở:

    presentation/folder/code_editor.py:41
    ModuleNotFoundError: No module named 'pygments'

Quét toàn bộ import bên thứ ba thì thiếu 8 thư viện, trong đó `pygments` và
`pydantic` là bắt buộc — import không có try/except, nên triệu chứng không
phải "tính năng đó không chạy" mà là app không mở được. Nghĩa là cài đúng
theo requirements.txt xong app vẫn hỏng.

Đã tách rõ nhóm bắt buộc / tuỳ chọn kèm lý do từng dòng.
`opendataloader-pdf` để nguyên dạng chú thích vì code tự cài khi cần qua
`core/deps.py::ensure_module`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:41:44 +09:00
anhtnm1andClaude Opus 5 81b9482011 fix(tools): 5 checker UI hỏng sau đợt tách widget R08
`tools/` không đổi một byte nào giữa hai bản, nhưng 5 checker vẫn chết vì
chúng tìm control bằng `getattr(root, "ten")` trên đúng widget cũ — mà R08 đã
dời control xuống widget con.

Thêm ba helper dùng chung vào `capture_screens.py`:
  * `_own_member` — tên do app khai trên widget, không phải thừa kế từ Qt
  * `owner_of`   — widget thật sự đang giữ tên đó, duyệt theo bề rộng
  * `control`    — lấy control dù nó nằm ở cấp nào

`check_controls_alive` từ "MẤT 24 control" về 0, kèm liệt kê 22 control đã
đổi chỗ và 2 cái đổi tên. `check_probes_bite` từ 1/4 lên 6/6 phép cấy lỗi đều
bị bắt — phép cấy thứ hai trỏ vào `ui/schedule_task_tab.py` đã bị xoá, nay
trỏ vào `presentation/scheduling/kanban_board_widget.py`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:37:06 +09:00
anhtnm1andClaude Opus 5 e5c184ce07 feat(gates): thêm cổng CASAN thứ tư (Gate O) và mở cổng LOC ra cả cây mã
Gate O — module production phải có ít nhất một nơi import
---------------------------------------------------------
Ba cổng đang có đều không bắt được mã chết, đúng như 1.400 dòng ở commit
trước đã chứng minh. Gate O dựng đồ thị import bằng AST từ
`__init__`/`__main__`/`app`, theo cả import muộn trong thân hàm.

Hai ngoại lệ tự động để `ALLOWLIST` không phải chép lại cùng một lý do nhiều
lần: `__init__.py` của gói mà mọi thành viên đều dormant, và module chỉ được
chính mã dormant đã miễn trừ import.

Cổng cũng đếm tuổi 9 seam chưa nối dây (nhãn `SEAM · dựng <ngày>`) và nhắc
khi quá 30 ngày. Chỉ [WARN], không làm CI đỏ: để nó đỏ thì CI sẽ đỏ vào một
buổi sáng mà không ai sửa gì, và cách nhanh nhất để xanh lại là sửa ngày.

Cổng LOC — quét 366 file thay vì 191
------------------------------------
`DEFAULT_TARGET_DIRS` chỉ có 4 gói Clean Architecture, nên một file 944 dòng
trong `ui/` vẫn qua cổng. Nay quét cả `ui/`, `core/`, `providers/`,
`security/`, `mcp_servers/` và các module ở thư mục gốc.

18 file đã dài hơn 400 dòng từ trước nằm trong `LEGACY_ALLOWANCE` — bánh cóc
chỉ quay một chiều, và nó đo DÒNG MÃ chứ không đo dòng vật lý. Bánh cóc chỉ
hỏi một câu, "file này có đang để thêm việc vào không?", mà viết thêm một
docstring thì không. Đếm dòng vật lý ở đó biến cổng thành thứ phạt người viết
tài liệu, và cách dễ nhất để làm nó xanh lại sẽ là xoá bớt chú thích. Trần
400 vẫn đếm dòng vật lý — đó là hợp đồng đã chốt của cổng S.

CI
--
Ghim tên thư mục checkout là `cowork_local`: nhiều test characterization sinh
tiến trình con `python -c "from cowork_local... import ..."`, mà tiến trình
con chỉ import được khi trên sys.path có thư mục mang đúng tên gói. Checkout
vào thư mục tên khác làm 73 test đỏ vì lý do không liên quan tới mã.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:36:52 +09:00
anhtnm1andClaude Opus 5 a71085b39e refactor: xoá 1.400 dòng mã chết còn sót sau merge và 5 gói rỗng
Hai bản tách song song của cùng một god-file cùng được giữ lại sau một lần
merge. Bản chết không ai import, và hai file trong đó còn không import nổi:
`graph_render.py` lấy `GraphQaMixin` không tồn tại, `task_actions.py` lấy
`ui.calendar_view` đã bị xoá.

Kèm theo 5 gói chỉ có `__init__.py` với docstring hứa những module chưa bao
giờ được tạo. Hai trong số đó (`adapters/qt/`, `infrastructure/platform/qt/`)
là vị trí đã bị bác bỏ có ghi lý do — `QtSchedulerClock` nằm ở
`infrastructure/qt/`, và lý do vì sao không đặt ở `platform/` vẫn còn nguyên
trong `infrastructure/qt/__init__.py`.

Không cổng nào bắt được đám này: file không ai import vẫn đúng chiều phụ
thuộc, vẫn sạch credential, vẫn dưới 400 dòng. Cổng O ở commit sau đi tìm
đúng khoảng trống đó.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:36:31 +09:00
huongltt35 95b3b27578 feat(R10): implement CI Quality Gates, Contributor Recipes, E2E Smoke Tests, and update docs 2026-08-28 11:08:53 +09:00
huongltt35 b8783526d0 feat(R08): finalize Chat UI Hub components, AudioRecorderWidget, and integration tests (100% PASS) 2026-08-28 10:45:43 +09:00
huongltt35 1de3336970 merge: merge origin/feature/teamhoa/r05-r06 (R07/R08) into feature/delta-team/epic-R04 2026-08-28 10:32:38 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 7e11e9676d refactor: nốt 3 chỗ R08 còn thiếu — ChatPanel và 2 tab admin về đúng chỗ
Soát lại từng dòng plan thì thấy tôi báo R08 xong hơi sớm. Ba chỗ thiếu thật:

  T06  ChatPanel vẫn ở ui/, plan đòi presentation/chat/chat_panel.py
  T08  agents_admin_tab.py (498) và tools_admin_tab.py (245) vẫn ở ui/

    presentation/chat/chat_panel.py                    346
    presentation/monitoring/tabs/agents_admin_tab.py   383
    presentation/monitoring/tabs/agent_edit_dialog.py  143
    presentation/monitoring/tabs/tools_admin_tab.py    245
    ui/chat_panel.py / agents_admin_tab.py / tools_admin_tab.py  ~10 mỗi cái

agents_admin_tab.py 498 dòng nên tách thêm agent_edit_dialog.py: bảng danh
sách và hộp thoại sửa là hai việc, và hộp thoại còn tự đi hỏi provider xem có
model nào — thứ bảng không cần biết.

BA CHỖ CÒN LẠI KHÔNG PHẢI THIẾU, đã kiểm từng cái:
* audio_recorder_widget.py (T04) — repo KHÔNG có chức năng ghi âm nào.
* connector_settings_widget.py (T07) — UI Connector đã dời khỏi Cài đặt.
* sandbox_status_tab.py / mcp_history_tab.py (T08) — Hiệp đặt tên sandbox_tab
  và mcp_tab, nội dung đủ.

R08: 14/14 task, 0 file thiếu thật sự.
756 test xanh. 24/24 checker qua.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 01:24:45 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 fdaedfa1c2 refactor(chat): tách nốt phần nối lại lượt đang chạy — chat_session_store 414 -> 352
chat_live_turns.py (90 dòng) là phần tinh tế nhất của khung chat: người dùng
mở phiên khác rồi quay lại trong khi lượt cũ vẫn đang chạy. Phải nối vào đúng
luồng đó và đúng danh sách tin nhắn đang sống, chứ không đọc bản trên đĩa (đã
cũ) hay khởi động lại. Sai thì hoặc mất phần agent viết lúc mình vắng mặt,
hoặc hai bên cùng ghi vào một file.

Giờ Gamma không còn file production nào vượt 400 dòng.

756 test xanh.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 01:11:22 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 577b81a641 refactor(chat): R08-T01..T06 — chat_panel.py 1821 -> 345, composer 663 -> 11
presentation/chat/
      chat_history_widget.py   348  T01  mạch hội thoại (từ ui/chat_view.py)
      chat_bubble_style.py     202  T01  cách vẽ bong bóng, diff, đường thời gian
      composer_widget.py       364  T02  thanh công cụ quanh ô nhập
      chat_input_box.py        328  T02  ô nhập: Ctrl+Enter, dán ảnh, popup /skill
      attachment_picker.py     215  T03  đọc tệp đính kèm + chặn theo chính sách
      chat_output_panel.py     186  T05  theo dõi thư mục output, hiện tệp mới
      chat_turn_runner.py      281  T06  chạy một lượt
      chat_event_stream.py     228  T06  nhận sự kiện phát về từ luồng nền
      chat_session_store.py    413  T06  lưu/nạp phiên, đếm token, nối lại lượt
      chat_agents.py           246  T06  chọn agent, skill, định tuyến model
      chat_panel_layout.py     148  T06  bố cục hai cột
      chat_helpers.py           53  T06  hàm và bảng tra dùng chung
    ui/chat_panel.py           345  __init__ + trạng thái
    ui/chat_view.py             10  vỏ chuyển tiếp
    ui/composer.py              11  vỏ chuyển tiếp

R08-T04 KHÔNG LÀM ĐƯỢC: plan đòi audio_recorder_widget.py, nhưng trong repo
KHÔNG CÓ chức năng ghi âm nào — grep 'audio|record|voice|micro' toàn ui/ chỉ
ra chữ 'record' trong nghĩa 'ghi lại transcript'. Không có gì để tách, và tôi
không dựng một widget mới nhân danh refactor. Giống hệt trường hợp
connector_settings_widget.py ở T07.

_start_turn (144 dòng) và _on_event (127) để nguyên có chủ ý: cái đầu dựng
trọn ngữ cảnh một lượt rồi giao cho luồng nền, cái sau phân nhánh theo loại sự
kiện. Cắt nhỏ thì phải chuyền hàng chục biến trạng thái qua lại, đọc khó hơn.

Hai lỗi tự gây, cả hai đều do script:
* regex bỏ import cũ chỉ cắt DÒNG ĐẦU của một import nhiều dòng, để lại phần
  đuôi mồ côi -> IndentationError.
* _build_layout dùng biến 'root' vốn cục bộ trong __init__. Bộ test bắt được
  cái này (2 bài integration đỏ), không phải checker — vì nó là lỗi dựng
  widget, không phải lỗi hình học.

756 test xanh. 24/24 checker qua.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 01:08:31 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 f0fd3a41cd refactor(folder): R08-T12 — folder_tab.py 1589 -> 305, tách 8 file
presentation/folder/
      ai_edit_runner.py            325  một lượt AI sửa file, từ gửi tới xem trước
      document_preview_manager.py  317  PDF/Word/Excel/PowerPoint/ảnh/HTML/mã
      ai_file_editor_dialog.py     317  dựng panel AI + chọn model
      code_editor.py               183  ô soạn mã, đánh số dòng, tô cú pháp
      ai_output_writer.py          140  phần DUY NHẤT chạm vào file người dùng
      image_model_picker.py        115  dò model sinh ảnh trên mọi provider
      file_helpers.py              112  nhận dạng loại file + ngưỡng
      workspace_file_tree.py        38  cây thư mục
    ui/folder_tab.py               305  lắp ráp + retranslate

Plan ghi 3 file; khối lượng thật cần 8. Hai file tôi thêm ngoài dự kiến vì
đọc kỹ thì chúng là ranh giới thật:

* ai_output_writer.py — tách ra vì đây là phần duy nhất THẬT SỰ ghi đè file
  của người dùng. Mọi thứ trước nó chỉ dựng bản xem trước. Ranh giới đó đáng
  nhìn thấy trong cấu trúc thư mục.
* image_model_picker.py — chỗ duy nhất trong màn Thư mục biết tới nhiều
  provider cùng lúc (nó gợi ý được model sinh ảnh của provider KHÁC cái đang
  chọn).

Gom mọi hằng nhận dạng loại file (_IMAGE_SUFFIXES, _HAS_PDF, _MAX_EDIT_BYTES…)
về file_helpers.py: cả tám file trong gói đều hỏi tới, để rải ra thì thêm một
đuôi file phải sửa vài chỗ.

LẠI IMPORT LAZY THỤT LỀ: regex đổi mức tương đối của tôi chỉ khớp đầu dòng
nên bỏ sót import nằm trong thân hàm — 3 checker đỏ. Lần này tôi sửa một lượt
cho CẢ cây presentation/ thay vì riêng thư mục vừa tách; nó tìm ra thêm 3 file
ở scheduling cũng đang sai mà chưa nổ.

756 test xanh. 24/24 checker qua.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 23:23:06 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 982fecc8dc refactor(scheduling): R08-T11 — schedule_task_tab.py 794 -> 297, tách 6 file
presentation/scheduling/
      calendar_view_widget.py    231  lịch tháng (chuyển từ ui/calendar_view.py)
      ai_task_creator_dialog.py  208  tạo task bằng AI
      task_actions.py            189  thêm/sửa/chạy/xoá/xem log một task
      kanban_board_widget.py      98  cột Kanban + vùng thả file
      run_history_dialog.py       82  lịch sử các lượt chạy
      ai_task_import_dialog.py    81  nhập task từ file
    ui/schedule_task_tab.py      297  dựng bảng + đổi chế độ xem
    ui/calendar_view.py           10  vỏ chuyển tiếp

Plan ghi 4 file; thực tế cần 6. Hai file thêm là run_history_dialog.py và
task_actions.py — không tách thì schedule_task_tab.py còn 517 dòng, vẫn vượt
ngưỡng 400.

ai_task_import_dialog.py làm mixin chứ không phải hộp thoại rời: plan gọi nó
là dialog, nhưng thực tế nó là TAB THỨ HAI của cùng hộp thoại tạo task, dùng
chung phần xem trước và nút Xác nhận. Tách hẳn thì phải nhân đôi cả hai.

LẠI LỖI DECORATOR: script này tôi quên dùng bản có tính dòng @, nên một
@staticmethod bị bỏ lại mồ côi -> IndentationError. Đây là lần thứ tư cùng
một lỗi. Đã thêm bước dọn decorator mồ côi vào script.

756 test xanh. 16 checker chạy đều qua.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 22:54:50 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 4fef41481b refactor(graph): R08-T14 — structure_graph_view.py 1034 -> 11, tách 6 file
presentation/graph/
      structure_graph_view.py  325  lớp chính + dựng giao diện
      graph_qa_widget.py       322  hỏi-đáp trên đồ thị (_ask 119 dòng)
      graph_render.py          226  quét, vẽ Qt + D3, xuất ảnh
      graph_scene.py           138  node, cạnh, khung nhìn — thuần đồ hoạ
      graph_project.py         109  chọn project, đổi tab xem
      graph_web.py              38  cờ có dùng được QtWebEngine không
    ui/structure_graph_view.py  11  vỏ chuyển tiếp, giữ đường import cũ

BA LẦN CẮT HỎNG, ĐỀU LÀ TÊN CẤP MODULE BỊ BỎ LẠI
------------------------------------------------
_HAS_WEB, QWebEngineView, QWebChannel, _Bridge, _Edge, _Node — tất cả định
nghĩa ở file gốc, dùng ở file mới, nên NameError ngay lúc chạy. Bộ test đơn
vị KHÔNG bắt được cái nào: 756 bài vẫn xanh suốt ba lần. Chỉ
check_graphrag_rescan bắt, vì nó gọi prewarm() thật rồi chờ đồ thị dựng xong.

Sau lần thứ ba tôi bỏ cách đuổi từng lỗi và viết bộ dò tên chưa định nghĩa có
tính đến phạm vi hàm (tham số, biến cục bộ, except-as, comprehension). Nó
tìm ra nốt _fmt_plan và _qcolor còn thiếu ở hai file Co4E đã tách hôm trước —
hai quả mìn chưa nổ.

_HAS_WEB tách hẳn ra graph_web.py: cả structure_graph_view.py lẫn
graph_render.py đều phải hỏi, để ở một trong hai là vòng import.

756 test xanh. 24/24 checker qua.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 22:45:25 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 062ea4ba21 refactor(dashboard): R08-T13 — dashboard_tab.py 438 -> 215, tách 3 widget
token_usage_card_widget.py    93   5 thẻ số liệu + thẻ Ngân sách
    usage_chart_widget.py        119   biểu đồ tuần/tháng/năm + đường so sánh
    habits_widget.py             171   thói quen dùng token + nhận xét của AI

Ba widget THẬT, không phải mixin — khác với shell và Co4E, ba mảng này tách
bạch trên màn hình và không đọc state của nhau. Giao tiếp bằng signal:
budget_applied, filter_changed, status_message.

Điểm cần biết: các nút lật khoảng và hai ô chọn thuộc về UsageChartWidget
nhưng được Dashboard nhấc lên hàng điều khiển ở trên. Chúng là control của
biểu đồ, chỉ hiển thị ở chỗ khác.

Giữ 18 cầu tương thích cho tên cũ vì check_dashboard, check_design_parity và
check_controls_alive đọc thẳng self.card_total, self._chart_period_lbl...

756 test xanh. check_dashboard, check_design_parity, check_controls_alive qua.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 22:05:48 +09:00
vudt15andClaude Sonnet 5 a8c6b5c20a docs(refactor): merge Team Hoa R05/R06 and R07/R08 reports into one
Replaces BaoCao_TeamHoa_R05_R06.md and BaoCao_TeamHoa_R07_R08.md with a
single BaoCao_TeamHoa_R05_R08.md covering all 4 EPICs (19/19 tasks) in
Team Hoa's scope, and updates the checklist's report links accordingly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 21:06:18 +09:00
vudt15andClaude Sonnet 5 1efa1d29d1 docs(refactor): add the Team Hoa completion report for R07/R08
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 20:58:08 +09:00
vudt15andClaude Sonnet 5 0e51356a7d feat(R08): split ScheduleTaskTab, FolderTab, DashboardTab, StructureGraphView
Team Hoa, EPIC R08 (UI/Application Separation) - Team Hoa scope only
(R08-T11 -> T14; R08-T01->T10 belong to Team Duy/Team Nam).

- R08-T11: ui/schedule_task_tab.py (795 lines) -> presentation/scheduling/
  {kanban_board_widget,calendar_view_widget,ai_task_creator_dialog,
  ai_task_import_dialog,run_history_dialog}.py + schedule_task_tab.py
  shell. Kanban CRUD/drag-drop now goes through
  application/scheduling/task_application_service.py (R07-T04) instead of
  ~30 lines of inline if/elif per drag target.
- R08-T12: ui/folder_tab.py (1587 lines, the largest of the four) ->
  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 + folder_tab.py shell.
  Closes the R06-T05 loop: FileWorkspaceService existed since R06 with
  zero production call sites (confirmed by grep); every plain-text write
  (save/create/write_content) now goes through it, gaining path
  containment and a Python-syntax warning the original code never had.
  Pure helpers (_read_text, _is_probably_text, _pptx_available,
  _split_code_block, _parse_ai_output) moved to
  application/workspaces/{file_preview_helpers,ai_edit_output}.py.
- R08-T13: ui/dashboard_tab.py (437 lines) -> presentation/dashboard/
  {token_usage_card_widget,usage_chart_widget,habits_widget}.py +
  dashboard_tab.py shell, backed by a new
  application/monitoring/dashboard_query_service.py (pricing/period/
  summary queries the three widgets used to each recompute separately).
  Directory-ownership note left in the checklist for Team Nam.
- R08-T14: ui/structure_graph_view.py (1035 lines) ->
  presentation/graph/{graph_scene_items,graph_renderer,
  graph_messages_view,graph_qa_widget}.py + structure_graph_view.py
  shell. Extraction helpers (_pdf_to_markdown, _extract_file_contents)
  moved to application/workspaces/graph_index_service.py (pure Python).
  Renderer and Q&A panel talk only through signals
  (node_selected/graph_rendered/raw_json_ready/project_changed) - neither
  imports the other.
- presentation/shared/web_engine_support.py: HAS_WEB_ENGINE, previously
  duplicated (folder_tab imported it FROM structure_graph_view.py) - now
  one shared flag instead of one screen importing another screen's module.

All four old ui/*.py files deleted; app.py and ui/workspace_tab.py updated
to the new import paths (each god-file only had 1-2 real construction
sites, so import sites were updated directly rather than kept as a
strangler-fig shim - unlike core/tools.py at R05, which had dozens).

pytest: 377 pass (+94 vs the R07 baseline of 328; same 4 pre-existing
failures as the R05/R06 baseline, unrelated to this work).
scripts/check_imports.py: PASS. python -c "import cowork_local.app": OK.
Every new file < 400 lines (largest: graph_renderer.py, 391).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 20:55:32 +09:00
vudt15andClaude Sonnet 5 69ab8e125b feat(R07): task repository, schedule calculator, Qt clock adapter, task/AI-planner services
Team Hoa, EPIC R07 (Scheduling & Workflow Runtime) - Team Hoa scope only
(R07-T01 -> T05; R07-T06 Co4EWorkflowService is Team Nam's).

- R07-T01: infrastructure/persistence/json/task_repository_impl.py wraps
  core/tasks.py's CRUD; core/tasks.py::save_task now writes through
  atomic_write.write_json (same durability fix as R06-T02, save_task was
  still doing a plain write_text).
- R07-T02: domain/tasks/schedule_calculator.py::ScheduleCalculator - the
  cron/interval/daily/weekly/monthly due-time math extracted from
  core/tasks.py, pure Python with is_holiday/make_cron injected so domain/
  never imports core (ADR-001 I2). core/tasks.py keeps its old function
  names as thin wrappers so every existing caller is unchanged. This was
  previously untested; now has its own unit suite.
- R07-T03: infrastructure/qt/qt_scheduler_clock.py::QtSchedulerClock wraps
  the QTimer TaskScheduler used to own directly, injected via a new
  `clock=` constructor param (defaults to a real one). Originally planned
  at platform/qt/... ; moved after confirming that name shadows the
  stdlib platform module (used by core/windows_sandbox_vm.py,
  core/appcontainer_sandbox.py) whenever the repo root is on sys.path.
  tests/fakes/fake_clock.py lets scheduler dispatch be tested tick-by-tick
  with no Qt event loop.
- R07-T04: application/scheduling/task_application_service.py centralizes
  run_now/duplicate/pause/delete/bulk_delete and the Kanban drag-drop
  business rules (move_to_status), currently only reachable by driving
  the real ui/schedule_task_tab.py widget.
- R07-T05: application/scheduling/ai_task_planner_service.py wraps
  core/ai_task_planner.py::plan_tasks and core/task_import.py::import_tasks
  as a seam, plus the attachment-stamping step that used to only exist
  inside the AI-create dialog's worker closure.

pytest: 328 pass (same 4 pre-existing failures as the R05/R06 baseline,
unrelated to this work - see docs/refactor/BaoCao_TeamHoa_R05_R06.md).
scripts/check_imports.py: PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:27:53 +09:00
huongltt35 f8e22f5f5b merge: merge origin/gamma/refactor and origin/feature/teamhoa/r05-r06 into feature/delta-team/epic-R04 2026-08-27 12:23:43 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 af8a3712e2 fix(co4e): 4 chỗ ghi JSON của Gamma đi qua AtomicJsonFile — tiêu chí nghiệm thu A
Soát lại plan.md thì thấy CASAN là NĂM tiêu chí C-A-S-A-N, không phải ba. Tiêu
chí A có hai vế, tôi mới đạt vế đầu:

  vế 1  0 API key plaintext trong JSON          -> đã đạt từ 25/08
  vế 2  MỌI thao tác ghi tệp đi qua AtomicJsonFile  -> CHƯA

Toàn repo còn 15 chỗ ghi JSON thẳng. Bốn trong đó là của Gamma (vùng Co4E):

  core/co4e.py:236   lưu workflow   ghi thẳng, không nguyên tử gì cả
  core/co4e.py:310   lưu agent      ghi thẳng
  core/co4e_run_manager.py:156                  tmp + replace tự viết
  application/workflows/co4e_workflow_service.py:178   tmp + replace tự viết

Hai chỗ đầu nguy hơn: tắt máy giữa lúc lưu là mất luôn workflow hoặc agent.

Hai chỗ sau nhìn thì có vẻ ổn vì đã tmp + replace, nhưng thiếu hai thứ:
* không fsync — dữ liệu có thể còn nằm trong bộ đệm ổ đĩa khi mất điện, nên
  "nguyên tử" chỉ đúng với crash tiến trình, không đúng với mất điện;
* dùng thẳng Path.replace, đúng chỗ dính PermissionError [WinError 5] mà tôi
  vá hôm 25/08 — Defender giữ handle file vừa tạo. Tần suất đo được khoảng
  1/140 lần lưu, nhân với số lần lưu lịch sử chạy flow.

11 chỗ còn lại thuộc team khác (accounts, admin_agents, custom_agents, flows,
groups, history, projects, skills, tasks). Không đụng vào; cần báo lên vì
tiêu chí A là tiêu chí TOÀN DỰ ÁN, Gamma sạch không cứu được cổng.

Đã kiểm application/ vẫn không kéo PySide6 vào sau khi thêm import mới
(tiêu chí C). 714 test xanh, 24/24 checker qua.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 11:38:44 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 6c68417103 refactor: chia nốt theme.py, i18n.py, usage_tracker.py — Gamma hết file vượt 400 dòng
Ba file dữ liệu cuối cùng của Gamma còn trên ngưỡng CASAN Check 2.

i18n.py  3075 -> 94
    Dict STRINGS 3.000 dòng cắt thành 10 cụm theo đúng mốc phân đoạn có sẵn
    trong file (mỗi mốc là một màn/hộp thoại), cụm nào quá dài thì cắt tiếp ở
    ranh giới khoá. i18n.py giờ chỉ gộp lại và giữ 4 hàm set_language/
    get_language/tr/on_language_changed.

    Kiểm bằng cách so với bản gốc lấy từ git: 1437 mục / 1431 khoá duy nhất
    (bản gốc vốn có 6 khoá lặp), sau khi chia vẫn 1431, KHÔNG thiếu khoá nào,
    KHÔNG thừa khoá nào, KHÔNG giá trị nào lệch. Thứ tự gộp giữ nguyên nên
    quy tắc "khoá trùng thì bản sau thắng" không đổi.

theme.py  907 -> 130
    theme_palettes.py 328  hai bảng màu Tối/Sáng + lớp Palette
    theme_qss.py      198  nửa vỏ (reset + shell)
    theme_qss_controls.py 307  nửa điều khiển (nút, ô nhập, tab, badge)

    Khuôn QSS 470 dòng cắt đôi đúng mốc `/* ---- surfaces */` của chính nó.
    Đã đối chiếu: stylesheet('dark') ra đúng 24762 ký tự y như trước — khớp
    từng byte, không phải "trông có vẻ giống".

core/usage_tracker.py  536 -> 307
    usage_cost.py      101  bảng giá, quy đổi token sang tiền, định dạng
    usage_periods.py   144  gộp theo ngày/tuần/tháng/quý, chuỗi vẽ biểu đồ
    usage_ai_report.py  56  dựng câu nhắc cho AI phân tích

HAI LẦN TỰ CẮT HỎNG, ĐỀU CÙNG MỘT GỐC
--------------------------------------
1. Cắt theo m.lineno mà quên dòng @decorator phía trên -> @dataclass của
   Palette bị bỏ lại mồ côi, "Palette() takes no arguments".
2. Đọc số dòng từ AST GỐC trong khi danh sách dòng đã bị cắt -> lần bóc thứ
   hai dùng toạ độ cũ và cắt vào giữa một chữ ký hàm.

Cả hai lộ ngay vì mỗi script tự parse lại sau khi ghi. Bài học đã áp vào cả
ba lần chia: parse lại sau mỗi lần cắt, và luôn tính cả decorator.

KẾT QUẢ CASAN CHECK 2
---------------------
    Nam       0 file vượt 400   (trước: 4, tổng 5.898 dòng)
    Hiệp      0                 (trước: 1)
    Lâm       0                 (trước: 1)
    file mới  0                 (61 file dưới presentation/ application/
                                 domain/ infrastructure/ — chưa cái nào vượt)

Gamma sạch. 23 file còn vượt đều thuộc team khác (chat_panel.py 1802,
folder_tab.py 1589, structure_graph_view.py 1034...) — cần báo lên sớm chứ
đừng để tới hạn 30/08 mới lộ.

714 test xanh. 24/24 checker qua. CASAN Check 1 sạch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 10:57:01 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 0e00bf3c2f refactor(co4e): co4e_tab.py 1885 -> 389, Co4ETab tách thành 7 mixin
File to nhất còn lại của Gamma. Lâm bàn giao ở 1.885 dòng với 100 method
trong một lớp; chia theo bảy mối quan tâm:

    co4e_runs.py           364   chạy flow, 3 chế độ, bảng lịch sử lượt chạy
    co4e_chat.py           343   khung chat + đếm token + định tuyến riêng
    co4e_layout.py         308   ba khung, bảng cấu hình, bố cục màn hẹp
    co4e_sidebar.py        251   thư viện workflow/agent/skill, 4 mục gập
    co4e_flow_tabs.py      180   dải tab các flow đang mở
    co4e_workflow_crud.py  154   tạo/sửa/xoá/nhân bản workflow
    co4e_agents.py          51   agent và skill dùng trong flow
    ui/co4e_tab.py         389   __init__, set_project, thư mục output

Mọi file dưới 400 dòng.

MỘT LỖI SUÝT LÀM HỎNG FILE: bản đầu tôi cắt method theo m.lineno, mà lineno
trỏ vào dòng `def`, không tính dòng `@...` phía trên. Decorator bị bỏ lại
thành mồ côi ngay trên một hằng số lớp -> file hỏng cú pháp. Bắt được vì
script tự parse lại sau mỗi lần cắt; nếu chỉ cắt rồi ghi thì đã đẩy lên một
file không import nổi.

Ba vòng sửa mức import tương đối: co4e_tab.py nằm ở ui/ (1 cấp), file mới ở
presentation/co4e/ (2 cấp). Còn co4e_canvas / co4e_config_panel /
co4e_agent_dialog thì VẪN ở ui/, nên `.co4e_canvas` phải thành
`...ui.co4e_canvas` chứ không phải `.co4e_canvas` cùng thư mục.

714 test xanh — trong đó có ~4.000 dòng test đặc tả Lâm viết cho đúng vùng
này, nên việc tách được soi khá kỹ. check_co4e, check_controls_alive,
check_layout_geometry, check_probes_bite đều qua.

Cập nhật đích đột biến thứ ba của check_probes_bite: dải tab flow nay ở
presentation/co4e/co4e_layout.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 10:43:33 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 70a0c2fdcf refactor(shell): R08-T10 xong — app.py 1293 -> 128, MainWindow tách thành 11 file
Đây là deliverable còn thiếu duy nhất trong 17 task của Gamma.

    app.py                     128   chỉ còn điểm vào chương trình
    presentation/shell/
      main_window.py           362   __init__ + vòng đời cửa sổ
      nav_rail.py              385   dựng rail + cây điều hướng + thu gọn
      top_bar.py               234   thanh trên + tài khoản + đáy rail
      session_events.py        104   lịch sử, thông báo task xong
      page_registry.py          82   4 màn chính, dựng lười, _goto
      rail_project.py          132   bộ chọn project + RECENTS
      lifecycle_coordinator.py 110   canh màn hình + tắt sạch
      tray_manager.py           76   khay hệ thống
      toast.py                  40   thông báo góc trên trái
      bootstrap.py              42   Composition Root
      branding.py               26   ASSETS + app_icon
      rail_metrics.py           37   kích thước rail + cách vẽ hàng

Mọi file dưới 400 dòng. Đây là ngưỡng CASAN Check 2.

NÓI THẲNG VỀ CÁCH TÁCH: sáu file trong đó là MIXIN, không phải widget rời.
Cả loạt phương thức đọc/ghi state của cửa sổ (self._page_widgets, self.workspace,
self.splitter...). Biến thành đối tượng cộng tác thì phải viết lại từng chỗ
self.X thành self.window.X — gần 800 dòng sửa chỉ để đổi cách gọi, rủi ro cao
mà không đổi hành vi. Mixin cho đúng thứ đang cần: mỗi mảng một file, ai sửa
rail thì mở file rail. Chuyển thành widget thật khi có cửa sổ thứ hai cần dùng
lại — hiện chưa có.

Giữ đường vào cũ: MainWindow, app_icon, _NAV_*, _Toast vẫn import được từ
cowork_local.app, nên 24 checker trong tools/ không phải sửa.

BA LỖI TỰ GÂY TRONG LÚC TÁCH, ĐỀU DO CHECKER BẮT
-------------------------------------------------
1. 12 import lazy nằm trong thân hàm bị thụt lề nên regex đổi mức tương đối
   của tôi bỏ sót -> ModuleNotFoundError khi bấm vào rail.
2. Bộ dò import thiếu của tôi tính cả import cục bộ trong hàm KHÁC, nên tưởng
   QHBoxLayout đã có -> 17 checker đỏ. Bỏ cách dò, cấp thẳng khối import đầy
   đủ rồi cắt phần không dùng.
3. Hằng số ASSETS và _NAV_* nằm ở khối tôi không mang theo -> NameError.

Cả ba đều là lỗi im lặng với bộ test đơn vị (714 vẫn xanh suốt) và chỉ lộ khi
dựng cửa sổ thật. Đó chính là lý do bộ checker trong tools/ tồn tại.

Cập nhật 2 đích đột biến của check_probes_bite: mã nó cần sửa đã dời khỏi
app.py sang rail_project.py và nav_rail.py.

714 test xanh. 24/24 checker qua.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 10:32:22 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 bc282c71d0 refactor(config): AppConfig thành vỏ mỏng trên repository + vá 3 chỗ gán im lặng hỏng
config.py 623 -> 377 dòng (qua ngưỡng 400 của CASAN Check 2).

Class AppConfig 278 dòng giờ còn 30: mọi lối vào dẫn tới JsonConfigRepository.
Không xoá hẳn vì cái tên còn nằm ở 41 file — 23 checker trong tools/ và 18 file
test, trong đó có test của cả ba người. Sửa 41 chỗ trong một commit là đổi thứ
không cần đổi và làm review không đọc nổi. Giữ tên, đổi ruột.

Thêm JsonConfigRepository.from_data() cho dạng AppConfig(data=..., path=...) mà
13 file test đang dùng: dựng thẳng từ dict, không đọc đĩa, không chạy migration
trên dữ liệu test.

MỘT LỖI TÔI GÂY RA HÔM 25/08, HÔM NAY MỚI LỘ
---------------------------------------------
Lúc tráo R02 tôi có đối chiếu API và kết luận "đủ 34/34 thành viên, thay được".
Đối chiếu đó chỉ so TÊN, không so việc một property có setter hay không.

AppConfig cũ là dataclass nên `config.language = "vi"` chạy bình thường.
Repository để language là property chỉ đọc -> gán vào là AttributeError. Ba chỗ
trong app.py đang gán: đổi ngôn ngữ, đổi giao diện, đổi provider trên thanh bên.

Khó thấy vì cả ba nằm trong slot của Qt, mà Qt NUỐT ngoại lệ trong slot. Không
traceback, không thông báo — bấm đổi ngôn ngữ thì không có gì xảy ra. 709 test
đơn vị vẫn xanh suốt. Chỉ check_nav bắt được vì nó bấm thật vào combo rồi kiểm.

Thêm setter cho theme/language/active_provider, và tests/test_config_gan_duoc.py
đi ngược từ mã nguồn: quét cả repo tìm mọi chỗ `config.X = ...` rồi thử gán
thật. Đã kiểm ngược — bỏ setter đi thì 2 bài đỏ.

BẮC CẦU CHO 55 CONTROL MONITORING
----------------------------------
check_controls_alive so với mốc git 291a611 và đòi 55 control ov_* của Tổng
quan phải còn tới được. Sau khi Hiệp tách 8 tab, chúng về đúng tab/thẻ của mình
và rụng tiền tố -> 3 checker đỏ.

Control còn đủ, chỉ đổi chỗ ở. Bắc cầu bằng __getattr__ định tuyến theo tiền tố
(ov_perm_ -> permissions_card, ov_sbx_ -> sandbox_card, ov_price_/ov_pricing_ ->
pricing_panel, còn lại -> overview_tab), cộng 3 hộp nhóm mà bản thân widget con
chính là hộp đó.

Định tuyến theo tiền tố chứ không dò mờ: overview_tab và permissions_card đều
có network_lbl — một cái là mức dùng mạng, một cái là quyền truy cập mạng. Bản
dò mờ đầu tiên tôi viết vớ nhầm cái đầu tiên tìm thấy.

714 test xanh. 24/24 checker qua (3 cái đã đỏ từ trước khi tôi bắt đầu, do phần
monitoring, nay xanh lại). CASAN Check 1 sạch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 01:03:28 +09:00
Hiep Ha Van 72ed3b4147 Merge remote-tracking branch 'origin/gamma/refactor' 2026-08-25 23:55:46 +09:00
Hiep Ha VanandClaude Sonnet 5 40b12ecb15 refactor(monitoring): N2 - tach monitoring_tab.py, CanonicalAuditLogger, MonitoringQueryService, go circular import, sandbox matrix
- ui/monitoring_tab.py (1546 dong) tach thanh presentation/monitoring/**
  (container + 7 tab/card + shared helper), ui/monitoring_tab.py con lai
  re-export shim de app.py khong doi.
- infrastructure/telemetry/audit_logger.py: CanonicalAuditLogger, core/audit_log.py
  thanh wrapper mong, tuong thich nguoc 100% voi schema .jsonl cu.
- application/monitoring/monitoring_query_service.py: MonitoringQueryService
  read-only, filter/sort/pagination, khong import PySide6.
- Go circular import model_pricing<->usage_tracker va agent_security<->
  agent_security_alert (core/agent_security_types.py moi).
- infrastructure/sandbox/sandbox_capabilities.py: SandboxCapabilityMatrix
  theo OS (Windows/Linux/macOS), chua dau noi vao core/sandbox_manager.py.
- conftest.py: sua loi checkout khong ten cowork_local khien pytest import
  nham thu muc khac.
- 77 test moi, 167/167 pass. QA da xac nhan UI/business logic khong doi
  (xem evidence/report/unified_report.html).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 23:52:36 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 4c3b097977 refactor(ui): xoá 108 dòng MS365 chết trong settings_dialog.py
Sót lại từ lần dời UI Connector sang Monitoring → Tools → Connector. Năm hàm:

    _refresh_ms365_status    13    _show_ms365_device_code   52
    _ms365_sign_in           34    _ms365_sign_out            4
    _close_ms365_code_dialog  5

Chứng minh chết trước khi xoá, không xoá theo cảm tính:

* Dựng đồ thị lời gọi bằng ast: **mọi** lời gọi tới năm hàm này đều xuất phát
  từ bên trong chính năm hàm đó. Không một đường vào nào từ ngoài cụm — cả
  trong file lẫn toàn repo.
* Ba thuộc tính chúng đọc — ms365_status, ms365_signin_btn, ms365_signout_btn
  — **chưa từng được gán ở đâu**. Gọi vào là AttributeError, không phải chạy sai
  mà là sập.
* _ms365_workers chỉ được append bên trong _ms365_sign_in, nên chết theo.

Dọn kèm 6 import chỉ còn dòng import: AgentWorker, icon, EXT_CATEGORIES,
ExtConnectorEditDialog, AppContext, QTreeWidget.

Viết lại docstring đầu file — bản cũ vẫn mô tả file này chứa nhóm Connector
(CAD/CAE/MS365/Other), thứ đã không còn ở đây từ lâu.

settings_dialog.py: 407 -> 303 dòng. Cộng cả R08-T07 thì từ 727 xuống 303.

632 test xanh. check_dialogs, check_no_hscroll, check_design_parity,
check_orphans, check_probes_bite đều qua.

Ghi lại một phát hiện phụ, CHƯA xử lý: i18n.py có 28 khoá settings.ms365_*
mồ côi — 20 khoá đã không ai dùng từ trước lần dời connector, 8 khoá vừa mồ
côi theo commit này. Chỉ 2 khoá còn sống (ms365_local_connected,
ms365_local_none, dùng ở ui/connectors_panel.py). Xoá khoá dịch là đụng vào
dữ liệu ba ngôn ngữ ở file khác nên để anh Nam quyết riêng.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 21:15:15 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 c77ce36191 refactor(shell): R08-T10 — bootstrap + tách TrayManager và LifecycleCoordinator
app.py 1356 -> 1293 dòng. presentation/shell/ có 3 file:

  bootstrap.py               Composition Root (đã vào ở commit trước)
  tray_manager.py            khay hệ thống + thông báo bong bóng
  lifecycle_coordinator.py   canh cửa sổ theo màn hình + tắt cho sạch

Vì sao tách khay: khay là thứ CÓ THỂ KHÔNG TỒN TẠI (một số môi trường Linux,
phiên RDP). Trước đây mỗi chỗ dùng phải tự nhớ kiểm `if self.tray is not None`
— có 6 chỗ như thế, và 3 chỗ còn phải tự bọc try/except quanh showMessage.
Gói lại thì chỗ gọi cứ gọi, không có khay thì không có gì xảy ra.

Vì sao tách vòng đời: hai việc trong đó không phải việc của giao diện. Canh
cửa sổ theo màn hình là số học thuần (anh Nam có hai màn khác độ phân giải và
khác tỉ lệ phóng — kéo qua lại là vùng làm việc đổi). Còn shutdown là thứ tự
dừng có ý nghĩa: bộ lập lịch trước để nó không kịp khởi động việc mới trong
lúc ta đang dừng việc cũ, rồi mới tới worker, rồi ngắt tiến trình MCP.

closeEvent/moveEvent/resizeEvent vẫn ở lớp cửa sổ vì Qt gọi thẳng vào đó,
nhưng phần quyết định đã chuyển đi. closeEvent từ 30 dòng còn 11.

Giữ self.tray thành property trỏ vào self._tray.icon — vài chỗ còn đọc tên cũ.

Đã lấy mốc trước khi bóc rồi so lại sau: 24/24 checker trong tools/ qua cả hai
lần. Đây là bộ đặc tả thật cho MainWindow (check_nav, check_rail_align,
check_layout_geometry, check_controls_alive... dựng cửa sổ thật offscreen trên
BẢN SAO của ~/.cowork_local, scheduler bị vô hiệu hoá). 632 test xanh.

CHƯA làm hết R08-T10: plan ghi tách thành main_window.py + tray_manager.py +
lifecycle_coordinator.py. Hai file sau đã xong, main_window.py thì chưa —
MainWindow vẫn nằm trong app.py và vẫn 1095 dòng. Đo lại thì khối lượng không
nằm ở ba cụm plan nêu mà ở hai cụm khác:

    nav rail    18 method, ~340 dòng
    topbar      8 method,  ~157 dòng
    __init__    279 dòng

Hai cụm đó dính chặt vào state của cửa sổ, chuyển đi cần đổi giao diện giữa
chúng chứ không phải dời chỗ, nên tôi dừng ở đây thay vì làm nửa vời.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 19:56:37 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 2246d55286 feat(infra): R02 vào thật — app chạy bằng JsonConfigRepository, khoá rời khỏi config.json
Từ 21/08 tôi đã viết xong 7 file R02 với 46 test xanh, và báo là "xong R02".
Báo sai: code mới nằm song song, KHÔNG một dòng nào ngoài infrastructure/ và
tests/ gọi tới nó. App vẫn chạy nguyên trên config.py, 29 file dùng nó, và
khoá API của người dùng vẫn nằm plaintext trong config.json suốt 4 ngày.

Commit này mới là phần refactor thật.

Bù 21 thành viên còn thiếu (85 dòng)
------------------------------------
JsonConfigRepository có 18/34 thành viên công khai của AppConfig nên không
tráo được. Chép nguyên ngữ nghĩa 21 cái còn lại: load, ms365_*, ext_connectors,
connect_external, routing_mode_for, seeded_*, mcp_servers, teams, history,
structure, monitoring_visibility, model_label, ca_bundle... Giờ 40/34, không
thiếu gì. Không phải thiết kế mới — chừng nào 29 file còn gọi qua ctx.config
thì repository phải trả lời được đúng các câu hỏi cũ.

ROUTING_MODES lấy theo bản Delta (4 chế độ, có "fallback" từ R03-T03) chứ
không theo bản main cũ 3 chế độ. Chép bản cũ là routing "fallback" âm thầm rơi
về "off" sau khi Delta merge, không lỗi nào báo.

Composition Root (R08-T10, phần đầu)
-------------------------------------
presentation/shell/bootstrap.py: một chỗ duy nhất quyết định app dựng bằng
mảnh nào. app.py::run giờ gọi build_context() thay cho AppConfig.load().
Đây cũng là chỗ ráp kho bí mật vào; máy không có keyring thì secrets=None và
mọi thứ chạy như cũ.

Kiểm trên dữ liệu thật
----------------------
Chạy lên máy tôi, migration tự chạy đúng như thiết kế:

  openai_compat  39 ký tự  config.json -> Windows Credential Manager
  ollama         giá trị bù nhìn, để nguyên trong file, không đẩy vào kho
  schema_version 1 -> 2
  sao lưu        config.json.v20260825-193206.bak

Sau khi bật lại app và để nó ghi cấu hình, config.json vẫn sạch: api_key rỗng,
không còn chuỗi nào có hình dạng khoá. scripts/audit_security.py sạch.

Tiêu chí nghiệm thu A của plan (dòng 244) — "0 lưu trữ plaintext API Key trong
JSON" — tới commit này mới thật sự đạt.

632 test xanh. check_dialogs, check_nav, check_design_parity đều qua.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 19:35:44 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 e4ce9b2f5f merge: lấy phần N3 của Lâm (6 widget UI Co4E) về nhánh chung
Không xung đột — Lâm động vào ui/co4e_tab.py và presentation/co4e/,
tôi động vào ui/settings_dialog.py và presentation/settings/. Đúng như
quy tắc phân chia sở hữu đặt ra từ đầu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 19:23:20 +09:00
Nam Pham Dinh ThanhandClaude Opus 5 2b90492994 refactor(ui): R08-T07 — bóc settings_dialog.py 727 → 407 dòng thành 4 widget
Bốn mục trong Cài đặt tách thành widget riêng dưới presentation/settings/:

    general_settings_widget.py     ngôn ngữ, giao diện, khay, gợi ý
    provider_settings_widget.py    provider, base URL, key, model + 2 nút nền
    parameter_settings_widget.py   đính kèm, cấu trúc, giới hạn sandbox
    routing_settings_widget.py     Auto Model Routing

Mỗi widget tự dựng control, tự nạp giá trị, tự có apply_to(data). Dialog chỉ
còn lắp ráp và gọi apply_to lúc lưu — _save từ 34 dòng xuống còn phần khung.

Làm lưới an toàn trước khi bóc: tests/ui/test_settings_dialog_dac_ta.py, 7
bài đặc tả hành vi hiện tại (mục nào có mặt, nạp đúng giá trị gì, lưu ghi vào
đúng ô nào, đổi % sang phân lẻ, xoá cache sau lưu). Bóc xong cả 7 vẫn xanh,
và trong lúc bóc chúng đã đỏ đúng hai lần ở chỗ đáng đỏ.

Đây là repo chưa từng có test Qt nào — thêm tests/ui/conftest.py dựng
QApplication offscreen. Offscreen là bắt buộc chứ không phải cho nhanh: máy
dev là máy làm việc thật, test bật cửa sổ lên là nó nhảy ra che màn hình.

Dọn kèm:
* bỏ vòng "dựng vào layout rồi lại gỡ ra" của mục Chung, cùng widget cao 0px
  làm mốc cuộn — không cần nữa khi mục đó tự là một widget
* bỏ _select_combo, _secret, _model_combo, _with_load và 4 hàm provider khác
  đã chuyển vào widget (127 dòng)
* bỏ 5 import chết theo (Dict, QSizePolicy, PROVIDER_LABELS, SegmentedControl,
  LANGUAGES)

Giữ cầu tương thích: self.routing_*, self.prov_*, self.attach_* … thành
property trỏ vào widget con, vì 5 checker trong tools/ đọc thẳng tên cũ. Bỏ
được khi tools/ chuyển sang đọc self._provider_page.

Hai điều KHÔNG làm, ghi lại để khỏi tưởng là quên:
1. Plan ghi 4 widget và có tên `connector`. Thực tế UI connector đã dời khỏi
   Cài đặt từ trước (ghi chú ở settings_dialog.py:180 bản cũ), nên số mục thật
   là 5, không phải 4, và không có mục nào tên connector. Bốn mục bóc ra là 4
   mục có thật; mục Bảo mật sandbox để nguyên trong dialog lần này.
2. Còn ~108 dòng chết của MS365 (_refresh_ms365_status, _ms365_sign_in,
   _show_ms365_device_code, _ms365_sign_out): đọc self.ms365_status,
   self.ms365_signin_btn, self.ms365_signout_btn — ba thuộc tính KHÔNG BAO GIỜ
   được gán, và không hàm nào có người gọi. Gọi vào là AttributeError. Chưa
   xoá vì đó là quyết định của anh Nam, không phải việc kèm theo của T07.

437 test xanh. check_dialogs, check_no_hscroll, check_design_parity đều qua.

Kèm docs/refactor/tin-gui-team-hoa.md — tin báo Hoa về platform/ -> adapters/
và bản vá Windows của AtomicJsonFile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 19:22:54 +09:00
lamhv7andClaude Sonnet 5 c890a20f45 merge: đồng bộ origin/gamma/refactor (R01/R03/R04 — routing unification,
conversation application service, AtomicJsonFile fix) vào sau khi tách 6
widget UI Co4E (N3)

Đã kiểm trước khi merge: ui/co4e_tab.py và ui/routing_toggle.py đều bị 2
bên cùng đụng, nhưng ở vùng dòng khác nhau hoàn toàn (bên kia sửa
_apply_co4e_routing/RoutingToggle cho R03-T05, N3 chỉ đụng phần dựng
sidebar/canvas/chat) — không có xung đột logic thật.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 18:43:56 +09:00
lamhv7andClaude Sonnet 5 0631abf85f feat(co4e): tách 6 widget UI khỏi ui/co4e_tab.py sang presentation/co4e/*
Lane N3 (Co4E Studio) — dùng bộ workflow refactor-god-file, mỗi bước có
characterization test trước khi tách, hậu kiểm ranh giới tầng sau mỗi bước:

- skills_list_panel.py / agent_list_panel.py — 2 khu vực sidebar
- co4e_canvas_widget.py + canvas_items.py + canvas_interaction_mixin.py —
  Co4ECanvas tách 3 file (vượt 400 dòng nếu đứng một mình)
- node_property_panel.py + node_property_actions_mixin.py +
  step_config_section.py — StepConfigPanel, cùng lý do
- co4e_run_control_widget.py — RunsPagePanel (trang Flow Status)
- co4e_chat_view.py — ChatPanel + _ChatInput + helper autocomplete
- palette_list.py — _PaletteList dời khỏi ui/co4e_tab.py, hết import ngược
  presentation -> ui (agent/skills panel giờ import top-level)

ui/co4e_tab.py giảm 2089 -> 1878 dòng, chỉ còn phần wiring + business logic
(Co4ERunManager/AgentWorker chưa đổi — nằm ngoài phạm vi này, xem docstring
presentation/co4e/co4e_tab.py). ui/co4e_canvas.py và ui/co4e_config_panel.py
còn lại là compat shim re-export, không đổi API cho bên gọi.

Thêm tests/test_co4e_integration.py — dựng thật Co4ETab qua build_co4e_tab(),
lái luồng qua nhiều panel trong cùng instance (thêm node, mở/gập chat, chuyển
trang Flow Status rồi quay lại không mất state canvas) — bắt lỗi wiring
xuyên-panel mà characterization test từng panel riêng không thấy được.

Đã xác minh: pytest 348 passed/1 skipped, tools/check_co4e.py sạch, không
file nào >400 dòng, domain/application không import PySide6, và so pixel
before/after (git worktree tại HEAD cũ) ra 0/1.125.000 pixel khác biệt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 18:43:42 +09:00
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
vudt15andClaude Sonnet 5 8ab29800db docs(refactor): add the Team Hoa completion report for R05/R06
Mirrors docs/refactor/BaoCao_TeamDuy_R01_R03_R04.md's structure: per-EPIC
results, test evidence, the two real bugs found and fixed, secondary
improvements, open items needing another team's sign-off, untested scope,
and what's next.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 21:30:52 +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
vudt15andClaude Sonnet 5 cf542b7416 feat(R06): workspace session snapshot, atomic persistence, history-dir race fix
EPIC R06 (Team Hoa) - workspace/filesystem isolation, no cross-project
mutable state.

R06-T01 domain/workspaces/workspace_session.py
  WorkspaceSession - project_id/workspace_root/sandbox_dir/allowed_paths
  frozen snapshot + is_allowed(path), same "capture once at submit time"
  shape as R04's ConversationExecutionRequest.

R06-T02 infrastructure/persistence/json/{atomic_write,workspace_repository_impl,conversation_repository_impl}.py
  Real bug fixed: core/projects.py::save_project and core/history.py's
  save_conversation/rename_conversation/set_pinned did a plain
  path.write_text(json.dumps(...)) - two syscalls, no atomicity. A crash
  between them leaves a half-written file that load_project/load_conversation
  then silently treat as "missing". All four now write through
  atomic_write.write_json (temp file + os.replace). WorkspaceRepository/
  ConversationRepository are thin object-shaped facades over the same
  (now-atomic) functions, for future application-layer callers.
  NOTE: atomic_write.py is deliberately NOT named atomic_json_file.py -
  R02-T01 (Team Nam) claims that filename for the same purpose app-wide;
  see the checklist for the consolidation TODO.

R06-T03 infrastructure/filesystem/execution_workspace.py
  ExecutionWorkspace names the output_dir/scratch_dir split that already
  exists (core/chat_agent.py's flat workspace_root/.scratch) - does not
  move anything.

R06-T04 ui/chat_panel.py
  The actual race: ChatPanel._persist_session (saves a BACKGROUND turn's
  conversation) resolved its save directory via a live
  self.ctx.config.history_dir() read at save time. ui/workspace_tab.py::
  _load_current mutates that same config field on every project switch, so
  a turn still running when the user switched projects got saved into the
  NEW project's history folder. Fixed by adding "home_history_dir" to the
  per-turn ctx dict (same "home_*" snapshot convention already used for
  session id/messages/title), captured at submit time. Verified with a real
  offscreen-Qt test, not just a unit double:
  tests/integration/test_history_dir_race.py.

R06-T05 application/workspaces/file_workspace_service.py
  FileWorkspaceService - the File Explorer / AI Editor entry point for the
  same safe read/write/edit operations the agent tool loop has, by calling
  core/tools.py::execute_tool directly (same dispatch, same ToolContext
  containment, same audit log) rather than reimplementing any of it.

New tests: tests/unit/test_workspace_session.py,
test_atomic_write_and_repositories.py, test_execution_workspace.py,
test_file_workspace_service.py, tests/integration/test_history_dir_race.py
(29 new tests, incl. 2 real offscreen-Qt integration tests).

Suite: 283 passed, 4 pre-existing failures unrelated to R05/R06 (see
checklist). check_imports: PASS. All new files < 400 LOC.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 22:34:57 +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
vudt15andClaude Sonnet 5 ae4fe72b2e feat(R05): tool capability registry, unified policy gateway, MCP lifecycle manager
EPIC R05 (Team Hoa) - one security/approval path for every tool call.

R05-T01 domain/tools/{tool_descriptor,tool_registry}.py
  ToolCapability (READ/WRITE/EXECUTE/NETWORK, composable) + ToolDescriptor +
  ToolRegistry, replacing three independently-maintained gating lists
  (core/tools.py::WRITE_TOOLS, code_agent.py's WRITE_TOOLS|MS365_WRITE_TOOLS,
  chat_agent.py's literal ("run_command","install_package") tuple) with one
  capability lookup.

R05-T02 infrastructure/filesystem/{file_tools,command_tools,fetch_tools,tool_context}.py
  core/tools.py's execute_tool if/elif chain split into per-concern modules.
  core/tools.py is now a strangler-fig shim: re-exports ToolContext/ToolError,
  dispatches through a {name: handler} dict built from the split modules.
  core/tools.py: 566 -> 291 lines.

R05-T03 application/conversations/tool_policy_gateway.py
  ToolPolicyGateway.allow(name, gate, payload) - capability-driven ALLOW vs
  ask-the-gate decision. Wired into both chat_agent.py::run_cowork and
  code_agent.py::run_code, replacing their separate hand-rolled checks.
  Verified equivalent to the old hardcoded sets by test.

R05-T04 (behavior change, not just refactor)
  MCP/connector tools (core/mcp_client.py, core/ext_connectors.py) reached
  chat_agent.py via extra_executor(name, args) with NO permission check at
  all. They are now tagged with a conservative default capability
  (WRITE|EXECUTE|NETWORK - no MCP tool self-declares risk) and routed through
  the SAME ToolPolicyGateway as built-ins. When "confirm before running
  commands" is on, MCP/connector calls now prompt like run_command already
  did - a real gap closed, and a user-visible change worth calling out.

R05-T05 infrastructure/mcp/mcp_source_manager.py
  McpToolSourceManager extracts the connection cache/lock/start-or-skip
  lifecycle out of state.py::AppContext (_mcp_connections/_conn_lock) into a
  standalone, directly-testable class. AppContext.build_mcp_tools and
  _ms365_builtin_connection now call ensure()/stop(); _ext_connections
  (unified Connectors) is out of scope for this task and keeps its own lock.

New tests: tests/unit/test_tool_registry_and_policy.py,
test_code_agent_tool_policy.py, test_cowork_extra_tool_policy.py,
test_mcp_source_manager.py (26 new tests).

Suite: 254 passed, 4 pre-existing failures unrelated to R05 (2 EPIC R02
config-security, 2 environment-dependent routing tests - see checklist).
check_imports: PASS. All new files < 400 LOC.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 22:20:57 +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
anhtnm1andClaude Opus 5 6d3217e0b5 docs(refactor): add the Team Duy completion report for R01/R03/R04
docs/refactor/BaoCao_TeamDuy_R01_R03_R04.md records what was delivered against
each of the 16 tasks, the measured evidence (243 tests, 218 of them in 1.22s;
check_imports PASS; no production file over 400 LOC), the three real defects
found while working - the routing_application() deadlock, the swallowed
"notice" event, and the suite silently testing a different checkout - plus the
six open decisions and, explicitly, what was NOT tested (no manual app launch,
no real provider traffic, tools/check_*.py not run).

Refactoring_Checklist.md now links to it from the progress block.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 10:58:36 +09:00
anhtnm1andClaude Opus 5 67b8d2edbb docs(refactor): correct the Team Duy scope block in the checklist
The previous commit recorded Team Duy as owning R01/R02/R04/R10. That is wrong.
Feature_Architecture_Proposal.md line 7 and DeltaTeam_prompt.md line 17 both
state R01, R03, R04, R08 (Chat UI) and R10; R02 belongs to Team Nam, which is
also who owns the two failing config-security tests.

The completed work itself (R01, R03, R04) was already correct and is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 10:52:40 +09:00
anhtnm1andClaude Opus 5 15e1d3eb65 test(R03/R04): cover the three code paths that were changed but never executed
Verification gap closed. The suite proved the new services correct in isolation,
but three paths I had modified had no test actually running them:

tests/integration/test_task_executor_flow.py (7 tests)
  The Schedule Task path after R04-T05. Pins that History is still re-saved from
  the LIVE message list mid-run (the reason begin_turn() exists - the pre-turn
  copy would have frozen progress at the first user message), that update_plan
  tracking still reports an unfinished checklist, and that a failed run still
  raises so execute_task writes error.txt.

tests/integration/test_routing_surfaces.py (11 tests)
  Real offscreen CoworkTab/Co4ETab/FolderTab calling the shared routing service:
  correct surface key per screen, Auto switches, Off does not consult the engine,
  Manual switches only on approval, a pinned Admin agent still wins, and AI-Edit
  still pins TaskType.CODING. Also pins the field contract ui/routing_toggle.py
  reads off RoutingDecision (from_model/to_model as provider/model keys) - a
  rename there would only fail inside a modal dialog.

Also updates docs/refactor/Refactoring_Checklist.md: the 16 completed R01/R03/R04
tasks, the Team Duy daily rows, and a status block recording the measured
numbers, the scope correction (team owns R01/R02/R04/R10), and what is still
outstanding.

Suite: 243 passed, 2 pre-existing failures (EPIC R02). Fast suite (unit +
contracts + characterization + routing): 218 passed in 1.16s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 10:45:05 +09:00
anhtnm1andClaude Opus 5 a53163ebaf feat(R04): immutable turn snapshot, typed agent events, conversation service
EPIC R04 (Team Duy) - the turn lifecycle leaves the widget.

R04-T01 domain/agents/conversation_execution_request.py
  Frozen snapshot of one turn, captured on the UI thread at submit time. The
  job closure used to read widget/workspace state from inside the worker
  thread, so a turn could run on a mix of submit-time and later state
  depending on thread timing.
R04-T02 domain/agents/agent_event.py
  13 frozen event types replacing untyped emit() dicts, with a two-way bridge
  so existing widgets keep consuming the legacy shape until EPIC R08. Adds
  TurnCompletedEvent - the end-of-turn signal the engine never had, which is
  why a cancelled turn and a failed turn look identical to the UI today.
R04-T03 application/conversations/conversation_application_service.py
  Runs a turn from a request and reports typed events. Never raises across the
  worker boundary; TurnResult.raise_if_failed() preserves the existing
  exception-based failure path. begin_turn()/execute_turn() expose the live
  message list for callers that autosave history mid-run.
R04-T04 ui/cowork_tab.py::build_job -> snapshot + service.
R04-T05 core/task_executors.py::_run_agent -> same service (was a second,
  slightly different assembly of the same call).

Caught while wiring the bridge: the first event vocabulary had no "notice"
event, so Agent Security warnings and auto-compaction notices would have been
silently swallowed. Added NoticeEvent plus a test that scans the engine sources
for emit() tags and fails when one has no typed counterpart.

New: tests/integration/ - real offscreen CoworkTab running a scripted turn end
to end (7 tests), including a characterisation of the extra provider call Agent
Security spends reviewing each request.

Suite: 225 passed, 2.74s. check_imports: PASS. All new files < 400 LOC.
2 pre-existing failures remain in test_config_security.py (EPIC R02/Team Nam).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 10:32:14 +09:00
anhtnm1andClaude Opus 5 96bec976e7 feat(R03): unify provider catalogue, routing decisions and usage telemetry
EPIC R03 (Team Duy) - one provider catalogue, one routing flow, one usage seam.

R03-T01 tests/contracts/test_providers.py
  29 contract tests every provider must satisfy: canonical assistant message,
  streamed text == returned content, reasoning never joins the answer, parsed
  tool arguments, ProviderError for every failure. Real adapters exercised
  offline by stubbing Provider._request.
R03-T02 domain/models/provider_descriptor.py
        infrastructure/providers/provider_registry.py
  Provider facts declared once (was split across providers/factory.py,
  DEFAULT_CONFIG and PROVIDER_LABELS). ProviderRegistry.build() also stamps the
  descriptor id onto the instance, so ollama/github_copilot/codex usage is no
  longer all attributed to "openai_compat", and never mutates the caller config.
R03-T03 application/model_routing/routing_application_service.py
  Pure-Python routing policy with four modes: Off, Auto, Manual and the new
  Fallback (switch only AFTER the current model fails). Depends on a RoutingPort
  protocol; production wires the existing core.routing engine underneath.
R03-T04/T05 ui/chat_panel.py, ui/co4e_tab.py, ui/folder_tab.py
  Three near-identical routing copies (~40 lines each) replaced by a call to
  ctx.routing_application() plus a confirm callback. Mode vocabulary now lives
  in one place (normalize_mode/is_valid_mode) instead of four literal tuples.
R03-T06 infrastructure/telemetry/usage_sink.py
  Token usage extracted from both providers into UsageEvent + UsageEventSink.
  Estimation pinned against core.usage_tracker so no recorded number changes.

Also fixes a deadlock introduced while wiring AppContext: routing_application()
held _routing_lock and called routing(), which takes the same non-reentrant lock.

Suite: 186 passed, 1.22s. check_imports: PASS. All new files < 400 LOC.
2 pre-existing failures remain in test_config_security.py (EPIC R02/Team Nam).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 10:22:28 +09:00
anhtnm1andClaude Opus 5 bbc09f628a feat(R01): architecture foundation, offline fakes and characterization net
EPIC R01 (Team Duy) - safety net before the parallel refactor starts.

R01-T01 docs/architecture/ADR-001-layered-architecture.md
  4-tier boundaries, allowed dependency directions, invariants I1-I6 and
  the strangler-fig migration strategy.
R01-T02 tests/fakes/{fake_provider,fake_tool_executor}.py
  Scripted, offline Provider and extra-tool executor doubles.
R01-T03 scripts/check_imports.py
  AST-based Clean Architecture Guard (CASAN Check 3). Also covers relative
  imports and function-local imports; ASCII-only output for cp932 consoles.
R01-T04 tests/characterization/test_run_cowork.py
  13 snapshot tests pinning run_cowork's current observable contract before
  EPIC R04 moves its orchestration into application/.
R01-T05 docs/architecture/dormant-code.md
  Import-graph scan: 43 unimported modules verified down to 6 genuinely
  dormant items (~1887 LOC); the rest run via subprocess/CLI entry points.

tests/conftest.py binds `cowork_local` to THIS checkout by absolute path -
previously sys.path discovery could import a sibling checkout and the suite
would silently test the wrong code.

Suite: 104 passed, 1.08s (2 pre-existing failures in test_config_security.py
remain - config.py still ships a hardcoded default password, EPIC R02/Team Nam).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 10:05:50 +09:00
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
anhtnm1andClaude Opus 5 d633dffae6 docs(refactor): add plan.md with roadmap sections VI-IX
CI / test (pull_request) Canceled after 0s
Copy of sections VI-IX from Feature_Architecture_Proposal.md
(roadmap, team assignment/KPI, anti-patterns, function migration map).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 22:22:16 +09:00
anhtnm1andClaude Opus 5 73c9e4344c rename prompt.md to DeltaTeam_prompt.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 22:14:49 +09:00
huongltt35 2331b86db9 move file to docs folder 2026-08-20 22:05:52 +09:00
huongltt35 34626546b4 refactor plan 2026-08-20 22:00:53 +09:00
493 changed files with 69867 additions and 19344 deletions
+50 -1
View File
@@ -12,16 +12,29 @@ jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: cowork_local
env:
# Tiến trình con của test import `cowork_local` qua đường này.
PYTHONPATH: ${{ github.workspace }}
steps:
# Checkout PHẢI nằm trong thư mục tên đúng `cowork_local`.
# Nhiều test characterization sinh tiến trình con chạy
# `python -c "from cowork_local... import ..."`; tiến trình con đó chỉ
# import được khi trên sys.path có một thư mục mang đúng tên gói. Checkout
# vào thư mục tên khác làm 73 test đỏ vì lý do không liên quan tới mã.
- name: Check out source
uses: actions/checkout@v4
with:
path: cowork_local
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip
cache-dependency-path: requirements-test.txt
cache-dependency-path: cowork_local/requirements-test.txt
- name: Install test dependencies
run: python -m pip install --disable-pip-version-check -r requirements-test.txt
@@ -39,3 +52,39 @@ 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
# Cổng O bổ sung sau đợt đối chiếu AS-IS/TO-BE: ba check trên đều không
# bắt được mã chết (file không ai import vẫn đúng chiều phụ thuộc, vẫn
# sạch credential, vẫn dưới 400 dòng). Đợt đó tìm ra 1.400 dòng mã trùng
# lặp chết lọt qua đúng theo cách này.
- name: "CASAN Check O — module production phải có nơi import"
run: python scripts/check_orphan_modules.py
+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*
+78 -13
View File
@@ -1,30 +1,95 @@
# Cowork Local
Cowork Local is the internal AI cowork desktop platform owned by the Cowork Team. It provides the Cowork runtime, workspace and agent experiences, MCP/connectors, security controls, and model routing foundation.
Cowork Local is the internal AI cowork desktop platform. It provides a local-first desktop runtime, multi-turn conversational agents, workspace isolation, task scheduling, MCP connectors, security guardrails, and model routing.
The Cowork Team owns this product and its stable branch. The FSG AI Core Team contributes selected reusable capabilities through branches and Pull Requests; it is not the owner or final merger of this repository.
---
## Quick start
## 🏛️ 4-Tier Clean Architecture
The imported application is a Python/PySide6 package. Run it from the directory that contains `cowork_local`:
The codebase strictly adheres to **Clean Architecture** with unidirectional inward dependencies:
```text
presentation/ (PySide6 UI, Shell, NavRail, Chat, Scheduling, Settings, Dashboard)
│
▼
application/ (Pure Python Orchestration: Conversations, Scheduling, Workspaces, Monitoring, Routing)
│
▼
domain/ (Pure Python: Entities, Immutable Execution Requests, Agent Events, Descriptors)
▲
│
infrastructure/ (Adapters, LLM Providers, Atomic Persistence, Keyring SecretStore, MCP)
```
- **Domain & Application Layers**: 100% Pure Python (zero Qt/UI imports).
- **Single Responsibility**: Every production module is strictly `<= 400 LOC`.
- **Security & Durability**: API keys stored in OS Keyring; atomic JSON disk persistence.
---
## 🚀 Quick Start
### 1. Windows — two double-clicks
```
install.bat once, to install the Python dependencies
run.bat every time, to start the app
```
`install.bat` builds an isolated virtualenv under `%LOCALAPPDATA%\CoworkLocal`
(deliberately **outside** the repo — the quality gates walk the whole directory
tree, so a `.venv` in here would turn every vendored module into a Gate O
violation). Add `--dev` to also install the test dependencies, or `--system` to
skip the virtualenv and install into the Python already on `PATH`.
Both scripts also make the source importable under its package name. That step
is not optional: `python -m cowork_local` only resolves when the checkout
directory is literally named `cowork_local`, and the MS365 MCP server is
launched as a subprocess with `python -m cowork_local.mcp_servers.ms365_server`,
so a differently-named checkout breaks the app *and* its subprocesses. The
scripts create a junction instead of forcing anyone to rename their folder.
### 2. Any platform — run from source
From the **parent** of a checkout directory named `cowork_local`:
```bash
python -m cowork_local
```
The source snapshot does not include a complete runtime dependency manifest. Use the Cowork Team's supported runtime environment until that packaging contract is documented. The reliable automated test surface currently checked by CI is:
### 3. Run Automated Tests
```bash
python -m pip install -r cowork_local/requirements-test.txt
python -m pytest cowork_local/tests -q
python -m pip install -r requirements-test.txt
pytest -q
```
When already inside this repository, run `python -m pytest tests -q`.
---
Configuration and runtime data live under `~/.cowork_local/`. Provider keys and local unlock codes must be supplied through environment variables or an approved secret manager; see `.env.example`.
## 🛡️ CASAN Quality Gate & Verification
## Contributing
Before submitting any Pull Request, run the unified CASAN Quality Gate:
Start with [START_CONTRIBUTING.md](START_CONTRIBUTING.md), then read [CONTRIBUTING.md](CONTRIBUTING.md). Core AI task execution remains in [fsg-ai-core-assets](http://34.143.229.138/gitea-admin/fsg-ai-core-assets); source changes are reviewed as Pull Requests in this repository.
```bash
# Run all 4 quality gates (Clean Arch, Secrets, LOC, and Pytest Suite)
python scripts/run_quality_gate.py
Security concerns should follow [SECURITY.md](SECURITY.md). Ownership and completion rules are documented under `docs/governance/`.
# Run static and architectural guards only (fast check)
python scripts/run_quality_gate.py --skip-tests
```
Individual guard scripts:
- **Clean Architecture Import Guard**: `python scripts/check_imports.py`
- **Secrets & Plaintext Audit**: `python scripts/audit_security.py`
- **Single Responsibility LOC Guard**: `python scripts/check_loc.py --max-lines 400`
- **Release E2E Smoke Test**: `pytest tests/e2e/test_smoke.py -v`
---
## 🤝 Contributing & Recipes
- **Quick Start Guide**: See [START_CONTRIBUTING.md](START_CONTRIBUTING.md).
- **Contributor Recipes**: See [docs/governance/contributor-recipes.md](docs/governance/contributor-recipes.md) for step-by-step recipes to:
1. Add a new AI Model Provider.
2. Add a new Built-in Tool / MCP Server.
3. Add a new Screen / Tab / Widget.
- **Security Policy**: See [SECURITY.md](SECURITY.md).
+42 -18
View File
@@ -1,40 +1,64 @@
# Start Contributing
## What is this repository?
Welcome to the **Cowork Local** contributor guide!
Cowork Local is the Cowork Team's product/platform repository: desktop runtime, UI/UX, workspaces, agents, MCP/connectors, security, and reusable platform foundations.
---
The Cowork Team owns architecture, product behavior, releases, the stable branch, final review, and merge. The FSG AI Core Team is a contributor for selected generic capabilities such as MCP integration, agent capabilities, orchestration/model-routing tests, evaluation/security integration, and reusable platform improvements.
## 🏛️ Architecture & Ground Rules
## Where are Core AI tasks?
1. **4-Tier Clean Architecture**:
- `domain/`: Business entities and immutable data structures (Pure Python).
- `application/`: Application services and orchestration (Pure Python).
- `infrastructure/`: External integrations, adapters, persistence, and secrets.
- `presentation/`: Desktop UI widgets, PySide6 components, and Qt signals.
- **Rule**: `domain/` and `application/` must NEVER import `PySide6` or any UI framework.
Use [fsg-ai-core-assets Issues/Project](http://34.143.229.138/gitea-admin/fsg-ai-core-assets) as the Core AI task source of truth. Pick and assign a contribution task there, then move it to `In Progress`.
2. **File Size Limit (LOC)**:
- Every file in `domain/`, `application/`, `infrastructure/`, and `presentation/` must be `<= 400 LOC`.
Do not copy the Core AI backlog, golden datasets, CASAN assets, agent catalog, or evaluation repository into Cowork Local. Only source/artifacts required by an agreed Cowork runtime contract belong here.
3. **In-Code Comments**:
- All code logic, error handling, and design rationales must be documented with clear **English comments**.
## Make the change
---
Create a focused branch:
## 🚀 Development Workflow
### 1. Create a Topic Branch
```bash
git switch -c core-ai/TL-xxx-short-name
git switch -c feat/my-new-feature
```
For Cowork-native work use `feat/`, `fix/`, `test/`, `docs/`, `perf/`, or `refactor/`. Keep one logical change in one Pull Request.
### 2. Implement Using Contributor Recipes
Follow the standardized recipes in [`docs/governance/contributor-recipes.md`](docs/governance/contributor-recipes.md):
- **Recipe 1**: Adding a new AI Model Provider.
- **Recipe 2**: Adding a new Tool or MCP Server.
- **Recipe 3**: Adding a new UI Screen or Widget.
Run the application from the parent directory with `python -m cowork_local`. Run the current automated test suite from this repository with:
### 3. Run CASAN Quality Gate Locally
Before committing and pushing your branch, ensure all quality gates pass:
```bash
python -m pip install -r requirements-test.txt
python -m pytest tests -q
python scripts/run_quality_gate.py
```
Use environment variables for credentials; never commit `.env`, `~/.cowork_local/`, logs, customer data, or generated runtime files.
---
## Review and completion
## 🧪 Testing Pyramid
Before opening a Pull Request, obtain Core AI pre-review and move the Core task to `Review`. Open the Pull Request in Cowork Local with the Core repository URL, issue, task ID, scope, validation evidence, and security impact. Then move the Core task to `Upstream Review`.
We maintain a strict multi-tier test pyramid:
- `tests/unit/`: Fast unit tests (no I/O, < 0.05s).
- `tests/contracts/`: Contract tests for Provider and Tool interfaces.
- `tests/integration/`: Component integration tests (Qt offscreen).
- `tests/e2e/`: End-to-End release smoke tests (`pytest tests/e2e/test_smoke.py`).
- `tests/fakes/`: Reusable in-memory test doubles (`FakeProvider`, `FakeToolRuntime`).
The Cowork Team may request changes or approve and merge. A Core AI task is `Done` only after the Cowork Pull Request is merged—not when implementation or Core AI review finishes. Record the Pull Request and merge reference in the Core issue.
---
See [CONTRIBUTING.md](CONTRIBUTING.md) for conventions and `docs/governance/` for ownership, review, and Definition of Done.
## 📋 Definition of Done (DoD)
A Pull Request is ready for merge only when:
- [x] All production files are `<= 400 LOC` (`python scripts/check_loc.py`).
- [x] Clean Architecture boundary check has 0 violations (`python scripts/check_imports.py`).
- [x] Secrets audit finds 0 plaintext credentials (`python scripts/audit_security.py`).
- [x] 100% of test suite passes without regressions (`pytest tests/`).
- [x] E2E release smoke tests pass (`pytest tests/e2e/test_smoke.py`).
+7
View File
@@ -17,6 +17,13 @@ def main() -> int:
# a plain script (`python __main__.py`), `__package__` is empty so the
# relative import fails — in that case put the package root (the parent
# of this file's directory) on sys.path and use an absolute import.
"""Điểm vào ``python -m cowork_local``.
Import muộn để công cụ kiểu ``-h`` và test nạp được gói mà không phải dựng cả
ứng dụng Qt. Chạy như script thường (``python __main__.py``) thì
``__package__`` rỗng nên import tương đối hỏng — lúc đó đưa thư mục cha vào
``sys.path`` và dùng import tuyệt đối.
"""
if __package__:
from .app import run
else:
+29 -1246
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
"""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.
"""
+12
View File
@@ -0,0 +1,12 @@
"""Application conversations package: turn lifecycle orchestration, agent execution, and tool approval policy."""
from .conversation_application_service import (
ConversationApplicationService,
)
from .tool_policy_gateway import ConfirmGate, ToolPolicyGateway
__all__ = [
"ConversationApplicationService",
"ToolPolicyGateway",
"ConfirmGate",
]
@@ -0,0 +1,328 @@
"""The turn lifecycle, once, in pure Python (R04-T03).
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.
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.
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 typing import Any, Dict, List, Optional, Tuple
from ...domain.agents.agent_event import (
AssistantMessageCompletedEvent,
ErrorEvent,
OutputsAddedEvent,
OutputsRemovedEvent,
PlanStep,
PlanUpdatedEvent,
ReasoningChunkEvent,
TextChunkEvent,
ToolCallFinishedEvent,
ToolCallStartedEvent,
ToolOutputChunkEvent,
)
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.application.conversations")
class ConversationApplicationService:
"""Runs one :class:`ConversationExecutionRequest` to completion."""
def __init__(
self,
model: ModelCallPort,
tools: ToolRuntimePort,
*,
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:
"""Nhận vào các cổng (port) thay vì tự dựng phụ thuộc.
``model`` và ``tools`` bắt buộc; mọi thứ còn lại là tuỳ chọn và để None thì
bỏ qua bước đó. Nhờ vậy test dựng được service với đúng phần nó cần kiểm,
không phải dựng cả provider thật lẫn sandbox.
"""
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
# -- 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.
``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.
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.
"""
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:
# 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
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 _compose_messages(self, request: ConversationExecutionRequest) -> List[Dict[str, Any]]:
"""History snapshot plus this turn's user message.
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
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 _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 _announce_budget_exhausted(request: ConversationExecutionRequest,
messages: List[Dict[str, Any]], sink: EventSink) -> None:
"""Report being cut off by the step ceiling.
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.
"""
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
def _finalize_outputs(self, before: Any, sink: EventSink, cancelled: bool) -> None:
"""Tidy the output folder and report what moved.
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:
removed, added = self._tools.finalize(before, cancelled=cancelled)
except Exception: # noqa: BLE001
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"]
@@ -0,0 +1,337 @@
"""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:
"""Bọc một provider của ``core/`` vào cổng ``ModelCallPort``."""
self._provider = provider
def call(self, messages, tools, on_text=None, on_reasoning=None, cancel=None):
"""Gọi model một lượt, có tự phục hồi khi tràn context hoặc bị giới hạn tốc độ."""
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:
"""Bọc bộ tool của ``core/`` vào cổng ``ToolRuntimePort``.
Tên các tool phụ được gom sẵn vào một ``set`` ngay tại đây: mỗi lượt gọi tool
đều phải tra tên, tra trên danh sách sẽ chậm dần theo số tool.
"""
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:
"""Tên các tool bổ sung (MCP, connector) ngoài bộ dựng sẵn."""
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:
"""Ảnh chụp thư mục kết quả trước lượt chạy — dùng để biết tệp nào mới sinh ra."""
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:
"""Chốt an toàn cho prompt trước khi gửi: quét dấu hiệu tiêm lệnh."""
from ...core import agent_security
agent_security.enforce_prompt(provider, messages, security_config, emit)
def command_guard(name: str, args: Dict[str, Any]) -> None:
"""Chốt an toàn cho lệnh shell trước khi chạy: phân loại rủi ro và chặn/hỏi."""
from ...core import agent_security
agent_security.enforce_command(provider, name, args, security_config, emit)
def compact(messages: List[Dict[str, Any]], cancel) -> None:
"""Nén lịch sử hội thoại khi gần đầy cửa sổ ngữ cảnh."""
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"]
@@ -0,0 +1,91 @@
"""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."""
"""Hỏi người dùng; trả về ``True`` nếu được đồng ý."""
def request(self, payload: Dict[str, Any]) -> bool:
"""Hỏi người dùng về một lời gọi tool; trả về ``True`` nếu được đồng ý."""
...
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:
"""Nhận sổ đăng ký tool và tập năng lực cần xin phép.
Truyền vào chứ không viết cứng: mỗi bề mặt chat có ngưỡng riêng, và test đặt
được ngưỡng của mình mà không đụng cấu hình thật.
"""
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",
]
+54
View File
@@ -0,0 +1,54 @@
"""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,
RoutingDecisionPort,
)
from .routing_models import (
RouteEvaluation,
RoutingMode,
RoutingOutcome,
RoutingRequest,
)
__all__ = [
"AppContextModeResolver",
"ConfirmationCallback",
"CoreRoutingEngine",
"ModeResolver",
"RouteEvaluation",
"RoutingApplicationService",
"RoutingDecisionPort",
"RoutingMode",
"RoutingOutcome",
"RoutingRequest",
"build_routing_application_service",
]
@@ -0,0 +1,173 @@
"""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:
"""Bọc ``core/routing/service.py`` vào cổng quyết định định tuyến."""
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:
"""Đọc chế độ định tuyến từ ``AppContext``, để tầng application không phải biết
hình dạng của context.
"""
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",
]
@@ -0,0 +1,240 @@
"""The one place that decides how a turn is routed (R03-T03).
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.
The dance now lives here, once, in pure Python:
* 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.
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
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]
@runtime_checkable
class RoutingDecisionPort(Protocol):
"""The routing engine, as this service needs it.
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.
"""
def evaluate(self, request: RoutingRequest, mode: RoutingMode) -> RouteEvaluation:
"""Rank candidates for ``request`` and report whether to switch."""
@runtime_checkable
class ModeResolver(Protocol):
"""Resolves the effective routing mode for a surface.
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.
"""
def mode_for(self, surface: str) -> RoutingMode:
"""Effective mode for ``surface``."""
class RoutingApplicationService:
"""Turn-time routing decisions for every chat surface."""
# 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,
decision_port: RoutingDecisionPort,
mode_resolver: Optional[ModeResolver] = None,
*,
confirm_timeout_sec: Optional[Callable[[], float]] = None,
) -> None:
"""``mode_resolver`` để None thì mọi bề mặt đều coi như đang ở chế độ mặc định.
``confirm_timeout_sec`` là hàm chứ không phải số: người dùng đổi thiết lập
giữa chừng thì lần hỏi sau phải theo giá trị mới.
"""
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
# -- public API ------------------------------------------------------ #
def resolve(
self,
request: RoutingRequest,
confirm: Optional[ConfirmationCallback] = None,
) -> RoutingOutcome:
"""Decide this turn's provider/model.
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.
"""
mode = request.mode or self._resolve_mode(request.surface)
try:
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")
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,
)
@staticmethod
def _fallback_verdict(evaluation: RouteEvaluation) -> tuple:
"""FALLBACK's accept rule: switch ONLY to rescue an unusable selection.
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(evaluation.decision, self.confirm_timeout()))
except Exception: # noqa: BLE001
logger.exception("routing: confirmation callback failed — keeping current model")
return False
__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"]
+6
View File
@@ -0,0 +1,6 @@
"""Application monitoring package: Monitoring and dashboard query services."""
from .dashboard_query_service import DashboardQueryService
from .monitoring_query_service import MonitoringQueryService
__all__ = ["DashboardQueryService", "MonitoringQueryService"]
@@ -0,0 +1,120 @@
"""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.
directory: Optional custom usage directory. If None, uses default USAGE_DIR.
"""
def __init__(self, ctx: Any, directory: Optional[Path] = None) -> None:
"""``directory`` để None thì đọc thư mục telemetry mặc định; test trỏ nó vào
``tmp_path`` để không chạm dữ liệu thật.
"""
self.ctx = ctx
self._directory = directory
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, directory=self._directory)
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(directory=self._directory) # 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(directory=self._directory)
return ut.period_totals(events, granularity, self.pricing(), offset)
def period_range_label(self, granularity: str, offset: int) -> str:
"""Nhãn hiển thị của một kỳ (tuần/tháng/năm cộng độ lệch)."""
from cowork_local.core import usage_tracker as ut
return ut.period_range_label(granularity, offset)
def budget_status(self):
"""Tình trạng ngân sách: đã dùng bao nhiêu, còn lại bao nhiêu, có vượt ngưỡng chưa."""
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:
"""Đặt hạn mức ngân sách mới — mở một chu kỳ đếm mới, chi tiêu trước đó không
còn được tính vào.
"""
from cowork_local.core import usage_tracker as ut
ut.set_budget(self.ctx.config, amount, currency)
__all__ = ["DashboardQueryService"]
+3
View File
@@ -0,0 +1,3 @@
"""DTO của phân hệ Giám sát: hình dạng dữ liệu mà tầng application trả cho
giao diện, không phụ thuộc nguồn đọc.
"""
@@ -0,0 +1,51 @@
"""Application-layer view of an audit event — decoupled from the
infrastructure ``CanonicalAuditEvent`` so ``application/`` doesn't need to
share a concrete class with ``infrastructure/`` (only the shape). Field names
match the canonical audit schema (see
``infrastructure/telemetry/audit_logger.py``) 1:1.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict
@dataclass(frozen=True)
class AuditEventDTO:
"""Một sự kiện kiểm toán ở dạng tầng application dùng — không phụ thuộc khuôn
lưu trên đĩa, nên đổi định dạng nhật ký không kéo theo sửa giao diện.
"""
ts: str
kind: str
name: str
ok: bool
detail: str
agent_role: str = ""
account: str = ""
role: str = ""
machine: str = ""
@classmethod
def from_raw(cls, raw: Dict[str, Any]) -> "AuditEventDTO":
"""Tolerant of missing keys — accepts both a
``CanonicalAuditEvent.to_dict()`` result and any historical raw
``.jsonl`` row."""
return cls(
ts=str(raw.get("ts", "")),
kind=str(raw.get("kind", "")),
name=str(raw.get("name", "")),
ok=bool(raw.get("ok", False)),
detail=str(raw.get("detail", "")),
agent_role=str(raw.get("agent_role", "")),
account=str(raw.get("account", "")),
role=str(raw.get("role", "")),
machine=str(raw.get("machine", "")),
)
def to_dict(self) -> Dict[str, Any]:
"""Bản ghi dưới dạng dict cho lớp giao diện."""
return {
"ts": self.ts, "kind": self.kind, "agent_role": self.agent_role,
"name": self.name, "ok": self.ok, "detail": self.detail,
"account": self.account, "role": self.role, "machine": self.machine,
}
@@ -0,0 +1,60 @@
"""Read-only query service over audit events — filter + sort + pagination.
Pure Python: no PySide6 import, no UI code. Depends only on an injected
``AuditEventRepository`` (see ``repository/audit_event_repository.py``), so it
is fully unit-testable with ``InMemoryAuditEventRepository`` and independent
of file I/O or Qt.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import List, Optional
from .dto.audit_event_dto import AuditEventDTO
from .repository.audit_event_repository import AuditEventRepository
@dataclass(frozen=True)
class Page:
"""Một trang kết quả truy vấn nhật ký: các mục, tổng số, số trang và cỡ trang."""
items: List[AuditEventDTO]
total: int
page: int
page_size: int
@property
def has_more(self) -> bool:
"""Còn trang sau nữa không."""
return self.page * self.page_size < self.total
class MonitoringQueryService:
"""Read-only. Callers ask for a filtered/sorted/paginated slice of the
audit log; this service never writes anything."""
def __init__(self, repository: AuditEventRepository) -> None:
"""Nhận kho sự kiện kiểm toán qua tham số — bản thật đọc đĩa, bản test nằm
trong bộ nhớ.
"""
self._repository = repository
def query(self, kind: Optional[str] = None, ok: Optional[bool] = None,
text: Optional[str] = None, sort_by: str = "ts",
descending: bool = True, page: int = 1, page_size: int = 50) -> Page:
"""Lọc theo loại/kết quả/từ khoá, sắp xếp rồi cắt thành một trang."""
events = self._repository.load(kind=kind)
if ok is not None:
events = [e for e in events if e.ok == ok]
if text:
needle = text.lower()
events = [e for e in events
if needle in e.name.lower() or needle in e.detail.lower()]
events = sorted(events, key=lambda e: getattr(e, sort_by, ""), reverse=descending)
total = len(events)
page = max(1, page)
start = (page - 1) * page_size
items = events[start:start + page_size] if page_size > 0 else events
return Page(items=items, total=total, page=page, page_size=page_size)
@@ -0,0 +1 @@
"""Cổng đọc dữ liệu của phân hệ Giám sát — hợp đồng, không phải cài đặt."""
@@ -0,0 +1,53 @@
"""Audit-event repository — the boundary between ``MonitoringQueryService``
and where events actually live. ``CanonicalAuditEventRepository`` is the real
adapter (wraps an injected ``CanonicalAuditLogger``); ``InMemoryAuditEventRepository``
is a constructor-injected test double, following this repo's existing
``Fake*``/``Recording*`` convention (see ``tests/routing/*``,
``tests/test_project_context_mcp_template.py``) rather than ``unittest.mock``.
"""
from __future__ import annotations
from typing import List, Optional, Protocol
from ..dto.audit_event_dto import AuditEventDTO
class AuditEventRepository(Protocol):
"""Cổng đọc nhật ký kiểm toán mà tầng application dùng.
Chỉ là hợp đồng: bản cài đặt thật đọc từ file cục bộ hoặc thư mục chia sẻ,
còn test truyền vào bộ giả.
"""
def load(self, kind: Optional[str] = None) -> List[AuditEventDTO]:
"""Đọc sự kiện kiểm toán, lọc theo loại nếu có."""
...
class CanonicalAuditEventRepository:
"""Adapter over ``infrastructure.telemetry.audit_logger.CanonicalAuditLogger``
— the only place this application service reaches into infrastructure."""
def __init__(self, audit_logger) -> None:
"""Bọc bộ ghi nhật ký kiểm toán chuẩn để đọc sự kiện ra."""
self._audit_logger = audit_logger
def load(self, kind: Optional[str] = None) -> List[AuditEventDTO]:
"""Đọc sự kiện từ nhật ký và đổi sang DTO của tầng application."""
events = self._audit_logger.load_events(kind=kind)
return [AuditEventDTO.from_raw(e.to_dict()) for e in events]
class InMemoryAuditEventRepository:
"""Test double — holds a fixed list of events, no file I/O."""
def __init__(self, events: List[AuditEventDTO]) -> None:
"""Nhận sẵn danh sách sự kiện. Chép lại chứ không giữ tham chiếu: bên gọi sửa
danh sách gốc thì kết quả test không được đổi theo.
"""
self._events = list(events)
def load(self, kind: Optional[str] = None) -> List[AuditEventDTO]:
"""Trả về danh sách đã nạp sẵn, lọc theo loại nếu có."""
if kind is None:
return list(self._events)
return [e for e in self._events if e.kind == kind]
+6
View File
@@ -0,0 +1,6 @@
"""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"]
@@ -0,0 +1,100 @@
"""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:
"""``provider_factory`` là hàm dựng provider, gọi lúc cần chứ không dựng sẵn —
provider có thể bị đổi giữa hai lần lập kế hoạch.
"""
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:
"""Provider dùng để lập kế hoạch; chưa cấu hình thì báo lỗi rõ ràng ngay tại
đây thay vì để lỗi nổ ra ở tận tầng HTTP.
"""
if self._provider_factory is None:
raise RuntimeError("No provider available to plan tasks.")
return self._provider_factory()
__all__ = ["AiTaskPlannerService"]
@@ -0,0 +1,176 @@
"""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:
"""``run_now`` để None thì service chỉ đọc/ghi task, không chạy được cái nào —
đúng cho ngữ cảnh không có scheduler (test, hay màn chỉ xem).
"""
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:
"""Xoá một task; trả về ``False`` nếu id không tồn tại."""
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 workflows package: Co4E graph execution orchestration."""
+99
View File
@@ -0,0 +1,99 @@
"""Đọc/ghi file lịch sử run của Co4E — tách khỏi ``co4e_workflow_service.py``.
``Co4EWorkflowService`` lo vòng đời các run đang chạy; chỗ này lo đúng một
việc: đưa ``RunRecord`` ra đĩa và lấy lại được. Tách ra vì hành vi đọc/ghi ở
đây có những ràng buộc rất riêng — được ghi lại nguyên vẹn bên dưới — mà trộn
lẫn vào file điều phối thì không ai đọc tới.
DTO ở ``domain/workflows/run_record.py`` không được chạm đĩa, nên việc này
nằm ở tầng application chứ không nằm trong domain.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Dict, List, Tuple
from ...domain.workflows.run_record import RunRecord
from ...infrastructure.persistence.json.atomic_json_file import AtomicJsonFile
#: Giữ N run gần nhất trên đĩa. Lịch sử chỉ để người dùng nhìn lại, không
#: phải sổ kiểm toán — để nó lớn vô hạn thì mỗi lần lưu lại phải tuần tự hoá
#: cả file, và lần lưu ấy nằm ngay trên đường đi của mọi sự kiện tiến độ.
HISTORY_CAP = 500
class RunHistoryStore:
"""Một file JSON chứa lịch sử run, kèm hai quy ước phải giữ nguyên.
**Không cách ly file hỏng.** Bản đầu dùng ``AtomicJsonFile.read()``, nhưng
review thấy nó đổi hành vi thật so với ``Co4ERunManager`` cũ: gặp JSON
hỏng, ``AtomicJsonFile.read()`` ĐỔI TÊN file thành ``<tên>.bad-<mốc>`` rồi
mới trả về mặc định, trong khi bản cũ chỉ bắt lỗi và ĐỂ NGUYÊN file tại
chỗ. Đó là thay đổi quan sát được trên đĩa mà không test nào khoá lại và
không có chú thích báo trước — Lâm (N3) quyết ngày 24/08: giữ hành vi cũ.
Vì thế :meth:`load` đọc thủ công bằng ``json.loads``.
**Ghi hỏng không được làm vỡ luồng gọi.** :meth:`save` nuốt ``OSError``,
đúng như ``core/co4e_run_manager.py::_save_history``. Nó nằm trên đường đi
của mọi hook tiến độ (``_on_event``/``_on_finished``/``_on_failed``); để
lỗi ghi đĩa (đầy đĩa, mất quyền) ném ra là vỡ cả lượt xử lý sự kiện đang
chạy, chỉ vì lịch sử lần này không lưu được. Người dùng vẫn thấy Flow
Status đúng trong phiên hiện tại, chỉ là bản ghi trên đĩa lùi một bước.
Ghi thì vẫn qua ``AtomicJsonFile``: bản tự viết bằng tmp + ``replace``
thiếu ``fsync`` (dữ liệu có thể còn trong bộ đệm khi mất điện) và
``Path.replace`` thỉnh thoảng bị Defender từ chối trên Windows.
"""
def __init__(self, path: Path):
"""Trỏ vào một file JSON. Chưa tồn tại cũng không sao — :meth:`load` coi như
lịch sử rỗng và :meth:`save` tự tạo thư mục cha.
"""
self.path = Path(path)
def load(self) -> Tuple[Dict[str, RunRecord], int]:
"""Đọc lịch sử; trả về ``({id: RunRecord}, số thứ tự lớn nhất đã dùng)``.
Số thứ tự trả kèm để bên gọi sinh id tiếp theo không đụng vào id đã có
trong lịch sử — không có nó thì sau mỗi lần khởi động lại, ``run1``
mới sẽ ghi đè ``run1`` cũ.
File không có, không đọc được, hay JSON hỏng đều trả về rỗng: mất lịch
sử là chuyện chấp nhận được, chặn ứng dụng khởi động thì không. Từng
bản ghi hỏng cũng bị bỏ riêng lẻ, để một dòng lỗi không kéo theo cả
file.
"""
try:
data = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}, 0
runs: Dict[str, RunRecord] = {}
max_seq = 0
for rec in data.get("runs", []):
try:
record = RunRecord.from_dict(rec)
except Exception:
continue
if not record.id:
continue
runs[record.id] = record
if record.id.startswith("run") and record.id[3:].isdigit():
max_seq = max(max_seq, int(record.id[3:]))
return runs, max_seq
def save(self, runs: List[RunRecord]) -> None:
"""Ghi ``HISTORY_CAP`` run gần nhất xuống đĩa, ghi nguyên tử.
Lỗi ghi bị nuốt có chủ ý — xem docstring của lớp.
"""
payload = {"runs": [r.to_dict() for r in runs[-HISTORY_CAP:]]}
try:
self.path.parent.mkdir(parents=True, exist_ok=True)
AtomicJsonFile(self.path).write(payload)
except OSError:
pass
__all__ = ["RunHistoryStore", "HISTORY_CAP"]
@@ -0,0 +1,388 @@
"""``Co4EWorkflowService`` — nửa "hành vi" tách ra từ ``Co4ERunManager`` cũ.
Bối cảnh: ``core/co4e_run_manager.py::Co4ERunManager`` là một ``QObject`` gộp
chung dữ liệu run (nay là ``domain/workflows/run_record.py::RunRecord``), logic
chạy job trên ``AgentWorker``/``QThread``, và logic đọc/ghi lịch sử ra đĩa. File
này là phần còn lại sau khi tách DTO: quản lý vòng đời nhiều run cùng lúc, các
hook nhận sự kiện từ worker, và lưu/nạp lịch sử — nhưng THUẦN PYTHON, không kế
thừa ``QObject`` và không tự dựng ``QThread`` (``application/`` cấm PySide6).
Hai điều thay ``Signal`` cũ:
* ``changed = Signal()`` -> danh sách callback ``self._changed_callbacks`` +
``on_changed(cb)`` để đăng ký; mọi chỗ code cũ gọi ``self.changed.emit()``
nay gọi ``self._emit_changed()``, gọi callback theo ĐÚNG thứ tự đã đăng ký.
* ``event = Signal(str, dict)`` -> ``self._event_callbacks`` + ``on_event(cb)``,
tương tự, thay ``self.event.emit(rid, ev)`` bằng ``self._emit_event(rid, ev)``.
* ``self.changed.connect(self._save_history)`` (lớp cũ tự nối signal của
chính nó vào slot riêng, trong ``__init__``) -> ở đây gọi thẳng
``self._save_history()`` làm bước ĐẦU TIÊN bên trong ``_emit_changed()``,
trước khi chạy các callback đã đăng ký từ bên ngoài. Chọn cách "gọi thẳng"
(thay vì "đăng ký như callback đầu tiên") vì nó khớp với thứ tự nối cũ
(``_save_history`` luôn được nối sớm nhất trong ``__init__`` nên luôn chạy
trước mọi slot ngoài nối sau) mà không cần một danh sách callback nội bộ
riêng chỉ để chứa đúng một phần tử cố định.
``start()`` KHÔNG tự tạo ``AgentWorker``/``QThread`` — nó nhận một ``runner``
(``WorkflowRunner`` Protocol, mặc định ``None``) tiêm qua constructor. Adapter
Qt thật (bọc ``AgentWorker`` — xem ``core/worker.py``) là việc của widget ở
``presentation/``, không viết ở đây; test dùng fake chạy đồng bộ
(``tests/fakes/fake_co4e_workflow_service.py`` hoặc fake cục bộ trong
``tests/test_co4e_workflow_service.py``).
KHÔNG xoá/sửa ``core/co4e_run_manager.py`` — lớp cũ tiếp tục chạy song song
cho tới khi widget Co4E Studio thật (``ui/co4e_tab.py``) chuyển hẳn sang dùng
service này.
SEAM · dựng 2026-08-25 · chưa nối dây (F-05)
------------------------------------------------------------
Được nối khi: ``ui/co4e_tab.py`` bỏ ``Co4ERunManager`` và nhận service này qua ``build_co4e_tab(ctx, workflow_service)``.
Để dormant thì sao: Hai bản cùng giữ vòng đời run đang chạy song song. Càng
để lâu thì sửa một lỗi lại phải sửa hai nơi — và đến một lúc sẽ có người
quên nơi thứ hai.
Cổng ``scripts/check_orphan_modules.py`` đếm tuổi seam từ ngày trên
và nhắc khi quá ``SEAM_MAX_AGE_DAYS``. Đổi nội dung dòng đó thì cổng
đọc theo — đừng sửa ngày để làm im lời nhắc.
"""
from __future__ import annotations
import os
from datetime import datetime
from pathlib import Path
from typing import Callable, Dict, List, Optional, Protocol, Set
from ...core.co4e import CO4E_DIR, STEP_DONE, STEP_ERROR, STEP_PLANNED, Workflow, slugify, workflow_to_dict
from ...domain.workflows.run_record import RunRecord
from .co4e_run_history import RunHistoryStore
_TERMINAL_NODE = {STEP_DONE, STEP_ERROR, STEP_PLANNED}
def _now_str() -> str:
"""Mốc thời gian hiện tại dạng 'YYYY-MM-DD HH:MM' — đúng định dạng lịch sử run đang lưu."""
return datetime.now().strftime("%Y-%m-%d %H:%M")
def _current_user() -> str:
"""Best-effort creator name for a run (signed-in MS365 identity -> OS user)."""
return os.environ.get("USERNAME") or os.environ.get("USER") or "you"
# ---- ports (Protocol) — thay QThread thật bằng thứ tiêm được ---------------
class RunnerJob(Protocol):
"""Bề mặt tối thiểu mà job workflow cần từ 'worker' của nó.
Tương ứng ``AgentWorker.emit_event``/``AgentWorker.is_cancelled`` cũ
(``core/worker.py``) — giữ nguyên chữ ký đó để hàm job bên trong
``co4e_runner.run_workflow`` không phải đổi khi runner đứng sau là
``AgentWorker``/``QThread`` thật (adapter ở presentation/) hay là fake
đồng bộ trong test.
"""
def emit_event(self, ev: dict) -> None:
"""Đẩy một sự kiện tiến độ từ luồng nền về service."""
...
def is_cancelled(self) -> bool:
"""``True`` khi người dùng đã bấm dừng — thân job phải tự kiểm để thoát sớm."""
...
class RunWorkerHandle(Protocol):
"""Điều khiển một job đang chạy nền — tương ứng phần
``AgentWorker.request_stop()`` cũ mà ``Co4ERunManager.stop()`` gọi."""
def request_stop(self) -> None:
"""Xin dừng run. Chỉ là yêu cầu: job đang chạy phải tự thấy qua
``is_cancelled()`` rồi thoát, không ai giết luồng giữa chừng.
"""
...
class WorkflowRunner(Protocol):
"""Cổng chạy một job nền, tiêm qua constructor ``Co4EWorkflowService``.
Thay cho việc service tự ``AgentWorker(job); worker.start()`` (cần
``QThread`` -> cấm ở ``application/``). Bên gọi ``start()`` truyền vào
``job`` với đúng chữ ký cũ (``job(worker) -> Optional[dict]``); runner chịu
trách nhiệm chạy nó (nền thật hay đồng bộ) và gọi lại ba callback tương ứng
ba signal cũ của ``AgentWorker`` (``event``/``finished_ok``/``failed``).
"""
def start(self, run_id: str, job: Callable[[RunnerJob], Optional[dict]],
on_event: Callable[[dict], None],
on_finished: Callable[[Optional[dict]], None],
on_failed: Callable[[str], None]) -> RunWorkerHandle:
"""Chạy ``job`` và trả về tay cầm để dừng nó."""
...
class Co4EWorkflowService:
"""Tầng application: vòng đời nhiều run Co4E cùng lúc, thuần Python.
Vai trò: đây là nơi ``build_co4e_tab(ctx, workflow_service)``
(``presentation/co4e/co4e_tab.py``) sẽ lấy ``workflow_service`` thật một
khi widget Co4E Studio được lắp lại để dùng nó — hiện widget thật
(``ui/co4e_tab.py``) vẫn dùng ``Co4ERunManager`` cũ song song.
"""
def __init__(self, ctx, *, history_path: Optional[Path] = None,
runner: Optional[WorkflowRunner] = None):
"""Dựng service.
``runner`` để None nghĩa là chưa có ai chạy được run — đúng trạng thái hiện
nay, vì adapter Qt thật thuộc về tầng ``presentation/`` và chưa được nối.
Test tiêm runner chạy đồng bộ vào đây.
"""
self.ctx = ctx
self._runs: Dict[str, RunRecord] = {}
self._worker_handles: Dict[str, RunWorkerHandle] = {}
self._seq = 0
self._output_root: Optional[Path] = None # thư mục output co4e của workspace đang chọn
self._project_id: str = "" # workspace đang chọn — Flow Status lọc theo no
self._runner = runner
# DTO domain khong duoc cham dia (xem domain/workflows/run_record.py),
# nen viec doc/ghi file lich su nam o tang application — cu the la
# co4e_run_history.py::RunHistoryStore.
self._history_path_value = (
Path(history_path) if history_path is not None else (CO4E_DIR / "run_history.json")
)
self._history = RunHistoryStore(self._history_path_value)
self._changed_callbacks: List[Callable[[], None]] = []
self._event_callbacks: List[Callable[[str, dict], None]] = []
self._load_history() # khoi phuc lich su cu de Flow Status
# giu du lich su qua cac lan restart
# ---- callback thay Signal ---------------------------------------------
def on_changed(self, cb: Callable[[], None]) -> None:
"""Đăng ký callback gọi mỗi khi danh sách run đổi — thay cho signal Qt cũ."""
self._changed_callbacks.append(cb)
def on_event(self, cb: Callable[[str, dict], None]) -> None:
"""Đăng ký callback nhận sự kiện tiến độ của từng run — thay cho signal Qt cũ."""
self._event_callbacks.append(cb)
def _emit_changed(self) -> None:
"""Lưu lịch sử rồi báo mọi người đăng ký."""
self._save_history() # xem docstring dau file: giu dung thu tu ban Qt cu
for cb in self._changed_callbacks:
cb()
def _emit_event(self, run_id: str, ev) -> None:
"""Chuyển một sự kiện tiến độ tới mọi callback đã đăng ký."""
for cb in self._event_callbacks:
cb(run_id, ev)
# ---- persistence --------------------------------------------------
def _load_history(self) -> None:
"""Khôi phục lịch sử run từ đĩa lúc khởi động.
Lấy luôn số thứ tự lớn nhất đã dùng để ``_next_id()`` không sinh trùng
id với run cũ.
"""
self._runs, self._seq = self._history.load()
def _save_history(self) -> None:
"""Ghi lịch sử xuống đĩa. Lỗi ghi bị nuốt có chủ ý — xem
``co4e_run_history.py::RunHistoryStore``.
"""
self._history.save(list(self._runs.values()))
# ---- lifecycle ----------------------------------------------------
def _next_id(self) -> str:
"""Sinh id run kế tiếp ('run1', 'run2', ...), không đụng id đã có trong lịch sử."""
self._seq += 1
return f"run{self._seq}"
def start(self, wf: Workflow, *, skill_map: Optional[Dict[str, str]] = None,
plan_mode: bool = False, only_nodes: Optional[set] = None,
seed_outputs: Optional[Dict[str, str]] = None,
manual: bool = False, label: Optional[str] = None) -> str:
"""Đăng ký một run mới và giao job cho ``self._runner`` (nếu có).
Không tự thực thi AI thật ở đây: khi ``self._runner`` là ``None``
(mặc định), run được ghi nhận nhưng không job nào được giao đi — dùng
cho test/khi chưa lắp adapter Qt thật.
"""
run_id = self._next_id()
total = len(only_nodes) if only_nodes else len(wf.nodes)
record = RunRecord(run_id, wf.id, label or wf.name, total, plan_mode, manual,
created_by=_current_user(), created_at=_now_str(),
project_id=self._project_id)
# workflow_to_dict() tu dung dataclasses.asdict() de dung ca cay (node,
# step, sub-agent) -> ban than no da la mot "deep copy" sang dict moi,
# khong con giu tham chieu toi wf.nodes/wf.edges song. Vi vay KHONG can
# deepcopy(wf) truoc nhu ban Qt cu (RunHandle.wf giu nguyen doi tuong
# Workflow) -- xem doc string dau file domain/workflows/run_record.py
# ve ly do snapshot o day la dict tho chu khong phai doi tuong.
record.wf = workflow_to_dict(wf)
nodes = list(wf.nodes)
edges = list(wf.edges)
out_dir = self._out_dir(wf)
record.out_dir = str(out_dir)
ctx = self.ctx
sk = dict(skill_map or {})
only: Optional[Set[str]] = set(only_nodes) if only_nodes else None
seed = dict(seed_outputs or {})
run_label = record.name
self._runs[run_id] = record
if self._runner is not None:
def job(worker: RunnerJob):
from ...core import co4e_runner
return co4e_runner.run_workflow(
ctx, nodes, edges, out_dir, worker.emit_event, worker.is_cancelled,
plan_mode=plan_mode, skill_map=sk, only_nodes=only, seed_outputs=seed,
usage_label=run_label)
self._worker_handles[run_id] = self._runner.start(
run_id, job,
on_event=lambda ev, rid=run_id: self._on_event(rid, ev),
on_finished=lambda _r=None, rid=run_id: self._on_finished(rid),
on_failed=lambda e, rid=run_id: self._on_failed(rid, e),
)
self._emit_changed()
return run_id
# ---- worker callbacks (goi tu runner, thay slot Qt cu) -----------------
def _on_event(self, run_id: str, ev) -> None:
"""Nhận sự kiện từ job đang chạy và cập nhật bản ghi run."""
record = self._runs.get(run_id)
if record is not None and isinstance(ev, dict):
t = ev.get("type")
if t == "node_status":
record.node_status[ev.get("node_id")] = ev.get("status")
record.done = sum(1 for s in record.node_status.values() if s in _TERMINAL_NODE)
self._emit_changed()
elif t == "run_done":
if record.status == "running":
record.status = "done" if ev.get("ok", True) else "error"
self._emit_changed()
# quirk co y giu nguyen (xem test_on_event_unknown_run_id... trong ca
# test cu lan test moi): re-emit VO DIEU KIEN, ke ca run_id la hoac ev
# khong phai dict/None -- khac _on_finished/_on_failed la no-op hoan
# toan khi run_id la.
#
# Khac biet CO CHU Y so voi ban Qt cu: Signal(str, dict) cua PySide6 ep
# ev=None thanh {} khi giao cho slot (tac dung phu cua kieu Signal khai
# bao cung). O day khong con Signal nen callback nhan DUNG gia tri ev
# goc (None neu goi voi None) -- khong gia lap lai viec ep kieu do vi
# no la tac dung phu cua Qt, khong phai quy tac nghiep vu can giu.
self._emit_event(run_id, ev)
def _on_finished(self, run_id: str) -> None:
"""Job kết thúc mà không phát ``run_done``: chốt trạng thái về 'done'."""
record = self._runs.get(run_id)
if record is not None and record.status == "running":
# job returned without a run_done event (shouldn't happen) — settle it
record.status = "done"
self._emit_changed()
def _on_failed(self, run_id: str, err: str) -> None:
"""Job ném lỗi: ghi lỗi vào bản ghi và báo ra ngoài một sự kiện ``run_error``."""
record = self._runs.get(run_id)
if record is not None:
record.status = "error"
record.error = str(err)
self._emit_event(run_id, {"type": "run_error", "error": str(err)})
self._emit_changed()
# ---- control --------------------------------------------------------
def stop(self, run_id: str) -> None:
"""Yêu cầu dừng một run đang chạy và đánh dấu 'stopped'."""
record = self._runs.get(run_id)
worker = self._worker_handles.get(run_id)
if record is not None and worker is not None and record.running:
worker.request_stop()
record.status = "stopped"
self._emit_changed()
def stop_all(self) -> None:
"""Dừng mọi run của workspace đang chọn (Flow Status vốn lọc theo project)."""
# Only the CURRENT workspace's runs (Flow Status is per-project).
for run_id in [r for r, rec in self._runs.items() if self._belongs(rec)]:
self.stop(run_id)
def rename(self, run_id: str, new_name: str) -> None:
"""Rename a run in the Flow Status history (and its kept workflow snapshot),
then persist + refresh views. No-op on a blank name / unknown run."""
record = self._runs.get(run_id)
new_name = (new_name or "").strip()
if record is None or not new_name or new_name == record.name:
return
record.name = new_name
# DTO doi: RunHandle.wf cu la doi tuong Workflow (gan record.wf.name),
# RunRecord.wf o day la dict tho (xem domain/workflows/run_record.py)
# nen doi truc tiep khoa "name" cua dict thay vi thuoc tinh doi tuong.
if record.wf is not None:
record.wf["name"] = new_name
self._emit_changed()
def remove(self, run_id: str) -> None:
"""Xoá một run khỏi lịch sử; đang chạy thì dừng trước."""
record = self._runs.get(run_id)
if record is not None and record.running:
self.stop(run_id)
self._runs.pop(run_id, None)
self._worker_handles.pop(run_id, None)
self._emit_changed()
def clear_finished(self) -> None:
"""Xoá mọi run đã kết thúc của workspace đang chọn, giữ nguyên run đang chạy."""
# Only clear finished runs of the CURRENT workspace.
for run_id in [r for r, rec in self._runs.items() if not rec.running and self._belongs(rec)]:
self._runs.pop(run_id, None)
self._worker_handles.pop(run_id, None)
self._emit_changed()
# ---- queries ----------------------------------------------------------
def _belongs(self, r: RunRecord) -> bool:
"""Whether a run belongs to the currently-selected workspace."""
return getattr(r, "project_id", "") == self._project_id
def runs(self) -> List[RunRecord]:
"""Runs of the CURRENT workspace only — Flow Status is per-project."""
return [r for r in self._runs.values() if self._belongs(r)]
def all_runs(self) -> List[RunRecord]:
"""Every tracked run across all workspaces (background tracking)."""
return list(self._runs.values())
def get(self, run_id: str) -> Optional[RunRecord]:
"""Lấy một run theo id; ``None`` nếu không có."""
return self._runs.get(run_id)
def active_count(self) -> int:
"""Số run đang chạy của workspace đang chọn — dùng cho huy hiệu trên tab."""
return sum(1 for r in self._runs.values() if r.running and self._belongs(r))
def set_current_project(self, project_id: str) -> None:
"""Filter Flow Status (and new runs) to this workspace. Runs started while
this is set are tagged with it; the Runs view shows only matching runs."""
pid = project_id or ""
if pid != self._project_id:
self._project_id = pid
self._emit_changed() # re-render Flow Status for the new workspace
def set_output_root(self, root: Optional[Path]) -> None:
"""Point flow outputs at the SELECTED workspace's co4e folder (set by the
Co4E tab when a project is chosen). ``None`` → fall back to the global
Cowork output dir."""
self._output_root = Path(root) if root else None
def _out_dir(self, wf: Workflow) -> Path:
"""Thư mục ghi kết quả của một luồng, tạo sẵn nếu chưa có."""
# Flow deliverables are written into the SELECTED workspace (the active
# project's folder) so they land where the user works with files (Folder
# tab), not in the config/install folder. One subfolder per flow keeps
# runs tidy. Falls back to the global Cowork output dir when no workspace
# is selected.
base = self._output_root
if base is None:
try:
base = self.ctx.config.cowork_output_dir() / "co4e"
except Exception: # noqa: BLE001 - fall back to the config dir if unavailable
base = CO4E_DIR / "runs" / "co4e"
d = Path(base) / slugify(wf.name or "flow")
d.mkdir(parents=True, exist_ok=True)
return d
+17
View File
@@ -0,0 +1,17 @@
"""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",
]
+40
View File
@@ -0,0 +1,40 @@
"""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"]
@@ -0,0 +1,62 @@
"""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:
"""Đọc tệp dạng văn bản, thay ký tự hỏng thay vì ném lỗi; không đọc được thì
trả về chuỗi rỗng.
"""
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:
"""Đoán tệp này có phải văn bản không, bằng cách tìm byte NUL trong phần đầu.
Đoán sai theo hướng "là văn bản" sẽ hiện một màn hình ký tự rác, nên phép
thử cố tình bảo thủ.
"""
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"]
@@ -0,0 +1,84 @@
"""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
"""Nhận một ``WorkspaceSession`` — mọi đường dẫn về sau đều bị nó chặn trong
phạm vi cho phép.
"""
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"]
@@ -0,0 +1,91 @@
"""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"]
+39 -267
View File
@@ -16,6 +16,8 @@ import json
import os
from dataclasses import dataclass, field
from pathlib import Path
from .infrastructure.config.json_config_repository import JsonConfigRepository
from typing import Any, Dict, List
CONFIG_DIR = Path.home() / ".cowork_local"
@@ -273,6 +275,11 @@ def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any
def _apply_env_overrides(data: Dict[str, Any]) -> Dict[str, Any]:
"""Cho phép biến môi trường ghi đè cấu hình.
Dùng khi chạy trong container/CI: đặt endpoint và khoá qua biến môi trường mà
không phải sửa file cấu hình.
"""
data = copy.deepcopy(data)
oc = data["providers"]["openai_compat"]
if os.getenv("OPENAI_API_KEY"):
@@ -343,274 +350,39 @@ def _migrate_connectors(data: Dict[str, Any]) -> None:
data["mcp_servers"] = [] # migrated — the UI no longer manages this
@dataclass
class AppConfig:
"""In-memory view of the configuration with load/save helpers."""
class AppConfig(JsonConfigRepository):
"""Vỏ tương thích — R02 đã thay lớp này bằng :class:`JsonConfigRepository`.
data: Dict[str, Any] = field(default_factory=lambda: copy.deepcopy(DEFAULT_CONFIG))
path: Path = CONFIG_PATH
Ngày 25/08 app chuyển hẳn sang repository (ghi nguyên tử, khoá nằm trong
kho bí mật của hệ điều hành). Nhưng cái tên ``AppConfig`` còn nằm ở 41 file
— 23 checker trong ``tools/`` và 18 file test, trong đó có test của cả ba
người. Sửa hết 41 chỗ trong một commit là đổi thứ không cần đổi và làm
review không đọc nổi.
Nên giữ tên, đổi ruột: mọi lối vào đều dẫn tới repository.
Bỏ hẳn được khi ``tools/`` và ``tests/`` chuyển sang gọi
``presentation.shell.bootstrap.build_context()``.
"""
def __init__(self, data=None, path: Path = CONFIG_PATH, **kw):
"""Mở cấu hình từ đĩa, hoặc dựng thẳng từ dict khi truyền ``data``.
Dạng ``AppConfig(data=..., path=...)`` là để 13 file test dựng cấu hình mà
không chạm đĩa; giữ nguyên vì bỏ đi là phải sửa cả 13 file.
"""
if data is None:
super().__init__(Path(path), **kw)
return
# Dạng AppConfig(data=..., path=...) mà 13 file test đang dùng: dựng
# thẳng từ dict, không đụng đĩa.
built = JsonConfigRepository.from_data(data, Path(path))
self.__dict__.update(built.__dict__)
# ---- persistence -------------------------------------------------
@classmethod
def load(cls, path: Path = CONFIG_PATH) -> "AppConfig":
merged = copy.deepcopy(DEFAULT_CONFIG)
if path.exists():
try:
stored = json.loads(path.read_text(encoding="utf-8"))
merged = _deep_merge(merged, stored)
except (json.JSONDecodeError, OSError):
# Corrupt config should never block startup.
merged = copy.deepcopy(DEFAULT_CONFIG)
merged = _apply_env_overrides(merged)
# "unlocked" is a runtime-only Settings-panel state (see the "ms365"
# comment in DEFAULT_CONFIG) — never trust a stored/hand-edited value,
# every launch starts locked.
merged.setdefault("ms365", {})["unlocked"] = False
_migrate_connectors(merged) # office→ms365 + legacy mcp_servers→other
return cls(data=merged, path=path)
def load(cls, path: Path = CONFIG_PATH) -> "JsonConfigRepository":
"""Điểm vào cũ. Giờ đi qua Composition Root nên checker và app dùng
chung một đường dựng — kể cả phần ráp kho bí mật."""
from .presentation.shell.bootstrap import build_config
return build_config(Path(path))
def save(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
to_write = self.data
if self.data.get("ms365", {}).get("unlocked"):
# Defense in depth: even if some caller saves without having gone
# through the Settings dialog's own auto-lock-after-save flow, the
# unlock state must never reach disk.
to_write = copy.deepcopy(self.data)
to_write["ms365"]["unlocked"] = False
self.path.write_text(
json.dumps(to_write, indent=2, ensure_ascii=False), encoding="utf-8"
)
# ---- convenience accessors --------------------------------------
@property
def active_provider(self) -> str:
# Migrate configs that still point at a removed provider (e.g. an older
# install saved "ollama") to a supported one, so the app never tries to
# build an unknown provider.
val = self.data.get("active_provider", "openai_compat")
return val if val in PROVIDER_LABELS else "openai_compat"
@active_provider.setter
def active_provider(self, value: str) -> None:
self.data["active_provider"] = value
def provider_conf(self, name: str | None = None) -> Dict[str, Any]:
name = name or self.active_provider
return self.data["providers"].get(name, {})
@property
def ca_bundle(self) -> str:
"""Path to a custom CA/certificate PEM file, or '' for normal validation.
Used as ``requests``' ``verify=`` argument for every outbound HTTPS call
— see the "tls_ca_bundle" comment above for when this is needed."""
return (self.data.get("tls_ca_bundle") or "").strip()
@ca_bundle.setter
def ca_bundle(self, value: str) -> None:
self.data["tls_ca_bundle"] = (value or "").strip()
# ---- Microsoft 365 connections (Settings-panel lock, see DEFAULT_CONFIG) --
@property
def ms365(self) -> Dict[str, Any]:
return self.data.setdefault("ms365", copy.deepcopy(DEFAULT_CONFIG["ms365"]))
# ---- Login / RBAC / shared cross-machine store (see DEFAULT_CONFIG) ------
@property
def auth(self) -> Dict[str, Any]:
return self.data.setdefault("auth", copy.deepcopy(DEFAULT_CONFIG["auth"]))
@property
def shared_dir(self) -> str:
return (self.auth.get("shared_dir") or "").strip()
def ms365_try_unlock(self, code: str) -> bool:
"""Unlock the MS365 Settings group for this session if ``code`` matches.
This is a client-side UI lock (prevents casually toggling a sensitive
section), NOT Microsoft authentication — see the DEFAULT_CONFIG
comment. Never persisted as unlocked; see ``save()``."""
if (code or "") and code == self.ms365.get("unlock_code", ""):
self.data["ms365"]["unlocked"] = True
return True
return False
def ms365_lock(self) -> None:
self.data.setdefault("ms365", {})["unlocked"] = False
@property
def theme(self) -> str:
return self.data.get("theme", "dark")
@theme.setter
def theme(self, value: str) -> None:
self.data["theme"] = value
@property
def language(self) -> str:
from .i18n import DEFAULT_LANGUAGE, LANGUAGES
val = self.data.get("language", DEFAULT_LANGUAGE)
return val if val in LANGUAGES else DEFAULT_LANGUAGE
@language.setter
def language(self, value: str) -> None:
self.data["language"] = value
@property
def code(self) -> Dict[str, Any]:
return self.data["code"]
@property
def tools_disabled(self) -> list:
"""Built-in agent tool names the admin has turned off (Monitoring → Tools)."""
return self.data.setdefault("tools", {}).setdefault("disabled", [])
def set_tool_enabled(self, name: str, enabled: bool) -> None:
"""Enable/disable a built-in agent tool by name and persist it."""
disabled = set(self.tools_disabled)
if enabled:
disabled.discard(name)
else:
disabled.add(name)
self.data.setdefault("tools", {})["disabled"] = sorted(disabled)
self.save()
@property
def connect_external(self) -> bool:
"""Master switch (Monitoring → Tools → Connector): when off, the agent
connects to NO external connectors (CAD/CAE/MS365/Other MCP + REST).
Defaults ON so existing setups keep working."""
return bool(self.data.setdefault("tools", {}).get("connect_external", True))
def set_connect_external(self, enabled: bool) -> None:
self.data.setdefault("tools", {})["connect_external"] = bool(enabled)
self.save()
# ---- one-time seeding bookkeeping (built-in skill library / flows) -------
@property
def seeded_library_skills(self) -> List[str]:
"""Slugs of bundled library skills already seeded into the user's Skill
Manager — so a user-deleted one is never silently re-seeded."""
return list(self.data.setdefault("seeded_library_skills", []))
@seeded_library_skills.setter
def seeded_library_skills(self, slugs) -> None:
self.data["seeded_library_skills"] = list(dict.fromkeys(slugs or []))
@property
def seeded_builtin_flows(self) -> List[str]:
"""Ids of built-in Co4E flows already seeded (same respect-user-deletion
rule as seeded_library_skills)."""
return list(self.data.setdefault("seeded_builtin_flows", []))
@seeded_builtin_flows.setter
def seeded_builtin_flows(self, ids) -> None:
self.data["seeded_builtin_flows"] = list(dict.fromkeys(ids or []))
@property
def teams(self) -> Dict[str, Any]:
return self.data["teams"]
@property
def history(self) -> Dict[str, Any]:
return self.data["history"]
@property
def codebase_memory(self) -> Dict[str, Any]:
return self.data["codebase_memory"]
@property
def agent_security(self) -> Dict[str, Any]:
return self.data["agent_security"]
@property
def mcp_servers(self) -> List[Dict[str, Any]]:
return self.data.setdefault("mcp_servers", [])
@property
def ext_connectors(self) -> Dict[str, List[Dict[str, Any]]]:
"""Unified Connectors (MCP), grouped by category CAD/CAE/MS365/Other —
see core/ext_connectors.py for the per-entry shape and CATEGORIES."""
d = self.data.setdefault("ext_connectors", {"cad": [], "cae": [], "ms365": [], "other": []})
for cat in ("cad", "cae", "ms365", "other"):
d.setdefault(cat, [])
return d
@property
def cowork(self) -> Dict[str, Any]:
return self.data["cowork"]
@property
def routing(self) -> Dict[str, Any]:
"""Auto Model Assessment & Routing behaviour config (see DEFAULT_CONFIG).
Always returns a dict with every expected key present, backfilling any
missing sub-keys from the defaults so older configs upgrade seamlessly."""
d = self.data.setdefault("routing", copy.deepcopy(DEFAULT_CONFIG["routing"]))
for k, v in DEFAULT_CONFIG["routing"].items():
d.setdefault(k, copy.deepcopy(v))
d.setdefault("surface_modes", {})
for surface in ("cowork", "co4e", "ai_edit"):
d["surface_modes"].setdefault(surface, "")
return d
def routing_mode_for(self, surface: str) -> str:
"""Effective Off/Auto/Manual mode for a chat surface.
A per-surface override ("auto"/"manual"/"off") wins; an empty override
falls back to the global ``switch_mode``."""
routing = self.routing
override = (routing.get("surface_modes", {}) or {}).get(surface, "")
mode = override or routing.get("switch_mode", "off")
return mode if mode in ("off", "auto", "manual") else "off"
def set_routing_mode_for(self, surface: str, mode: str) -> None:
"""Persist a chat surface's Off/Auto/Manual toggle selection."""
mode = mode if mode in ("off", "auto", "manual") else "off"
self.routing.setdefault("surface_modes", {})[surface] = mode
self.save()
@property
def structure(self) -> Dict[str, Any]:
return self.data.setdefault("structure", {"max_nodes": 400, "max_edges": 400})
@property
def monitoring_visibility(self) -> Dict[str, bool]:
return self.data.setdefault(
"monitoring_visibility", copy.deepcopy(DEFAULT_CONFIG["monitoring_visibility"]))
def cowork_output_dir(self) -> Path:
"""Where Cowork saves generated files (OneDrive folder by default)."""
custom = (self.cowork.get("output_dir") or "").strip()
if custom:
return Path(custom).expanduser()
from . import paths # local import avoids any import cycle
root = paths.primary_onedrive_root()
if root is not None:
return root / "CoworkLocal" / "output"
return CONFIG_DIR / "output" / "cowork"
def history_dir(self) -> Path:
"""Resolve where conversation history is stored.
When a project is open, its history is stored INSIDE the project's
workspace folder (``_project_history_dir``, set by the Workspace screen)
so that sharing/syncing that folder shares the history — another machine
opening the same folder sees the conversations and can continue them.
Otherwise: Local (default) or OneDrive."""
rt = getattr(self, "_project_history_dir", None)
if rt:
return Path(rt)
custom = (self.history.get("custom_dir") or "").strip()
if custom:
return Path(custom).expanduser()
if self.history.get("location") == "onedrive":
from . import paths # local import avoids any import cycle
root = paths.primary_onedrive_root()
if root is not None:
return root / "CoworkLocal" / "history"
return HISTORY_DIR
def model_label(self) -> str:
return str(self.provider_conf().get("model", "?"))
+32
View File
@@ -0,0 +1,32 @@
"""Root pytest conftest — loaded before ``tests/conftest.py``.
This checkout lives on disk as ``Refactor`` (not ``cowork_local``), while
``tests/`` imports everything as ``from cowork_local... import ...`` and
``tests/conftest.py`` makes that resolve by putting this repo's *parent*
directory on ``sys.path`` (expecting the repo root itself to be named
``cowork_local``). A sibling folder literally named ``cowork_local`` (an
unrelated, older checkout) already exists next to this one, so without this
file Python would silently import THAT folder instead of this repository
whenever a test does ``import cowork_local``.
Registering the alias here — before ``tests/conftest.py`` touches
``sys.path`` — caches this repository in ``sys.modules['cowork_local']``
first, so the later ``sys.path`` mutation has nothing left to do (imports
are cached by name; the first successful import of a given name wins for
the rest of the process).
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
_ROOT = Path(__file__).resolve().parent
if "cowork_local" not in sys.modules:
spec = importlib.util.spec_from_file_location(
"cowork_local", _ROOT / "__init__.py", submodule_search_locations=[str(_ROOT)],
)
module = importlib.util.module_from_spec(spec)
sys.modules["cowork_local"] = module
spec.loader.exec_module(module)
+9
View File
@@ -35,6 +35,7 @@ _LAST_LOGIN_PATH = CONFIG_DIR / "last_login.json"
def save_last_login(username: str, role: str) -> None:
"""Nhớ tài khoản đăng nhập gần nhất để lần mở sau điền sẵn."""
try:
_LAST_LOGIN_PATH.parent.mkdir(parents=True, exist_ok=True)
_LAST_LOGIN_PATH.write_text(
@@ -44,6 +45,7 @@ def save_last_login(username: str, role: str) -> None:
def load_last_login() -> Optional[Tuple[str, str]]:
"""Cặp (tên đăng nhập, vai trò) của lần đăng nhập gần nhất; ``None`` nếu chưa có."""
try:
data = json.loads(_LAST_LOGIN_PATH.read_text(encoding="utf-8"))
username, role = data.get("username", ""), data.get("role", "")
@@ -61,6 +63,7 @@ CODE_LENGTH = 12
@dataclass
class Account:
"""Một tài khoản người dùng: tên đăng nhập, vai trò, tên hiển thị và nhóm."""
username: str
role: str
display_name: str = ""
@@ -73,6 +76,7 @@ class Account:
def accounts_dir(shared_dir: str) -> Path:
"""Thư mục chứa tài khoản, nằm trong thư mục chia sẻ của đội."""
return Path(shared_dir).expanduser() / "accounts"
@@ -93,6 +97,7 @@ def generate_code(existing_codes: Optional[Set[str]] = None) -> str:
def save_account(account: Account, directory: Path) -> Path:
"""Ghi một tài khoản ra ``<username>.json`` (tên file đã được làm sạch)."""
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{_safe_username(account.username)}.json"
path.write_text(json.dumps(asdict(account), ensure_ascii=False, indent=2), encoding="utf-8")
@@ -100,6 +105,7 @@ def save_account(account: Account, directory: Path) -> Path:
def load_account(username: str, directory: Path) -> Optional[Account]:
"""Đọc một tài khoản theo tên đăng nhập; không có thì trả ``None``."""
path = directory / f"{_safe_username(username)}.json"
if not path.exists():
return None
@@ -112,6 +118,7 @@ def load_account(username: str, directory: Path) -> Optional[Account]:
def list_accounts(directory: Path) -> List[Account]:
"""Liệt kê mọi tài khoản trong thư mục; thư mục chưa có thì trả list rỗng."""
if not directory.exists():
return []
out: List[Account] = []
@@ -124,6 +131,7 @@ def list_accounts(directory: Path) -> List[Account]:
def delete_account(username: str, directory: Path) -> bool:
"""Xoá file tài khoản; trả về ``True`` nếu có file để xoá."""
path = directory / f"{_safe_username(username)}.json"
try:
path.unlink()
@@ -133,6 +141,7 @@ def delete_account(username: str, directory: Path) -> bool:
def find_by_username(username: str, directory: Path) -> Optional[Account]:
"""Bí danh của :func:`load_account`, giữ cho mã cũ gọi theo tên này vẫn chạy."""
return load_account(username, directory)
+13
View File
@@ -74,6 +74,7 @@ _KIND_PROMPTS = {
@dataclass
class AdminAgent:
"""Một agent chuyên trách do quản trị cấu hình: prompt riêng, provider và model riêng."""
agent_id: str
name: str
task_kind: str = "cowork"
@@ -85,6 +86,9 @@ class AdminAgent:
updated_by: str = ""
def effective_prompt(self) -> str:
"""Prompt hệ thống thật sự dùng: prompt mặc định theo loại việc, rồi tới phần
quản trị viết thêm.
"""
parts = [_KIND_PROMPTS.get(self.task_kind, ""), (self.prompt or "").strip()]
return "\n\n".join(p for p in parts if p)
@@ -98,12 +102,17 @@ def agents_admin_dir(shared_dir: str = "") -> Path:
def _slug(name: str) -> str:
"""Định danh an toàn cho tên file, suy từ tên agent."""
s = re.sub(r"[^\w\-]+", "-", (name or "").strip().lower()).strip("-")
return s or "agent"
def new_agent(name: str, task_kind: str = "cowork", prompt: str = "",
provider: str = "", model: str = "", updated_by: str = "") -> AdminAgent:
"""Tạo một agent quản trị mới; loại việc lạ thì rơi về 'cowork'.
Id ghép slug với 6 ký tự ngẫu nhiên để hai agent trùng tên không đè file nhau.
"""
return AdminAgent(
agent_id=f"{_slug(name)}-{uuid.uuid4().hex[:6]}",
name=name.strip(), task_kind=task_kind if task_kind in TASK_KINDS else "cowork",
@@ -113,6 +122,7 @@ def new_agent(name: str, task_kind: str = "cowork", prompt: str = "",
def save_agent(agent: AdminAgent, directory: Path) -> Path:
"""Ghi một agent ra ``<agent_id>.json``."""
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{agent.agent_id}.json"
path.write_text(json.dumps(asdict(agent), ensure_ascii=False, indent=2), encoding="utf-8")
@@ -120,6 +130,7 @@ def save_agent(agent: AdminAgent, directory: Path) -> Path:
def load_agent(agent_id: str, directory: Path) -> Optional[AdminAgent]:
"""Đọc một agent theo id; không có thì trả ``None``."""
path = directory / f"{agent_id}.json"
if not path.exists():
return None
@@ -132,6 +143,7 @@ def load_agent(agent_id: str, directory: Path) -> Optional[AdminAgent]:
def list_agents(directory: Path, enabled_only: bool = False) -> List[AdminAgent]:
"""Liệt kê agent trong thư mục; ``enabled_only`` chỉ lấy agent đang bật."""
if not directory.exists():
return []
out: List[AdminAgent] = []
@@ -165,6 +177,7 @@ def ensure_help_agent(directory: Path) -> AdminAgent:
def delete_agent(agent_id: str, directory: Path) -> bool:
"""Xoá file agent; trả về ``True`` nếu có file để xoá."""
try:
(directory / f"{agent_id}.json").unlink()
return True
+7
View File
@@ -31,6 +31,7 @@ _CMD = re.compile(r"(?<!\S)/agent(?::([\w\-.]+))?(?=$|[\s.,;:!?)\]}»”’'\"
def _slug(name: str) -> str:
"""Định danh an toàn suy từ tên agent (dùng chung hàm với Co4E)."""
from .co4e import slugify
return slugify(name)
@@ -45,6 +46,11 @@ def collect_agents(shared_dir: str = "") -> List[dict]:
seen: set[str] = set()
def _add(slug: str, name: str, desc: str, persona: str, source: str) -> None:
"""Thêm một agent vào danh sách gộp; bỏ qua nếu trùng slug hoặc thiếu persona.
Agent không có persona thì không dùng được — thêm vào chỉ làm bảng gợi ý dài
ra mà chọn vào lại không chạy.
"""
if not slug or slug in seen or not persona.strip():
return
seen.add(slug)
@@ -69,6 +75,7 @@ def collect_agents(shared_dir: str = "") -> List[dict]:
def _persona_block(agent: dict) -> str:
"""Khối prompt mô tả một agent, chèn vào đầu lượt chat khi người dùng gõ ``/agent:``."""
return f"## Agent: {agent['name']}\n{agent['persona']}"
+2
View File
@@ -37,6 +37,7 @@ HELP = "help"
class AgentRole(NamedTuple):
"""Một vai trò agent: khoá, nhãn hiển thị và prompt mặc định."""
key: str
label: str
description: str
@@ -61,5 +62,6 @@ ROLES: Dict[str, AgentRole] = {
def label_for(role_key: str) -> str:
"""Nhãn của một vai trò; khoá lạ thì trả về chính khoá, rỗng thì trả về "—"."""
role = ROLES.get(role_key)
return role.label if role else (role_key or "—")
+13 -19
View File
@@ -27,27 +27,12 @@ from __future__ import annotations
import json
import re
from dataclasses import dataclass
from typing import List, Optional
from ..providers.base import Provider
from . import security_rules
class SecurityBlocked(RuntimeError):
"""A guardrail refused an action. ``verdict`` carries the full detail for
the admin alert; ``str(exc)`` is the short, user-facing reason."""
def __init__(self, verdict: "SecurityVerdict"):
super().__init__(verdict.reason or f"Blocked by agent security ({verdict.layer}).")
self.verdict = verdict
@dataclass
class SecurityVerdict:
allowed: bool
reason: str = ""
layer: str = "" # "prompt" | "attachment" | "command"
from .agent_security_alert import notify_admin
from .agent_security_types import SecurityBlocked, SecurityVerdict
def combined_rules_text(config, max_chars: int = 8000, agent_kind: str = "cowork") -> str:
@@ -164,6 +149,10 @@ def _ai_verdict(provider: Provider, system_prompt: str, content: str, layer: str
def validate_prompt(provider: Provider, user_text: str, rules_text: str) -> SecurityVerdict:
"""Nhờ model xét prompt người dùng theo bộ luật an toàn.
Prompt rỗng thì cho qua ngay, khỏi tốn một lượt gọi.
"""
if not (user_text or "").strip():
return SecurityVerdict(True, "", "prompt")
system = _PROMPT_SYSTEM.format(rules=rules_text or "(no additional rules configured)")
@@ -172,6 +161,7 @@ def validate_prompt(provider: Provider, user_text: str, rules_text: str) -> Secu
def validate_attachment(provider: Provider, filename: str, content: str,
rules_text: str) -> SecurityVerdict:
"""Nhờ model xét nội dung một tệp đính kèm theo bộ luật an toàn."""
if not (content or "").strip():
return SecurityVerdict(True, "", "attachment")
system = _ATTACHMENT_SYSTEM.format(rules=rules_text or "(no additional rules configured)")
@@ -180,6 +170,11 @@ def validate_attachment(provider: Provider, filename: str, content: str,
def validate_command(provider: Provider, command: str,
rules_text: str, ai_enabled: bool) -> SecurityVerdict:
"""Nhờ model xét một lệnh shell theo bộ luật an toàn.
``ai_enabled=False`` thì cho qua — người dùng đã tắt lớp xét bằng AI, bộ luật
tĩnh vẫn chạy ở chỗ khác.
"""
if not ai_enabled:
return SecurityVerdict(True, "", "command")
system = _COMMAND_SYSTEM.format(rules=rules_text or "(no additional rules configured)")
@@ -188,6 +183,7 @@ def validate_command(provider: Provider, command: str,
# ---- call-site convenience wrappers (used by chat_agent.py / code_agent.py) --
def _security_conf(config) -> dict:
"""Nhóm cấu hình ``agent_security``; không có config thì trả dict rỗng."""
return (config.data.get("agent_security", {}) if config is not None else {})
@@ -236,7 +232,6 @@ def enforce_prompt(provider: Provider, messages: List[dict], config, emit,
emit({"type": "notice", "level": "warning",
"text": f"🛡 Yêu cầu bị chặn bởi Agent Security: {verdict.reason}"})
from . import audit_log
from .agent_security_alert import notify_admin
audit_log.record("security_block", "prompt", False, verdict.reason)
notify_admin(config, verdict, detail=user_text[:1000])
@@ -266,7 +261,6 @@ def enforce_command(provider: Provider, name: str, args: dict, config, emit,
emit({"type": "notice", "level": "warning",
"text": f"🛡 Lệnh bị chặn bởi Agent Security ({verdict.layer}): {verdict.reason}"})
from . import audit_log
from .agent_security_alert import notify_admin
audit_log.record("security_block", name, False, f"{verdict.layer}: {verdict.reason}")
notify_admin(config, verdict, detail=command)
+1 -1
View File
@@ -12,7 +12,7 @@ from __future__ import annotations
from typing import Tuple
from . import ms365_graph
from .agent_security import SecurityVerdict
from .agent_security_types import SecurityVerdict
from .ms365_auth import Ms365AuthError, get_access_token
+35
View File
@@ -0,0 +1,35 @@
"""Shared value types for the Agent Security guardrails.
``SecurityVerdict``/``SecurityBlocked`` used to be defined in
``agent_security.py``, which forced ``agent_security_alert.py`` (which only
needs the *type*, to annotate/read ``notify_admin``'s ``verdict`` argument) to
import from it — while ``agent_security.py`` itself needed to call
``agent_security_alert.notify_admin()``, an architectural cycle only avoided
at runtime by deferring that second import inside a function body.
Hoisting the shared type into this dependency-free leaf module lets both
sides import it directly, so ``agent_security.py`` can import
``agent_security_alert`` at module top level too — no cycle, no deferred
imports needed for this pair.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class SecurityVerdict:
"""Kết quả một lớp kiểm an toàn: cho qua hay không, lý do, và lớp nào ra phán quyết."""
allowed: bool
reason: str = ""
layer: str = "" # "prompt" | "attachment" | "command"
class SecurityBlocked(RuntimeError):
"""A guardrail refused an action. ``verdict`` carries the full detail for
the admin alert; ``str(exc)`` is the short, user-facing reason."""
def __init__(self, verdict: SecurityVerdict):
"""Lấy lý do trong phán quyết làm thông điệp; không có lý do thì ghi rõ lớp nào chặn."""
super().__init__(verdict.reason or f"Blocked by agent security ({verdict.layer}).")
self.verdict = verdict
+4
View File
@@ -54,6 +54,10 @@ def _extract_json(text: str) -> Optional[dict]:
def _clamp(value, allowed, default):
"""Ép một giá trị về tập hợp lệ; ngoài tập thì lấy mặc định.
Cần vì model hay trả về giá trị gần đúng ('High' thay vì 'high').
"""
return value if value in allowed else default
+1
View File
@@ -48,6 +48,7 @@ class AppContainerSandbox:
display_name: str = "CoworkLocal Sandbox",
description: str = "Isolated execution environment for Cowork Local agent",
):
"""Đặt tên và mô tả cho hồ sơ AppContainer; chưa tạo gì trên máy."""
self.profile_name = profile_name
self.display_name = display_name
self.description = description
+15 -72
View File
@@ -6,15 +6,23 @@ storage systems).
One JSON line per event, one file per day under ``~/.cowork_local/audit/`` —
same on-disk shape as ``usage_tracker.py`` (day-sharded ``.jsonl``, append-only,
``record()`` never raises so audit logging can never break a chat turn).
This module is now a thin, backward-compatible wrapper around
:class:`infrastructure.telemetry.audit_logger.CanonicalAuditLogger` — every
existing call site (``agent_security.py``, ``chat_agent.py``, ``tools.py``,
``ext_connectors.py``, ``mcp_client.py``, ``ms365_local.py``,
``permissions.py``, ``ui/structure_graph_view.py``, ``app.py``) keeps calling
``audit_log.set_identity``/``record``/``load_events`` exactly as before; only
the implementation moved.
"""
from __future__ import annotations
import json
from datetime import date, datetime
from datetime import date
from pathlib import Path
from typing import Any, Dict, List, Optional
from ..config import CONFIG_DIR
from ..infrastructure.telemetry.audit_logger import CanonicalAuditLogger
AUDIT_DIR = CONFIG_DIR / "audit"
@@ -23,66 +31,21 @@ AUDIT_DIR = CONFIG_DIR / "audit"
# action), "mcp_call" (a call to an external MCP server's tool).
Kind = str
# Process-global identity — who's logged in, their role, and this machine's
# name — set once right after login (app.py::run()), mirroring
# usage_tracker.py's identical pattern. NOT thread-local: fixed per process.
_identity_account = ""
_identity_role = ""
_identity_machine = ""
_identity_shared_dir = ""
_logger = CanonicalAuditLogger(AUDIT_DIR)
def set_identity(account: str, machine: str, role: str = "", shared_dir: str = "") -> None:
"""Called once after login succeeds. ``shared_dir``, when reachable,
makes every subsequent :func:`record` ALSO best-effort-append to the
shared cross-machine telemetry store (see :mod:`telemetry_shared`)."""
global _identity_account, _identity_role, _identity_machine, _identity_shared_dir
_identity_account = account or ""
_identity_role = role or ""
_identity_machine = machine or ""
_identity_shared_dir = shared_dir or ""
_logger.set_identity(account, machine, role=role, shared_dir=shared_dir)
def record(kind: Kind, name: str, ok: bool, detail: str = "",
agent_role: str = "") -> None:
"""Append one audit event. Never raises — audit logging must never break
a chat turn, a permission decision, or a tool call."""
try:
now = datetime.now()
event = {
"ts": now.isoformat(timespec="seconds"),
"kind": kind,
"agent_role": agent_role or "",
"name": name or "",
"ok": bool(ok),
"detail": (detail or "")[:2000], # bounded — never let a huge blob bloat the log
"account": _identity_account,
"role": _identity_role,
"machine": _identity_machine,
}
AUDIT_DIR.mkdir(parents=True, exist_ok=True)
path = AUDIT_DIR / f"{now.strftime('%Y-%m-%d')}.jsonl"
with path.open("a", encoding="utf-8") as f:
f.write(json.dumps(event, ensure_ascii=False) + "\n")
_write_shared(event, now)
except Exception: # noqa: BLE001
pass
def _write_shared(event: Dict[str, Any], now: datetime) -> None:
"""Best-effort mirror of ``event`` into the shared cross-machine store —
one file PER MACHINE per day, so no two machines ever write the same
file. Never raises."""
if not _identity_shared_dir or not _identity_machine:
return
try:
shared = Path(_identity_shared_dir).expanduser() / "telemetry" / "audit"
shared.mkdir(parents=True, exist_ok=True)
path = shared / f"{_identity_machine}-{now.strftime('%Y-%m-%d')}.jsonl"
with path.open("a", encoding="utf-8") as f:
f.write(json.dumps(event, ensure_ascii=False) + "\n")
except Exception: # noqa: BLE001
pass
_logger.record(kind, name, ok, detail=detail, agent_role=agent_role)
def load_events(start: Optional[date] = None, end: Optional[date] = None,
@@ -91,25 +54,5 @@ def load_events(start: Optional[date] = None, end: Optional[date] = None,
"""Events between ``start``/``end`` (inclusive; None = unbounded),
optionally filtered to one ``kind`` — this IS how each Monitoring
Dashboard panel gets its own slice of the same underlying log."""
directory = directory or AUDIT_DIR
if not directory.exists():
return []
events: List[Dict[str, Any]] = []
for path in sorted(directory.glob("*.jsonl")):
try:
day = datetime.strptime(path.stem, "%Y-%m-%d").date()
except ValueError:
continue
if (start and day < start) or (end and day > end):
continue
try:
for line in path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
event = json.loads(line)
if kind is not None and event.get("kind") != kind:
continue
events.append(event)
except (OSError, json.JSONDecodeError):
continue
return events
events = _logger.load_events(start=start, end=end, kind=kind, directory=directory)
return [e.to_dict() for e in events]
+58 -10
View File
@@ -11,6 +11,8 @@ 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
@@ -27,6 +29,13 @@ 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]
@@ -126,6 +135,12 @@ _UNSAFE = re.compile(r'[\\/:*?"<>|\x00-\x1f]+')
def _safe_filename(name: str) -> str:
"""Làm sạch tên tệp do model đề xuất: bỏ đường dẫn, thay ký tự cấm, không bao
giờ trả về chuỗi rỗng.
Model hay trả về tên có dấu ``/`` hoặc ``..`` — ghi thẳng là thoát khỏi thư
mục làm việc.
"""
base = Path(str(name)).name.strip()
base = _UNSAFE.sub("_", base).strip(" _.") or "output.txt"
if "." not in base:
@@ -298,17 +313,23 @@ def run_chat(
emit: EmitFn,
cancel: Optional[CancelFn] = None,
) -> Dict[str, Any]:
"""Chạy một lượt chat thuần (không có tool) và phát nội dung dần ra ngoài.
Tự chèn prompt hệ thống nếu tin nhắn đầu chưa phải ``system``.
"""
if not messages or messages[0].get("role") != "system":
messages.insert(0, {"role": "system", "content": COWORK_SYSTEM_PROMPT})
# Rulebase: always attach security rules so the agent follows them every turn
_apply_security_rules(messages, load_rules())
def on_text(piece: str) -> None:
"""Đẩy từng mẩu câu trả lời ra ngoài."""
emit({"type": "text", "delta": piece})
def on_reasoning(piece: str) -> None:
# Stream the model's reasoning so the UI can show a live, collapsible
# "Thinking" box (and keep the indicator active).
"""Đẩy từng mẩu suy luận nội bộ ra ngoài, để giao diện hiện hộp "Đang nghĩ"."""
emit({"type": "reasoning", "delta": piece})
assistant = provider.chat(messages, tools=None, on_text=on_text, cancel=cancel,
@@ -388,6 +409,19 @@ 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
@@ -489,6 +523,18 @@ 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", "")})
@@ -528,16 +574,18 @@ 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.
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
# 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 name == "save_file":
result = _do_save_file(output_dir, title, args)
+63 -2
View File
@@ -18,6 +18,8 @@ existing ``core/skills.py`` registry.
"""
from __future__ import annotations
from ..infrastructure.persistence.json.atomic_json_file import AtomicJsonFile
import json
from dataclasses import asdict, dataclass, field
from pathlib import Path
@@ -52,6 +54,9 @@ RUN_MODES = ("auto", "plan", "manual")
def slugify(value: str) -> str:
"""Chuyển một chuỗi thành slug an toàn cho tên file: chỉ chữ/số/gạch, gộp gạch
liên tiếp. Rỗng thì trả về 'step' để không bao giờ sinh ra tên file trống.
"""
s = "".join(c if (c.isalnum() or c in "-_") else "-" for c in (value or "").strip().lower())
return "-".join(filter(None, s.split("-"))) or "step"
@@ -86,11 +91,13 @@ class Step:
@property
def is_parallel(self) -> bool:
"""Bước này có chạy nhiều sub-agent song song hay không."""
return self.variant == "parallel"
@dataclass
class Node:
"""Một node trên khung vẽ: id, toạ độ, và bước (:class:`Step`) mà nó đại diện."""
id: str
x: float = 0.0
y: float = 0.0
@@ -99,6 +106,7 @@ class Node:
@dataclass
class Edge:
"""Một cạnh nối hai node, quy định thứ tự chạy giữa chúng."""
id: str
source: str
target: str
@@ -106,6 +114,7 @@ class Edge:
@dataclass
class Workflow:
"""Một luồng Co4E: danh sách node, cạnh, và cờ đánh dấu đây có phải mẫu không."""
id: str
name: str = "Untitled flow"
is_template: bool = False
@@ -130,6 +139,10 @@ class CustomAgent:
# ---- (de)serialization ---------------------------------------------------
def step_from_dict(d: dict) -> Step:
"""Dựng :class:`Step` từ dict đọc trên đĩa.
Lọc bỏ khoá lạ để file luồng của phiên bản mới hơn không làm vỡ bản cũ.
"""
d = dict(d or {})
subs = d.pop("sub_agents", None) or []
known = Step().__dict__.keys()
@@ -143,11 +156,13 @@ def step_from_dict(d: dict) -> Step:
def node_from_dict(d: dict) -> Node:
"""Dựng :class:`Node` từ dict đọc trên đĩa."""
return Node(id=str(d.get("id", "")), x=float(d.get("x", 0) or 0),
y=float(d.get("y", 0) or 0), data=step_from_dict(d.get("data", {})))
def workflow_from_dict(d: dict) -> Workflow:
"""Dựng :class:`Workflow` từ dict đọc trên đĩa."""
return Workflow(
id=str(d.get("id", "")),
name=d.get("name", "Untitled flow"),
@@ -159,6 +174,7 @@ def workflow_from_dict(d: dict) -> Workflow:
def workflow_to_dict(wf: Workflow) -> dict:
"""Chuyển một luồng thành dict để ghi JSON."""
return {
"id": wf.id, "name": wf.name, "is_template": wf.is_template,
"nodes": [{"id": n.id, "x": n.x, "y": n.y, "data": _step_dict(n.data)} for n in wf.nodes],
@@ -167,16 +183,19 @@ def workflow_to_dict(wf: Workflow) -> dict:
def _step_dict(step: Step) -> dict:
"""Chuyển một bước thành dict; ``asdict`` đã tự chuyển ``sub_agents`` thành list dict."""
d = asdict(step)
# asdict already turns sub_agents into list[dict]
return d
def agent_to_dict(a: CustomAgent) -> dict:
"""Chuyển một agent tự tạo thành dict để ghi JSON."""
return asdict(a)
def agent_from_dict(d: dict) -> CustomAgent:
"""Dựng :class:`CustomAgent` từ dict, lọc bỏ khoá lạ."""
known = CustomAgent(id="").__dict__.keys()
d = {k: v for k, v in (d or {}).items() if k in known}
d.setdefault("id", "")
@@ -191,32 +210,43 @@ _counter = {"n": 0}
def _mint_id(prefix: str) -> str:
"""Sinh id tăng dần dạng ``<prefix>_000001``."""
_counter["n"] += 1
return f"{prefix}_{_counter['n']:06d}"
def new_node_id() -> str:
"""Id mới cho một node."""
return _mint_id("node")
def new_edge_id(source: str, target: str) -> str:
"""Id cạnh suy ra TỪ cặp nguồn/đích.
Cố ý không ngẫu nhiên: nhờ vậy nối lại đúng cặp node đó luôn cho ra cùng
một id, và không thể sinh ra hai cạnh trùng nhau.
"""
return f"e_{source}__{target}"
def new_workflow(name: str = "Untitled flow") -> Workflow:
"""Tạo một luồng rỗng với id mới."""
return Workflow(id=_mint_id("wf"), name=name)
def new_custom_agent(name: str = "") -> CustomAgent:
"""Tạo một agent tự tạo rỗng với id mới."""
return CustomAgent(id=_mint_id("agent"), name=name)
# ---- workflow store ------------------------------------------------------
def workflows_dir() -> Path:
"""Thư mục chứa file luồng."""
return WORKFLOWS_DIR
def list_workflows(directory: Optional[Path] = None) -> List[Workflow]:
"""Liệt kê mọi luồng đã lưu; thư mục chưa có thì trả list rỗng."""
directory = directory or WORKFLOWS_DIR
if not directory.exists():
return []
@@ -230,14 +260,18 @@ def list_workflows(directory: Optional[Path] = None) -> List[Workflow]:
def save_workflow(wf: Workflow, directory: Optional[Path] = None) -> Path:
"""Ghi một luồng ra ``<id>.json``, tự tạo thư mục nếu chưa có."""
directory = directory or WORKFLOWS_DIR
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{wf.id}.json"
path.write_text(json.dumps(workflow_to_dict(wf), ensure_ascii=False, indent=2), encoding="utf-8")
# Tiêu chí nghiệm thu A: mọi thao tác ghi tệp đi qua AtomicJsonFile. Trước
# đây ghi thẳng, nên tắt máy giữa lúc lưu là mất luôn workflow.
AtomicJsonFile(path).write(workflow_to_dict(wf))
return path
def get_workflow(wf_id: str, directory: Optional[Path] = None) -> Optional[Workflow]:
"""Đọc một luồng theo id; ``None`` nếu không có."""
directory = directory or WORKFLOWS_DIR
path = directory / f"{wf_id}.json"
if not path.exists():
@@ -276,6 +310,7 @@ def tr_copy_suffix() -> str:
def delete_workflow(wf_id: str, directory: Optional[Path] = None) -> None:
"""Xoá file luồng theo id; không có thì bỏ qua."""
directory = directory or WORKFLOWS_DIR
path = directory / f"{wf_id}.json"
if path.exists():
@@ -287,10 +322,12 @@ def delete_workflow(wf_id: str, directory: Optional[Path] = None) -> None:
# ---- custom-agent store --------------------------------------------------
def agents_dir() -> Path:
"""Thư mục chứa file agent tự tạo."""
return AGENTS_DIR
def list_custom_agents(directory: Optional[Path] = None) -> List[CustomAgent]:
"""Liệt kê mọi agent tự tạo; thư mục chưa có thì trả list rỗng."""
directory = directory or AGENTS_DIR
if not directory.exists():
return []
@@ -304,14 +341,16 @@ def list_custom_agents(directory: Optional[Path] = None) -> List[CustomAgent]:
def save_custom_agent(agent: CustomAgent, directory: Optional[Path] = None) -> Path:
"""Ghi một agent tự tạo ra ``<id>.json``."""
directory = directory or AGENTS_DIR
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{agent.id}.json"
path.write_text(json.dumps(agent_to_dict(agent), ensure_ascii=False, indent=2), encoding="utf-8")
AtomicJsonFile(path).write(agent_to_dict(agent))
return path
def delete_custom_agent(agent_id: str, directory: Optional[Path] = None) -> None:
"""Xoá file agent tự tạo theo id; không có thì bỏ qua."""
directory = directory or AGENTS_DIR
path = directory / f"{agent_id}.json"
if path.exists():
@@ -336,6 +375,11 @@ def compute_waves(nodes: List[Node], edges: List[Edge]) -> Dict[str, int]:
limit = len(nodes) + 1
def depth(nid: str, seen: frozenset) -> int:
"""Độ sâu của một node = lớp chạy của nó.
Có nhớ kết quả và chặn theo ``limit``: đồ thị có vòng sẽ khiến đệ quy chạy
mãi, nên gặp node đã thấy trong nhánh hiện tại thì dừng.
"""
if nid in wave:
return wave[nid]
if nid in seen or len(seen) > limit:
@@ -356,12 +400,14 @@ def connected_component_count(nodes: List[Node], edges: List[Edge]) -> int:
parent = {n.id: n.id for n in nodes}
def find(x):
"""Tìm gốc của một phần tử, kèm nén đường đi (union-find)."""
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(a, b):
"""Gộp hai tập hợp lại làm một (union-find)."""
ra, rb = find(a), find(b)
if ra != rb:
parent[ra] = rb
@@ -375,6 +421,7 @@ def connected_component_count(nodes: List[Node], edges: List[Edge]) -> int:
# ---- run-stage compilation ----------------------------------------------
@dataclass
class RunStage:
"""Một chặng chạy: ứng với một node, hoặc một nhánh song song / bước gộp của nó."""
id: str # node id, or "<node>__p<i>" / "<node>__pjoin"
node_id: str # which canvas node this stage maps back onto
wave: int
@@ -391,6 +438,7 @@ PLAN_MODE_PREAMBLE = (
def build_skills_block(skills: List[str], skill_map: Dict[str, str]) -> str:
"""Ghép nội dung các skill được chọn thành một khối chèn vào prompt."""
parts = []
for name in skills or []:
content = (skill_map.get(name) or "").strip()
@@ -403,6 +451,9 @@ def build_skills_block(skills: List[str], skill_map: Dict[str, str]) -> str:
def _shared_prompt_parts(step: Step, skill_map: Dict[str, str], extra_context: str) -> str:
"""Phần prompt dùng chung cho cả ba loại chặng: chỉ dẫn của bước, khối skill,
và ngữ cảnh thêm từ các bước trước.
"""
parts = []
if step.instructions.strip():
parts.append(step.instructions.strip())
@@ -419,6 +470,7 @@ def _shared_prompt_parts(step: Step, skill_map: Dict[str, str], extra_context: s
def build_step_prompt(step: Step, skill_map: Dict[str, str], extra_context: str = "") -> str:
"""Prompt cho một bước chạy tuần tự bình thường."""
head = f'You are the {step.role} agent for the workflow step "{step.label}".'
body = _shared_prompt_parts(step, skill_map, extra_context)
return f"{head}\n{body}".strip()
@@ -426,6 +478,11 @@ def build_step_prompt(step: Step, skill_map: Dict[str, str], extra_context: str
def build_subagent_prompt(step: Step, sub: SubAgent, peers: List[str],
skill_map: Dict[str, str], extra_context: str = "") -> str:
"""Prompt cho một sub-agent chạy song song.
Nói rõ nó đang chạy CÙNG LÚC với những ai và phải ở trong phạm vi của mình —
không có câu đó, các sub-agent hay làm chồng việc của nhau.
"""
peer_txt = ", ".join(p for p in peers if p) or "peers"
head = (f'You are the "{sub.agent}" agent working concurrently (in parallel with '
f'{peer_txt}) on the workflow step "{step.label}". Stay within your own scope.')
@@ -439,6 +496,7 @@ def build_subagent_prompt(step: Step, sub: SubAgent, peers: List[str],
def build_join_prompt(step: Step, skill_map: Dict[str, str], extra_context: str = "") -> str:
"""Prompt cho bước gộp: hợp nhất đầu ra của các sub-agent thành một kết quả."""
head = (f'You are the coordinator for the parallel step "{step.label}". Consolidate the '
f"outputs of the sub-agents (provided above as prior outputs) into one coherent result.")
body = _shared_prompt_parts(step, skill_map, extra_context)
@@ -457,6 +515,9 @@ def compile_run_stages(nodes: List[Node], edges: List[Edge],
stages: List[RunStage] = []
def finalize(prompt: str, preset: str) -> tuple:
"""Chốt prompt của một chặng: áp phạm vi theo preset, và thêm lời mở đầu chế
độ lập kế hoạch nếu đang chạy ở chế độ đó.
"""
scope = PRESET_SCOPES.get(preset)
if plan_mode:
prompt = PLAN_MODE_PREAMBLE + prompt
+1
View File
@@ -15,6 +15,7 @@ from .co4e import (
@dataclass
class BuiltinAgent:
"""Một agent dựng sẵn của Co4E: slug, tên, vai trò và prompt mặc định."""
slug: str
name: str
role: str
+45 -4
View File
@@ -13,6 +13,8 @@ the run that is currently open.
"""
from __future__ import annotations
from ..infrastructure.persistence.json.atomic_json_file import AtomicJsonFile
import json
from pathlib import Path
from typing import Dict, List, Optional
@@ -26,6 +28,7 @@ _HISTORY_CAP = 500 # keep the most-recent N runs on disk
def _now_str() -> str:
"""Mốc thời gian hiện tại dạng 'YYYY-MM-DD HH:MM' cho lịch sử run."""
from datetime import datetime
return datetime.now().strftime("%Y-%m-%d %H:%M")
@@ -42,6 +45,11 @@ class RunHandle:
def __init__(self, run_id: str, wf_id: str, name: str, total: int,
plan_mode: bool, manual: bool, created_by: str = "", created_at: str = "",
project_id: str = ""):
"""Một lượt chạy workflow đang sống trong bộ nhớ.
``total`` âm bị kẹp về 0 — số bước không thể âm, và để lọt xuống thì thanh
tiến độ vẽ ngược.
"""
self.id = run_id
self.wf_id = wf_id
self.name = name
@@ -62,9 +70,11 @@ class RunHandle:
@property
def running(self) -> bool:
"""Lượt chạy này còn đang chạy hay không."""
return self.status == "running"
def progress_text(self) -> str:
"""Chuỗi tiến độ 'xong/tổng'; chưa biết tổng thì hiện trạng thái."""
return f"{self.done}/{self.total}" if self.total else self.status
# ---- persistence ------------------------------------------------------
@@ -85,6 +95,7 @@ class RunHandle:
@classmethod
def from_record(cls, rec: dict) -> "RunHandle":
"""Dựng lại một ``RunHandle`` từ bản ghi đọc trong lịch sử trên đĩa."""
from .co4e import workflow_from_dict
rec = dict(rec or {})
h = cls(str(rec.get("id", "")), str(rec.get("wf_id", "")),
@@ -107,10 +118,18 @@ class RunHandle:
class Co4ERunManager(QObject):
"""Quản lý vòng đời nhiều lượt chạy luồng Co4E cùng lúc.
Flow Status lọc theo project, nên hầu hết truy vấn ở đây chỉ tính run thuộc
workspace ĐANG chọn — xem ``_belongs``.
"""
changed = Signal() # any run's status/progress changed → refresh views
event = Signal(str, dict) # (run_id, ev) — node-level events, for mirroring
def __init__(self, ctx):
"""Dựng bộ quản lý run và khôi phục lịch sử cũ ngay, để tab Flow Status có nội
dung ngay khi mở chứ không trống cho tới lần chạy đầu tiên.
"""
super().__init__()
self.ctx = ctx
self._runs: Dict[str, RunHandle] = {}
@@ -124,10 +143,12 @@ class Co4ERunManager(QObject):
# ---- persistence ------------------------------------------------------
def _history_path(self) -> Path:
"""Đường dẫn file lịch sử run."""
from .co4e import CO4E_DIR
return CO4E_DIR / "run_history.json"
def _load_history(self) -> None:
"""Khôi phục lịch sử run từ đĩa lúc khởi động; file hỏng thì bỏ qua lặng lẽ."""
path = self._history_path()
try:
data = json.loads(path.read_text(encoding="utf-8"))
@@ -147,20 +168,22 @@ class Co4ERunManager(QObject):
self._seq = max_seq # avoid minting ids that collide with history
def _save_history(self) -> None:
"""Ghi ``_HISTORY_CAP`` run gần nhất xuống đĩa."""
path = self._history_path()
runs = list(self._runs.values())[-_HISTORY_CAP:]
payload = {"runs": [h.to_record() for h in runs]}
try:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8")
tmp.replace(path) # atomic — never leaves a half-written file
# AtomicJsonFile thay cho tmp+replace tự viết: bản cũ thiếu fsync
# (dữ liệu có thể còn trong bộ đệm khi mất điện) và dùng thẳng
# Path.replace, vốn thỉnh thoảng bị Defender từ chối trên Windows.
AtomicJsonFile(path).write(payload)
except OSError:
pass
# ---- lifecycle --------------------------------------------------------
def _next_id(self) -> str:
"""Sinh id run kế tiếp dạng 'runN'."""
self._seq += 1
return f"run{self._seq}"
@@ -196,6 +219,7 @@ class Co4ERunManager(QObject):
run_label = handle.name
def job(worker: AgentWorker):
"""Chạy nền: thực thi luồng, chuyển tiếp sự kiện tiến độ và cờ huỷ."""
return co4e_runner.run_workflow(
ctx, nodes, edges, out_dir, worker.emit_event, worker.is_cancelled,
plan_mode=plan_mode, skill_map=sk, only_nodes=only, seed_outputs=seed,
@@ -213,6 +237,7 @@ class Co4ERunManager(QObject):
# ---- worker callbacks -------------------------------------------------
def _on_event(self, run_id: str, ev: dict) -> None:
"""Nhận sự kiện từ luồng đang chạy và cập nhật trạng thái/tiến độ của run."""
handle = self._runs.get(run_id)
if handle is not None and isinstance(ev, dict):
t = ev.get("type")
@@ -227,6 +252,10 @@ class Co4ERunManager(QObject):
self.event.emit(run_id, ev)
def _on_finished(self, run_id: str) -> None:
"""Job kết thúc mà không phát ``run_done``: chốt trạng thái về 'done'.
Lẽ ra không xảy ra, nhưng thiếu bước này thì run kẹt ở 'running' mãi.
"""
handle = self._runs.get(run_id)
if handle is not None and handle.status == "running":
# job returned without a run_done event (shouldn't happen) — settle it
@@ -234,6 +263,7 @@ class Co4ERunManager(QObject):
self.changed.emit()
def _on_failed(self, run_id: str, err: str) -> None:
"""Job ném lỗi: ghi lỗi vào bản ghi run và báo ra ngoài."""
handle = self._runs.get(run_id)
if handle is not None:
handle.status = "error"
@@ -243,6 +273,7 @@ class Co4ERunManager(QObject):
# ---- control ----------------------------------------------------------
def stop(self, run_id: str) -> None:
"""Yêu cầu dừng một run đang chạy."""
handle = self._runs.get(run_id)
if handle is not None and handle.worker is not None and handle.running:
handle.worker.request_stop()
@@ -251,6 +282,7 @@ class Co4ERunManager(QObject):
def stop_all(self) -> None:
# Only the CURRENT workspace's runs (Flow Status is per-project).
"""Dừng mọi run của workspace đang chọn."""
for run_id in [r for r, h in self._runs.items() if self._belongs(h)]:
self.stop(run_id)
@@ -267,6 +299,7 @@ class Co4ERunManager(QObject):
self.changed.emit()
def remove(self, run_id: str) -> None:
"""Xoá một run khỏi lịch sử; đang chạy thì dừng trước."""
handle = self._runs.get(run_id)
if handle is not None and handle.running:
self.stop(run_id)
@@ -275,6 +308,7 @@ class Co4ERunManager(QObject):
def clear_finished(self) -> None:
# Only clear finished runs of the CURRENT workspace.
"""Xoá mọi run đã kết thúc của workspace đang chọn, giữ nguyên run đang chạy."""
for run_id in [r for r, h in self._runs.items() if not h.running and self._belongs(h)]:
self._runs.pop(run_id, None)
self.changed.emit()
@@ -293,9 +327,11 @@ class Co4ERunManager(QObject):
return list(self._runs.values())
def get(self, run_id: str) -> Optional[RunHandle]:
"""Bản ghi của một run theo id; ``None`` nếu không có."""
return self._runs.get(run_id)
def active_count(self) -> int:
"""Số run đang chạy của workspace đang chọn."""
return sum(1 for h in self._runs.values() if h.running and self._belongs(h))
def set_current_project(self, project_id: str) -> None:
@@ -318,6 +354,11 @@ class Co4ERunManager(QObject):
# tab), not in the config/install folder. One subfolder per flow keeps
# runs tidy. Falls back to the global Cowork output dir when no workspace
# is selected.
"""Thư mục ghi kết quả của một luồng, tạo sẵn nếu chưa có.
Ưu tiên thư mục của workspace đang chọn để file rơi đúng chỗ người dùng làm
việc (màn Thư mục), không rơi vào thư mục cài đặt.
"""
from .co4e import slugify
base = self._output_root
if base is None:
+10
View File
@@ -29,6 +29,9 @@ CancelFn = Callable[[], bool]
def _predecessors(nodes: List[Node], edges: List[Edge]) -> Dict[str, List[str]]:
"""Bảng ``{node: các node đứng trước}`` — dùng để gom đầu ra của bước trước làm
ngữ cảnh cho bước sau.
"""
ids = {n.id for n in nodes}
preds: Dict[str, List[str]] = {n.id: [] for n in nodes}
for e in edges:
@@ -38,6 +41,7 @@ def _predecessors(nodes: List[Node], edges: List[Edge]) -> Dict[str, List[str]]:
def _label_of(nodes: List[Node], node_id: str) -> str:
"""Nhãn hiển thị của một node; trả về chính id nếu không tìm thấy."""
for n in nodes:
if n.id == node_id:
return n.data.label
@@ -62,6 +66,10 @@ def _attachments_text(node, out_dir=None) -> str:
parts, budget = [], _MAX_ATTACH_CHARS
def _read_into(path, label, indent=""):
"""Đọc một tệp đính kèm vào phần ngữ cảnh, trừ dần vào hạn mức ký tự chung.
Có hạn mức vì vài tệp lớn là đủ đẩy cả lượt chạy vượt cửa sổ ngữ cảnh.
"""
nonlocal budget
name = _P(path).name
if is_image(path):
@@ -97,6 +105,7 @@ def _attachments_text(node, out_dir=None) -> str:
def _last_assistant_text(messages: List[dict]) -> str:
"""Nội dung trả lời cuối cùng của assistant; '' nếu không có."""
for m in reversed(messages):
if m.get("role") == "assistant" and m.get("content"):
return str(m["content"])
@@ -245,6 +254,7 @@ def run_workflow(ctx, nodes: List[Node], edges: List[Edge], out_dir: Path,
# Group compiled stages by wave, preserving per-node context threading.
def extra_context_for(node_id: str) -> Dict[str, str]:
"""Ngữ cảnh thêm cho một bước: tệp đính kèm của nó cộng đầu ra của các bước đứng trước."""
parts = []
att = _attachments_text(by_id.get(node_id), out_dir)
if att:
+20 -4
View File
@@ -12,6 +12,8 @@ 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
@@ -29,6 +31,11 @@ _TOOL_LINE = re.compile(r"@@TOOL\s+(\w+)\s+(\{.*\})", re.DOTALL)
def code_system_prompt(workdir: Path, has_memory: bool = False, plan: bool = False,
has_plan_tool: bool = False, has_ms365: bool = False) -> str:
"""Prompt hệ thống cho Code agent, ghép theo năng lực thật của lượt chạy.
Chỉ liệt kê những tool đang BẬT, và thêm ghi chú chế độ lập kế hoạch khi cần —
nói với model về một tool nó không có sẽ khiến nó gọi rồi báo lỗi.
"""
names = ", ".join(t.name for t in TOOL_SPECS)
plan_note = ("PLAN MODE: only analyze and propose a detailed plan; do NOT write files or run "
"commands. When the user asks to gencode/implement, the app switches to ACT.\n"
@@ -225,6 +232,14 @@ 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)
@@ -297,10 +312,11 @@ def run_code(
agent_security.enforce_command(provider, name, args, security_config, emit,
agent_kind="code")
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
# 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 cancel():
return messages
+17
View File
@@ -26,6 +26,7 @@ _INDEX_TIMEOUT = 900
class CodebaseMemoryError(RuntimeError):
"""Lỗi khi gọi công cụ codebase-memory-mcp bên ngoài."""
pass
@@ -75,14 +76,24 @@ def _extract_json(text: str):
class CodebaseMemory:
"""Vỏ bọc quanh CLI ``codebase-memory-mcp``: đánh chỉ mục và tra cứu mã nguồn.
Đây là phần mềm ngoài, có thể không được cài — luôn kiểm :meth:`available`
trước khi dùng.
"""
def __init__(self, binary_path: str = ""):
"""Tìm file thực thi codebase-memory; không có thì ``available`` là False và
mọi lượt gọi về sau tự bỏ qua.
"""
self.binary = resolve_binary(binary_path)
@property
def available(self) -> bool:
"""Đã tìm thấy CLI trên máy chưa."""
return self.binary is not None
def _run(self, tool: str, args: Dict[str, Any], timeout: int) -> Dict[str, Any]:
"""Gọi một tool của CLI và trả kết quả JSON; chưa cài thì báo lỗi kèm hướng dẫn."""
if not self.binary:
raise CodebaseMemoryError(
"codebase-memory-mcp is not installed. See the instructions in Settings."
@@ -107,12 +118,15 @@ class CodebaseMemory:
# ---- high level ops ---------------------------------------------
def index_repository(self, repo_path: str) -> Dict[str, Any]:
"""Đánh chỉ mục một repository (chạy lâu — dùng hạn giờ dài hơn)."""
return self._run("index_repository", {"repo_path": str(repo_path)}, _INDEX_TIMEOUT)
def list_projects(self) -> Dict[str, Any]:
"""Danh sách project đã được đánh chỉ mục."""
return self._run("list_projects", {}, _QUERY_TIMEOUT)
def call(self, tool: str, args: Dict[str, Any]) -> Dict[str, Any]:
"""Gọi một tool bất kỳ, tự chọn hạn giờ theo loại việc."""
timeout = _INDEX_TIMEOUT if tool == "index_repository" else _QUERY_TIMEOUT
return self._run(tool, args, timeout)
@@ -187,6 +201,9 @@ def make_executor(mem: CodebaseMemory):
"""Return an executor(name, args) -> {ok, output} for cmem_* tools."""
def execute(name: str, args: Dict[str, Any]) -> Dict[str, Any]:
"""Bộ thực thi tool codebase-memory cho agent; tên tool lạ thì trả về lỗi thay
vì ném ngoại lệ.
"""
cli_tool = _CLI_NAME.get(name)
if not cli_tool:
return {"ok": False, "output": f"Unsupported codebase-memory tool: {name}"}
+16
View File
@@ -33,6 +33,9 @@ class CmemUiError(RuntimeError):
asset) — a different remedy than a generic startup/timeout failure."""
def __init__(self, message: str, no_ui_build: bool = False):
"""``no_ui_build`` đánh dấu trường hợp riêng: chạy được nhưng bản cài không kèm
phần giao diện — thông báo cho người dùng phải khác hẳn lỗi chạy thường.
"""
super().__init__(message)
self.no_ui_build = no_ui_build
@@ -41,16 +44,21 @@ class CodebaseMemoryUiServer:
"""One ``codebase-memory-mcp --ui`` process, started on demand."""
def __init__(self, binary_path: str = "", port: int = DEFAULT_PORT):
"""Chuẩn bị chỗ chạy máy chủ giao diện; chưa khởi động tiến trình nào."""
self.binary = resolve_binary(binary_path)
self.port = port
self._proc: Optional[subprocess.Popen] = None
@property
def url(self) -> str:
"""Địa chỉ để mở giao diện. Chỉ nghe trên 127.0.0.1 — đây là công cụ cục bộ,
không mở ra mạng.
"""
return f"http://127.0.0.1:{self.port}/"
@property
def running(self) -> bool:
"""Tiến trình máy chủ còn sống không."""
return self._proc is not None and self._proc.poll() is None
def start(self, repo_path: str = "") -> str:
@@ -75,6 +83,9 @@ class CodebaseMemoryUiServer:
no_ui_event = threading.Event()
def _reader() -> None:
"""Chạy nền: đọc đầu ra của tiến trình, giữ lại để báo lỗi và bật cờ khi thấy
dấu hiệu bản cài không có phần giao diện.
"""
try:
stream = self._proc.stdout
if stream is None:
@@ -111,6 +122,11 @@ class CodebaseMemoryUiServer:
raise CmemUiError(f"Hết thời gian chờ UI trên cổng {self.port}.")
def stop(self) -> None:
"""Dừng máy chủ. Xin dừng tử tế trước, quá 3 giây thì buộc tắt.
Mọi lỗi đều bị nuốt có chủ ý: đây là dọn dẹp lúc thoát, ném lỗi ở đây chỉ
làm kẹt đường thoát của cả ứng dụng.
"""
proc, self._proc = self._proc, None
if proc is not None and proc.poll() is None:
try:
+16
View File
@@ -33,6 +33,9 @@ _MODEL_LIMITS = {
def model_context_limit(model: str) -> int:
"""Cửa sổ ngữ cảnh (token) của một model, dò theo tiền tố tên dài nhất khớp
trong bảng; không khớp gì thì lấy ``DEFAULT_LIMIT``.
"""
m = (model or "").lower()
best = 0
limit = DEFAULT_LIMIT
@@ -43,6 +46,7 @@ def model_context_limit(model: str) -> int:
def _ctx_conf(config) -> Dict[str, Any]:
"""Nhóm cấu hình ``context``; không có config thì trả dict rỗng."""
if config is None:
return {}
try:
@@ -59,11 +63,13 @@ def context_limit(config, model: str = "") -> int:
def auto_compact_enabled(config) -> bool:
"""Có tự nén lịch sử khi gần đầy ngữ cảnh không (mặc định bật)."""
conf = _ctx_conf(config)
return bool(conf.get("auto_compact", True))
def threshold(config) -> float:
"""Ngưỡng nén, tính theo tỉ lệ cửa sổ ngữ cảnh đã dùng (mặc định 0,8)."""
conf = _ctx_conf(config)
try:
t = float(conf.get("compact_threshold", DEFAULT_THRESHOLD))
@@ -73,6 +79,9 @@ def threshold(config) -> float:
def _msg_text(m: Dict[str, Any]) -> str:
"""Rút phần văn bản của một tin nhắn, kể cả khi nội dung là danh sách block
(tin nhắn có ảnh).
"""
c = m.get("content", "")
if isinstance(c, str):
return c
@@ -81,11 +90,17 @@ def _msg_text(m: Dict[str, Any]) -> str:
def estimate_messages_tokens(messages: List[Dict[str, Any]]) -> int:
"""Ước lượng tổng token của cả danh sách tin nhắn."""
return sum(estimate_tokens(_msg_text(m)) for m in messages)
def should_compact(messages: List[Dict[str, Any]], limit: int,
thresh: float = DEFAULT_THRESHOLD) -> bool:
"""Đã đến lúc nén lịch sử chưa.
Không nén khi hội thoại còn quá ngắn: nén một cuộc mới vài lượt thì mất nội
dung mà chẳng tiết kiệm được bao nhiêu.
"""
if limit <= 0 or len(messages) <= _KEEP_RECENT + 2:
return False
return estimate_messages_tokens(messages) > limit * thresh
@@ -99,6 +114,7 @@ _SUMMARY_PROMPT = (
def _summarize(provider, middle: List[Dict[str, Any]], cancel=None) -> str:
"""Nhờ model tóm tắt phần giữa của hội thoại thành một đoạn ngắn."""
convo = "\n\n".join(f"[{m.get('role', '?')}] {_msg_text(m)}" for m in middle)
try:
a = provider.chat([{"role": "system", "content": _SUMMARY_PROMPT},
+15
View File
@@ -15,10 +15,14 @@ _SEARCH_DAYS = 366 * 2 # give up after two years (an expression that never fir
class CronError(ValueError):
"""Biểu thức cron sai cú pháp."""
pass
def _parse_field(spec: str, lo: int, hi: int) -> Set[int]:
"""Đọc một trường cron thành tập giá trị: hỗ trợ ``*``, danh sách ``a,b``,
khoảng ``a-b`` và bước ``*/n``.
"""
values: Set[int] = set()
for part in spec.split(","):
part = part.strip()
@@ -55,7 +59,13 @@ def _parse_field(spec: str, lo: int, hi: int) -> Set[int]:
class Cron:
"""Biểu thức cron 5 trường (phút, giờ, ngày, tháng, thứ)."""
def __init__(self, expression: str):
"""Phân tích một biểu thức cron 5 trường.
Sai số trường là ném ``CronError`` ngay tại đây chứ không đợi tới lúc chạy:
lịch sai giờ khó phát hiện hơn nhiều so với một lỗi lúc nhập.
"""
fields = (expression or "").split()
if len(fields) != 5:
raise CronError("Cron expression needs exactly 5 fields: "
@@ -69,6 +79,11 @@ class Cron:
self._dow_star = fields[4].strip() == "*"
def _day_matches(self, dt: datetime) -> bool:
"""Ngày này có khớp biểu thức không.
Theo chuẩn cron: khi cả trường NGÀY và trường THỨ đều được đặt cụ thể thì
khớp một trong hai là đủ (OR), chứ không phải cả hai (AND).
"""
if dt.month not in self.months:
return False
cron_dow = (dt.weekday() + 1) % 7 # Python Mon=0 → cron Sun=0
+25
View File
@@ -21,6 +21,14 @@ AGENTS_DIR = CONFIG_DIR / "agents"
@dataclass
class CustomAgent:
"""Một agent do người dùng tự tạo: tên, mô tả, prompt mặc định và tuỳ chọn
provider/model riêng.
Bỏ trống ``provider``/``model`` nghĩa là dùng theo bước gọi nó hoặc theo cấu
hình chung — nhờ vậy một agent viết một lần chạy được với mọi provider.
Đã được ``core/co4e.py`` thay thế; giữ lại làm bản đối chiếu.
"""
name: str
description: str = ""
prompt: str = "" # default task; a Flow sub-agent can still override it
@@ -29,16 +37,25 @@ class CustomAgent:
@property
def slug(self) -> str:
"""Tên rút gọn an toàn để đặt tên file, ví dụ "Trợ lý Code" -> "tro-ly-code".
Tên không còn ký tự hợp lệ nào thì rơi về "agent".
"""
keep = "-_"
s = "".join(c if (c.isalnum() or c in keep) else "-" for c in self.name.strip().lower())
return "-".join(filter(None, s.split("-"))) or "agent"
def agents_dir() -> Path:
"""Thư mục chứa file agent tự tạo."""
return AGENTS_DIR
def list_agents(directory: Path = AGENTS_DIR) -> List[CustomAgent]:
"""Đọc mọi agent trong thư mục, sắp theo tên file.
File hỏng bị bỏ riêng lẻ chứ không làm hỏng cả danh sách — một file sai
không được phép làm mất hết agent còn lại.
"""
if not directory.exists():
return []
agents: List[CustomAgent] = []
@@ -58,6 +75,11 @@ def list_agents(directory: Path = AGENTS_DIR) -> List[CustomAgent]:
def save_agent(agent: CustomAgent, directory: Path = AGENTS_DIR, old_name: str = "") -> Path:
"""Ghi một agent xuống đĩa.
Truyền ``old_name`` khi đổi tên: file cũ bị xoá trước, nếu không sẽ có hai
file cùng nội dung với hai tên khác nhau.
"""
directory.mkdir(parents=True, exist_ok=True)
if old_name and old_name != agent.name:
delete_agent(old_name, directory)
@@ -67,6 +89,9 @@ def save_agent(agent: CustomAgent, directory: Path = AGENTS_DIR, old_name: str =
def delete_agent(name: str, directory: Path = AGENTS_DIR) -> None:
"""Xoá file của một agent theo tên. Không có file thì thôi; lỗi xoá bị nuốt,
không chặn giao diện.
"""
path = directory / f"{CustomAgent(name=name).slug}.json"
if path.exists():
try:
+4
View File
@@ -18,15 +18,18 @@ _MAX_BYTES = 200_000
def icons_dir() -> Path:
"""Thư mục chứa icon do người dùng thêm."""
return ICONS_DIR
def slugify(name: str) -> str:
"""Định danh an toàn cho tên file icon; rỗng thì trả về 'icon'."""
s = "".join(c if (c.isalnum() or c in "-_") else "-" for c in (name or "").strip().lower())
return "-".join(filter(None, s.split("-"))) or "icon"
def list_custom(directory: Optional[Path] = None) -> List[str]:
"""Tên các icon tự thêm; thư mục chưa có thì trả list rỗng."""
directory = directory or ICONS_DIR
if not directory.exists():
return []
@@ -69,6 +72,7 @@ def add_from_file(path, name: str = "", directory: Optional[Path] = None) -> str
def delete_custom(name: str, directory: Optional[Path] = None) -> None:
"""Xoá một icon tự thêm; không có thì bỏ qua."""
directory = directory or ICONS_DIR
path = directory / f"{slugify(name)}.svg"
if path.exists():
+1
View File
@@ -18,6 +18,7 @@ _CDN_D3 = '<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.j
def build_html(graph) -> str:
"""Dựng trang HTML D3 cho đồ thị: nhét dữ liệu node/cạnh vào bản mẫu."""
html = TEMPLATE.read_text(encoding="utf-8")
# Inline a bundled d3 (offline) if present; else keep the CDN reference.
+13
View File
@@ -29,6 +29,7 @@ _ACTIVE_PIDS: set[int] = set()
def active_pids() -> List[int]:
"""Pid của các tiến trình con đang chạy — dùng để dọn sạch khi thoát app."""
with _active_pids_lock:
return sorted(_ACTIVE_PIDS)
@@ -142,6 +143,11 @@ def _run_cancellable_body(
proc: "subprocess.Popen", cancel: CancelFn, timeout: Optional[float],
on_output: Optional[Callable[[str], None]], limits: Optional[Dict[str, float]],
) -> Tuple[Optional[int], str, bool, bool, bool]:
"""Chạy một tiến trình con có thể huỷ giữa chừng, có hạn giờ và có giới hạn tài nguyên.
Trên Windows gắn tiến trình vào một Job Object để khi giết là giết cả cây
tiến trình con — giết mỗi tiến trình cha sẽ để lại đám con mồ côi.
"""
job_handle = None
if sys.platform == "win32":
from .win_job import assign_process, create_job_object
@@ -158,6 +164,11 @@ def _run_cancellable_body(
collected: Dict[str, list] = {"out": [], "err": []}
def _read_stream(stream, key: str) -> None:
"""Đọc một luồng đầu ra theo từng dòng ở luồng riêng.
Phải đọc song song stdout và stderr: đọc lần lượt sẽ kẹt khi tiến trình con
làm đầy bộ đệm của luồng còn lại.
"""
try:
for line in iter(stream.readline, ""):
collected[key].append(line)
@@ -239,6 +250,7 @@ def network_blocked_env(base_env: Optional[Dict[str, str]] = None) -> Dict[str,
def _can_pip() -> bool:
# A PyInstaller/py2exe build has no usable pip; don't attempt installs there.
"""Bản đóng gói (PyInstaller) không có pip dùng được — đừng thử cài gì ở đó."""
return not getattr(sys, "frozen", False)
@@ -267,6 +279,7 @@ def ensure_module(module: str, package: str | None = None):
def venv_python_path(venv_dir: Path) -> Path:
"""Đường dẫn tới ``python`` trong một virtualenv, khác nhau giữa Windows và POSIX."""
return venv_dir / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python")
+21
View File
@@ -23,6 +23,7 @@ IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".tiff", ".tif",
def is_image(path) -> bool:
"""Đuôi tệp này có phải ảnh không."""
return Path(path).suffix.lower() in IMAGE_EXTS
@@ -164,6 +165,10 @@ def extract_text(path, progress=None) -> tuple[str | None, str]:
# Office Open XML (docx / xlsx / pptx)
# --------------------------------------------------------------------------
def _docx(p: Path) -> str:
"""Trích văn bản từ .docx bằng cách đọc thẳng XML trong gói zip.
Không cần thư viện ngoài — .docx vốn là một file zip chứa XML.
"""
with zipfile.ZipFile(p) as z:
xml = z.read("word/document.xml").decode("utf-8", "replace")
out: list[str] = []
@@ -179,6 +184,7 @@ def _docx(p: Path) -> str:
def _pptx(p: Path) -> str:
"""Trích văn bản từ .pptx, đi theo đúng thứ tự slide."""
out: list[str] = []
with zipfile.ZipFile(p) as z:
slides = [n for n in z.namelist() if re.match(r"ppt/slides/slide\d+\.xml$", n)]
@@ -192,6 +198,11 @@ def _pptx(p: Path) -> str:
def _xlsx(p: Path) -> str:
"""Trích văn bản từ .xlsx, có phân giải bảng chuỗi dùng chung.
Excel lưu chuỗi trong một bảng riêng và ô chỉ giữ chỉ số — đọc thẳng ô sẽ ra
toàn số.
"""
with zipfile.ZipFile(p) as z:
names = z.namelist()
shared: list[str] = []
@@ -235,6 +246,7 @@ def _xlsx(p: Path) -> str:
# OpenDocument (odt / ods / odp)
# --------------------------------------------------------------------------
def _odf(p: Path) -> str:
"""Trích văn bản từ tài liệu OpenDocument (.odt/.ods/.odp)."""
with zipfile.ZipFile(p) as z:
xml = z.read("content.xml").decode("utf-8", "replace")
xml = re.sub(r"<text:line-break\s*/>", "\n", xml)
@@ -249,6 +261,10 @@ def _odf(p: Path) -> str:
# PDF + LibreOffice fallback
# --------------------------------------------------------------------------
def _pdf(p: Path, progress=None) -> tuple[str | None, str]:
"""Trích văn bản từ PDF bằng ``pypdf``, tự cài nếu thiếu.
Trả về (văn bản, ghi chú); văn bản là ``None`` khi không trích được.
"""
from .deps import ensure_module
# Auto-install pypdf when missing (no manual install needed); fall back to
@@ -369,6 +385,11 @@ def _office_com_to_pdf(src: Path, pdf: Path) -> str | None:
def _soffice_to_text(p: Path) -> tuple[str | None, str]:
"""Cách dự phòng cuối: nhờ LibreOffice chuyển tài liệu sang văn bản.
Dùng cho định dạng không có bộ đọc riêng; không cài LibreOffice thì trả về
lý do để chỗ gọi hiện ra.
"""
soffice = find_soffice()
if not soffice:
return None, "no extractor available (install LibreOffice)"
+4
View File
@@ -21,6 +21,10 @@ _RUN_PREVIEW_CHARS = 40
def _run_style(font) -> str:
"""Mô tả định dạng một đoạn chữ (đậm, nghiêng, cỡ, màu) thành chuỗi ngắn.
Dùng để AI sửa tài liệu mà vẫn giữ được định dạng gốc.
"""
bits: list[str] = []
try:
if font.name:
+11
View File
@@ -79,6 +79,7 @@ def new_connector(category: str, preset_id: str = "", name: str = "") -> Dict[st
def _redact(entry: Dict[str, Any]) -> Dict[str, Any]:
"""Bản sao đã che các trường nhạy cảm (khoá, token) — dùng khi ghi log/kiểm toán."""
out = dict(entry)
for k in _SENSITIVE_KEYS:
if out.get(k):
@@ -92,6 +93,11 @@ class RestApiConnector:
vendor documents without this app knowing that vendor's API shape."""
def __init__(self, entry: Dict[str, Any]):
"""Đọc một khai báo connector REST.
``base_url`` luôn được chuẩn hoá thành có đúng một dấu ``/`` ở cuối, để ghép
đường dẫn về sau không sinh ra ``//`` hay dính liền.
"""
self.id = entry.get("id") or entry.get("name", "")
self.display_name = entry.get("name") or self.id
self.base_url = (entry.get("base_url") or "").rstrip("/") + "/"
@@ -100,6 +106,9 @@ class RestApiConnector:
self.auth_scheme = entry.get("auth_scheme") or "Bearer"
def tool_spec(self) -> ToolSpec:
"""Khai báo tool để đưa cho model; tên tool có tiền tố là id connector nên hai
connector không đụng tên nhau.
"""
return ToolSpec(
name=f"{self.id}{_SEP}http_request",
description=(
@@ -122,6 +131,7 @@ class RestApiConnector:
)
def call(self, args: Dict[str, Any]) -> Dict[str, Any]:
"""Gọi API theo tham số model đưa ra, đi qua lớp TLS có ghim chứng chỉ nội bộ."""
from .tls_trust import request_any_method as tls_request
method = str(args.get("method", "GET")).upper()
@@ -153,6 +163,7 @@ class RestApiConnector:
return {"ok": ok, "output": f"HTTP {resp.status_code}\n{text}"}
def test_connection(self) -> Tuple[bool, str]:
"""Thử kết nối tới endpoint; trả về (thành công, thông điệp)."""
from .tls_trust import request as tls_request
if not self.base_url.strip("/"):
+24
View File
@@ -30,6 +30,7 @@ class SubAgent:
@dataclass
class FlowStep:
"""Một bước trong luồng cũ: prompt, skill áp dụng, và danh sách agent chạy song song."""
name: str
prompt: str = ""
skill: str = "" # skill name to apply on this step ("" = none)
@@ -44,11 +45,13 @@ class FlowStep:
@property
def is_parallel(self) -> bool:
"""Bước này có chạy nhiều agent song song hay không."""
return bool(self.parallel_agents)
@dataclass
class Flow:
"""Một luồng cũ: tên, mô tả, và danh sách bước chạy tuần tự."""
name: str
description: str = ""
steps: List[FlowStep] = field(default_factory=list)
@@ -77,6 +80,11 @@ class FlowRunStatus:
substeps: List[dict] = field(default_factory=list)
def state_of(self, i: int) -> str:
"""Trạng thái hiển thị của bước thứ ``i``: xong, đang chạy, lỗi hay còn chờ.
Chỉ bước ngay TRƯỚC con trỏ mới được đánh dấu lỗi — các bước xong trước đó
vẫn là xong.
"""
if i < self.done:
if self.last_error and i == self.done - 1:
return STEP_ERROR
@@ -97,11 +105,13 @@ class FlowRunStatus:
def _slug(name: str) -> str:
"""Định danh an toàn cho tên file, suy từ tên luồng."""
s = "".join(c if (c.isalnum() or c in "-_") else "-" for c in name.strip().lower())
return "-".join(filter(None, s.split("-"))) or "flow"
def flows_dir() -> Path:
"""Thư mục chứa file luồng cũ."""
return FLOWS_DIR
@@ -126,11 +136,13 @@ def default_req_to_demo() -> Flow:
def to_dict(flow: Flow) -> dict:
"""Chuyển một luồng thành dict để ghi JSON."""
return {"name": flow.name, "description": flow.description,
"steps": [asdict(s) for s in flow.steps]}
def from_dict(data: dict) -> Flow:
"""Dựng :class:`Flow` từ dict đọc trên đĩa, lọc bỏ khoá lạ."""
steps = []
for raw in data.get("steps", []):
raw = dict(raw)
@@ -142,6 +154,7 @@ def from_dict(data: dict) -> Flow:
def list_flows(directory: Path = FLOWS_DIR) -> List[Flow]:
"""Liệt kê mọi luồng đã lưu; thư mục chưa có thì trả list rỗng."""
if not directory.exists():
return []
flows: List[Flow] = []
@@ -154,6 +167,11 @@ def list_flows(directory: Path = FLOWS_DIR) -> List[Flow]:
def save_flow(flow: Flow, directory: Path = FLOWS_DIR, old_name: str = "") -> Path:
"""Ghi một luồng xuống đĩa.
Đổi tên thì XOÁ file cũ trước — tên file suy từ tên luồng, không xoá sẽ để
lại một bản sao dưới tên cũ.
"""
directory.mkdir(parents=True, exist_ok=True)
if old_name and old_name != flow.name:
delete_flow(old_name, directory)
@@ -163,6 +181,7 @@ def save_flow(flow: Flow, directory: Path = FLOWS_DIR, old_name: str = "") -> Pa
def delete_flow(name: str, directory: Path = FLOWS_DIR) -> None:
"""Xoá file luồng theo tên; không có thì bỏ qua."""
path = directory / f"{_slug(name)}.json"
if path.exists():
try:
@@ -269,19 +288,23 @@ class FlowRunner:
@property
def step_index(self) -> int:
"""Chỉ số bước đang chạy."""
return self._index
def current_step(self) -> Optional[FlowStep]:
"""Bước đang chạy; ``None`` khi đã hết bước."""
if 0 <= self._index < len(self.flow.steps):
return self.flow.steps[self._index]
return None
def start(self) -> FlowAction:
"""Bắt đầu chạy luồng và trả về hành động đầu tiên cần thực hiện."""
if self.current_step() is None:
return FlowAction(kind="done")
return self._step_action()
def _step_action(self) -> FlowAction:
"""Hành động cho bước hiện tại: chạy một agent, hay chia ra nhiều agent song song."""
step = self.current_step()
self._phase = "step"
if step.is_parallel:
@@ -326,6 +349,7 @@ class FlowRunner:
return self._advance(compact=compact)
def _advance(self, compact: bool) -> FlowAction:
"""Sang bước kế tiếp; hết bước thì báo luồng đã xong."""
self._index += 1
if self.current_step() is None:
return FlowAction(kind="done", compact=compact)
+18
View File
@@ -28,6 +28,12 @@ class GraphServer:
"""Lazy singleton-per-instance localhost server for the D3 graph page."""
def __init__(self) -> None:
"""Chuẩn bị máy chủ; chưa mở cổng nào.
Một token ngẫu nhiên được sinh ngay lúc này và mọi yêu cầu đều phải mang
nó: máy chủ nghe trên localhost, nhưng mọi tiến trình khác trên cùng máy đều
gọi được localhost.
"""
self._html = _PLACEHOLDER
self._token = secrets.token_urlsafe(16)
self._lock = threading.Lock()
@@ -37,6 +43,7 @@ class GraphServer:
# ---- content / callbacks ----------------------------------------
def set_html(self, html: str) -> None:
"""Đặt nội dung HTML sẽ phục vụ; có khoá vì luồng nền ghi còn luồng HTTP đọc."""
with self._lock:
self._html = html
@@ -47,10 +54,12 @@ class GraphServer:
# ---- lifecycle ----------------------------------------------------
@property
def running(self) -> bool:
"""Máy chủ có đang chạy không."""
return self._httpd is not None
@property
def url(self) -> str:
"""URL đầy đủ kèm token; '' nếu chưa chạy."""
if self._httpd is None:
return ""
port = self._httpd.server_address[1]
@@ -63,14 +72,22 @@ class GraphServer:
server = self
class Handler(BaseHTTPRequestHandler):
"""Handler HTTP: chỉ phục vụ đúng trang đồ thị, và chỉ khi token khớp."""
def log_message(self, *_a) -> None: # keep the GUI console silent
"""Tắt log của thư viện chuẩn — nếu không, console GUI bị ngập request."""
pass
def _authorized(self, query: dict) -> bool:
"""Kiểm token trong query, so sánh theo kiểu chống dò thời gian.
Máy chủ này nghe trên localhost nhưng vẫn cần token: mọi tiến trình khác
trên cùng máy đều gọi được nó.
"""
supplied = (query.get("t") or [""])[0]
return secrets.compare_digest(supplied, server._token)
def do_GET(self) -> None: # noqa: N802 - stdlib naming
"""Trả trang đồ thị khi token đúng; sai token thì trả 403."""
parsed = urlparse(self.path)
query = parse_qs(parsed.query)
if not self._authorized(query):
@@ -108,6 +125,7 @@ class GraphServer:
return self.url
def stop(self) -> None:
"""Dừng máy chủ và giải phóng cổng."""
httpd, self._httpd = self._httpd, None
if httpd is not None:
httpd.shutdown()
+7
View File
@@ -15,6 +15,7 @@ from typing import List, Optional
@dataclass
class Group:
"""Một nhóm người dùng: id, tên, và tài khoản quản trị nhóm."""
group_id: str
name: str
subadmin_username: str = ""
@@ -23,16 +24,19 @@ class Group:
def groups_dir(shared_dir: str) -> Path:
"""Thư mục chứa nhóm, nằm trong thư mục chia sẻ của đội."""
return Path(shared_dir).expanduser() / "groups"
def new_group(name: str, subadmin_username: str = "") -> Group:
"""Tạo một nhóm mới với id ngẫu nhiên và mốc thời gian tạo."""
return Group(group_id=uuid.uuid4().hex, name=name.strip() or "Group",
subadmin_username=subadmin_username,
created=datetime.now().isoformat(timespec="seconds"))
def save_group(group: Group, directory: Path) -> Path:
"""Ghi một nhóm ra ``<group_id>.json``."""
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{group.group_id}.json"
path.write_text(json.dumps(asdict(group), ensure_ascii=False, indent=2), encoding="utf-8")
@@ -40,6 +44,7 @@ def save_group(group: Group, directory: Path) -> Path:
def load_group(group_id: str, directory: Path) -> Optional[Group]:
"""Đọc một nhóm theo id; id được làm sạch trước để không thoát khỏi thư mục."""
safe_id = re.sub(r"[^\w\-]", "", group_id or "")
path = directory / f"{safe_id}.json"
if not path.exists():
@@ -53,6 +58,7 @@ def load_group(group_id: str, directory: Path) -> Optional[Group]:
def list_groups(directory: Path) -> List[Group]:
"""Liệt kê mọi nhóm trong thư mục; thư mục chưa có thì trả list rỗng."""
if not directory.exists():
return []
out: List[Group] = []
@@ -65,6 +71,7 @@ def list_groups(directory: Path) -> List[Group]:
def delete_group(group_id: str, directory: Path) -> bool:
"""Xoá file nhóm; id rỗng hoặc không có file thì trả ``False``."""
safe_id = re.sub(r"[^\w\-]", "", group_id or "")
if not safe_id:
return False
+30 -6
View File
@@ -16,14 +16,17 @@ from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List
from ..config import HISTORY_DIR
def new_session_id() -> str:
"""Id phiên mới theo mốc thời gian, chính xác tới mili giây."""
return datetime.now().strftime("%Y%m%d-%H%M%S-%f")[:-3]
def derive_title(messages: List[Dict[str, Any]]) -> str:
"""Suy tiêu đề hội thoại từ tin nhắn đầu tiên của người dùng.
Dùng khi người dùng chưa tự đặt tên — cắt gọn cho vừa một dòng danh sách.
"""
for m in messages:
if m.get("role") == "user" and m.get("content"):
text = " ".join(m["content"].split())
@@ -42,6 +45,12 @@ def save_conversation(
outputs: List[str] | None = None,
project_id: str = "",
) -> Path:
"""Ghi một hội thoại xuống ``<kind>__<session_id>.json``.
Ghi nguyên tử (R06-T02). Cờ ghim và project_id của lần lưu trước được GIỮ
LẠI: hàm này bị gọi tự động sau mỗi lượt chat, ghi đè chúng sẽ âm thầm bỏ
ghim và đẩy hội thoại ra khỏi project của nó.
"""
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{kind}__{session_id}.json"
pinned = False # preserve pin flag + project across autosaves
@@ -66,11 +75,14 @@ def save_conversation(
"outputs": list(outputs or []),
"messages": messages,
}
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
# R06-T02: atomic write - see infrastructure/persistence/json/atomic_write.py.
from ..infrastructure.persistence.json.atomic_write import write_json
write_json(path, payload)
return path
def delete_conversation(path) -> None:
"""Xoá file hội thoại; không có thì bỏ qua."""
try:
Path(path).unlink()
except OSError:
@@ -78,18 +90,27 @@ def delete_conversation(path) -> None:
def rename_conversation(path, new_title: str) -> None:
"""Đổi tiêu đề một hội thoại và ghi lại (nguyên tử)."""
from ..infrastructure.persistence.json.atomic_write import write_json
data = load_conversation(path)
data["title"] = new_title
Path(path).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
write_json(Path(path), data)
def set_pinned(path, pinned: bool) -> None:
"""Ghim/bỏ ghim một hội thoại để nó nằm trên đầu danh sách."""
from ..infrastructure.persistence.json.atomic_write import write_json
data = load_conversation(path)
data["pinned"] = bool(pinned)
Path(path).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
write_json(Path(path), data)
def load_conversation(path: Path) -> Dict[str, Any]:
"""Đọc một hội thoại; file hỏng hoặc không đọc được thì trả về dict rỗng thay
vì ném lỗi — một file hỏng không được phép làm chết cả danh sách lịch sử.
"""
try:
data = json.loads(Path(path).read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
@@ -111,13 +132,16 @@ def _matches_query(query: str, title: str, messages: List[Dict[str, Any]]) -> bo
return False
def list_conversations(directory: Path = HISTORY_DIR, query: str = "") -> List[Dict[str, Any]]:
def list_conversations(directory: Optional[Path] = None, query: str = "") -> List[Dict[str, Any]]:
"""List saved conversations, most recent first (pinned always on top).
``query`` (from the sidebar's search box), when non-empty, keeps only
conversations whose title OR any message's content contains it
(case-insensitive) — since every file is already parsed to build the
metadata below, this search costs no extra I/O over listing alone."""
if directory is None:
from ..config import HISTORY_DIR
directory = HISTORY_DIR
if not directory or not directory.exists():
return []
q = (query or "").strip().lower()
+5
View File
@@ -44,6 +44,11 @@ def _package_calendar(country: str, year: int):
def is_holiday(d: date, country: Optional[str]) -> bool:
"""Ngày này có phải ngày nghỉ của một quốc gia không.
Không đặt quốc gia thì luôn trả ``False`` — không suy đoán lịch nghỉ thay
người dùng.
"""
country = (country or "").strip().upper()
if not country:
return False
+4
View File
@@ -28,6 +28,10 @@ _IMAGE_MODEL_MARKERS = (
def looks_like_image_model(name: str) -> bool:
"""Đoán một model có sinh ảnh được không, dựa trên dấu hiệu trong tên.
Đoán theo tên vì không provider nào khai báo năng lực này qua API.
"""
n = (name or "").lower()
return any(m in n for m in _IMAGE_MODEL_MARKERS)
+4
View File
@@ -20,11 +20,13 @@ _KEY_RE = re.compile(r"\b([A-Z][A-Z0-9]+-\d+)\b")
def _conf(config: Dict[str, Any] | None) -> Dict[str, str]:
"""Ba trường cấu hình Jira đã cắt khoảng trắng: base_url, email, api_token."""
return {k: str((config or {}).get(k, "") or "").strip()
for k in ("base_url", "email", "api_token")}
def configured(config: Dict[str, Any] | None) -> bool:
"""Đã cấu hình đủ ba trường để gọi Jira chưa."""
c = _conf(config)
return bool(c["base_url"] and c["email"] and c["api_token"])
@@ -82,6 +84,7 @@ def get_issue_by_url(config: Dict[str, Any] | None, url: str) -> str:
def _get(config: Dict[str, Any], path: str, params: dict = None):
"""Gọi Jira REST API bằng xác thực cơ bản, qua lớp TLS có ghim chứng chỉ nội bộ."""
from . import tls_trust
c = _conf(config)
@@ -97,6 +100,7 @@ def _get(config: Dict[str, Any], path: str, params: dict = None):
def _fmt_issue(it: dict) -> str:
"""Một dòng tóm tắt issue: mã, trạng thái và tiêu đề."""
f = it.get("fields", {}) or {}
status = (f.get("status") or {}).get("name", "?")
assignee = (f.get("assignee") or {}).get("displayName", "unassigned")
+7
View File
@@ -48,6 +48,9 @@ _DOC_SUFFIXES = {".pdf", ".doc", ".docx", ".docm", ".xls", ".xlsx", ".xlsm",
def _html_to_text(html: str) -> str:
"""Rút văn bản đọc được từ HTML: bỏ script/style, đổi thẻ thành xuống dòng rồi
gộp khoảng trắng thừa.
"""
text = _SCRIPT_STYLE_RE.sub(" ", html)
text = _TAG_RE.sub("\n", text)
text = _WS_RE.sub(" ", text)
@@ -81,6 +84,10 @@ _ONEDRIVE_HOSTS = {"1drv.ms", "onedrive.live.com"}
def _is_share_link(url: str) -> bool:
"""Link này có phải link chia sẻ SharePoint/OneDrive không.
Loại link đó cần đi qua đường xác thực MS365 thay vì tải HTTP thường.
"""
host = (urlparse(url).hostname or "").lower()
return bool(_SHAREPOINT_HOST_RE.search(host)) or host in _ONEDRIVE_HOSTS
+25
View File
@@ -27,6 +27,7 @@ _SEP = "__"
class McpServerError(RuntimeError):
"""Lỗi khi nối hoặc gọi một MCP server."""
pass
@@ -35,6 +36,9 @@ class McpServerConnection:
def __init__(self, name: str, command: str, args: Optional[List[str]] = None,
env: Optional[Dict[str, str]] = None):
"""Ghi nhận cách khởi động một máy chủ MCP; chưa chạy tiến trình nào cho tới
lần dùng đầu tiên.
"""
self.name = name
self.command = command
self.args = list(args or [])
@@ -59,6 +63,7 @@ class McpServerConnection:
raise McpServerError(f"MCP server '{self.name}' failed to start: {self._start_error}")
def _run_loop(self) -> None:
"""Thân luồng nền: dựng vòng lặp asyncio riêng và giữ nó chạy."""
loop = asyncio.new_event_loop()
self._loop = loop
asyncio.set_event_loop(loop)
@@ -79,6 +84,7 @@ class McpServerConnection:
loop.close()
async def _connect(self) -> None:
"""Khởi động tiến trình con và bắt tay phiên MCP."""
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
@@ -93,6 +99,10 @@ class McpServerConnection:
self._session = session
async def _aclose(self) -> None:
"""Đóng các context đã mở theo THỨ TỰ NGƯỢC.
Đóng xuôi sẽ đóng transport trước phiên và treo ở bước dọn dẹp.
"""
for cm in reversed(self._cm_stack):
try:
await cm.__aexit__(None, None, None)
@@ -101,11 +111,19 @@ class McpServerConnection:
self._cm_stack.clear()
def stop(self) -> None:
"""Dừng kết nối: tắt vòng lặp asyncio và chờ luồng nền kết thúc."""
if self._loop is not None and self._loop.is_running():
self._loop.call_soon_threadsafe(self._loop.stop)
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
@@ -134,6 +152,10 @@ class McpServerConnection:
return {"ok": ok, "output": output}
def _run_coro(self, coro):
"""Chạy một coroutine trên vòng lặp của kết nối và chờ kết quả.
Đây là cầu nối duy nhất giữa mã đồng bộ của app và phiên MCP bất đồng bộ.
"""
if self._loop is None:
raise McpServerError(f"MCP server '{self.name}' is not connected")
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
@@ -159,6 +181,9 @@ def build_mcp_tools(servers: List[McpServerConnection]) -> Tuple[List[ToolSpec],
return [], None
def executor(name: str, args: Dict[str, Any]) -> Dict[str, Any]:
"""Bộ thực thi cho tool MCP: định tuyến theo tên về đúng server và ghi nhật ký
kiểm toán cho mỗi lần gọi.
"""
from . import audit_log
server = routing.get(name)
+27 -3
View File
@@ -32,6 +32,14 @@ _SYMBOL_CCY = {"₫": "VND", "vnd": "VND", "đ": "VND",
_DEFAULT_UNIT = "Million tokens"
# Flat USD/1M-token fallback rates used by turn_cost_usd() when a model isn't
# in the price table. Owned here (not usage_tracker.DEFAULT_PRICING) so this
# module never needs to import usage_tracker — usage_tracker imports this
# module instead, keeping the dependency one-directional. Values match
# usage_tracker.DEFAULT_PRICING's price_per_mtok_in_usd/out_usd exactly.
_FALLBACK_RATE_IN_USD = 0.5
_FALLBACK_RATE_OUT_USD = 1.5
# ---- currency ------------------------------------------------------------
def _rates(config) -> Dict[str, float]:
@@ -57,6 +65,9 @@ _DIGITS = {"VND": 0, "JPY": 1, "USD": 4}
def format_price(amount: float, ccy: str) -> str:
"""Định dạng số tiền kèm ký hiệu tiền tệ, số chữ số thập phân theo từng loại
tiền (VND 0, JPY 1, USD 4).
"""
ccy = (ccy or "USD").upper()
return f"{amount:,.{_DIGITS.get(ccy, 2)}f} {_SYMBOLS.get(ccy, '')}".strip()
@@ -91,14 +102,21 @@ def parse_price(text: Any) -> tuple:
# ---- store ---------------------------------------------------------------
def _bucket(config) -> Dict[str, Any]:
"""Nhóm cấu hình ``model_pricing``; tự tạo nếu chưa có."""
return config.data.setdefault("model_pricing", {})
def list_entries(config) -> List[Dict[str, Any]]:
"""Danh sách dòng đơn giá đã lưu (bản sao, sửa không ảnh hưởng cấu hình)."""
return list(_bucket(config).get("entries", []) or [])
def save_entries(config, entries: List[Dict[str, Any]]) -> None:
"""Ghi lại toàn bộ bảng đơn giá và đồng bộ sang bộ tính chi phí.
Đồng bộ ngay tại đây để Tổng quan và Dashboard không hiện số tiền tính theo
bảng giá cũ.
"""
_bucket(config)["entries"] = [dict(e) for e in entries]
sync_to_usage(config) # keep the cost engine (Overview + Dashboard) in sync
@@ -107,6 +125,7 @@ def _norm_entry(model: str, ctx_len: str = "", max_out: str = "",
in_price=0.0, in_ccy: Optional[str] = None, in_unit: str = _DEFAULT_UNIT,
out_price=0.0, out_ccy: Optional[str] = None, out_unit: str = _DEFAULT_UNIT,
default_ccy: str = "USD") -> Dict[str, Any]:
"""Chuẩn hoá một dòng đơn giá về đúng khuôn lưu trữ, điền mặc định cho ô trống."""
return {
"model": str(model).strip(),
"context_length": str(ctx_len).strip(),
@@ -138,9 +157,11 @@ def turn_cost_usd(model: str, in_tok: int, out_tok: int, config) -> float:
switches models (a different model → its own row / rates)."""
rates = usd_rates_for(model, config)
if rates is None:
from . import usage_tracker as ut
p = {**ut.DEFAULT_PRICING, **((getattr(config, "data", {}) or {}).get("usage") or {})}
rates = {"in": float(p["price_per_mtok_in_usd"]), "out": float(p["price_per_mtok_out_usd"])}
usage = (getattr(config, "data", {}) or {}).get("usage") or {}
rates = {
"in": float(usage.get("price_per_mtok_in_usd", _FALLBACK_RATE_IN_USD)),
"out": float(usage.get("price_per_mtok_out_usd", _FALLBACK_RATE_OUT_USD)),
}
return (in_tok or 0) / 1e6 * rates["in"] + (out_tok or 0) / 1e6 * rates["out"]
@@ -179,6 +200,7 @@ def format_tokens(n: int) -> str:
def add_entry(config, entry: Dict[str, Any]) -> None:
"""Thêm một dòng đơn giá; đã có model đó thì THAY THẾ chứ không thêm trùng."""
entries = list_entries(config)
entries = [e for e in entries if e.get("model") != entry.get("model")] # replace same model
entries.append(entry)
@@ -243,6 +265,7 @@ def import_table(path: str | Path, default_ccy: str = "USD") -> List[Dict[str, A
def _rows_from_xlsx(path: Path) -> List[List[Any]]:
"""Đọc các dòng từ file Excel (lấy giá trị đã tính, không lấy công thức)."""
from openpyxl import load_workbook
try:
wb = load_workbook(str(path), data_only=True)
@@ -253,6 +276,7 @@ def _rows_from_xlsx(path: Path) -> List[List[Any]]:
def _rows_from_csv(path: Path) -> List[List[Any]]:
"""Đọc các dòng từ file CSV, chấp nhận BOM của Excel."""
try:
text = path.read_text(encoding="utf-8-sig")
except OSError as exc:
+10
View File
@@ -57,10 +57,12 @@ SCOPES: List[str] = [
class Ms365AuthError(Exception):
"""Lỗi khi đăng nhập hoặc lấy token Microsoft 365."""
pass
def _load_cache():
"""Nạp kho token đã lưu từ đĩa (nếu có)."""
import msal
cache = msal.SerializableTokenCache()
@@ -84,6 +86,7 @@ def _load_cache():
def _save_cache(cache) -> None:
"""Ghi kho token xuống đĩa, chỉ khi nó thật sự thay đổi."""
if not cache.has_state_changed:
return
serialized = cache.serialize()
@@ -105,6 +108,9 @@ def _save_cache(cache) -> None:
def _app(tenant_id: str, client_id: str):
"""Dựng ứng dụng MSAL cho tenant/client đã cấu hình; thiếu ``msal`` thì báo lỗi
kèm hướng dẫn cài.
"""
try:
import msal
except ImportError as exc:
@@ -192,6 +198,7 @@ def get_access_token(tenant_id: str, client_id: str) -> str:
# The UI calls these with no args for the "connect like Claude" flow; they read
# the optional config overrides so a custom Azure app still works.
def _ids(config=None):
"""Cặp (tenant_id, client_id) đọc từ cấu hình MS365."""
ms365 = (config.ms365 if config is not None else {}) or {}
return ms365.get("tenant_id", ""), ms365.get("client_id", "")
@@ -203,6 +210,7 @@ def current_identity(config=None) -> str:
def is_signed_in(config=None) -> bool:
"""Đã có tài khoản MS365 đăng nhập sẵn hay chưa."""
return signed_in_account(*_ids(config)) is not None
@@ -213,10 +221,12 @@ def sign_in(on_code: Callable[[dict], None], config=None) -> dict:
def sign_out_default(config=None) -> None:
"""Đăng xuất tài khoản MS365 theo cấu hình hiện tại."""
sign_out(*_ids(config))
def sign_out(tenant_id: str, client_id: str) -> None:
"""Đăng xuất và xoá token của một tenant/client khỏi kho."""
try:
app, cache = _app(tenant_id, client_id)
for acc in app.get_accounts():
+24
View File
@@ -22,14 +22,17 @@ TIMEOUT = 30
class Ms365GraphError(Exception):
"""Lỗi khi gọi Microsoft Graph API."""
pass
class TeamsLinkError(Exception):
"""Link Teams không phân giải được thành team/channel/chat hợp lệ."""
pass
def _headers(token: str, extra: Optional[dict] = None) -> Dict[str, str]:
"""Header cho một lượt gọi Graph: Bearer token cộng phần thêm (nếu có)."""
h = {"Authorization": f"Bearer {token}"}
if extra:
h.update(extra)
@@ -37,6 +40,9 @@ def _headers(token: str, extra: Optional[dict] = None) -> Dict[str, str]:
def _request(method: str, url: str, token: str, **kwargs) -> requests.Response:
"""Gọi Graph API, tự ghép ``GRAPH_BASE`` cho đường dẫn tương đối và đổi lỗi HTTP
thành :class:`Ms365GraphError` kèm thông điệp đọc được.
"""
if not url.startswith("http"):
url = f"{GRAPH_BASE}{url}"
headers = _headers(token, kwargs.pop("headers", None))
@@ -73,6 +79,7 @@ def _path_segment(path: str) -> str:
# ---- Outlook ---------------------------------------------------------------
def list_mail(token: str, top: int = 10, folder: str = "inbox") -> List[dict]:
"""Danh sách thư trong một thư mục hộp thư (mặc định Inbox)."""
resp = _request("GET", f"/me/mailFolders/{quote(folder)}/messages"
f"?$top={int(top)}&$select=subject,from,receivedDateTime,bodyPreview,webLink",
token)
@@ -80,6 +87,7 @@ def list_mail(token: str, top: int = 10, folder: str = "inbox") -> List[dict]:
def send_mail(token: str, to: str, subject: str, body: str) -> None:
"""Gửi một email qua tài khoản đang đăng nhập."""
payload = {
"message": {
"subject": subject,
@@ -91,6 +99,7 @@ def send_mail(token: str, to: str, subject: str, body: str) -> None:
def list_calendar_events(token: str, top: int = 10) -> List[dict]:
"""Danh sách sự kiện lịch sắp tới, xếp theo thời gian bắt đầu."""
resp = _request("GET", f"/me/events?$top={int(top)}"
"&$select=subject,start,end,organizer,location&$orderby=start/dateTime",
token)
@@ -99,38 +108,45 @@ def list_calendar_events(token: str, top: int = 10) -> List[dict]:
# ---- Teams ------------------------------------------------------------------
def list_teams(token: str) -> List[dict]:
"""Các team mà người dùng đang tham gia."""
resp = _request("GET", "/me/joinedTeams", token)
return resp.json().get("value", [])
def list_channels(token: str, team_id: str) -> List[dict]:
"""Các kênh trong một team."""
resp = _request("GET", f"/teams/{quote(team_id)}/channels", token)
return resp.json().get("value", [])
def list_channel_messages(token: str, team_id: str, channel_id: str, top: int = 20) -> List[dict]:
"""Tin nhắn gần đây trong một kênh."""
resp = _request("GET", f"/teams/{quote(team_id)}/channels/{quote(channel_id)}/messages"
f"?$top={int(top)}", token)
return resp.json().get("value", [])
def send_channel_message(token: str, team_id: str, channel_id: str, text: str) -> None:
"""Gửi tin nhắn vào một kênh Teams."""
payload = {"body": {"content": text}}
_request("POST", f"/teams/{quote(team_id)}/channels/{quote(channel_id)}/messages", token,
json=payload)
def get_channel(token: str, team_id: str, channel_id: str) -> dict:
"""Thông tin một kênh Teams."""
resp = _request("GET", f"/teams/{quote(team_id)}/channels/{quote(channel_id)}", token)
return resp.json()
def get_chat(token: str, chat_id: str) -> dict:
"""Thông tin một cuộc trò chuyện Teams."""
resp = _request("GET", f"/chats/{quote(chat_id)}", token)
return resp.json()
def send_chat_message(token: str, chat_id: str, text: str) -> None:
"""Gửi tin nhắn vào một cuộc trò chuyện Teams."""
_request("POST", f"/chats/{quote(chat_id)}/messages", token, json={"body": {"content": text}})
@@ -160,17 +176,20 @@ def parse_teams_link(url: str) -> Dict[str, str]:
# ---- OneDrive -----------------------------------------------------------
def list_onedrive_files(token: str, path: str = "") -> List[dict]:
"""Liệt kê tệp/thư mục trong OneDrive; ``path`` rỗng là thư mục gốc."""
url = "/me/drive/root/children" if not path else f"/me/drive/root:/{_path_segment(path)}:/children"
resp = _request("GET", url, token)
return resp.json().get("value", [])
def read_onedrive_file(token: str, path: str, max_chars: int = 50_000) -> str:
"""Đọc nội dung một tệp OneDrive dưới dạng văn bản, cắt ở ``max_chars``."""
resp = _request("GET", f"/me/drive/root:/{_path_segment(path)}:/content", token)
return resp.content.decode("utf-8", errors="replace")[:max_chars]
def write_onedrive_file(token: str, path: str, content: str) -> dict:
"""Ghi nội dung văn bản vào một tệp OneDrive (tạo mới hoặc ghi đè)."""
resp = _request("PUT", f"/me/drive/root:/{_path_segment(path)}:/content", token,
data=content.encode("utf-8"),
headers={"Content-Type": "text/plain"})
@@ -197,11 +216,13 @@ def read_shared_file(token: str, share_url: str, max_chars: int = 50_000) -> str
# ---- SharePoint --------------------------------------------------------
def list_sharepoint_sites(token: str, query: str) -> List[dict]:
"""Tìm site SharePoint theo từ khoá."""
resp = _request("GET", f"/sites?search={quote(query)}", token)
return resp.json().get("value", [])
def list_sharepoint_files(token: str, site_id: str, path: str = "") -> List[dict]:
"""Liệt kê tệp/thư mục trong thư viện tài liệu của một site SharePoint."""
url = (f"/sites/{quote(site_id)}/drive/root/children" if not path
else f"/sites/{quote(site_id)}/drive/root:/{_path_segment(path)}:/children")
resp = _request("GET", url, token)
@@ -210,18 +231,21 @@ def list_sharepoint_files(token: str, site_id: str, path: str = "") -> List[dict
# ---- Teams meeting transcripts ------------------------------------------
def find_online_meeting(token: str, join_url: str) -> List[dict]:
"""Tìm cuộc họp online theo link tham gia."""
resp = _request("GET", f"/me/onlineMeetings?$filter=JoinWebUrl eq '{quote(join_url, safe='')}'",
token)
return resp.json().get("value", [])
def list_meeting_transcripts(token: str, meeting_id: str) -> List[dict]:
"""Danh sách bản ghi lời thoại của một cuộc họp."""
resp = _request("GET", f"/me/onlineMeetings/{quote(meeting_id)}/transcripts", token)
return resp.json().get("value", [])
def get_meeting_transcript_content(token: str, meeting_id: str, transcript_id: str,
max_chars: int = 50_000) -> str:
"""Nội dung một bản ghi lời thoại, cắt ở ``max_chars``."""
resp = _request(
"GET",
f"/me/onlineMeetings/{quote(meeting_id)}/transcripts/{quote(transcript_id)}/content"
+8
View File
@@ -28,10 +28,12 @@ _PREFIX = "ms365_local"
def _roots() -> List[Path]:
"""Mọi thư mục OneDrive tìm thấy trên máy."""
return paths.detect_onedrive_roots()
def _primary_root() -> Optional[Path]:
"""Thư mục OneDrive chính; ``None`` nếu không có."""
return paths.primary_onedrive_root()
@@ -45,6 +47,7 @@ def _resolve_under(root: Path, rel: str) -> Path:
def _list_dir(base: Path, rel: str) -> dict:
"""Liệt kê nội dung một thư mục con của OneDrive, chặn thoát ra ngoài gốc."""
target = _resolve_under(base, rel)
if not target.exists():
raise FileNotFoundError(f"Not found: {rel or '.'}")
@@ -60,6 +63,7 @@ def _list_dir(base: Path, rel: str) -> dict:
def _read_file(base: Path, rel: str) -> str:
"""Đọc một tệp trong OneDrive dưới dạng văn bản, chặn thoát ra ngoài gốc."""
target = _resolve_under(base, rel)
if not target.is_file():
raise FileNotFoundError(f"Not a file: {rel}")
@@ -68,6 +72,7 @@ def _read_file(base: Path, rel: str) -> str:
def _write_file(base: Path, rel: str, content: str) -> dict:
"""Ghi một tệp trong OneDrive, tự tạo thư mục cha, chặn thoát ra ngoài gốc."""
target = _resolve_under(base, rel)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content or "", encoding="utf-8")
@@ -125,6 +130,9 @@ def build_ms365_local_tools(config) -> Tuple[List[ToolSpec], Optional[Callable[[
]
def executor(name: str, args: dict) -> dict:
"""Bộ thực thi tool MS365 cục bộ (đọc/ghi thẳng thư mục OneDrive đồng bộ trên
máy, không cần đăng nhập Graph), ghi nhật ký kiểm toán cho mỗi lần gọi.
"""
ok = False
detail = ""
try:
+6
View File
@@ -168,6 +168,9 @@ _MAX_OUTPUT_CHARS = 20_000
def _dump(data: Any) -> str:
"""Kết quả tool dưới dạng JSON đã cắt ở ``_MAX_OUTPUT_CHARS`` — một hộp thư đầy
sẽ nuốt trọn cửa sổ ngữ cảnh nếu trả về nguyên vẹn.
"""
text = json.dumps(data, ensure_ascii=False, indent=2, default=str)
if len(text) > _MAX_OUTPUT_CHARS:
text = text[:_MAX_OUTPUT_CHARS] + f"\n…(truncated to {_MAX_OUTPUT_CHARS} chars)…"
@@ -197,6 +200,9 @@ def build_ms365_tools(config) -> Tuple[List[ToolSpec], Optional[Callable[[str, d
return [], None
def executor(name: str, args: dict) -> dict:
"""Bộ thực thi các tool MS365 (mail, lịch, Teams, OneDrive, SharePoint), gói lỗi
thành kết quả thay vì ném ra.
"""
args = args or {}
try:
token = get_access_token(tenant_id, client_id)
+13
View File
@@ -13,8 +13,19 @@ RequestFn = Callable[[Dict[str, Any]], None]
class PermissionGate:
"""Cổng phê duyệt tool: chặn lượt chạy lại và chờ người dùng đồng ý.
Ba chế độ: 'auto' cho qua hết, 'confirm' hỏi trước mỗi lệnh có rủi ro, và
'deny' chặn thẳng. Dùng ``threading.Event`` để luồng nền đứng chờ trong khi
luồng giao diện hiện hộp thoại.
"""
def __init__(self, mode: str = "confirm", on_request: Optional[RequestFn] = None,
agent_role: str = ""):
"""``mode`` quyết định cách xử: hỏi, cho qua hết, hay chặn hết.
``on_request`` là hàm hiện hộp thoại; để None (không có giao diện) thì cổng
rơi về quyết định mặc định của ``mode`` thay vì treo mãi.
"""
self.mode = mode
self.on_request = on_request
self.agent_role = agent_role
@@ -22,6 +33,7 @@ class PermissionGate:
self._approved = False
def set_mode(self, mode: str) -> None:
"""Đổi chế độ phê duyệt giữa chừng."""
self.mode = mode
def request(self, action: Dict[str, Any]) -> bool:
@@ -43,6 +55,7 @@ class PermissionGate:
return self._approved
def resolve(self, approved: bool) -> None:
"""Người dùng đã trả lời: ghi kết quả và đánh thức luồng đang chờ."""
self._approved = approved
self._event.set()
+10
View File
@@ -37,6 +37,7 @@ _KEEP_PREFIX = "(" # image values like "(keep …)" mean "don't change"
def is_available() -> bool:
"""Máy đã cài ``python-pptx`` chưa — không có thì mọi tính năng PowerPoint tắt."""
try:
import pptx # noqa: F401
return True
@@ -45,10 +46,12 @@ def is_available() -> bool:
def _in(emu) -> float:
"""Đổi đơn vị EMU của Office sang inch, làm tròn 2 chữ số."""
return round((emu or 0) / _EMU_PER_IN, 2)
def _kind(shape) -> str:
"""Loại hình khối trong slide: ảnh, bảng, biểu đồ hay hộp văn bản."""
from pptx.enum.shapes import MSO_SHAPE_TYPE
try:
if shape.shape_type == MSO_SHAPE_TYPE.PICTURE:
@@ -182,6 +185,11 @@ def _apply_font(shape, spec: str) -> bool:
def _parse(text: str) -> Dict[Tuple[int, int], dict]:
"""Đọc dạng văn bản đánh dấu của slide trở lại thành cấu trúc.
Đây là khuôn trung gian giữa PowerPoint và ô soạn thảo: người dùng (và AI)
sửa văn bản, hàm này dựng lại thành thao tác trên deck.
"""
blocks: Dict[Tuple[int, int], dict] = {}
cur: Tuple[int, int] | None = None
fields: dict = {}
@@ -189,6 +197,7 @@ def _parse(text: str) -> Dict[Tuple[int, int], dict]:
textbuf: List[str] = []
def _flush():
"""Chốt khối đang đọc dở và đưa vào kết quả."""
if cur is not None:
if in_text:
fields["text"] = "\n".join(textbuf).strip("\n")
@@ -221,6 +230,7 @@ def _parse(text: str) -> Dict[Tuple[int, int], dict]:
def _pair(val: str):
"""Đọc chuỗi 'a, b' thành cặp số (dùng cho toạ độ và kích thước)."""
try:
a, b = (x.strip() for x in val.split(",", 1))
return float(a), float(b)
+12 -3
View File
@@ -44,6 +44,11 @@ STARTER_PROJECT_NAME = "My Workspace"
@dataclass
class Project:
"""Một project: id, tên, mô tả, chỉ dẫn chung và thư mục sandbox.
Chỉ dẫn chung được chèn vào MỌI lượt chat thuộc project, nên đây là chỗ đặt
bối cảnh dùng lại thay vì gõ lại ở từng tin nhắn.
"""
project_id: str
name: str
description: str = ""
@@ -92,6 +97,7 @@ def ensure_starter_project(directory: Path = None) -> Project:
def _slugify(name: str) -> str:
"""Định danh an toàn cho tên file, suy từ tên project."""
s = "".join(c if (c.isalnum() or c in "-_") else "-" for c in name.strip().lower())
s = "-".join(filter(None, s.split("-")))
return s or "project"
@@ -115,11 +121,14 @@ def new_project(name: str, description: str = "", instructions: str = "",
def save_project(project: Project, directory: Path = None) -> Path:
"""Ghi một project ra ``<project_id>.json`` (ghi nguyên tử)."""
directory = directory or PROJECTS_DIR
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{project.project_id}.json"
path.write_text(json.dumps(asdict(project), ensure_ascii=False, indent=2),
encoding="utf-8")
# 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))
return path
+5
View File
@@ -73,6 +73,11 @@ LLMClassifier = Callable[[str], str]
def _heuristic_scores(text: str) -> dict[TaskType, int]:
"""Chấm điểm loại việc bằng từ khoá, không cần gọi model.
Bước lọc rẻ đứng trước bộ phân loại bằng AI: phần lớn câu hỏi phân loại được
ngay tại đây mà không tốn lượt gọi nào.
"""
low = (text or "").lower()
scores: dict[TaskType, int] = {tt: 0 for tt in TaskType}
for tt, entries in _COMPILED.items():
+6
View File
@@ -25,6 +25,7 @@ class CompletionResult:
@property
def ok(self) -> bool:
"""Lượt dò có thành công không (không có lỗi)."""
return self.error is None
@@ -37,6 +38,7 @@ class ProbeClient(Protocol):
model_id: str,
messages: List[Dict[str, Any]],
) -> CompletionResult:
"""Gọi một model và trả về kết quả kèm số token, độ trễ và lỗi (nếu có)."""
...
@@ -59,6 +61,7 @@ class AppProbeClient:
"""
def __init__(self, ctx: Any) -> None:
"""Giữ ``AppContext`` để dựng provider lúc cần thăm dò."""
self.ctx = ctx
def complete(
@@ -67,6 +70,9 @@ class AppProbeClient:
model_id: str,
messages: List[Dict[str, Any]],
) -> CompletionResult:
"""Gọi model qua provider thật; lỗi được gói vào kết quả chứ không ném ra —
một model hỏng không được làm dừng cả lượt chấm điểm danh mục.
"""
try:
prov = self.ctx.build_provider_for(provider, model_id or None)
# Non-streaming: no on_text/on_reasoning callbacks. cancel=None.
+1
View File
@@ -138,6 +138,7 @@ class ModelAssessment(BaseModel):
@property
def key(self) -> str:
"""Khoá định danh của model được chấm điểm (provider + model id)."""
return self.metadata.key
def fit_for(self, task_type: TaskType) -> float:
+3
View File
@@ -126,6 +126,9 @@ def check_and_update(
call_count = {"n": 0}
def _tick() -> None:
"""Một nhịp đếm trong lúc chờ người dùng xác nhận đổi model — đếm lùi và tự
quyết định khi hết giờ.
"""
call_count["n"] += 1
pairs = [(p, m) for (p, m, _tier) in candidates]
+12
View File
@@ -70,6 +70,7 @@ _SCORE_RE = re.compile(r'"score"\s*:\s*([0-9]*\.?[0-9]+)')
def _clamp01(x: float) -> float:
"""Chặn một số về khoảng 0..1."""
return min(1.0, max(0.0, float(x)))
@@ -108,6 +109,10 @@ def make_judge(
"""
def judge(task_type: TaskType, prompt: str, answer: str) -> float:
"""Chấm điểm câu trả lời của một model theo rubric, trả về điểm 0..1.
Cắt câu trả lời ở 4000 ký tự để một lượt chấm không tự nó tràn ngữ cảnh.
"""
rubric = _JUDGE_RUBRIC.format(
task=task_type.value, prompt=prompt, answer=(answer or "")[:4000]
)
@@ -160,11 +165,17 @@ class _PerProviderSemaphores:
"""Lazily-created, per-provider bounded semaphores for rate-limit safety."""
def __init__(self, limit: int) -> None:
"""Giới hạn số lượt thăm dò song song TRÊN MỖI provider.
Đếm riêng từng provider chứ không đếm chung: một provider chậm không được
phép chiếm hết suất của những provider còn lại.
"""
self._limit = max(1, int(limit))
self._sems: Dict[str, threading.Semaphore] = {}
self._lock = threading.Lock()
def get(self, provider: str) -> threading.Semaphore:
"""Semaphore của một provider, tạo lười ở lần dùng đầu."""
with self._lock:
sem = self._sems.get(provider)
if sem is None:
@@ -201,6 +212,7 @@ def probe_candidates(
results_lock = threading.Lock()
def _one(provider: str, model_id: str, task_type: TaskType) -> None:
"""Dò một cặp (provider, model) cho một loại việc, tôn trọng giới hạn song song."""
sem = sems.get(provider)
with sem:
if call_counter is not None:
+10
View File
@@ -33,6 +33,7 @@ class RoutingScheduler(QObject):
reassess_finished = Signal(int) # number of models assessed
def __init__(self, ctx: Any, service: Any, parent: Optional[QObject] = None) -> None:
"""Dựng bộ hẹn giờ chạy thăm dò định kỳ. Chưa chạy cho tới khi gọi ``start()``."""
super().__init__(parent)
self.ctx = ctx
self.service = service
@@ -49,16 +50,19 @@ class RoutingScheduler(QObject):
self._timer.start()
def stop(self) -> None:
"""Dừng hẹn giờ."""
self._timer.stop()
# -- tick ----------------------------------------------------------- #
def _interval_hours(self) -> float:
"""Chu kỳ chấm điểm lại, tính bằng giờ; giá trị lạ thì coi như tắt."""
try:
return float(self.ctx.config.routing.get("reassess_interval_hours", 24) or 0)
except Exception: # noqa: BLE001
return 24.0
def _hours_since_last(self) -> Optional[float]:
"""Số giờ kể từ lần chấm điểm gần nhất; ``None`` nếu chưa chấm lần nào."""
last = self.service.store.last_updated()
if not last:
return None # never assessed
@@ -87,6 +91,11 @@ class RoutingScheduler(QObject):
return False
def is_due(self) -> bool:
"""Đã đến lúc chấm điểm lại chưa.
Tắt định tuyến ở mọi bề mặt thì KHÔNG dò — dò model là lượt gọi có tính phí,
không được tiêu tiền cho một tính năng người dùng đã tắt.
"""
if not self._routing_enabled_anywhere():
return False # routing off everywhere → don't probe (would be wasted cost)
interval = self._interval_hours()
@@ -111,6 +120,7 @@ class RoutingScheduler(QObject):
self.reassess_started.emit()
def _done(result) -> None:
"""Chấm điểm xong: báo ra ngoài số model đã đánh giá."""
self.reassess_finished.emit(len(result or {}))
self.service.reassess_background(on_done=_done)
+7
View File
@@ -27,6 +27,7 @@ class RankedCandidate:
@property
def key(self) -> str:
"""Khoá định danh của ứng viên (provider + model)."""
return self.assessment.key
@@ -40,6 +41,7 @@ class Ranking:
@property
def best(self) -> Optional[RankedCandidate]:
"""Ứng viên đứng đầu; ``None`` nếu không có ứng viên nào."""
return self.ranked[0] if self.ranked else None
def score_of(self, key: str) -> float:
@@ -66,6 +68,7 @@ class Ranking:
def _has_capabilities(assessment: ModelAssessment, required: Set[str]) -> bool:
"""Model này có đủ mọi năng lực mà lượt chạy đòi hỏi không."""
return required.issubset(assessment.metadata.capabilities)
@@ -99,6 +102,10 @@ def rank_models(
scored.append(RankedCandidate(assessment=a, score=score))
def _sort_key(c: RankedCandidate):
"""Khoá sắp xếp ứng viên: điểm cao trước, cùng điểm thì rẻ hơn trước.
Model chưa biết giá bị xếp cuối (coi như vô cùng đắt) chứ không phải miễn phí.
"""
cost = c.assessment.metadata.avg_cost_per_1k
cost = cost if cost is not None else float("inf")
# score desc, then cheaper, then model id for determinism.
+18
View File
@@ -64,6 +64,7 @@ class RouteResult:
@property
def should_switch(self) -> bool:
"""Có nên đổi sang model khác cho lượt này không."""
return self.decision.should_switch
@property
@@ -89,6 +90,9 @@ class RoutingService:
client: Optional[ProbeClient] = None,
clock: Optional[Callable[[], float]] = None,
) -> None:
"""``store``/``client``/``clock`` đều tiêm được: test thay đồng hồ để tua thời
gian mà không phải chờ thật, và thay client để không gọi mạng.
"""
self.ctx = ctx
self.store = store or AssessmentStore()
self._client = client # None → lazily build AppProbeClient(ctx)
@@ -100,6 +104,7 @@ class RoutingService:
# -- config helpers ------------------------------------------------- #
@property
def _routing_cfg(self) -> Dict[str, Any]:
"""Nhóm cấu hình định tuyến hiện tại."""
return self.ctx.config.routing
def get_routing_config(self) -> Dict[str, Any]:
@@ -125,6 +130,7 @@ class RoutingService:
return dict(cfg)
def _policy(self) -> Policy:
"""Chính sách chấm điểm đang chọn; giá trị lạ thì rơi về 'balanced'."""
raw = (self._routing_cfg.get("policy") or "balanced").lower()
try:
return Policy(raw)
@@ -132,6 +138,7 @@ class RoutingService:
return Policy.BALANCED
def _client_or_build(self) -> ProbeClient:
"""Client dò model, dựng lười để chưa bật định tuyến thì không tốn gì."""
if self._client is None:
self._client = AppProbeClient(self.ctx)
return self._client
@@ -160,6 +167,9 @@ class RoutingService:
seen = set()
def _add(provider: str, model_id: str, tier: Optional[str]) -> None:
"""Thêm một ứng viên (provider, model) vào danh sách, bỏ qua mục thiếu thông tin
hoặc trùng.
"""
if not provider or not model_id:
return
key = candidate_key(provider, model_id)
@@ -246,6 +256,11 @@ class RoutingService:
) -> threading.Thread:
"""Run :meth:`reassess` on a daemon thread (non-Qt, headless-safe)."""
def _run() -> None:
"""Chạy nền: chấm điểm lại danh mục model.
Nuốt mọi ngoại lệ có chủ ý — một lần chấm điểm hỏng không được phép làm
chết ứng dụng, vì đây là việc chạy ngầm người dùng không yêu cầu.
"""
try:
result = self.reassess(policy)
except Exception: # noqa: BLE001 — never let a reassess crash the app
@@ -262,10 +277,12 @@ class RoutingService:
return t
def is_reassessing(self) -> bool:
"""Có đang chấm điểm lại danh mục model hay không."""
return self._reassessing
# -- query ---------------------------------------------------------- #
def assessments(self) -> Dict[str, ModelAssessment]:
"""Bảng điểm model đã lưu, đọc từ kho đánh giá."""
return self.store.load()
def status(self) -> Dict[str, Any]:
@@ -347,6 +364,7 @@ class RoutingService:
return self.pending.resolve(request_id, approve, run)
def get_pending(self, request_id: str) -> Optional[PendingSwitch]:
"""Đề nghị đổi model đang chờ người dùng xác nhận; ``None`` nếu không có."""
return self.pending.get(request_id)
def sweep_pending(self) -> List[str]:
+7
View File
@@ -68,6 +68,11 @@ class AssessmentStore:
store_path: Optional[Path] = None,
history_dir: Optional[Path] = None,
) -> None:
"""``store_path`` để None thì dùng file mặc định trong thư mục cấu hình.
Import ``CONFIG_DIR`` muộn ngay trong thân hàm: nạp nó lúc import module sẽ
kéo theo cả cây cấu hình vào mọi test dùng lớp này.
"""
if store_path is None:
from ...config import CONFIG_DIR # lazy: avoids import cost in tests
store_path = CONFIG_DIR / _DEFAULT_STORE_NAME
@@ -107,9 +112,11 @@ class AssessmentStore:
return out
def last_updated(self) -> Optional[str]:
"""Mốc thời gian lần chấm điểm gần nhất; ``None`` nếu chưa chấm lần nào."""
return self.load_raw().get("last_updated")
def policy(self) -> str:
"""Chính sách chấm điểm đã lưu; chưa có thì mặc định 'balanced'."""
return self.load_raw().get("policy") or Policy.BALANCED.value
# -- write ---------------------------------------------------------- #
+3
View File
@@ -141,6 +141,7 @@ class PendingSwitchRegistry:
_RESOLVE_WAIT_SEC = 600.0
def __init__(self, clock: Callable[[], float] = time.time) -> None:
"""``clock`` tiêm được để test kiểm hết hạn mà không phải chờ thật."""
self._items: Dict[str, PendingSwitch] = {}
self._events: Dict[str, threading.Event] = {}
self._running: Set[str] = set()
@@ -180,6 +181,7 @@ class PendingSwitchRegistry:
return ps
def _maybe_expire_locked(self, ps: PendingSwitch) -> None:
"""Đánh dấu hết hạn nếu đã quá hạn chờ. Gọi trong lúc đang giữ khoá."""
if ps.status == SwitchStatus.PENDING and self._clock() >= ps.expires_at:
ps.status = SwitchStatus.EXPIRED
@@ -270,6 +272,7 @@ class PendingSwitchRegistry:
return removed
def pending_ids(self) -> List[str]:
"""Id các đề nghị còn đang chờ (đã loại những cái vừa hết hạn)."""
with self._lock:
return [
rid for rid, ps in self._items.items()
+1
View File
@@ -44,6 +44,7 @@ class SandboxManager:
"""Central sandbox manager that selects and routes to the right backend."""
def __init__(self, config: Optional[ExecutionConfig] = None):
"""Chưa dựng backend nào — chúng được tạo muộn, lúc thật sự cần chạy lệnh."""
self.config = config or ExecutionConfig()
self._backends: Dict[str, Any] = {}
+15
View File
@@ -36,6 +36,7 @@ LIBRARY_DIR = Path(__file__).resolve().parent.parent / "skill_library"
@dataclass
class Skill:
"""Một Skill: tên, mô tả, phần chỉ dẫn chèn vào prompt, và cờ bật/tắt."""
name: str
description: str = ""
instructions: str = ""
@@ -43,12 +44,14 @@ class Skill:
@property
def slug(self) -> str:
"""Định danh an toàn cho tên file, suy từ tên skill."""
keep = "-_"
s = "".join(c if (c.isalnum() or c in keep) else "-" for c in self.name.strip().lower())
return "-".join(filter(None, s.split("-"))) or "skill"
def skills_dir() -> Path:
"""Thư mục chứa skill của người dùng."""
return SKILLS_DIR
@@ -180,6 +183,9 @@ def builtin_skills() -> List[Skill]:
def _builtin_slugs() -> set[str]:
"""Tập slug của các skill dựng sẵn — dùng để không gieo trùng và không cho sửa
chúng như skill thường.
"""
return {s.slug for s in builtin_skills()}
@@ -277,6 +283,11 @@ def list_skills(directory: Path | None = None) -> List[Skill]:
def save_skill(skill: Skill, directory: Path | None = None, old_name: str = "") -> Path:
"""Ghi một skill xuống đĩa.
Đổi tên thì XOÁ file cũ trước — tên file suy từ tên skill, không xoá sẽ để
lại một bản sao dưới tên cũ.
"""
directory = directory or SKILLS_DIR
directory.mkdir(parents=True, exist_ok=True)
if old_name and old_name != skill.name:
@@ -287,6 +298,7 @@ def save_skill(skill: Skill, directory: Path | None = None, old_name: str = "")
def delete_skill(name: str, directory: Path | None = None) -> None:
"""Xoá file skill theo tên; không có thì bỏ qua."""
directory = directory or SKILLS_DIR
path = directory / f"{Skill(name=name).slug}.json"
if path.exists():
@@ -304,6 +316,9 @@ def _load_skill_from_zip(path: Path) -> "Skill | None":
import zipfile
def _rank(n: str) -> int:
"""Thứ tự ưu tiên khi gói zip có nhiều file Markdown: ``skill.md`` trước, rồi
tới file ở gốc gói, cuối cùng mới tới file nằm sâu.
"""
low = n.lower()
if low.endswith("skill.md"):
return 0
+30
View File
@@ -35,6 +35,7 @@ MAX_JSON_KEYS_PER_LEVEL = 200 # cap per object, so one huge JSON can't flood th
@dataclass
class GNode:
"""Một node trong đồ thị cấu trúc: thư mục, tệp, lớp, hàm, phương thức hay mục tài liệu."""
id: str
label: str
kind: str # dir | file | class | function | method | module | section
@@ -44,6 +45,7 @@ class GNode:
@dataclass
class GEdge:
"""Một cạnh trong đồ thị cấu trúc, kèm LOẠI quan hệ (chứa / định nghĩa / import…)."""
source: str
target: str
type: str = "" # contains | defines | method | imports | subsection
@@ -51,6 +53,11 @@ class GEdge:
@dataclass
class StructureGraph:
"""Đồ thị cấu trúc mã nguồn, có trần số node/cạnh.
Chạm trần thì bật cờ ``truncated`` và ngừng thêm — đồ thị quá lớn làm treo
khung vẽ, thà hiện một phần kèm cảnh báo còn hơn đứng hình.
"""
nodes: List[GNode] = field(default_factory=list)
edges: List[GEdge] = field(default_factory=list)
truncated: bool = False
@@ -58,9 +65,13 @@ class StructureGraph:
max_edges: int = 0 # 0 = unlimited
def __post_init__(self):
"""Dựng sẵn tập id node để kiểm tra một cạnh có hợp lệ không trong thời gian
hằng số, thay vì quét cả danh sách node cho từng cạnh.
"""
self._ids = {n.id for n in self.nodes}
def add_node(self, node: GNode) -> bool:
"""Thêm một node; trả về ``False`` nếu trùng id hoặc đã chạm trần."""
if node.id in self._ids:
return False
if self.max_nodes and len(self.nodes) >= self.max_nodes:
@@ -71,6 +82,7 @@ class StructureGraph:
return True
def add_edge(self, source: str, target: str, type_: str = "") -> None:
"""Thêm một cạnh; bỏ qua nếu một trong hai đầu chưa có node, hoặc đã chạm trần."""
if source in self._ids and target in self._ids:
if self.max_edges and len(self.edges) >= self.max_edges:
self.truncated = True
@@ -78,6 +90,7 @@ class StructureGraph:
self.edges.append(GEdge(source, target, type_))
def has(self, node_id: str) -> bool:
"""Đồ thị đã có node với id này chưa."""
return node_id in self._ids
@@ -144,6 +157,7 @@ def _add_generic_file(graph: StructureGraph, dir_id: str, fpath: Path, root: Pat
def _rel(path: Path, root: Path) -> str:
"""Đường dẫn tương đối so với thư mục gốc; nằm ngoài gốc thì trả nguyên đường dẫn."""
try:
return str(path.relative_to(root))
except ValueError:
@@ -151,6 +165,7 @@ def _rel(path: Path, root: Path) -> str:
def _add_python_file(graph: StructureGraph, dir_id: str, fpath: Path, root: Path) -> None:
"""Thêm một tệp Python vào đồ thị: node tệp, các lớp, hàm, phương thức và import."""
file_id = f"file:{fpath}"
if not graph.add_node(GNode(file_id, fpath.name, "file", _rel(fpath, root), str(fpath))):
return
@@ -184,6 +199,7 @@ def _add_python_file(graph: StructureGraph, dir_id: str, fpath: Path, root: Path
def _module_imports(tree: ast.AST) -> List[str]:
"""Tên các module mà một cây AST import vào."""
mods: List[str] = []
for node in ast.walk(tree):
if isinstance(node, ast.Import):
@@ -199,6 +215,7 @@ def _module_imports(tree: ast.AST) -> List[str]:
def _add_doc_file(graph: StructureGraph, dir_id: str, fpath: Path, root: Path) -> None:
"""Thêm một tệp tài liệu (Markdown…) vào đồ thị, tách theo cấp tiêu đề."""
file_id = f"file:{fpath}"
fp = str(fpath)
if not graph.add_node(GNode(file_id, fpath.name, "file", _rel(fpath, root), fp)):
@@ -254,6 +271,7 @@ def _add_json_file(graph: StructureGraph, dir_id: str, fpath: Path, root: Path)
def _json_scalar_preview(value) -> str:
"""Chuỗi xem trước ngắn cho một giá trị JSON, để nhãn node không quá dài."""
if isinstance(value, dict):
return f"{{…}} ({len(value)} keys)"
if isinstance(value, list):
@@ -262,6 +280,11 @@ def _json_scalar_preview(value) -> str:
def _add_json_value(graph: StructureGraph, parent_id: str, fp: str, value, depth: int) -> None:
"""Thêm cấu trúc một giá trị JSON vào đồ thị, chặn ở ``MAX_JSON_DEPTH``.
Có trần độ sâu vì JSON lồng sâu sẽ sinh ra hàng nghìn node mà chẳng nói lên
điều gì về cấu trúc dự án.
"""
if depth >= MAX_JSON_DEPTH:
return
if isinstance(value, dict):
@@ -325,6 +348,9 @@ def build_from_codebase_memory(mem, repo_path, mode: str = "all",
def _iter_results(res):
"""Duyệt kết quả trả về từ bộ nhớ mã nguồn, chấp nhận nhiều khuôn khoá khác nhau
(``results`` / ``nodes`` / ``items`` / ``data``).
"""
if isinstance(res, dict):
for key in ("results", "nodes", "items", "data"):
val = res.get(key)
@@ -339,6 +365,10 @@ def _iter_results(res):
# Layout (layered by distance from roots)
# --------------------------------------------------------------------------
def layered_layout(graph: StructureGraph, col_w: int = 280, row_h: int = 64) -> Tuple[Dict[str, Tuple[int, int]], Dict[str, int]]:
"""Xếp đồ thị thành các cột theo bậc phụ thuộc, trong cột xếp dọc.
Trả về (toạ độ từng node, lớp của từng node).
"""
indeg = {n.id: 0 for n in graph.nodes}
adj = defaultdict(list)
for e in graph.edges:
+2
View File
@@ -42,10 +42,12 @@ _TRUE = {"yes", "y", "true", "1", "x", "có", "co"}
def _bool(value) -> bool:
"""Đọc giá trị đúng/sai từ ô Excel, chấp nhận nhiều cách ghi."""
return str(value or "").strip().lower() in _TRUE
def _clamp(value, allowed, default):
"""Ép một giá trị về tập hợp lệ; ngoài tập thì lấy mặc định."""
v = str(value or "").strip().lower()
return v if v in allowed else default
+82 -19
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
@@ -36,16 +38,21 @@ CancelFn = Callable[[], bool]
def new_run_id() -> str:
"""Id lượt chạy mới: mốc thời gian cộng 6 ký tự ngẫu nhiên (chống trùng khi hai
task khởi động cùng giây).
"""
return datetime.now().strftime("%Y%m%d-%H%M%S-") + uuid.uuid4().hex[:6]
def artifact_dir(task_id: str, run_id: str) -> Path:
"""Thư mục hiện vật của một lượt chạy, tạo sẵn cả thư mục con ``generated_files``."""
d = ARTIFACTS_DIR / task_id / run_id
(d / "generated_files").mkdir(parents=True, exist_ok=True)
return d
def _last_assistant_text(messages) -> str:
"""Nội dung trả lời cuối cùng của assistant trong hội thoại; '' nếu không có."""
for m in reversed(messages or []):
if m.get("role") == "assistant" and (m.get("content") or "").strip():
return m["content"]
@@ -62,6 +69,7 @@ _OUTPUT_MODE_HINTS = {
def _output_mode_hint(task: Dict[str, Any]) -> str:
"""Câu hướng dẫn định dạng đầu ra tương ứng chế độ output của task."""
return _OUTPUT_MODE_HINTS.get(task.get("output", {}).get("output_mode", "text"), "")
@@ -102,6 +110,9 @@ def _project_folder_input_text(project: Optional[projects.Project], max_files: i
def _build_prompt(task: Dict[str, Any], tasks_dir: Path = None,
project: Optional[projects.Project] = None,
max_files: int = 10) -> str:
"""Ghép prompt cho một task: mô tả, dữ liệu vào đã phân giải, chỉ dẫn chung của
project, và gợi ý định dạng đầu ra.
"""
parts = [task.get("description") or task.get("title") or ""]
extra = resolve_input_text(task, tasks_dir)
if extra:
@@ -162,6 +173,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
@@ -176,6 +211,7 @@ def _cancel_with_timeout(cancel: CancelFn, timeout_sec: Optional[int]) -> Tuple[
state = {"timed_out": False}
def wrapped() -> bool:
"""Cờ huỷ có thêm hạn giờ: người dùng bấm Dừng HOẶC quá thời gian cho phép."""
if cancel():
return True
if time.monotonic() >= deadline:
@@ -218,36 +254,29 @@ 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}"
)
# 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 ""
@@ -261,6 +290,7 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
last_plan_steps: List[Dict[str, str]] = []
def emit_and_autosave(ev):
"""Chuyển tiếp sự kiện tiến độ và tự lưu hội thoại tại các mốc an toàn."""
emit(ev)
if not isinstance(ev, dict):
return
@@ -273,10 +303,41 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
watched_cancel, timed_out = _cancel_with_timeout(cancel, timeout_sec)
try:
if task_type == "cowork":
from .chat_agent import run_cowork
run_cowork(provider, messages, out_dir, emit_and_autosave, watched_cancel,
security_config=ctx.config, agent_role=agent_roles.TASK,
project_context=project_context)
# 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,
)
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)
@@ -298,6 +359,7 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
def _run_script(command: str, out_dir: Path, timeout_sec: int) -> str:
"""Chạy một task kiểu script bằng shell trong thư mục kết quả, có hạn giờ."""
if not command.strip():
raise RuntimeError("Script task has no command configured.")
proc = subprocess.run(command, shell=True, cwd=str(out_dir),
@@ -406,6 +468,7 @@ def _run_co4e_flow(ctx, task: Dict[str, Any], wf, gen_dir: Path,
outputs: Dict[str, str] = {}
def _emit(ev):
"""Chuyển tiếp sự kiện của luồng Co4E về dạng sự kiện task."""
if not isinstance(ev, dict):
return
t = ev.get("type")
+9
View File
@@ -73,6 +73,7 @@ def auto_chain_in_order(tasks: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
# ---- CSV -----------------------------------------------------------------
def _import_csv(path: Path) -> List[Dict[str, Any]]:
"""Đọc danh sách task từ file CSV (chấp nhận BOM của Excel)."""
try:
text = path.read_text(encoding="utf-8-sig")
except OSError as exc:
@@ -111,6 +112,7 @@ def _import_csv(path: Path) -> List[Dict[str, Any]]:
# ---- JSON ----------------------------------------------------------------
def _import_json(path: Path) -> List[Dict[str, Any]]:
"""Đọc danh sách task từ file JSON."""
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
@@ -138,6 +140,11 @@ def _import_json(path: Path) -> List[Dict[str, Any]]:
def _pick(d: Dict[str, Any], *keys, default=""):
"""Lấy giá trị đầu tiên khác rỗng trong các khoá được nêu.
File nhập từ nhiều nguồn đặt tên cột khác nhau (``title``/``name``/``Tiêu đề``),
nên phải thử lần lượt.
"""
for k in keys:
if k in d and d[k] not in (None, ""):
return d[k]
@@ -145,6 +152,7 @@ def _pick(d: Dict[str, Any], *keys, default=""):
def _clamp(value, allowed, default):
"""Ép một giá trị về tập hợp lệ; ngoài tập thì lấy mặc định."""
v = str(value or "").strip().lower()
return v if v in allowed else default
@@ -213,6 +221,7 @@ _TRUE = {"yes", "y", "true", "1", "x", "có", "co"}
def _truthy(value) -> bool:
"""Đọc giá trị đúng/sai từ nhiều kiểu ghi khác nhau (bool, 'yes', '1', 'có'…)."""
if isinstance(value, bool):
return value
return str(value or "").strip().lower() in _TRUE
+48 -10
View File
@@ -17,7 +17,7 @@ from datetime import datetime
from pathlib import Path
from typing import Dict, Optional
from PySide6.QtCore import QCoreApplication, QObject, QTimer, Signal
from PySide6.QtCore import QObject, Signal
from .tasks import (
advance_after_run, chain_action, dependencies_met, due_tasks, format_run_at,
@@ -31,6 +31,13 @@ STOP_WAIT_SECS = 10.0
class TaskScheduler(QObject):
"""Bộ chạy task theo lịch: cứ mỗi nhịp lại tìm task tới hạn và chạy chúng ở
luồng nền.
Đồng hồ được tiêm qua ``clock=`` (R07-T03) nên test chạy được mà không cần
``QTimer`` thật, và các checker UI vô hiệu hoá được nó để việc dựng cửa sổ
không vô tình chạy task thật của người dùng.
"""
tasks_changed = Signal() # any status/log change → UI refresh
task_started = Signal(str) # task_id
task_finished = Signal(str, bool) # task_id, ok
@@ -39,22 +46,38 @@ 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):
def __init__(self, ctx, tasks_dir: Optional[Path] = None, parent=None, clock=None):
"""``clock`` để None thì tự dựng ``QtSchedulerClock`` thật, nên mọi chỗ gọi cũ
không phải sửa; test tiêm ``FakeClock`` để điều khiển nhịp bằng tay.
"""
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
self._timer = QTimer(self)
self._timer.setInterval(TICK_MS)
self._timer.timeout.connect(self.tick)
# R07-T03: the QTimer this class used to own directly is now behind a
# small clock interface (start/stop/pump) — see
# infrastructure/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
# ---- lifecycle ----------------------------------------------------
def start(self) -> None:
"""Bắt đầu chạy: thu dọn task còn kẹt từ lần chạy trước, đuổi kịp task đã quá
hạn, rồi bật nhịp đếm.
"""
self._recover_orphans()
self.tick() # catch up overdue tasks right at app start
self._timer.start()
self._clock.start(TICK_MS, self.tick)
def stop(self) -> None:
"""Request every running worker to stop, then WAIT (bounded) for them
@@ -67,15 +90,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
``processEvents()`` here lets that queued signal actually get
delivered before the app finishes quitting.
the clock here lets that queued signal actually get delivered before
the app finishes quitting.
"""
self._timer.stop()
self._clock.stop()
deadline = time.monotonic() + STOP_WAIT_SECS
while self._workers and time.monotonic() < deadline:
for w in list(self._workers.values()):
w.request_stop()
QCoreApplication.processEvents()
self._clock.pump()
for w in list(self._workers.values()):
w.wait(50)
# Anything still alive past the deadline is abandoned here;
@@ -101,6 +124,7 @@ class TaskScheduler(QObject):
# ---- tick / dispatch ----------------------------------------------
def tick(self) -> None:
"""Một nhịp: chạy mọi task đã tới hạn tại thời điểm này."""
now = datetime.now()
changed = False
for task in due_tasks(list_tasks(self.tasks_dir), now):
@@ -137,6 +161,7 @@ class TaskScheduler(QObject):
return True
def is_running(self, task_id: str) -> bool:
"""Task này có đang chạy không."""
return task_id in self._workers
def running_count(self) -> int:
@@ -153,6 +178,7 @@ class TaskScheduler(QObject):
# ---- internals -----------------------------------------------------
def _start(self, task: dict) -> None:
"""Khởi động một task ở luồng nền và đánh dấu trạng thái 'running'."""
tid = task["task_id"]
run_id = new_run_id()
task["status"] = "running"
@@ -160,6 +186,7 @@ class TaskScheduler(QObject):
self.task_started.emit(tid)
def job(worker: AgentWorker):
"""Chạy nền: thực thi task, chuyển tiếp sự kiện tiến độ và cờ huỷ."""
return execute_task(self.ctx, task, run_id,
emit=worker.emit_event, cancel=worker.is_cancelled,
tasks_dir=self.tasks_dir)
@@ -187,6 +214,7 @@ class TaskScheduler(QObject):
self.history_ready.emit(task_id)
def _on_done(self, task_id: str, run_id: str, result: dict) -> None:
"""Task chạy xong: ghi kết quả, tính lần chạy kế tiếp, và kích hoạt task nối tiếp."""
self._workers.pop(task_id, None)
self._session_ids.pop(task_id, None)
task = load_task(task_id, self.tasks_dir)
@@ -239,6 +267,11 @@ class TaskScheduler(QObject):
self._start(task)
def _apply_chain(self, task: dict, verb: str, next_id: str) -> None:
"""Kích hoạt task nối tiếp theo luật ``run_next``.
Task kế đang tạm dừng thì BỎ QUA — trình sửa task có cảnh báo trước về
điều này.
"""
nxt = load_task(next_id, self.tasks_dir)
if not nxt or nxt.get("status") == "paused":
return # paused next task is skipped (warned about in the editor)
@@ -255,6 +288,11 @@ class TaskScheduler(QObject):
save_task(nxt, self.tasks_dir)
def _notify(self, task: dict, ok: bool, error: str) -> None:
"""Gửi nhắc việc qua Teams hoặc Outlook khi task kết thúc.
Đã chọn kênh thì báo cả khi chạy xong LẪN khi lỗi — im lặng lúc lỗi là
kiểu hỏng tệ nhất của một tác vụ chạy nền.
"""
ex = task["execution"]
channel = ex.get("notify_channel", "none")
# A chosen channel notifies on BOTH completion and error; the legacy
+42 -71
View File
@@ -98,10 +98,12 @@ DEFAULT_TASK: Dict[str, Any] = {
def _now_str() -> str:
"""Mốc thời gian hiện tại theo đúng định dạng lưu trong file task."""
return datetime.now().strftime(_TIME_FMT)
def parse_run_at(value: Optional[str]) -> Optional[datetime]:
"""Đọc chuỗi thời gian chạy thành ``datetime``; sai định dạng thì trả ``None``."""
if not value:
return None
try:
@@ -111,6 +113,7 @@ def parse_run_at(value: Optional[str]) -> Optional[datetime]:
def format_run_at(dt: datetime) -> str:
"""Ghi ``datetime`` thành chuỗi thời gian chạy."""
return dt.strftime(_TIME_FMT)
@@ -143,19 +146,29 @@ def _normalize(task: Dict[str, Any]) -> Dict[str, Any]:
# ---- repository ----------------------------------------------------------
def task_path(task_id: str, directory: Path = None) -> Path:
"""Đường dẫn file JSON của một task."""
return (directory or TASKS_DIR) / f"{task_id}.json"
def save_task(task: Dict[str, Any], directory: Path = None) -> Path:
"""Ghi task xuống đĩa (ghi nguyên tử) và cập nhật ``updated_at``."""
directory = directory or TASKS_DIR
directory.mkdir(parents=True, exist_ok=True)
task["updated_at"] = datetime.now().isoformat(timespec="seconds")
path = task_path(task["task_id"], directory)
path.write_text(json.dumps(task, ensure_ascii=False, indent=2), encoding="utf-8")
# 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)
return path
def load_task(task_id: str, directory: Path = None) -> Optional[Dict[str, Any]]:
"""Đọc một task theo id và chuẩn hoá; không có hoặc hỏng thì trả ``None``."""
path = task_path(task_id, directory)
try:
return _normalize(json.loads(path.read_text(encoding="utf-8")))
@@ -164,6 +177,7 @@ def load_task(task_id: str, directory: Path = None) -> Optional[Dict[str, Any]]:
def list_tasks(directory: Path = None) -> List[Dict[str, Any]]:
"""Liệt kê mọi task trong thư mục; thư mục chưa có thì trả list rỗng."""
directory = directory or TASKS_DIR
if not directory.exists():
return []
@@ -178,6 +192,7 @@ def list_tasks(directory: Path = None) -> List[Dict[str, Any]]:
def delete_task(task_id: str, directory: Path = None) -> None:
"""Xoá file task; không có thì bỏ qua."""
try:
task_path(task_id, directory).unlink()
except OSError:
@@ -287,36 +302,37 @@ def chain_error(tasks: List[Dict[str, Any]], task_id: str,
# ---- schedule math --------------------------------------------------------
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"):
# 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():
"""Bộ tính lịch (:class:`ScheduleCalculator`), dựng một lần rồi dùng lại.
Dựng lười để ``core/tasks.py`` không kéo theo cả module cron ở mỗi lần
import.
"""
global _calculator
if _calculator is None:
from .cron import Cron
from .holiday_calendar import is_holiday
from ..domain.tasks.schedule_calculator import ScheduleCalculator
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)
_calculator = ScheduleCalculator(is_holiday=is_holiday, make_cron=Cron)
return _calculator
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."""
guard = 0
while _is_excluded_day(dt, sched) and guard < 400:
dt += timedelta(days=1)
guard += 1
return dt
return _get_calculator().shift_off_excluded_days(dt, sched)
def compute_next_run(task: Dict[str, Any], after: datetime) -> Optional[datetime]:
@@ -324,57 +340,12 @@ 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."""
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
return _get_calculator().compute_next_run(task, after)
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."""
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
return _get_calculator().due_tasks(tasks, now)
# ---- post-run bookkeeping (pure; scheduler applies + saves) ---------------
+12
View File
@@ -17,12 +17,21 @@ ACCENT = "F37021"
class TeamsNotifier:
"""Gửi thông báo lên Microsoft Teams qua webhook.
Tự thử hai khuôn thẻ: Adaptive Card (webhook Workflows mới) rồi tới
MessageCard (webhook Connector cũ) — hai loại webhook không nhận chung một khuôn.
"""
def __init__(self, webhook_url: str = "", ca_bundle: str = ""):
"""``webhook_url`` rỗng nghĩa là chưa cấu hình — mọi lượt gửi về sau lặng lẽ bỏ
qua thay vì lỗi.
"""
self.webhook_url = (webhook_url or "").strip()
self.ca_bundle = (ca_bundle or "").strip()
@property
def configured(self) -> bool:
"""Đã cấu hình webhook hợp lệ chưa."""
return self.webhook_url.startswith("http")
def send(
@@ -113,6 +122,7 @@ class TeamsNotifier:
@staticmethod
def _explain(resp) -> str:
"""Đổi phản hồi lỗi của Teams thành câu đọc được, kèm mã HTTP và 200 ký tự thân."""
code = resp.status_code
body = (getattr(resp, "text", "") or "")[:200]
if code == 405:
@@ -127,6 +137,7 @@ class TeamsNotifier:
@staticmethod
def _message_card(title: str, text: str, facts: Optional[Dict[str, str]]) -> Dict:
"""Dựng payload khuôn MessageCard (webhook Connector cũ)."""
section: Dict = {"activityTitle": title, "text": text}
if facts:
section["facts"] = [{"name": k, "value": v} for k, v in facts.items()]
@@ -140,6 +151,7 @@ class TeamsNotifier:
@staticmethod
def _adaptive_card(title: str, text: str, facts: Optional[Dict[str, str]]) -> Dict:
"""Dựng payload khuôn Adaptive Card (webhook Workflows mới)."""
body: List[Dict] = [
{"type": "TextBlock", "text": title, "weight": "Bolder", "size": "Medium"},
{"type": "TextBlock", "text": text, "wrap": True},

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