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:
@@ -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 ------------------------------------------------------------
|
||||
|
||||
@@ -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"]
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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", "")))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user