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>
This commit is contained in:
2026-08-30 10:41:45 +09:00
co-authored by Claude Opus 5
parent d20306be08
commit e29a0ccdbd
264 changed files with 4593 additions and 359 deletions
@@ -12,6 +12,17 @@ thật, xếp theo số lần gọi.
Một chỗ cố ý KHÔNG đưa vào: ``config.data`` (36 lần gọi, nhiều nhất). Đó là
đống dict thô — cho nó vào interface là bê nguyên vấn đề cũ sang kiến trúc mới.
Ai đang cần ``data`` thì mở issue để bổ sung một thuộc tính có kiểu rõ ràng.
SEAM · dựng 2026-08-21 · chưa nối dây (F-05)
------------------------------------------------------------
Được nối khi: một chỗ chú thích kiểu thật sự nhận ``ConfigRepository`` thay vì ``AppConfig``.
Để dormant thì sao: Protocol không ai chú thích tới thì không có bộ kiểm
kiểu nào đối chiếu nó với ``JsonConfigRepository``, nên hai bên lệch nhau
lúc nào không hay.
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
@@ -30,6 +41,7 @@ class ConfigRepository(Protocol):
...
def set_active_provider(self, name: str) -> None:
"""Đổi provider đang dùng."""
...
def provider_conf(self, name: str | None = None) -> Dict[str, Any]:
@@ -62,6 +74,7 @@ class ConfigRepository(Protocol):
...
def set_theme(self, value: str) -> None:
"""Đổi giao diện sáng/tối."""
...
@property
@@ -70,6 +83,7 @@ class ConfigRepository(Protocol):
...
def set_language(self, value: str) -> None:
"""Đổi ngôn ngữ hiển thị."""
...
# ---- các nhóm cấu hình còn lại -------------------------------------
@@ -94,6 +108,7 @@ class ConfigRepository(Protocol):
...
def set_tool_enabled(self, name: str, enabled: bool) -> None:
"""Bật/tắt một tool theo tên."""
...
# ---- ghi ------------------------------------------------------------
+128
View File
@@ -0,0 +1,128 @@
"""Các nhóm cấu hình đọc thẳng từ dict — tách khỏi ``json_config_repository.py``.
Mỗi thành viên ở đây chỉ làm đúng một việc: đặt tên cho một khoá trong file
cấu hình và nói rõ giá trị mặc định khi khoá đó chưa có. Không có hành vi nào
đáng bàn, nhưng có đến hơn hai mươi cái — để chung với phần có logic thật
(trộn mặc định, kho bí mật, di trú schema, ghi nguyên tử) thì phần ấy bị chìm.
Dùng ``setdefault`` chứ không ``get``: bên gọi sửa thẳng vào dict trả về
(``cfg.routing["switch_mode"] = ...``) rồi mới ``save()``, nên cái trả về phải
là dict THẬT nằm trong ``data``, không phải một bản sao rồi bị vứt đi.
Mixin chứ không phải lớp riêng: 29 file đang gọi ``ctx.config.<tên>`` thẳng,
nên tách thành ``ctx.config.sections.<tên>`` sẽ là đổi API công khai — việc
này chỉ chia file, không chia bề mặt.
"""
from __future__ import annotations
import copy
from typing import Any, Dict
class ConfigSectionsMixin:
"""Phần truy cập nhóm cấu hình của :class:`JsonConfigRepository`.
Chỉ trông vào hai thứ của lớp chủ: ``self.data`` (dict cấu hình đã trộn
mặc định) và ``self.save()``. Không tự đứng một mình được — và cũng không
cần, vì không có ai khác dùng.
"""
@property
def code(self) -> Dict[str, Any]:
"""Nhóm ``code``: thiết lập của agent lập trình (thư mục làm việc, model)."""
return self.data["code"]
@property
def teams(self) -> Dict[str, Any]:
"""Nhóm ``teams``: thiết lập tích hợp Microsoft Teams."""
return self.data["teams"]
@property
def history(self) -> Dict[str, Any]:
"""Nhóm ``history``: lưu lịch sử hội thoại (bật/tắt, giới hạn)."""
return self.data["history"]
@property
def codebase_memory(self) -> Dict[str, Any]:
"""Nhóm ``codebase_memory``: bộ nhớ mã nguồn cho agent lập trình."""
return self.data["codebase_memory"]
@property
def cowork(self) -> Dict[str, Any]:
"""Nhóm ``cowork``: thiết lập màn Cowork (thư mục kết quả, model mặc định)."""
return self.data["cowork"]
@property
def mcp_servers(self) -> list:
"""Danh sách máy chủ MCP đã khai báo; rỗng nếu chưa có cái nào."""
return self.data.setdefault("mcp_servers", [])
@property
def structure(self) -> Dict[str, Any]:
"""Nhóm ``structure``: trần số node/cạnh khi vẽ đồ thị GraphRAG.
Mặc định 400/400 — vượt ngưỡng đó thì đồ thị vừa vẽ chậm vừa rối, không
còn đọc được nữa.
"""
return self.data.setdefault("structure", {"max_nodes": 400, "max_edges": 400})
@property
def monitoring_visibility(self) -> Dict[str, bool]:
"""Nhóm ``monitoring_visibility``: tab nào của màn Giám sát được hiện.
Lấy bản sao sâu của mặc định khi khoá chưa có, để người dùng tắt một tab
không vô tình sửa luôn bộ mặc định dùng chung.
"""
return self.data.setdefault(
"monitoring_visibility",
copy.deepcopy(self._defaults["monitoring_visibility"]))
@property
def ext_connectors(self) -> Dict[str, list]:
"""Connector (MCP) gom theo nhóm CAD/CAE/MS365/Other."""
d = self.data.setdefault(
"ext_connectors", {"cad": [], "cae": [], "ms365": [], "other": []})
for cat in ("cad", "cae", "ms365", "other"):
d.setdefault(cat, [])
return d
# ---- công tắc tổng cho connector -------------------------------------
@property
def connect_external(self) -> bool:
"""Tắt cái này là agent không nối tới connector ngoài nào cả. Mặc định
BẬT để cấu hình đang chạy không đổi hành vi."""
return bool(self.data.setdefault("tools", {}).get("connect_external", True))
def set_connect_external(self, enabled: bool) -> None:
"""Bật/tắt công tắc tổng cho connector ngoài, ghi đĩa ngay."""
self.data.setdefault("tools", {})["connect_external"] = bool(enabled)
self.save()
# ---- những thứ đã gieo sẵn -------------------------------------------
@property
def seeded_library_skills(self) -> list:
"""Slug của skill thư viện đã gieo — để cái người dùng xoá đi không bị
lặng lẽ gieo lại."""
return list(self.data.setdefault("seeded_library_skills", []))
@seeded_library_skills.setter
def seeded_library_skills(self, slugs) -> None:
"""Ghi lại danh sách slug đã gieo, bỏ trùng và giữ nguyên thứ tự."""
self.data["seeded_library_skills"] = list(dict.fromkeys(slugs or []))
@property
def seeded_builtin_flows(self) -> list:
"""Id của flow Co4E dựng sẵn đã gieo (cùng quy tắc tôn trọng việc người
dùng đã xoá như seeded_library_skills)."""
return list(self.data.setdefault("seeded_builtin_flows", []))
@seeded_builtin_flows.setter
def seeded_builtin_flows(self, ids) -> None:
"""Ghi lại danh sách id flow đã gieo, bỏ trùng và giữ nguyên thứ tự."""
self.data["seeded_builtin_flows"] = list(dict.fromkeys(ids or []))
__all__ = ["ConfigSectionsMixin"]
+51 -80
View File
@@ -22,20 +22,33 @@ from typing import Any, Dict
from ..persistence.json.atomic_json_file import AtomicJsonFile
from ..secrets.secret_store import SecretStore, provider_key
from .config_sections import ConfigSectionsMixin
from .schema_migration import CURRENT_VERSION, migrate
class JsonConfigRepository:
class JsonConfigRepository(ConfigSectionsMixin):
"""Cấu hình đọc/ghi từ một file JSON, bí mật để trong ``SecretStore``.
``secrets`` để None nghĩa là không có kho bí mật — mọi thứ vẫn chạy, chỉ
là ``api_key`` lấy nguyên từ file như trước. Cần vậy để chuyển dần
(R02-T05) chứ không phải đổi một phát cả app.
Các nhóm cấu hình đọc thẳng từ dict (``code``, ``teams``, ``history``,
connector, những thứ đã gieo sẵn…) nằm ở
``config_sections.py::ConfigSectionsMixin``: chúng không có logic nào
riêng, chỉ đặt tên cho một khoá và một giá trị mặc định, nên để chung chỉ
làm trôi mất phần thật sự có hành vi của lớp này.
"""
def __init__(self, path: Path, *, secrets: SecretStore | None = None,
defaults: Dict[str, Any] | None = None,
env_overrides=None):
"""Mở một file cấu hình.
``defaults``/``env_overrides`` để None thì lấy thẳng từ ``config.py`` — hai
bên phải dùng chung một bộ mặc định trong suốt giai đoạn chuyển, nếu không
ứng dụng sẽ thấy hai bộ cấu hình khác nhau tuỳ đường nào gọi tới.
"""
self._file = AtomicJsonFile(path)
self._secrets = secrets
# Lấy thẳng từ config.py để hai bên không lệch nhau trong lúc chuyển.
@@ -66,6 +79,7 @@ class JsonConfigRepository:
# ---- nạp ------------------------------------------------------------
def _load(self) -> Dict[str, Any]:
"""Đọc file JSON, nâng cấp schema rồi trộn lên trên bộ mặc định."""
merged = copy.deepcopy(self._defaults)
stored = self._file.read(default=None)
if isinstance(stored, dict):
@@ -84,11 +98,13 @@ class JsonConfigRepository:
return merged
def reload(self) -> None:
"""Đọc lại toàn bộ cấu hình từ đĩa, bỏ mọi sửa đổi chưa ``save()``."""
self.data = self._load()
# ---- provider --------------------------------------------------------
@property
def active_provider(self) -> str:
"""Id provider đang chọn ('openai_compat', 'anthropic', ...); '' nếu chưa đặt."""
return self.data.get("active_provider", "")
@active_provider.setter
@@ -100,6 +116,9 @@ class JsonConfigRepository:
self.data["active_provider"] = name
def set_active_provider(self, name: str) -> None:
"""Đổi provider đang dùng. Bản hàm của property cùng tên, cho chỗ gọi thích
gọi hàm hơn gán thuộc tính.
"""
self.data["active_provider"] = name
def provider_conf(self, name: str | None = None) -> Dict[str, Any]:
@@ -132,9 +151,13 @@ class JsonConfigRepository:
# ---- đường dẫn -------------------------------------------------------
@property
def shared_dir(self) -> str:
"""Thư mục dùng chung (OneDrive/mạng) chứa tài khoản và agent quản trị; '' là
chưa cấu hình.
"""
return self.data.get("shared_dir", "")
def history_dir(self) -> Path:
"""Thư mục chứa lịch sử hội thoại."""
rt = getattr(self, "_project_history_dir", None) or self.data.get("_project_history_dir")
if rt:
return Path(rt)
@@ -145,6 +168,7 @@ class JsonConfigRepository:
return CONFIG_DIR / "history"
def cowork_output_dir(self) -> Path:
"""Thư mục agent ghi kết quả ra."""
custom = (self.data.get("cowork", {}).get("output_dir") or "").strip()
if custom:
return Path(custom).expanduser()
@@ -158,44 +182,64 @@ class JsonConfigRepository:
# ---- giao diện -------------------------------------------------------
@property
def theme(self) -> str:
"""Giao diện đang chọn: 'dark' | 'light' | 'system'. Mặc định 'dark'."""
return self.data.get("theme", "dark")
@theme.setter
def theme(self, value: str) -> None:
"""Đổi giao diện. Chỉ ghi vào bộ nhớ — phải ``save()`` mới xuống đĩa."""
self.data["theme"] = value
def set_theme(self, value: str) -> None:
"""Đổi giao diện sáng/tối. Bản hàm của property ``theme``."""
self.data["theme"] = value
@property
def language(self) -> str:
"""Mã ngôn ngữ đang chọn: 'vi' | 'en' | 'ja'. Mặc định 'vi'."""
return self.data.get("language", "vi")
@language.setter
def language(self, value: str) -> None:
"""Đổi ngôn ngữ. Chỉ ghi vào bộ nhớ — phải ``save()`` mới xuống đĩa."""
self.data["language"] = value
def set_language(self, value: str) -> None:
"""Đổi ngôn ngữ. Bản hàm của property ``language``."""
self.data["language"] = value
# ---- nhóm cấu hình ---------------------------------------------------
@property
def routing(self) -> Dict[str, Any]:
"""Nhóm ``routing``: chế độ tự chọn model và các thiết lập riêng theo bề mặt chat."""
return self.data.setdefault("routing", {})
@property
def auth(self) -> Dict[str, Any]:
"""Nhóm ``auth``: thiết lập đăng nhập. Bản này không có lớp đăng nhập nên
thường rỗng.
"""
return self.data.setdefault("auth", {})
@property
def agent_security(self) -> Dict[str, Any]:
"""Nhóm ``agent_security``: ngưỡng rủi ro và các mục agent phải xin phép."""
return self.data.setdefault("agent_security", {})
@property
def tools_disabled(self) -> list[str]:
"""Danh sách tool BỊ TẮT.
Lưu theo chiều "bị tắt" chứ không phải "được bật": tool mới thêm vào bản
cập nhật sẽ tự chạy được mà không cần ai vào bật thủ công.
Trả về BẢN SAO — sửa danh sách phải đi qua ``set_tool_enabled`` để còn ghi
đĩa.
"""
return list(self.data.get("tools_disabled", []))
def set_tool_enabled(self, name: str, enabled: bool) -> None:
"""Bật/tắt một tool. Lưu dưới dạng danh sách tool BỊ TẮT nên tool mới"""
disabled = list(self.data.get("tools_disabled", []))
if enabled:
disabled = [t for t in disabled if t != name]
@@ -225,6 +269,7 @@ class JsonConfigRepository:
@property
def path(self) -> Path:
"""Đường dẫn file config.json đang dùng."""
return self._file.path
# ---- TLS -------------------------------------------------------------
@@ -238,12 +283,14 @@ class JsonConfigRepository:
@ca_bundle.setter
def ca_bundle(self, value: str) -> None:
"""Đặt file PEM riêng; chuỗi rỗng nghĩa là quay về kiểm chứng chỉ mặc định."""
self.data["tls_ca_bundle"] = (value or "").strip()
# ---- MS365 -----------------------------------------------------------
@property
def ms365(self) -> Dict[str, Any]:
"""Nhóm ``ms365``: thiết lập Microsoft 365 kèm mã mở khoá phía giao diện."""
return self.data.setdefault("ms365", copy.deepcopy(self._defaults["ms365"]))
def ms365_try_unlock(self, code: str) -> bool:
@@ -257,87 +304,9 @@ class JsonConfigRepository:
return False
def ms365_lock(self) -> None:
"""Khoá lại nhóm MS365 trong Cài đặt. Trạng thái khoá chỉ tồn tại lúc chạy."""
self.data.setdefault("ms365", {})["unlocked"] = False
# ---- nhóm cấu hình đọc thẳng ------------------------------------------
@property
def code(self) -> Dict[str, Any]:
return self.data["code"]
@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 cowork(self) -> Dict[str, Any]:
return self.data["cowork"]
@property
def mcp_servers(self) -> list:
return self.data.setdefault("mcp_servers", [])
@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(self._defaults["monitoring_visibility"]))
@property
def ext_connectors(self) -> Dict[str, list]:
"""Connector (MCP) gom theo nhóm CAD/CAE/MS365/Other."""
d = self.data.setdefault(
"ext_connectors", {"cad": [], "cae": [], "ms365": [], "other": []})
for cat in ("cad", "cae", "ms365", "other"):
d.setdefault(cat, [])
return d
# ---- công tắc tổng cho connector -------------------------------------
@property
def connect_external(self) -> bool:
"""Tắt cái này là agent không nối tới connector ngoài nào cả. Mặc định
BẬT để cấu hình đang chạy không đổi hành vi."""
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()
# ---- những thứ đã gieo sẵn -------------------------------------------
@property
def seeded_library_skills(self) -> list:
"""Slug của skill thư viện đã gieo — để cái người dùng xoá đi không bị
lặng lẽ gieo lại."""
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:
"""Id của flow Co4E dựng sẵn đã gieo (cùng quy tắc tôn trọng việc người
dùng đã xoá như 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 []))
# ---- định tuyến theo từng bề mặt chat --------------------------------
def routing_mode_for(self, surface: str) -> str:
@@ -352,6 +321,7 @@ class JsonConfigRepository:
return mode if mode in self.ROUTING_MODES else "off"
def set_routing_mode_for(self, surface: str, mode: str) -> None:
"""Đặt chế độ định tuyến riêng cho một bề mặt chat, ghi đĩa ngay."""
mode = mode if mode in self.ROUTING_MODES else "off"
self.routing.setdefault("surface_modes", {})[surface] = mode
self.save()
@@ -359,6 +329,7 @@ class JsonConfigRepository:
# ---- tiện ích --------------------------------------------------------
def model_label(self) -> str:
"""Tên model đang dùng, để hiện trên thanh trạng thái; '?' nếu chưa đặt."""
return str(self.provider_conf().get("model", "?"))
# ---- ghi -------------------------------------------------------------
@@ -38,6 +38,11 @@ ASSUMED_VERSION = 1
def read_version(data: Dict[str, Any]) -> int:
"""Phiên bản schema của một dict cấu hình.
File cũ chưa có trường này thì coi như ``ASSUMED_VERSION`` — đó chính là
phiên bản trước khi trường được thêm vào.
"""
try:
return int(data.get("schema_version", ASSUMED_VERSION))
except (TypeError, ValueError):
+38
View File
@@ -12,6 +12,16 @@ chứ không phải mili giây.
Cố ý KHÔNG dùng dataclass đông cứng: đây là *khung nhìn* lên dict cấu hình
sống, sửa qua đây là sửa vào dict rồi ``save()`` là xuống đĩa. Sao chép thành
dataclass thì lại sinh chuyện đồng bộ hai chiều.
SEAM · dựng 2026-08-22 · chưa nối dây (F-05)
------------------------------------------------------------
Được nối khi: ít nhất một trong 156 lời gọi ``ctx.config.*`` chuyển sang đọc qua ``Settings``.
Để dormant thì sao: Mục đích của nó là chặn lỗi gõ sai tên khoá. Không ai
dùng thì không chặn được gì cả.
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
@@ -22,9 +32,15 @@ class _View:
"""Khung nhìn lên một nhánh của dict cấu hình."""
def __init__(self, data: Dict[str, Any]):
"""Bọc một nhánh dict cấu hình để đọc bằng thuộc tính thay vì tra khoá."""
self._d = data
def _get(self, key: str, default: Any) -> Any:
"""Đọc một khoá, coi ``None`` như thiếu.
Cấu hình cũ có chỗ ghi ``null``; nếu trả thẳng ``None`` ra ngoài thì
``str(None)`` thành chuỗi "None" và lỗi hiện ra ở tận nơi dùng.
"""
value = self._d.get(key, default)
return default if value is None else value
@@ -46,14 +62,17 @@ class ProviderSettings(_View):
@property
def base_url(self) -> str:
"""Endpoint của provider. '' nghĩa là chưa cấu hình."""
return str(self._get("base_url", ""))
@property
def model(self) -> str:
"""Model mặc định của provider này. '' nghĩa là chưa chọn."""
return str(self._get("model", ""))
@property
def api_key(self) -> str:
"""Khoá API — đã được ``provider_conf()`` ghép từ kho bí mật của hệ điều hành."""
return str(self._get("api_key", ""))
@property
@@ -76,10 +95,12 @@ class RoutingSettings(_View):
@switch_mode.setter
def switch_mode(self, value: str) -> None:
"""Đặt chế độ định tuyến chung. Sửa thẳng vào dict cấu hình sống."""
self._d["switch_mode"] = value
@property
def enabled(self) -> bool:
"""Định tuyến tự động có đang bật không (tức ``switch_mode`` khác "off")."""
return self.switch_mode != "off"
@property
@@ -99,18 +120,22 @@ class RoutingSettings(_View):
@property
def reassess_interval_hours(self) -> int:
"""GIỜ giữa hai lần chấm điểm lại danh mục model."""
return int(self._get("reassess_interval_hours", 24))
@property
def per_provider_concurrency(self) -> int:
"""Số lượt gọi chạy song song tối đa cho mỗi provider khi dò/chấm điểm."""
return int(self._get("per_provider_concurrency", 2))
@property
def judge_provider(self) -> str:
"""Provider dùng làm trọng tài chấm điểm model. '' nghĩa là dùng provider đang chọn."""
return str(self._get("judge_provider", ""))
@property
def judge_model(self) -> str:
"""Model dùng làm trọng tài chấm điểm. '' nghĩa là dùng model mặc định của trọng tài."""
return str(self._get("judge_model", ""))
@@ -119,22 +144,27 @@ class SecuritySettings(_View):
@property
def enabled(self) -> bool:
"""Công tắc tổng của lớp an toàn. Tắt là bỏ qua mọi bước kiểm dưới đây."""
return bool(self._get("enabled", True))
@property
def validate_prompt(self) -> bool:
"""Có quét prompt người dùng tìm dấu hiệu tấn công tiêm lệnh không."""
return bool(self._get("validate_prompt", True))
@property
def validate_attachments(self) -> bool:
"""Có kiểm tệp đính kèm (đuôi/kiểu MIME nguy hiểm) trước khi đưa vào lượt chat không."""
return bool(self._get("validate_attachments", True))
@property
def validate_commands(self) -> bool:
"""Có phân loại rủi ro lệnh shell trước khi chạy không."""
return bool(self._get("validate_commands", True))
@property
def command_ai_check(self) -> bool:
"""Có nhờ thêm AI xét lệnh khi bộ luật tĩnh chưa chắc chắn không. Mặc định tắt vì tốn một lượt gọi."""
return bool(self._get("command_ai_check", False))
@property
@@ -148,10 +178,12 @@ class SecuritySettings(_View):
@property
def rules_onedrive_url(self) -> str:
"""Link OneDrive tới bộ luật an toàn dùng chung. '' nghĩa là dùng bản đóng gói sẵn trong app."""
return str(self._get("rules_onedrive_url", ""))
@property
def admin_email(self) -> str:
"""Email quản trị nhận cảnh báo vi phạm. '' nghĩa là không gửi."""
return str(self._get("admin_email", ""))
@@ -164,15 +196,21 @@ class Settings:
"""
def __init__(self, repo):
"""Bọc một ``ConfigRepository`` — mọi lượt đọc/ghi đều đi xuống nó, lớp này chỉ
đổi cách gọi cho dễ đọc.
"""
self._repo = repo
def provider(self, name: str | None = None) -> ProviderSettings:
"""Khung nhìn cấu hình của một provider; bỏ trống thì lấy provider đang chọn."""
return ProviderSettings(self._repo.provider_conf(name))
@property
def routing(self) -> RoutingSettings:
"""Khung nhìn nhóm cấu hình định tuyến model."""
return RoutingSettings(self._repo.routing)
@property
def security(self) -> SecuritySettings:
"""Khung nhìn nhóm cấu hình an toàn cho agent."""
return SecuritySettings(self._repo.agent_security)
@@ -55,6 +55,11 @@ def _sandbox_python(ctx: ToolContext, cancel: Optional[CancelFn] = None,
def run_command(ctx: ToolContext, args: Dict[str, Any],
cancel: Optional[CancelFn] = None,
on_output=None) -> Dict[str, Any]:
"""Chạy một lệnh shell trong thư mục làm việc, có hạn giờ và có sandbox.
Biến môi trường được lọc và mạng bị chặn theo cấu hình an toàn — agent chạy
lệnh không được thừa hưởng toàn bộ môi trường của người dùng.
"""
from cowork_local.core.deps import network_blocked_env, run_cancellable, sandbox_env
from cowork_local.core.sandbox_manager import ExecutionConfig, SandboxManager
from cowork_local.security.command_risk_classifier import classify_command
@@ -96,6 +101,11 @@ def run_command(ctx: ToolContext, args: Dict[str, Any],
def install_package(ctx: ToolContext, args: Dict[str, Any],
cancel: Optional[CancelFn] = None,
on_output=None) -> Dict[str, Any]:
"""Cài một gói Python vào môi trường phụ trợ của lượt chạy.
Cài vào venv riêng chứ không vào Python của hệ thống — một task không được
phép làm hỏng môi trường của cả máy.
"""
from cowork_local.core.deps import pip_install
package = str(args.get("package", "")).strip()
@@ -14,6 +14,17 @@ exactly what it always was. It exists so a caller (an application service,
R06-T05's ``FileWorkspaceService``, or a future turn-cleanup step) can ask
for "the output dir" / "the scratch dir" instead of hand-building the path
and hoping the convention hasn't drifted.
SEAM · dựng 2026-08-21 · chưa nối dây (F-05)
------------------------------------------------------------
Được nối khi: một chỗ gọi thật hỏi ``output_dir``/``scratch_dir`` thay vì tự ghép ``workdir / ".scratch"``.
Để dormant thì sao: Quy ước ``.scratch`` vẫn nằm rải trong ``file_tools.py``
và ``core/chat_agent.py``. File này đặt tên cho nó nhưng chưa ai dùng, nên
quy ước vẫn trôi được.
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
@@ -51,10 +62,12 @@ class ExecutionWorkspace:
@property
def output_dir(self) -> Path:
"""Thư mục agent được phép ghi kết quả — chính là gốc của phiên làm việc."""
return self.session.workspace_root
@property
def scratch_dir(self) -> Path:
"""Thư mục nháp bên trong phiên, cho file tạm không phải kết quả cuối."""
return self.session.workspace_root / SCRATCH_DIRNAME
def ensure_dirs(self) -> None:
+2
View File
@@ -36,6 +36,7 @@ def fetch_url(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
def jira_search(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
"""Tìm issue trên Jira bằng JQL."""
from cowork_local.core import jira_tool
out = jira_tool.search(ctx.jira, str(args.get("jql", "")),
@@ -45,6 +46,7 @@ def jira_search(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
def jira_get_issue(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
"""Lấy chi tiết một issue Jira theo mã."""
from cowork_local.core import jira_tool
out = jira_tool.get_issue(ctx.jira, str(args.get("key", "")))
+10
View File
@@ -52,6 +52,10 @@ def _check_python_syntax(target: Path, content: str) -> str:
def read_file(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
"""Đọc một tệp trong thư mục làm việc, cắt ở ``MAX_READ_BYTES``.
Đường dẫn được ``ctx.resolve`` kiểm trước — thoát ra ngoài thư mục là bị từ chối.
"""
target = ctx.resolve(str(args.get("path", "")))
if not target.exists():
return {"ok": False, "output": f"File not found: {args.get('path')}"}
@@ -61,6 +65,7 @@ def read_file(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
def list_dir(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
"""Liệt kê tệp và thư mục con tại một đường dẫn (mặc định là gốc thư mục làm việc)."""
rel = str(args.get("path", ".") or ".")
target = ctx.resolve(rel)
# A missing/not-yet-created path is NOT a tool failure — report it as an
@@ -79,6 +84,11 @@ def list_dir(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
def write_file(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
"""Tạo mới hoặc ghi đè một tệp, tự tạo thư mục cha.
``ctx.flatten_writes`` ép mọi tệp ghi thẳng vào gốc — dùng cho lượt chạy mà
cấu trúc thư mục do agent bịa ra không có ý nghĩa gì.
"""
rel = str(args.get("path", ""))
if ctx.flatten_writes:
rel = _flatten_rel(rel)
@@ -18,11 +18,17 @@ CancelFn = Callable[[], bool]
class ToolError(Exception):
"""Lỗi khi chạy một tool — thông điệp được đưa thẳng cho model đọc."""
pass
@dataclass
class ToolContext:
"""Bối cảnh một lượt chạy tool: thư mục làm việc và các quy tắc ghi.
``flatten_writes`` ép mọi tệp ghi thẳng vào gốc — dùng cho Cowork, nơi cấu
trúc thư mục do model bịa ra không có ý nghĩa với người dùng.
"""
workdir: Path
flatten_writes: bool = False # Cowork: force every write into the workdir root
sandbox: bool = False # Code tab: isolate run_command/install_package into <workdir>/.venv
+6
View File
@@ -34,6 +34,11 @@ class McpToolSourceManager:
"""
def __init__(self, connection_factory=McpServerConnection) -> None:
"""``connection_factory`` tiêm được để test không phải chạy tiến trình con thật.
Có khoá riêng vì nhiều lượt chat song song cùng gọi tới đây: kiểm-rồi-tạo mà
không khoá sẽ dựng hai kết nối cho cùng một máy chủ.
"""
self._connections: Dict[str, McpServerConnection] = {}
self._lock = threading.Lock()
self._connection_factory = connection_factory
@@ -69,6 +74,7 @@ class McpToolSourceManager:
return self._connections.get(name)
def is_alive(self, name: str) -> bool:
"""Kết nối tới một MCP server còn sống không."""
connection = self._connections.get(name)
return connection is not None and connection.is_alive()
@@ -40,6 +40,9 @@ class AtomicJsonFile:
"""
def __init__(self, path: Path, *, indent: int = 2):
"""Trỏ vào một file JSON. ``indent`` giữ file còn đọc và so sánh được bằng mắt
trong git diff.
"""
self.path = Path(path)
self.indent = indent
@@ -63,6 +66,10 @@ class AtomicJsonFile:
return default
def _quarantine(self) -> Path | None:
"""Đổi tên file JSON hỏng thành ``.bad-<mốc thời gian>`` thay vì xoá.
Giữ lại để còn cứu dữ liệu, và để lần ghi sau bắt đầu từ file sạch.
"""
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
target = self.path.with_suffix(self.path.suffix + f".bad-{stamp}")
try:
@@ -125,7 +132,9 @@ class AtomicJsonFile:
# ---- tiện ích -------------------------------------------------------
def exists(self) -> bool:
"""File đã tồn tại trên đĩa chưa."""
return self.path.exists()
def __repr__(self) -> str:
"""Biểu diễn ngắn kèm đường dẫn, cho log và thông báo lỗi."""
return f"AtomicJsonFile({self.path})"
@@ -22,6 +22,11 @@ class ConversationRepository:
``directory`` (defaults to the app's real ``HISTORY_DIR``)."""
def __init__(self, directory: Optional[Path] = None) -> None:
"""``directory`` để None thì dùng thư mục lịch sử mặc định.
Import muộn ngay trong thân hàm để nạp module này không kéo theo cả cây cấu
hình — test trỏ thẳng vào ``tmp_path``.
"""
if directory is not None:
self._directory = Path(directory)
else:
@@ -29,18 +34,28 @@ class ConversationRepository:
self._directory = HISTORY_DIR
def new_session_id(self) -> str:
"""Sinh id phiên mới cho một cuộc hội thoại."""
return new_session_id()
def save(self, kind: str, session_id: str, messages: List[Dict[str, Any]], **kwargs) -> Path:
"""Ghi hội thoại xuống đĩa (ghi nguyên tử) và trả về đường dẫn file."""
return save_conversation(self._directory, kind, session_id, messages, **kwargs)
def load(self, path: Path) -> Dict[str, Any]:
"""Đọc một hội thoại từ đường dẫn file."""
return load_conversation(path)
def list(self, query: str = "", **kwargs) -> List[Dict[str, Any]]:
"""Liệt kê hội thoại trong thư mục; ``query`` lọc theo tiêu đề và nội dung."""
return list_conversations(self._directory, query=query)
def _resolve_path(self, target: Any) -> Path:
"""Đổi id phiên (hoặc đường dẫn) thành đường dẫn file thật.
Nhận cả ba dạng: Path sẵn, đường dẫn tuyệt đối, và id phiên trần —
id trần thì dò theo mẫu ``*__<id>.json`` vì tiền tố là loại hội thoại
(cowork/co4e/...) mà chỗ gọi không phải lúc nào cũng biết.
"""
if isinstance(target, Path):
return target
p = Path(str(target))
@@ -51,12 +66,15 @@ class ConversationRepository:
return self._directory / f"cowork__{target}.json"
def rename(self, target: Any, new_title: str) -> None:
"""Đổi tiêu đề một hội thoại."""
rename_conversation(self._resolve_path(target), new_title)
def delete(self, target: Any) -> None:
"""Xoá hẳn một hội thoại khỏi đĩa."""
delete_conversation(self._resolve_path(target))
def set_pinned(self, target: Any, pinned: bool) -> None:
"""Ghim/bỏ ghim một hội thoại để nó nằm trên đầu danh sách lịch sử."""
set_pinned(self._resolve_path(target), pinned)
@@ -29,6 +29,11 @@ class TaskRepository:
folder)."""
def __init__(self, directory: Optional[Path] = None) -> None:
"""``directory`` để None thì dùng thư mục task mặc định.
Hai đường import cho cùng một hằng số: gói có thể được nạp dưới tên đầy đủ
``cowork_local`` hoặc dưới dạng tương đối tuỳ cách chạy.
"""
if directory is None:
try:
from cowork_local.core.tasks import TASKS_DIR
@@ -39,6 +44,7 @@ class TaskRepository:
self._directory = directory
def list(self) -> List[Dict[str, Any]]:
"""Liệt kê mọi task trong thư mục."""
try:
from cowork_local.core.tasks import list_tasks
except ImportError:
@@ -46,6 +52,7 @@ class TaskRepository:
return list_tasks(self._directory)
def get(self, task_id: str) -> Optional[Dict[str, Any]]:
"""Đọc một task theo id; trả về ``None`` nếu không có."""
try:
from cowork_local.core.tasks import load_task
except ImportError:
@@ -53,6 +60,7 @@ class TaskRepository:
return load_task(task_id, self._directory)
def save(self, task: Dict[str, Any]) -> Path:
"""Ghi task xuống đĩa (ghi nguyên tử) và trả về đường dẫn file."""
try:
from cowork_local.core.tasks import save_task
except ImportError:
@@ -60,6 +68,7 @@ class TaskRepository:
return save_task(task, self._directory)
def create(self, title: str = "", **overrides: Any) -> Dict[str, Any]:
"""Tạo một task mới trong bộ nhớ theo mẫu mặc định; chưa ghi đĩa."""
try:
from cowork_local.core.tasks import new_task
except ImportError:
@@ -67,6 +76,7 @@ class TaskRepository:
return new_task(title, **overrides)
def duplicate(self, task: Dict[str, Any]) -> Dict[str, Any]:
"""Nhân bản một task (id mới, trạng thái và lịch sử chạy được đặt lại)."""
try:
from cowork_local.core.tasks import duplicate_task
except ImportError:
@@ -74,6 +84,7 @@ class TaskRepository:
return duplicate_task(task)
def delete(self, task_id: str) -> None:
"""Xoá hẳn một task khỏi đĩa."""
try:
from cowork_local.core.tasks import delete_task
except ImportError:
@@ -16,6 +16,9 @@ class WorkspaceRepository:
``tmp_path`` so nothing touches the user's real config folder)."""
def __init__(self, directory: Optional[Path] = None) -> None:
"""``directory`` để None thì dùng thư mục dự án mặc định; import muộn để không
kéo cấu hình vào lúc nạp module.
"""
if directory is not None:
self._directory = Path(directory)
else:
@@ -23,25 +26,31 @@ class WorkspaceRepository:
self._directory = PROJECTS_DIR
def list(self) -> List["Project"]:
"""Liệt kê mọi project trong thư mục."""
from cowork_local.core.projects import list_projects
return list_projects(self._directory)
def get(self, project_id: str) -> Optional["Project"]:
"""Đọc một project theo id; trả về ``None`` nếu không có."""
from cowork_local.core.projects import load_project
return load_project(project_id, self._directory)
def save(self, project: "Project") -> None:
"""Ghi project xuống đĩa (ghi nguyên tử)."""
from cowork_local.core.projects import save_project
save_project(project, self._directory)
def create(self, name: str, **kwargs) -> "Project":
"""Tạo project mới và ghi ngay xuống đĩa."""
from cowork_local.core.projects import new_project
return new_project(name, directory=self._directory, **kwargs)
def new(self, name: str, **kwargs) -> "Project":
"""Bí danh của :meth:`create` — giữ cho mã cũ gọi ``new()`` vẫn chạy."""
return self.create(name, **kwargs)
def delete(self, project_id: str) -> bool:
"""Xoá project; trả về ``True`` nếu có project để xoá."""
from cowork_local.core.projects import delete_project
return delete_project(project_id, self._directory)
@@ -116,6 +116,12 @@ class ProviderRegistry:
def __init__(self, descriptors: Optional[Iterable[ProviderDescriptor]] = None) -> None:
# Keyed by canonical id; alias resolution walks the values so an alias
# can never shadow a real provider id.
"""Đăng ký sẵn một loạt provider.
Khoá theo id chuẩn, còn bí danh được dò khi tra: nhờ vậy một bí danh không
bao giờ che mất một id thật. Có khoá riêng vì sổ đăng ký bị đọc từ nhiều
luồng.
"""
self._by_id: Dict[str, ProviderDescriptor] = {}
self._lock = threading.RLock()
for descriptor in descriptors or ():
@@ -206,9 +212,13 @@ class ProviderRegistry:
return sorted(self._by_id)
def __contains__(self, provider_id: object) -> bool:
"""``"tên" in registry`` — tính cả bí danh, và giá trị không phải chuỗi thì
trả về False thay vì ném lỗi.
"""
return isinstance(provider_id, str) and self.find(provider_id) is not None
def __len__(self) -> int:
"""Số provider đã đăng ký (không đếm bí danh)."""
with self._lock:
return len(self._by_id)
+5
View File
@@ -40,11 +40,15 @@ class QtSchedulerClock:
# Parented so the timer is torn down with its owner instead of
# outliving it — the same lifetime QTimer(self) gave it inside
# TaskScheduler before this extraction.
"""Dựng ``QTimer`` gắn vào ``parent`` để nó bị dọn cùng chủ sở hữu, đúng vòng
đời nó vốn có khi còn nằm trong ``TaskScheduler``.
"""
self._timer = QTimer(parent)
self._timer.timeout.connect(self._on_timeout)
self._callback: Optional[Callable[[], None]] = None
def _on_timeout(self) -> None:
"""Mỗi nhịp ``QTimer``: gọi callback đã đăng ký."""
if self._callback is not None:
self._callback()
@@ -57,6 +61,7 @@ class QtSchedulerClock:
self._timer.start()
def stop(self) -> None:
"""Dừng nhịp đếm."""
self._timer.stop()
def pump(self) -> None:
@@ -17,6 +17,16 @@ don't exist yet. Adding a real Linux/macOS backend later is a 1-line flip of
``implemented`` plus whatever backend module implements it; adding a whole
new OS is a call to :func:`register_profile`, no changes to
:class:`SandboxCapabilityMatrix` itself.
SEAM · dựng 2026-08-25 · chưa nối dây (F-05)
------------------------------------------------------------
Được nối khi: ``core/sandbox_manager.py`` chọn backend bằng ma trận này thay cho chuỗi ``if`` theo hệ điều hành.
Để dormant thì sao: Ma trận mô tả cả những backend CHƯA có bản cài. Không ai
đọc thì nó lệch với thực tế lúc nào không biết.
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
@@ -58,6 +68,11 @@ def detect_os(platform_name: Optional[str] = None) -> str:
@dataclass(frozen=True)
class SandboxBackend:
"""Một cơ chế cách ly cụ thể của hệ điều hành.
``implemented`` phân biệt cái đã có bản cài thật với cái mới chỉ khai báo —
ma trận này mô tả cả những gì CHƯA làm, nên thiếu cờ đó thì nó nói dối.
"""
name: str
isolation_level: str # "none" | "resource_limits" | "restricted_token" | "namespace" | "seatbelt" | "full_vm"
implemented: bool # whether a real backend exists today, vs. a declared placeholder
@@ -65,6 +80,9 @@ class SandboxBackend:
@dataclass(frozen=True)
class OsSandboxProfile:
"""Hồ sơ sandbox của một hệ điều hành: có những backend nào và mức rủi ro nào
thì ưu tiên backend nào.
"""
operating_system: str
backends: Tuple[SandboxBackend, ...]
# risk tier -> ordered list of preferred backend names (first available wins)
@@ -73,6 +91,7 @@ class OsSandboxProfile:
def _profile(operating_system: str, backends: Tuple[SandboxBackend, ...],
routing: Dict[str, Tuple[str, ...]]) -> OsSandboxProfile:
"""Dựng một ``OsSandboxProfile`` — chỉ để bảng khai báo bên dưới đọc gọn hơn."""
return OsSandboxProfile(operating_system=operating_system, backends=backends, routing=routing)
@@ -150,6 +169,11 @@ class SandboxCapabilityMatrix:
def __init__(self, operating_system: Optional[str] = None,
allow_direct_fallback: bool = True) -> None:
"""``operating_system`` để None thì tự dò hệ điều hành đang chạy.
Hệ không có hồ sơ rơi về hồ sơ "không biết": thà chạy ở mức bảo thủ còn hơn
coi như không có ràng buộc nào.
"""
self.operating_system = operating_system if operating_system is not None else detect_os()
self._profile = _PROFILES.get(self.operating_system, _UNKNOWN_PROFILE)
self.allow_direct_fallback = allow_direct_fallback
+3
View File
@@ -0,0 +1,3 @@
"""Kho bí mật: đọc/ghi khoá API qua keyring của hệ điều hành thay vì để trong
file cấu hình (R02).
"""
+13
View File
@@ -28,6 +28,11 @@ class KeyringAdapter:
"""
def __init__(self, service: str = SERVICE):
"""Dò xem máy có kho khoá dùng được không.
Backend ``fail`` của keyring vẫn import trót lọt nhưng ném lỗi ở mọi lượt
gọi, nên phải nhận ra nó ngay tại đây và coi như không có kho khoá.
"""
self.service = service
self._backend = None
self._available = False
@@ -57,6 +62,7 @@ class KeyringAdapter:
# ---- SecretStore ----------------------------------------------------
def get(self, key: str) -> str | None:
"""Đọc một bí mật; máy không có kho bí mật thì trả ``None``."""
if not self._available:
return None
try:
@@ -66,6 +72,11 @@ class KeyringAdapter:
return None
def set(self, key: str, value: str) -> None:
"""Lưu một bí mật; không có kho thì chỉ ghi cảnh báo, KHÔNG ném lỗi.
App phải chạy được trên máy thiếu keyring — lúc đó khoá nằm lại trong file
cấu hình như trước.
"""
if not self._available:
log.warning("không lưu được %r: máy này không có kho bí mật", key)
return
@@ -75,6 +86,7 @@ class KeyringAdapter:
log.warning("lưu khoá %r thất bại: %s", key, exc)
def delete(self, key: str) -> None:
"""Xoá một bí mật khỏi kho; không có kho thì bỏ qua."""
if not self._available:
return
try:
@@ -83,4 +95,5 @@ class KeyringAdapter:
pass
def has(self, key: str) -> bool:
"""Kho có chứa bí mật này không."""
return self.get(key) is not None
+10
View File
@@ -47,6 +47,7 @@ class CanonicalAuditEvent:
machine: str
def to_dict(self) -> Dict[str, Any]:
"""Bản ghi dưới dạng dict để ghi JSONL."""
return {
"ts": self.ts,
"kind": self.kind,
@@ -77,6 +78,7 @@ class CanonicalAuditEvent:
@dataclass
class _Identity:
"""Danh tính gắn vào mọi bản ghi: tài khoản, vai trò, máy và thư mục chia sẻ."""
account: str = ""
role: str = ""
machine: str = ""
@@ -90,6 +92,9 @@ class CanonicalAuditLogger:
class can be constructed/injected instead of relying on globals."""
def __init__(self, audit_dir: Path):
"""Danh tính (người dùng, máy) được lấy một lần lúc dựng: nó không đổi trong
một phiên, và mỗi dòng nhật ký đều cần tới.
"""
self.audit_dir = Path(audit_dir)
self._identity = _Identity()
@@ -126,6 +131,11 @@ class CanonicalAuditLogger:
pass
def _write_shared(self, event: CanonicalAuditEvent, now: datetime) -> None:
"""Ghi thêm một bản sao vào thư mục chia sẻ của đội, nếu có cấu hình.
Thiếu thư mục chia sẻ hoặc thiếu tên máy thì bỏ qua — bản ghi cục bộ vẫn có,
và một lỗi ghi mạng không được làm hỏng lượt chạy.
"""
identity = self._identity
if not identity.shared_dir or not identity.machine:
return
+16
View File
@@ -98,6 +98,9 @@ class UsageTrackerSink:
def __init__(self, recorder=None) -> None:
# The recorder is injectable so a test can verify the forwarding
# contract without importing the real tracker (and its config paths).
"""``recorder`` tiêm được để test kiểm việc chuyển tiếp mà không phải nạp bộ
theo dõi thật (và cả đống đường dẫn cấu hình của nó).
"""
self._recorder = recorder
def _resolve_recorder(self):
@@ -150,6 +153,7 @@ class InMemoryUsageSink:
"""Collects events in a list — the test double for usage assertions."""
def __init__(self) -> None:
"""Giữ sự kiện trong bộ nhớ cho test. Có khoá vì sự kiện đến từ nhiều luồng."""
self.events: List[UsageEvent] = []
self._lock = threading.Lock()
@@ -166,11 +170,13 @@ class InMemoryUsageSink:
return list(self.events)
def clear(self) -> None:
"""Xoá sạch sự kiện đã ghi (dùng trong test)."""
with self._lock:
self.events.clear()
@property
def total_tokens(self) -> int:
"""Tổng token của mọi sự kiện đã ghi."""
return sum(e.total_tokens for e in self.snapshot())
@@ -184,10 +190,14 @@ class CompositeUsageSink:
"""
def __init__(self, sinks=None) -> None:
"""Gộp nhiều đích nhận sự kiện thành một. Dùng ``RLock`` vì một đích có thể gọi
ngược lại vào composite trong lúc đang phát.
"""
self._sinks: List[UsageEventSink] = list(sinks or ())
self._lock = threading.RLock()
def add(self, sink: UsageEventSink) -> None:
"""Thêm một đích ghi vào nhóm."""
with self._lock:
self._sinks.append(sink)
@@ -199,10 +209,16 @@ class CompositeUsageSink:
self._sinks.remove(sink)
def sinks(self) -> List[UsageEventSink]:
"""Bản sao danh sách đích ghi hiện tại (an toàn khi duyệt)."""
with self._lock:
return list(self._sinks)
def emit(self, event: UsageEvent) -> None:
"""Đẩy sự kiện tới mọi đích.
Một đích lỗi không được làm hỏng các đích còn lại — ghi số liệu là việc phụ,
không được phép làm vỡ lượt chat đang chạy.
"""
for sink in self.sinks():
try:
sink.emit(event)