merge: cập nhật từ origin/feature/delta-team/epic-R04 (dọn dead code, gộp i18n/theme, CASAN Gate O, launcher)
Kéo 7 commit mới từ remote — chủ yếu dọn dẹp và siết chất lượng, không đổi API đang dùng: - Xoá 1.400 dòng mã chết + 5 gói rỗng còn sót sau các lần merge trước. - Thêm CASAN Gate O (LOC) áp cho toàn cây mã. - Sửa 5 checker UI hỏng sau đợt tách widget R08, vá 4 hồi quy. - install.bat/run.bat, gộp requirements-test.txt vào requirements.txt. - Gom i18n_*.py / theme_*.py rời rạc thành gói i18n/ và theme/. Merge sạch, không có conflict marker nào (git tự resolve toàn bộ). Đã kiểm tra lại 4 điểm đã vá ở 2 lần merge trước (config.py circular import, ai_edit_model_resolver.py dùng resolve() thay route_turn(), _confirm_routing_switch nhận timeout, RoutingApplicationService.resolve()) — cả 4 vẫn nguyên vẹn sau merge này. pytest tests/: 793 passed — giống hệt số liệu trước khi merge, không phát sinh fail/error mới (8 fail còn lại vẫn là do môi trường sandbox: thiếu keyring, tên thư mục cowork-local vs cowork_local). Co-Authored-By: Claude Sonnet 5 <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)
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Infrastructure platform adapters package."""
|
||||
@@ -1 +0,0 @@
|
||||
"""Infrastructure Qt platform adapters: QtSchedulerClock."""
|
||||
@@ -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